Skip to content

Commit 96aa968

Browse files
committed
Suppress the generic overlay echo for permission and operator gates
recordDecision already writes the authoritative transcript row for a permission decision, carrying the full subject and chosen scope, but the generic overlay-accept echo fired alongside it because neither overlay open passed echoChoice: false. The operator overlay had no equivalent recorder at all, so suppressing its echo the same way would have gone silent instead of de-duplicated — it now records its own decision (option picked, typed answer, or cancel) the same way. Esc/deny on both overlays previously wrote nothing; it now records the decision too, so every terminal path leaves exactly one row.
1 parent da6f2ce commit 96aa968

5 files changed

Lines changed: 171 additions & 12 deletions

File tree

src/tui-opentui/gate-wire.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,53 @@ describe("each gate decision appends exactly one transcript row", () => {
552552
}
553553
})
554554
})
555+
556+
test("permission auto-deny on timeout", async () => {
557+
await withTestRenderer(async (h) => {
558+
const shell = createAppShell(h.renderer, {
559+
terminal: { columns: 80, rows: 24 },
560+
run: "idle",
561+
})
562+
const emitter = new EventEmitter()
563+
try {
564+
wireGates(emitter, shell)
565+
const before = shell.streamLog.length
566+
emitter.emit("permission.gate", {
567+
request: baseRequest(),
568+
resolve: () => {},
569+
timeoutMs: 5,
570+
})
571+
await new Promise((r) => setTimeout(r, 20))
572+
expect(shell.streamLog.length - before).toBe(1)
573+
} finally {
574+
shell.dispose()
575+
}
576+
})
577+
})
578+
579+
test("permission auto-deny on abort", async () => {
580+
await withTestRenderer(async (h) => {
581+
const shell = createAppShell(h.renderer, {
582+
terminal: { columns: 80, rows: 24 },
583+
run: "idle",
584+
})
585+
const emitter = new EventEmitter()
586+
const controller = new AbortController()
587+
try {
588+
wireGates(emitter, shell)
589+
const before = shell.streamLog.length
590+
emitter.emit("permission.gate", {
591+
request: baseRequest(),
592+
resolve: () => {},
593+
signal: controller.signal,
594+
})
595+
controller.abort()
596+
expect(shell.streamLog.length - before).toBe(1)
597+
} finally {
598+
shell.dispose()
599+
}
600+
})
601+
})
555602
})
556603

557604
describe("permission.gate auto-deny", () => {

src/tui-opentui/gate-wire.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,21 @@ function recordDecision(
227227
appendStreamRow(shell, { role: "system", text, meta: "permission" })
228228
}
229229

230+
/**
231+
* Write the operator's question and answer to the transcript, once decided.
232+
* Mirrors recordDecision: the overlay already shows this text while it is
233+
* open, so an immediate echo would print every operator question twice.
234+
*/
235+
function recordOperatorDecision(
236+
shell: AppShell,
237+
question: string,
238+
label: string,
239+
): void {
240+
const body = middleEllipsis(question, 500)
241+
const text = `${body}\n→ ${label}`
242+
appendStreamRow(shell, { role: "system", text, meta: "operator" })
243+
}
244+
230245
/**
231246
* Subscribe the permission/operator gate events to the shell's overlays.
232247
* Returns a dispose function that removes exactly the listeners this call added.
@@ -289,6 +304,10 @@ export function wireGates(
289304
items: choices.items,
290305
itemIds: choices.itemIds,
291306
body: collapsedBody,
307+
// recordDecision below is the authoritative transcript row for every
308+
// terminal path — the overlay's own accept/answer echo would
309+
// duplicate it.
310+
echoChoice: false,
292311
...(collapsedAnything ? { onToggleExpand } : {}),
293312
onAccept: (sel: OverlaySelection) => {
294313
if (settled) return
@@ -307,12 +326,9 @@ export function wireGates(
307326
if (settled) return
308327
settled = true
309328
clearTimers()
310-
ev.resolve(
311-
approvalOutcomeFromSelection(choices, {
312-
index: 0,
313-
id: PERMISSION_DENY_ID,
314-
}),
315-
)
329+
const gateSelection = { index: 0, id: PERMISSION_DENY_ID }
330+
recordDecision(shell, ev.request, choices, gateSelection)
331+
ev.resolve(approvalOutcomeFromSelection(choices, gateSelection))
316332
},
317333
})
318334
}
@@ -332,6 +348,10 @@ export function wireGates(
332348
if (settled) return
333349
settled = true
334350
clearTimers()
351+
recordDecision(shell, ev.request, choices, {
352+
index: 0,
353+
id: PERMISSION_DENY_ID,
354+
})
335355
if (isOpen) {
336356
closeInsetOverlay(shell)
337357
} else {
@@ -368,9 +388,14 @@ export function wireGates(
368388
body: ev.question,
369389
choices: choices.items,
370390
itemIds: choices.itemIds,
391+
// recordOperatorDecision below is the authoritative transcript row for
392+
// every terminal path — the overlay's own accept/answer echo would
393+
// duplicate it.
394+
echoChoice: false,
371395
onAccept: (sel: OverlaySelection) => {
372396
if (settled) return
373397
settled = true
398+
recordOperatorDecision(shell, ev.question, sel.label)
374399
ev.resolve(
375400
operatorResultFromSelection(ev.options, {
376401
index: sel.index,
@@ -383,13 +408,15 @@ export function wireGates(
383408
onTextAnswer: (text: string) => {
384409
if (settled) return
385410
settled = true
411+
recordOperatorDecision(shell, ev.question, text)
386412
ev.resolve(operatorCustomResult(text))
387413
},
388414
// Esc must settle the awaited promise (as a cancel), not abandon it —
389415
// an unresolved gate hangs the run until the process is killed.
390416
onCancel: () => {
391417
if (settled) return
392418
settled = true
419+
recordOperatorDecision(shell, ev.question, "Cancelled")
393420
ev.resolve(operatorCancelResult())
394421
},
395422
}))

src/tui-opentui/overlays.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,68 @@ describe("accept echo reads the chosen value structurally", () => {
478478
)
479479
})
480480
})
481+
482+
describe("echoChoice defaults to on for callers with no recorder of their own", () => {
483+
test("openPermissionsOverlay with no echoChoice opt still echoes on accept", async () => {
484+
await withTestRenderer(
485+
async (h) => {
486+
const shell = createAppShell(h.renderer, {
487+
terminal: { columns: 80, rows: 24 },
488+
wireKeys: false,
489+
})
490+
try {
491+
openPermissionsOverlay(shell, { items: makePermissionItems(3) })
492+
const before = shell.streamLog.length
493+
acceptOverlaySelection(shell)
494+
expect(shell.streamLog.length - before).toBe(1)
495+
} finally {
496+
shell.dispose()
497+
}
498+
},
499+
{ width: 80, height: 24 },
500+
)
501+
})
502+
503+
test("openPermissionsOverlay with echoChoice: false suppresses it", async () => {
504+
await withTestRenderer(
505+
async (h) => {
506+
const shell = createAppShell(h.renderer, {
507+
terminal: { columns: 80, rows: 24 },
508+
wireKeys: false,
509+
})
510+
try {
511+
openPermissionsOverlay(shell, {
512+
items: makePermissionItems(3),
513+
echoChoice: false,
514+
})
515+
const before = shell.streamLog.length
516+
acceptOverlaySelection(shell)
517+
expect(shell.streamLog.length - before).toBe(0)
518+
} finally {
519+
shell.dispose()
520+
}
521+
},
522+
{ width: 80, height: 24 },
523+
)
524+
})
525+
526+
test("openOperatorOverlay with no echoChoice opt still echoes on accept", async () => {
527+
await withTestRenderer(
528+
async (h) => {
529+
const shell = createAppShell(h.renderer, {
530+
terminal: { columns: 80, rows: 24 },
531+
wireKeys: false,
532+
})
533+
try {
534+
openOperatorOverlay(shell, { choices: ["A", "B"] })
535+
const before = shell.streamLog.length
536+
acceptOverlaySelection(shell)
537+
expect(shell.streamLog.length - before).toBe(1)
538+
} finally {
539+
shell.dispose()
540+
}
541+
},
542+
{ width: 80, height: 24 },
543+
)
544+
})
545+
})

src/tui-opentui/overlays.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ export type OpenPermissionsOpts = {
9191
readonly onToggleExpand?: () => void
9292
/** Per-open Esc/dismiss; host binds resolve(ApprovalOutcome) so Esc denies instead of hanging. */
9393
readonly onCancel?: () => void
94+
/**
95+
* Suppress the generic accept/answer echo for this open. Callers that
96+
* record their own authoritative decision row (e.g. gate-wire's
97+
* recordDecision) pass `false` so the generic echo does not duplicate it;
98+
* callers with no such recorder (e.g. the standalone demo) get the default
99+
* echo so their choice still leaves a trace.
100+
*/
101+
readonly echoChoice?: boolean
94102
}
95103

96104
export function openPermissionsOverlay(
@@ -111,6 +119,7 @@ export function openPermissionsOverlay(
111119
: {}),
112120
...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}),
113121
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
122+
...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}),
114123
})
115124
}
116125

@@ -125,6 +134,14 @@ export type OpenOperatorOpts = {
125134
readonly onTextAnswer?: (text: string) => void
126135
/** Per-open Esc/dismiss; host binds resolve(cancel) so Esc cancels instead of hanging. */
127136
readonly onCancel?: () => void
137+
/**
138+
* Suppress the generic accept/answer echo for this open. Callers that
139+
* record their own authoritative decision row (e.g. gate-wire's
140+
* recordOperatorDecision) pass `false` so the generic echo does not
141+
* duplicate it; callers with no such recorder (e.g. the standalone demo)
142+
* get the default echo so their choice still leaves a trace.
143+
*/
144+
readonly echoChoice?: boolean
128145
}
129146

130147
/**
@@ -156,6 +173,7 @@ export function openOperatorOverlay(
156173
? { onTextAnswer: opts.onTextAnswer }
157174
: {}),
158175
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
176+
...(opts?.echoChoice !== undefined ? { echoChoice: opts.echoChoice } : {}),
159177
})
160178
}
161179

src/tui-opentui/shell.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3434,14 +3434,16 @@ export function handleOverlayAnswerKey(
34343434
if (answer.text.length === 0) return true
34353435
const text = answer.text
34363436
const submit = answer.onSubmit
3437-
appendStreamRow(shell, {
3438-
role: "system",
3439-
text: `answered: ${text}`,
3440-
meta: overlayKindWord(shell.overlayKind ?? "operator"),
3441-
})
3437+
const bag = internals.get(shell)
3438+
if (bag?.overlayEchoChoice !== false) {
3439+
appendStreamRow(shell, {
3440+
role: "system",
3441+
text: `answered: ${text}`,
3442+
meta: overlayKindWord(shell.overlayKind ?? "operator"),
3443+
})
3444+
}
34423445
// Deliberate submit, not a dismiss — closeInsetOverlay must not also fire
34433446
// the Esc/cancel path.
3444-
const bag = internals.get(shell)
34453447
if (bag) bag.overlayOnCancel = null
34463448
closeInsetOverlay(shell)
34473449
submit(text)

0 commit comments

Comments
 (0)