diff --git a/src/tui-opentui/gate-wire.test.ts b/src/tui-opentui/gate-wire.test.ts index dd588556d..8261b3d85 100644 --- a/src/tui-opentui/gate-wire.test.ts +++ b/src/tui-opentui/gate-wire.test.ts @@ -424,6 +424,183 @@ describe("wireGates", () => { }) }) +describe("each gate decision appends exactly one transcript row", () => { + test("permission accept", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + emitter.emit("permission.gate", { request: baseRequest(), resolve: () => {} }) + + const before = shell.streamLog.length + acceptOverlaySelection(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("permission Esc/deny", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + emitter.emit("permission.gate", { request: baseRequest(), resolve: () => {} }) + + const before = shell.streamLog.length + closeInsetOverlay(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("operator accept", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + emitter.emit("operator.gate", { + question: "Proceed?", + options: ["Cancel", "Continue"], + resolve: () => {}, + }) + + const before = shell.streamLog.length + acceptOverlaySelection(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("operator Esc/cancel", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + emitter.emit("operator.gate", { + question: "Proceed?", + options: ["Cancel", "Continue"], + resolve: () => {}, + }) + + const before = shell.streamLog.length + closeInsetOverlay(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("operator typed answer", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + emitter.emit("operator.gate", { + question: "Proceed?", + options: ["Cancel", "Continue"], + resolve: () => {}, + }) + + setOverlayAnswerActive(shell, true) + const before = shell.streamLog.length + for (const ch of "yes") { + handleOverlayAnswerKey(shell, { + name: ch, + sequence: ch, + ctrl: false, + meta: false, + option: false, + } as unknown as KeyEvent) + } + handleOverlayAnswerKey(shell, { + name: "return", + sequence: "", + ctrl: false, + meta: false, + option: false, + } as unknown as KeyEvent) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("permission auto-deny on timeout", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + try { + wireGates(emitter, shell) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => {}, + timeoutMs: 5, + }) + await new Promise((r) => setTimeout(r, 20)) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("permission auto-deny on abort", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + const controller = new AbortController() + try { + wireGates(emitter, shell) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => {}, + signal: controller.signal, + }) + controller.abort() + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) +}) + describe("permission.gate auto-deny", () => { test("timeoutMs elapsing auto-denies with the timeout message and closes the overlay", async () => { await withTestRenderer(async (h) => { diff --git a/src/tui-opentui/gate-wire.ts b/src/tui-opentui/gate-wire.ts index c19053d79..ef8945f92 100644 --- a/src/tui-opentui/gate-wire.ts +++ b/src/tui-opentui/gate-wire.ts @@ -227,6 +227,21 @@ function recordDecision( appendStreamRow(shell, { role: "system", text, meta: "permission" }) } +/** + * Write the operator's question and answer to the transcript, once decided. + * Mirrors recordDecision: the overlay already shows this text while it is + * open, so an immediate echo would print every operator question twice. + */ +function recordOperatorDecision( + shell: AppShell, + question: string, + label: string, +): void { + const body = middleEllipsis(question, 500) + const text = `${body}\n→ ${label}` + appendStreamRow(shell, { role: "system", text, meta: "operator" }) +} + /** * Subscribe the permission/operator gate events to the shell's overlays. * Returns a dispose function that removes exactly the listeners this call added. @@ -289,6 +304,10 @@ export function wireGates( items: choices.items, itemIds: choices.itemIds, body: collapsedBody, + // recordDecision below is the authoritative transcript row for every + // terminal path — the overlay's own accept/answer echo would + // duplicate it. + echoChoice: false, ...(collapsedAnything ? { onToggleExpand } : {}), onAccept: (sel: OverlaySelection) => { if (settled) return @@ -307,12 +326,9 @@ export function wireGates( if (settled) return settled = true clearTimers() - ev.resolve( - approvalOutcomeFromSelection(choices, { - index: 0, - id: PERMISSION_DENY_ID, - }), - ) + const gateSelection = { index: 0, id: PERMISSION_DENY_ID } + recordDecision(shell, ev.request, choices, gateSelection) + ev.resolve(approvalOutcomeFromSelection(choices, gateSelection)) }, }) } @@ -332,6 +348,10 @@ export function wireGates( if (settled) return settled = true clearTimers() + recordDecision(shell, ev.request, choices, { + index: 0, + id: PERMISSION_DENY_ID, + }) if (isOpen) { closeInsetOverlay(shell) } else { @@ -368,9 +388,14 @@ export function wireGates( body: ev.question, choices: choices.items, itemIds: choices.itemIds, + // recordOperatorDecision below is the authoritative transcript row for + // every terminal path — the overlay's own accept/answer echo would + // duplicate it. + echoChoice: false, onAccept: (sel: OverlaySelection) => { if (settled) return settled = true + recordOperatorDecision(shell, ev.question, sel.label) ev.resolve( operatorResultFromSelection(ev.options, { index: sel.index, @@ -383,6 +408,7 @@ export function wireGates( onTextAnswer: (text: string) => { if (settled) return settled = true + recordOperatorDecision(shell, ev.question, text) ev.resolve(operatorCustomResult(text)) }, // Esc must settle the awaited promise (as a cancel), not abandon it — @@ -390,6 +416,7 @@ export function wireGates( onCancel: () => { if (settled) return settled = true + recordOperatorDecision(shell, ev.question, "Cancelled") ev.resolve(operatorCancelResult()) }, })) diff --git a/src/tui-opentui/overlays.test.ts b/src/tui-opentui/overlays.test.ts index 4f486a801..6a3ceaf56 100644 --- a/src/tui-opentui/overlays.test.ts +++ b/src/tui-opentui/overlays.test.ts @@ -478,3 +478,68 @@ describe("accept echo reads the chosen value structurally", () => { ) }) }) + +describe("echoChoice defaults to on for callers with no recorder of their own", () => { + test("openPermissionsOverlay with no echoChoice opt still echoes on accept", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + openPermissionsOverlay(shell, { items: makePermissionItems(3) }) + const before = shell.streamLog.length + acceptOverlaySelection(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("openPermissionsOverlay with echoChoice: false suppresses it", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + openPermissionsOverlay(shell, { + items: makePermissionItems(3), + echoChoice: false, + }) + const before = shell.streamLog.length + acceptOverlaySelection(shell) + expect(shell.streamLog.length - before).toBe(0) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("openOperatorOverlay with no echoChoice opt still echoes on accept", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + openOperatorOverlay(shell, { choices: ["A", "B"] }) + const before = shell.streamLog.length + acceptOverlaySelection(shell) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) diff --git a/src/tui-opentui/overlays.ts b/src/tui-opentui/overlays.ts index b8e20c83e..2f62ad670 100644 --- a/src/tui-opentui/overlays.ts +++ b/src/tui-opentui/overlays.ts @@ -91,6 +91,14 @@ export type OpenPermissionsOpts = { readonly onToggleExpand?: () => void /** Per-open Esc/dismiss; host binds resolve(ApprovalOutcome) so Esc denies instead of hanging. */ readonly onCancel?: () => void + /** + * Suppress the generic accept/answer echo for this open. Callers that + * record their own authoritative decision row (e.g. gate-wire's + * recordDecision) pass `false` so the generic echo does not duplicate it; + * callers with no such recorder (e.g. the standalone demo) get the default + * echo so their choice still leaves a trace. + */ + readonly echoChoice?: boolean } export function openPermissionsOverlay( @@ -111,6 +119,7 @@ export function openPermissionsOverlay( : {}), ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), + ...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}), }) } @@ -125,6 +134,14 @@ export type OpenOperatorOpts = { readonly onTextAnswer?: (text: string) => void /** Per-open Esc/dismiss; host binds resolve(cancel) so Esc cancels instead of hanging. */ readonly onCancel?: () => void + /** + * Suppress the generic accept/answer echo for this open. Callers that + * record their own authoritative decision row (e.g. gate-wire's + * recordOperatorDecision) pass `false` so the generic echo does not + * duplicate it; callers with no such recorder (e.g. the standalone demo) + * get the default echo so their choice still leaves a trace. + */ + readonly echoChoice?: boolean } /** @@ -156,6 +173,7 @@ export function openOperatorOverlay( ? { onTextAnswer: opts.onTextAnswer } : {}), ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), + ...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}), }) } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 7889ffe1e..fc45ca5b7 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3434,14 +3434,16 @@ export function handleOverlayAnswerKey( if (answer.text.length === 0) return true const text = answer.text const submit = answer.onSubmit - appendStreamRow(shell, { - role: "system", - text: `answered: ${text}`, - meta: overlayKindWord(shell.overlayKind ?? "operator"), - }) + const bag = internals.get(shell) + if (bag?.overlayEchoChoice !== false) { + appendStreamRow(shell, { + role: "system", + text: `answered: ${text}`, + meta: overlayKindWord(shell.overlayKind ?? "operator"), + }) + } // Deliberate submit, not a dismiss — closeInsetOverlay must not also fire // the Esc/cancel path. - const bag = internals.get(shell) if (bag) bag.overlayOnCancel = null closeInsetOverlay(shell) submit(text)