import type { ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import { Editor, type EditorTheme, Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI, } from "@earendil-works/pi-tui"; export interface OverlayChoice { value: T; label: string; description?: string; } function sanitizeLine(value: string) { return value .replace( /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g, "", ) .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "") .replaceAll("\t", " "); } export function sanitizeTerminalText(value: string) { return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n"); } function fit(value: string, width: number) { const fitted = truncateToWidth(value, Math.max(1, width), ""); return value.includes("\x1b") ? fitted : sanitizeLine(fitted); } function pad(value: string, width: number) { const bounded = fit(value, width); return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`; } function card( theme: Theme, title: string, contents: string[], width: number, ) { const innerWidth = Math.max(1, width - 2); const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth); const titleWidth = visibleWidth(safeTitle); const trailing = Math.max(0, innerWidth - titleWidth); return [ `${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`, ...contents.map( (line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`, ), theme.fg("border", `╰${"─".repeat(innerWidth)}╯`), ].map((line) => fit(line, width)); } export class WorkflowSelectionOverlay implements Component { private selected = 0; private readonly title: string; private readonly choices: OverlayChoice[]; private readonly theme: Theme; private readonly done: (value: T | undefined) => void; private readonly maxRows: number; constructor( title: string, choices: OverlayChoice[], theme: Theme, done: (value: T | undefined) => void, maxRows = 24, ) { this.title = title; this.choices = choices; this.theme = theme; this.done = done; this.maxRows = Math.max(4, Math.floor(maxRows)); } handleInput(data: string) { if (matchesKey(data, Key.escape)) { this.done(undefined); } else if (matchesKey(data, Key.up)) { this.selected = Math.max(0, this.selected - 1); } else if (matchesKey(data, Key.down)) { this.selected = Math.min(this.choices.length - 1, this.selected + 1); } else if (matchesKey(data, Key.home)) { this.selected = 0; } else if (matchesKey(data, Key.end)) { this.selected = Math.max(0, this.choices.length - 1); } else if (matchesKey(data, Key.enter)) { const choice = this.choices[this.selected]; if (choice) this.done(choice.value); } } render(width: number) { const bodyRows = Math.max(2, this.maxRows - 2); const showLeadingSpace = bodyRows >= 5; const showPagination = this.choices.length > 1 && bodyRows >= 3; const selectedChoice = this.choices[this.selected]; const descriptionWidth = Math.max(1, width - 8); const selectedDescription = selectedChoice?.description ? sanitizeTerminalText(selectedChoice.description) .split("\n") .flatMap((line) => { const wrapped = wrapTextWithAnsi(line, descriptionWidth); return wrapped.length > 0 ? wrapped : [""]; }) : []; const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination); const choiceRows = Math.max(1, bodyRows - fixedRows); const descriptionRows = Math.min( selectedDescription.length, Math.max(0, Math.floor((choiceRows - 1) / 2)), ); const maxVisible = Math.max( 1, Math.min(12, this.choices.length || 1, choiceRows - descriptionRows), ); const start = Math.max( 0, Math.min( this.selected - Math.floor(maxVisible / 2), Math.max(0, this.choices.length - maxVisible), ), ); const body: string[] = showLeadingSpace ? [""] : []; for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) { const index = start + offset; const selected = index === this.selected; const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`; body.push( selected ? this.theme.bg("selectedBg", this.theme.fg("text", text)) : this.theme.fg("text", text), ); if (selected) { for (const line of selectedDescription.slice(0, descriptionRows)) { body.push(this.theme.fg("muted", ` ${line}`)); } } } if (this.choices.length === 0) { body.push(this.theme.fg("muted", " No choices available")); } if (showPagination && this.choices.length > maxVisible) { body.push( this.theme.fg( "dim", ` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`, ), ); } body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back")); return card(this.theme, this.title, body, width).slice(0, this.maxRows); } invalidate() {} } abstract class FocusedTextOverlay implements Component, Focusable { private _focused = false; abstract readonly child: Input | Editor; get focused() { return this._focused; } set focused(value: boolean) { this._focused = value; this.child.focused = value; } abstract render(width: number): string[]; abstract handleInput(data: string): void; abstract invalidate(): void; } class WorkflowInputOverlay extends FocusedTextOverlay { readonly child = new Input(); private readonly title: string; private readonly prompt: string; private readonly theme: Theme; private readonly maxRows: number; constructor( title: string, prompt: string, theme: Theme, done: (value: string | undefined) => void, maxRows: number, ) { super(); this.title = title; this.prompt = prompt; this.theme = theme; this.maxRows = Math.max(4, Math.floor(maxRows)); this.child.onSubmit = (value) => done(value); this.child.onEscape = () => done(undefined); } handleInput(data: string) { this.child.handleInput(data); } render(width: number) { const innerWidth = Math.max(1, width - 4); const wrappedPrompt = sanitizeTerminalText(this.prompt) .split("\n") .flatMap((line) => { const wrapped = wrapTextWithAnsi(line, innerWidth); return wrapped.length > 0 ? wrapped : [""]; }); const bodyRows = this.maxRows - 2; const showLeadingSpace = bodyRows >= 5; const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace)); const prompt = wrappedPrompt.slice(0, promptRows); if (wrappedPrompt.length > promptRows && prompt.length > 0) { const last = prompt.length - 1; prompt[last] = fit( `${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`, innerWidth, ); } const body = [ ...(showLeadingSpace ? [""] : []), ...prompt.map((line) => ` ${line}`), ` ${this.child.render(innerWidth)[0] ?? ""}`, this.theme.fg("dim", " enter submit esc back"), ]; return card(this.theme, this.title, body, width).slice(0, this.maxRows); } invalidate() { this.child.invalidate(); } } class WorkflowEditorOverlay extends FocusedTextOverlay { readonly child: Editor; private readonly title: string; private readonly prompt: string; private readonly theme: Theme; constructor( tui: TUI, title: string, prompt: string, initial: string, theme: Theme, done: (value: string | undefined) => void, ) { super(); this.title = title; this.prompt = prompt; this.theme = theme; const editorTheme: EditorTheme = { borderColor: (value) => theme.fg("borderAccent", value), selectList: { selectedPrefix: (value) => theme.fg("accent", value), selectedText: (value) => theme.fg("accent", value), description: (value) => theme.fg("muted", value), scrollInfo: (value) => theme.fg("dim", value), noMatch: (value) => theme.fg("warning", value), }, }; this.child = new Editor(tui, editorTheme, { paddingX: 0 }); this.child.setText(initial); this.child.onSubmit = (value) => done(value); } handleInput(data: string) { if (matchesKey(data, Key.escape)) return; this.child.handleInput(data); } render(width: number) { const innerWidth = Math.max(1, width - 4); const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth); return card( this.theme, this.title, ["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")], width, ); } invalidate() { this.child.invalidate(); } } function assertTui(ctx: ExtensionContext) { if (ctx.mode !== "tui") { throw new Error("workflow overlays require TUI mode"); } } export async function workflowSelect( ctx: ExtensionContext, title: string, choices: OverlayChoice[], ) { assertTui(ctx); return ctx.ui.custom( (tui, theme, _keybindings, done) => { const overlay = new WorkflowSelectionOverlay( title, choices, theme, done, Math.floor(tui.terminal.rows * 0.8), ); return { render: (width) => overlay.render(width), invalidate: () => overlay.invalidate(), handleInput: (data) => { overlay.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "80%", margin: 1, }, }, ); } export function workflowConfirm( ctx: ExtensionContext, title: string, message: string, ) { return workflowSelect(ctx, title, [ { value: true, label: "Confirm", description: message }, { value: false, label: "Cancel" }, ]).then((value) => value === true); } export async function workflowInput( ctx: ExtensionContext, title: string, prompt: string, ) { assertTui(ctx); return ctx.ui.custom( (tui, theme, _keybindings, done) => { const overlay = new WorkflowInputOverlay( title, prompt, theme, done, Math.max(4, Math.floor(tui.terminal.rows * 0.7)), ); return { get focused() { return overlay.focused; }, set focused(value: boolean) { overlay.focused = value; }, render: (width) => overlay.render(width), invalidate: () => overlay.invalidate(), handleInput: (data) => { overlay.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 }, }, ); } export async function workflowEditor( ctx: ExtensionContext, title: string, prompt: string, initial = "", ) { assertTui(ctx); return ctx.ui.custom( (tui, theme, _keybindings, done) => { const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done); return { get focused() { return overlay.focused; }, set focused(value: boolean) { overlay.focused = value; }, render: (width) => overlay.render(width), invalidate: () => overlay.invalidate(), handleInput: (data) => { if (matchesKey(data, Key.escape)) done(undefined); else overlay.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 }, }, ); } export interface PlanHeading { level: 1 | 2 | 3; text: string; line: number; } export interface PlanReaderModel { title: string; lines: string[]; headings: PlanHeading[]; } export interface PlanReaderState { topRow: number; } export type PlanReaderAction = | "up" | "down" | "page-up" | "page-down" | "home" | "end"; interface PlanDisplayRow { text: string; sourceLine: number; } export function createPlanReaderModel(title: string, contents: string): PlanReaderModel { const lines = sanitizeTerminalText(contents).split("\n"); const headings = lines.flatMap((line, index): PlanHeading[] => { const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line); return match ? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }] : []; }); return { title: sanitizeLine(title), lines, headings }; } export function createPlanReaderState(): PlanReaderState { return { topRow: 0 }; } function readerBodyHeight(height: number) { return Math.max(1, height - 4); } function readerOutlineWidth(width: number) { return Math.max(24, Math.min(32, Math.floor(width * 0.34))); } function readerDocumentWidth(width: number) { return width < 80 ? Math.max(1, width - 2) : Math.max(1, width - readerOutlineWidth(width) - 3); } function displayRows(model: PlanReaderModel, width: number) { return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => { const wrapped = wrapTextWithAnsi(line, Math.max(1, width)); return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({ text, sourceLine, })); }); } export function reducePlanReaderState( model: PlanReaderModel, state: PlanReaderState, action: PlanReaderAction, dimensions: { width: number; height: number }, ): PlanReaderState { const rows = displayRows(model, readerDocumentWidth(dimensions.width)); const last = Math.max(0, rows.length - 1); const page = Math.max(1, readerBodyHeight(dimensions.height) - 1); const delta = action === "up" ? -1 : action === "down" ? 1 : action === "page-up" ? -page : action === "page-down" ? page : 0; const topRow = action === "home" ? 0 : action === "end" ? last : Math.max(0, Math.min(last, state.topRow + delta)); return { topRow }; } function currentHeading(model: PlanReaderModel, sourceLine: number) { return [...model.headings].reverse().find((heading) => heading.line <= sourceLine); } function viewportRows( rows: PlanDisplayRow[], topRow: number, height: number, ) { return rows.slice(topRow, topRow + readerBodyHeight(height)); } export function renderPlanReader( model: PlanReaderModel, state: PlanReaderState, dimensions: { width: number; height: number }, theme?: Theme, ) { const { width, height } = dimensions; if (width < 48 || height < 12) { const lines = [ "", "terminal too small", "", "workflow plan needs 48x12", `current terminal is ${width}x${height}`, "", "resize the terminal or press esc", ]; const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2)); return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height); } const documentWidth = readerDocumentWidth(width); const rows = displayRows(model, documentWidth); const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow)); const sourceLine = rows[topRow]?.sourceLine ?? 0; const heading = currentHeading(model, sourceLine); const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`; const headingLabel = width < 80 && heading ? ` · ${heading.text}` : ""; const header = fit(` workflow plan · ${model.title}${headingLabel} · ${position}`, width); const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width); const bodyHeight = readerBodyHeight(height); const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) => theme ? theme.fg(token, value) : value; const viewport = viewportRows(rows, topRow, height); if (width < 80) { const body = viewport.map((row) => { const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const; return fit(` ${paint(token, row.text)}`, width); }); while (body.length < bodyHeight) body.push(""); return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height); } const outlineWidth = readerOutlineWidth(width); const gap = 3; const body = viewport.map((row) => row.text); const selectedHeading = heading?.line; const selectedHeadingIndex = Math.max( 0, model.headings.findIndex((entry) => entry.line === selectedHeading), ); const outlineStart = Math.max( 0, Math.min( selectedHeadingIndex - Math.floor(bodyHeight / 2), Math.max(0, model.headings.length - bodyHeight), ), ); const outline = model.headings .slice(outlineStart, outlineStart + bodyHeight) .map((entry) => { const prefix = entry.line === selectedHeading ? "> " : " "; const indent = " ".repeat(entry.level - 1); const text = `${prefix}${indent}${entry.text}`; return entry.line === selectedHeading ? paint("accent", text) : paint(entry.level === 1 ? "text" : "muted", text); }); const renderedRows = Array.from({ length: bodyHeight }, (_, index) => { const left = pad(outline[index] ?? "", outlineWidth); const right = fit(body[index] ?? "", documentWidth); return fit(`${left}${" ".repeat(gap)}${right}`, width); }); return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height); } class PlanReaderComponent implements Component { private state = createPlanReaderState(); private width: number; private readonly tui: TUI; private readonly model: PlanReaderModel; private readonly theme: Theme; private readonly done: () => void; constructor( tui: TUI, model: PlanReaderModel, theme: Theme, done: () => void, ) { this.tui = tui; this.width = tui.terminal.columns; this.model = model; this.theme = theme; this.done = done; } handleInput(data: string) { if (matchesKey(data, Key.escape)) { this.done(); return; } const action = matchesKey(data, Key.up) ? "up" : matchesKey(data, Key.down) ? "down" : matchesKey(data, Key.pageUp) ? "page-up" : matchesKey(data, Key.pageDown) ? "page-down" : matchesKey(data, Key.home) ? "home" : matchesKey(data, Key.end) ? "end" : undefined; if (action) { this.state = reducePlanReaderState( this.model, this.state, action, { width: this.width, height: this.tui.terminal.rows }, ); } } render(width: number) { this.width = width; return renderPlanReader( this.model, this.state, { width, height: this.tui.terminal.rows }, this.theme, ); } invalidate() {} } export async function workflowPlanReader( ctx: ExtensionContext, title: string, contents: string, ) { assertTui(ctx); const model = createPlanReaderModel(title, contents); await ctx.ui.custom( (tui, theme, _keybindings, done) => { const reader = new PlanReaderComponent(tui, model, theme, done); return { render: (width) => reader.render(width), invalidate: () => reader.invalidate(), handleInput: (data) => { reader.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0, }, }, ); }