import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { type Component, CURSOR_MARKER, decodeKittyPrintable, type Focusable, type KeybindingsManager, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; function decodePrintableKey(data: string): string | undefined { const kittyPrintable = decodeKittyPrintable(data); if (kittyPrintable !== undefined) return kittyPrintable; const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); if (!match) return undefined; const modifier = Number.parseInt(match[1], 10) - 1; const codepoint = Number.parseInt(match[2], 10); const lockMask = 64 | 128; if (((modifier & ~lockMask) & ~1) !== 0 || codepoint < 32) return undefined; try { return String.fromCodePoint(codepoint); } catch { return undefined; } } type UICustomCarrier = { ui: Pick; }; export interface CompactConfirmOptions { /** Optional abort signal. If aborted before resolution, resolves to false. */ signal?: AbortSignal; } /** * Show a single-line yes/no prompt. * * The user must type the literal word "yes" or "no" (case-insensitive, * whitespace-trimmed) and press Enter. Esc or Ctrl-C cancels and resolves * `false` — deny is always the safe default. */ export async function compactConfirm( ctx: UICustomCarrier, heading: string, summary?: string, opts: CompactConfirmOptions = {}, ): Promise { if (opts.signal?.aborted) return false; return ctx.ui.custom( (_tui, theme, keybindings, done) => new CompactConfirmComponent( heading, summary, theme, keybindings, opts.signal, done, ), ); } const VALID_RESPONSES = new Set(["yes", "no"]); class CompactConfirmComponent implements Component, Focusable { focused = true; private heading: string; private summary: string | undefined; private theme: Theme; private keybindings: KeybindingsManager; private done: (result: boolean) => void; private buffer = ""; private settled = false; private cachedWidth?: number; private cachedLines?: string[]; private cachedBuffer?: string; private abortCleanup?: () => void; constructor( heading: string, summary: string | undefined, theme: Theme, keybindings: KeybindingsManager, signal: AbortSignal | undefined, done: (result: boolean) => void, ) { this.heading = heading; this.summary = summary; this.theme = theme; this.keybindings = keybindings; this.done = done; if (signal) { const onAbort = () => this.resolve(false); signal.addEventListener("abort", onAbort, { once: true }); this.abortCleanup = () => signal.removeEventListener("abort", onAbort); } } handleInput(data: string): void { if (this.settled) return; // Bracketed paste: strip the markers and treat the content as a single // typed chunk, with newlines/tabs removed so a pasted "yes\n" cannot // auto-submit. if (data.startsWith("\x1b[200~")) { const end = data.indexOf("\x1b[201~"); if (end !== -1) { const pasted = data.slice("\x1b[200~".length, end).replace(/[\r\n\t]/g, ""); this.appendPrintable(pasted); this.invalidate(); return; } } if (this.keybindings.matches(data, "tui.select.cancel")) { return this.resolve(false); } if (this.keybindings.matches(data, "tui.input.submit") || data === "\n" || data === "\r") { const value = this.buffer.trim().toLowerCase(); if (value === "yes") return this.resolve(true); if (value === "no") return this.resolve(false); // Otherwise ignore — force the user to type a recognised response. return; } if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) { if (this.buffer.length > 0) { this.buffer = this.buffer.slice(0, -1); this.invalidate(); } return; } if (this.keybindings.matches(data, "tui.editor.deleteWordBackward")) { if (this.buffer.length > 0) { this.buffer = ""; this.invalidate(); } return; } const printable = decodePrintableKey(data) ?? (this.isPrintable(data) ? data : undefined); if (printable !== undefined) { this.appendPrintable(printable); this.invalidate(); } } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; this.cachedBuffer = undefined; } render(width: number): string[] { if ( this.cachedLines && this.cachedWidth === width && this.cachedBuffer === this.buffer ) { return this.cachedLines; } const t = this.theme; const value = this.buffer.trim().toLowerCase(); const isValid = VALID_RESPONSES.has(value); const headingPart = t.fg("muted", this.heading); const summaryPart = this.summary ? ` ${t.bold(t.fg("accent", this.summary))}` : ""; const hintLabel = isValid ? "" : ' ("yes" or "no")'; const hintPart = t.fg("dim", `${hintLabel}: `); const bufferPart = isValid ? t.fg("accent", this.buffer) : this.buffer; const cursorPart = this.focused ? `${CURSOR_MARKER}\x1b[7m \x1b[27m` : ""; const line = `${headingPart}${summaryPart}${hintPart}${bufferPart}${cursorPart}`; const padX = 1; const contentWidth = Math.max(1, width - padX * 2); const margin = " ".repeat(padX); const wrapped = wrapTextWithAnsi(line, contentWidth).map((row) => { const padded = margin + row + margin; const pad = Math.max(0, width - visibleWidth(padded)); return padded + " ".repeat(pad); }); const lines = wrapped.length > 0 ? wrapped : [" ".repeat(width)]; this.cachedLines = lines; this.cachedWidth = width; this.cachedBuffer = this.buffer; return lines; } dispose(): void { this.abortCleanup?.(); } private appendPrintable(text: string): void { // Don't let the buffer grow without bound — five chars is enough for // "yes"/"no" with a typo or two of headroom. const room = Math.max(0, 16 - this.buffer.length); if (room === 0) return; this.buffer += text.slice(0, room); } private isPrintable(data: string): boolean { if (data.length === 0) return false; for (const ch of data) { const code = ch.charCodeAt(0); if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { return false; } } return true; } private resolve(value: boolean): void { if (this.settled) return; this.settled = true; this.done(value); } }