import type { Theme } from "@oh-my-pi/pi-coding-agent"; import { type Component, matchesKey, replaceTabs, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; import copy from "./copy.json"; import type { ReviewAction } from "./domain"; // Untrusted note text is data, never terminal control or ANSI styling. export function safeText(text: string): string { return replaceTabs(Bun.stripANSI(text)).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, ""); } export class BoundedText implements Component { private readonly text: Text; constructor(content: string, theme?: Theme) { this.text = new Text(safeText(content), 0, 0); if (theme) this.text.setStyleFn(line => theme.fg("text", line)); } invalidate(): void { this.text.invalidate(); } render(width: number): readonly string[] { const columns = Math.max(0, Math.min(100, Math.floor(width))); if (columns === 0) return []; return this.text.render(columns).map(line => truncateToWidth(line, columns, "")); } } export class ReviewPanel implements Component { private readonly title: BoundedText; private readonly body: BoundedText; private readonly keys: BoundedText; private offset = 0; private maxOffset = 0; private disposed = false; constructor(text: string, theme: Theme, private readonly redraw: () => void, private readonly done: (action: Exclude) => void) { this.title = new BoundedText(copy.panelTitle, theme); this.body = new BoundedText(text, theme); this.keys = new BoundedText(`${copy.panelKeys}\n${copy.panelEnd}`, theme); } invalidate(): void { this.title.invalidate(); this.body.invalidate(); this.keys.invalidate(); } render(width: number): readonly string[] { if (this.disposed || width < 1) return []; const body = this.body.render(width); this.maxOffset = Math.max(0, body.length - 8); this.offset = Math.min(this.offset, this.maxOffset); return [...this.title.render(width), ...body.slice(this.offset, this.offset + 8), ...this.keys.render(width)]; } handleInput(data: string): void { if (this.disposed) return; if (matchesKey(data, "escape")) this.finish("cancel"); else if (data === "a") this.finish("accept"); else if (data === "r") this.finish("reject"); else if (matchesKey(data, "up") || matchesKey(data, "down")) { this.offset = Math.max(0, Math.min(this.maxOffset, this.offset + (matchesKey(data, "up") ? -1 : 1))); this.redraw(); } } private finish(action: Exclude): void { this.dispose(); this.done(action); } dispose(): void { this.disposed = true; } }