Skip to content

Commit 5fd36a3

Browse files
committed
Stop restating settled permission prompts in the chat
1 parent 619a2b7 commit 5fd36a3

6 files changed

Lines changed: 112 additions & 226 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### TUI
17+
18+
- **Settled permission and operator prompts no longer recap into the chat.**
19+
The overlay is the question; answering it used to leave a grey
20+
`permission` / `operator` card restating the same command and the chosen
21+
option. After a decision those recap rows are gone — the tool row that
22+
follows is the outcome. Expanding a collapsed payload while the overlay is
23+
still open still writes the full payload into the transcript, because that
24+
text would otherwise be unreachable before approval.
25+
1426
## [0.2.98] - 2026-08-17
1527

1628
Corrupt resume state no longer kills sessions, Codex quota errors name the

docs/TUI.md

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -138,28 +138,20 @@ removed line), not a decision marker, and no decision-marker shares that row.
138138

139139
## The live task list panel
140140

141-
The `task` chrome zone renders a standing panel in the bottom chrome above the
142-
prompt, one row per open task the task tool has written (`manage_tasks`) —
143-
distinct from live sub-agent progress. A task is a unit of work with a status;
144-
an agent is an executor with its own context and transcript. They are never
145-
merged into one panel: `formatTasksPanel` (`src/tui/chrome-state.ts`) feeds the
146-
checklist zone; live workers paint as `● Task …` transcript rows (see below).
147-
148-
**One live surface at a time.** While any sub-agent session is `running`,
149-
`formatChromeZones` suppresses the task checklist and keeps the agents zone
150-
empty — the same work must not stand as a FLEET board *and* a checklist *and*
151-
live Task rows. When no lane is running, the checklist returns for open work
152-
(if the operator has opted in with Alt+T). A list that is only done/cancelled
153-
collapses to null (no permanent wall of `[x]` rows); while open work remains,
154-
recently-done rows trail so the operator can see items flip complete without a
155-
second status log.
156-
157-
Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done,
158-
`[-]` cancelled) ahead of the title. Open work is listed first. The panel is
159-
bounded to `TASKS_PANEL_MAX_VISIBLE` rows: a longer list degrades to a trailing
160-
`+N more` row rather than growing the zone without limit, and it shrinks one
161-
row at a time under space pressure (`COLLAPSE_ORDER` in `geometry/zones.ts`)
162-
rather than vanishing in one step.
141+
**Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`)
142+
always returns `{ task: null, agents: null }` — neither the checklist strip nor
143+
the agents/fleet board auto-paints. Live work stays on transcript `● Task …`
144+
rows (see below). `formatTasksPanel` / `formatAgentsPanel` remain for a future
145+
rebuild; demos and shell tests may still feed preformatted rows via
146+
`setChromeZones` directly, and Alt+T (`toggleTasksPanel`) still toggles the
147+
shell's hidden flag for those manual paints.
148+
149+
A task is a unit of work with a status; an agent is an executor with its own
150+
context and transcript. They are never merged into one panel. When the
151+
checklist strip is rebuilt, each row will show a bracket status marker (`[ ]`
152+
todo, `[~]` doing, `[x]` done, `[-]` cancelled) ahead of the title, bounded to
153+
`TASKS_PANEL_MAX_VISIBLE` with a trailing `+N more` under overflow, and
154+
shrinking via `COLLAPSE_ORDER` in `geometry/zones.ts`.
163155

164156
Two independent mechanisms keep the task panel from ever costing the prompt
165157
box a row on a short terminal, and they guarantee different things.
@@ -174,20 +166,17 @@ mechanism substitutes for the other: the cap bounds the prompt's own growth
174166
on any terminal, tall or short; the collapse order bounds what other zones
175167
are allowed to take from it once the transcript floor is at risk.
176168

177-
The panel is **hidden by default** (CL-5847): a fresh shell does not paint the
178-
checklist even when `manage_tasks` has open work. `toggleTasksPanel` (bound to
179-
Alt+T) opts in for the shell's lifetime — it flips a hidden flag held on the
180-
shell in memory only, nothing written to storage — while the live task list
181-
keeps updating underneath it. Un-hiding shows the current list, not a stale
182-
snapshot from before the hide. Hidden or empty, the zone costs zero rows. The
183-
default is opt-in because the checklist's chrome owns too much of the screen to
184-
force into view; the operator toggles it on when they want it, and live Task
185-
rows still win while a fleet is running.
169+
The panel stays **hidden by default** (CL-5847): a fresh shell does not paint
170+
the checklist. `toggleTasksPanel` (bound to Alt+T) opts in for the shell's
171+
lifetime — it flips a hidden flag held on the shell in memory only — so demos
172+
and tests that call `setChromeZones` with task rows can still show them.
173+
Because `formatChromeZones` parks auto-paint, Alt+T alone does not surface a
174+
live `manage_tasks` list today.
186175

187176
The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`),
188177
which calls `onTasksChange` on every `manage_tasks` tool call and on session
189-
hydrate. `manage_tasks` calls paint no transcript rows — the checklist is the
190-
only surface for that list.
178+
hydrate. `manage_tasks` calls paint no transcript rows; with chrome strips
179+
parked, that list has no standing chrome surface until rebuild.
191180

192181
## Live sub-agent rows (Task tool)
193182

@@ -202,8 +191,10 @@ operator-preferred Amp/Codex-style lines:
202191
`runtime-bridge` paints each `task` call as a stream row and rewrites it in
203192
place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool,
204193
stall marker). There is no standing FLEET board and no dual-rail agents chrome:
205-
`formatChromeZones` always returns `agents: null`, and geometry is stack-only
206-
(`layoutMode: "stack"`, `railWidth: 0`).
194+
`formatChromeZones` always returns both zones null (`task` and `agents`), and
195+
geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and
196+
agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may
197+
still paint for demos and tests.
207198

208199
### Unprompted fleet reports
209200

@@ -251,6 +242,16 @@ because an earlier version could abandon the awaited promise on Escape and
251242
leave the session parked with no recovery path short of killing the process;
252243
Escape must always settle the promise it is dismissing.
253244

245+
Once a permission or operator prompt is answered — or cancelled, timed out,
246+
or auto-settled by a grant / abort / teardown — it leaves the screen and
247+
does **not** replay the request, the command, or the chosen option into the
248+
transcript. The overlay is the question; the tool row that follows is the
249+
outcome. Grey `permission` / `operator` recap cards restated the same ask
250+
after it was already decided. Expanding a collapsed payload while the
251+
overlay is open still writes the full payload into the scrollable
252+
transcript, because that text would otherwise be unreachable before
253+
approval.
254+
254255
The decision surfaces (permission approval, operator question) are the one
255256
framed content in the shell, and they are shaped rather than merely listed
256257
(`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the

src/tui/gate-wire.test.ts

Lines changed: 35 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ describe("wireGates", () => {
384384
})
385385
})
386386

387-
test("gate content reaches the transcript only after the operator decides", async () => {
387+
test("gate decisions do not replay the request into the transcript", async () => {
388388
await withTestRenderer(async (h) => {
389389
const shell = createAppShell(h.renderer, {
390390
terminal: { columns: 96, rows: 30 },
@@ -401,20 +401,15 @@ describe("wireGates", () => {
401401
const dispose = wireGates(emitter, shell)
402402
emitter.emit("permission.gate", { request, resolve: () => {} })
403403

404-
// The overlay is showing this text; a transcript copy directly above it
405-
// reads as a second, unrelated request.
406404
expect(
407405
shell.streamLog.filter((r) => r.meta === "permission"),
408406
).toHaveLength(0)
409407

410408
acceptOverlaySelection(shell)
411409

412-
const recorded = shell.streamLog
413-
.filter((r) => r.meta === "permission")
414-
.map((r) => r.text)
415-
.join("\n")
416-
expect(recorded).toContain("ls -la ~/.corbits/projects")
417-
expect(recorded).toContain("Reject")
410+
expect(
411+
shell.streamLog.filter((r) => r.meta === "permission"),
412+
).toHaveLength(0)
418413

419414
dispose()
420415
} finally {
@@ -424,7 +419,7 @@ describe("wireGates", () => {
424419
})
425420
})
426421

427-
describe("each gate decision appends exactly one transcript row", () => {
422+
describe("gate decisions stay out of the transcript", () => {
428423
test("permission accept", async () => {
429424
await withTestRenderer(async (h) => {
430425
const shell = createAppShell(h.renderer, {
@@ -438,7 +433,7 @@ describe("each gate decision appends exactly one transcript row", () => {
438433

439434
const before = shell.streamLog.length
440435
acceptOverlaySelection(shell)
441-
expect(shell.streamLog.length - before).toBe(1)
436+
expect(shell.streamLog.length - before).toBe(0)
442437
} finally {
443438
shell.dispose()
444439
}
@@ -458,7 +453,7 @@ describe("each gate decision appends exactly one transcript row", () => {
458453

459454
const before = shell.streamLog.length
460455
closeInsetOverlay(shell)
461-
expect(shell.streamLog.length - before).toBe(1)
456+
expect(shell.streamLog.length - before).toBe(0)
462457
} finally {
463458
shell.dispose()
464459
}
@@ -482,7 +477,7 @@ describe("each gate decision appends exactly one transcript row", () => {
482477

483478
const before = shell.streamLog.length
484479
acceptOverlaySelection(shell)
485-
expect(shell.streamLog.length - before).toBe(1)
480+
expect(shell.streamLog.length - before).toBe(0)
486481
} finally {
487482
shell.dispose()
488483
}
@@ -506,7 +501,7 @@ describe("each gate decision appends exactly one transcript row", () => {
506501

507502
const before = shell.streamLog.length
508503
closeInsetOverlay(shell)
509-
expect(shell.streamLog.length - before).toBe(1)
504+
expect(shell.streamLog.length - before).toBe(0)
510505
} finally {
511506
shell.dispose()
512507
}
@@ -546,7 +541,7 @@ describe("each gate decision appends exactly one transcript row", () => {
546541
meta: false,
547542
option: false,
548543
} as unknown as KeyEvent)
549-
expect(shell.streamLog.length - before).toBe(1)
544+
expect(shell.streamLog.length - before).toBe(0)
550545
} finally {
551546
shell.dispose()
552547
}
@@ -569,7 +564,7 @@ describe("each gate decision appends exactly one transcript row", () => {
569564
timeoutMs: 5,
570565
})
571566
await new Promise((r) => setTimeout(r, 20))
572-
expect(shell.streamLog.length - before).toBe(1)
567+
expect(shell.streamLog.length - before).toBe(0)
573568
} finally {
574569
shell.dispose()
575570
}
@@ -593,20 +588,19 @@ describe("each gate decision appends exactly one transcript row", () => {
593588
signal: controller.signal,
594589
})
595590
controller.abort()
596-
expect(shell.streamLog.length - before).toBe(1)
591+
expect(shell.streamLog.length - before).toBe(0)
597592
} finally {
598593
shell.dispose()
599594
}
600595
})
601596
})
602597

603-
// The queue (settle-once guard) and the transcript recorder (record-once
604-
// per decision) are two independent mechanisms layered on the same set of
605-
// terminal paths. Racing a timeout against an abort on the same request
606-
// exercises both at once: clearTimers must retire the loser before it can
607-
// run autoDeny a second time, so ev.resolve fires exactly once and exactly
608-
// one row lands, no matter which trigger wins.
609-
test("a timeout and an abort racing the same request settle once and record once", async () => {
598+
// The queue's settle-once guard is independent of the transcript: racing a
599+
// timeout against an abort on the same request exercises that guard.
600+
// clearTimers must retire the loser before it can run autoDeny a second
601+
// time, so ev.resolve fires exactly once no matter which trigger wins, and
602+
// neither path writes a recap row.
603+
test("a timeout and an abort racing the same request settle once", async () => {
610604
await withTestRenderer(async (h) => {
611605
const shell = createAppShell(h.renderer, {
612606
terminal: { columns: 80, rows: 24 },
@@ -633,14 +627,14 @@ describe("each gate decision appends exactly one transcript row", () => {
633627
controller.abort()
634628

635629
expect(resolveCount).toBe(1)
636-
expect(shell.streamLog.length - before).toBe(1)
630+
expect(shell.streamLog.length - before).toBe(0)
637631
} finally {
638632
shell.dispose()
639633
}
640634
})
641635
})
642636

643-
test("a queued gate's timeout settles once and records once, only after it is displayed", async () => {
637+
test("a queued gate's timeout settles once, only after it is displayed", async () => {
644638
await withTestRenderer(async (h) => {
645639
const shell = createAppShell(h.renderer, {
646640
terminal: { columns: 80, rows: 24 },
@@ -673,7 +667,7 @@ describe("each gate decision appends exactly one transcript row", () => {
673667
await new Promise((r) => setTimeout(r, 20))
674668

675669
expect(resolveCount).toBe(1)
676-
expect(shell.streamLog.length - before).toBe(2) // first gate's row + the queued gate's timeout row
670+
expect(shell.streamLog.length - before).toBe(0) // first gate + queued timeout both silent
677671
} finally {
678672
shell.dispose()
679673
}
@@ -682,8 +676,8 @@ describe("each gate decision appends exactly one transcript row", () => {
682676

683677
// reconcile() (src/permission/queue.ts) settles a queued request directly
684678
// when a grant covers it, with no accept/cancel/autoDeny callback of its
685-
// own to hang a row on — this is the one terminal path that has no natural
686-
// call site, so it needs its own coverage.
679+
// own. Coverage here is that the queued request still resolves, without
680+
// ever opening and without writing a recap row.
687681
test("a grant draining a queued request without ever displaying it", async () => {
688682
await withTestRenderer(async (h) => {
689683
const shell = createAppShell(h.renderer, {
@@ -717,17 +711,14 @@ describe("each gate decision appends exactly one transcript row", () => {
717711

718712
expect(resolveCount).toBe(1)
719713
expect(resolved).toEqual({ allow: true })
720-
expect(shell.streamLog.length - before).toBe(1)
721-
expect(shell.streamLog.at(-1)?.text).toContain(
722-
"Auto-approved (already granted)",
723-
)
714+
expect(shell.streamLog.length - before).toBe(0)
724715
} finally {
725716
shell.dispose()
726717
}
727718
})
728719
})
729720

730-
test("a grant draining the currently displayed request closes it and records once", async () => {
721+
test("a grant draining the currently displayed request closes it without a recap", async () => {
731722
await withTestRenderer(async (h) => {
732723
const shell = createAppShell(h.renderer, {
733724
terminal: { columns: 80, rows: 24 },
@@ -753,10 +744,7 @@ describe("each gate decision appends exactly one transcript row", () => {
753744

754745
expect(resolveCount).toBe(1)
755746
expect(shell.overlayList).toBeNull()
756-
expect(shell.streamLog.length - before).toBe(1)
757-
expect(shell.streamLog.at(-1)?.text).toContain(
758-
"Auto-approved (already granted)",
759-
)
747+
expect(shell.streamLog.length - before).toBe(0)
760748
} finally {
761749
shell.dispose()
762750
}
@@ -765,19 +753,18 @@ describe("each gate decision appends exactly one transcript row", () => {
765753

766754
// drain() (src/permission/queue.ts) denies whatever is still queued on
767755
// teardown — the same no-call-site path as a grant drain, but the
768-
// opposite outcome. Mislabeling this "Auto-approved" would tell the
769-
// operator a request ran when it was actually dropped unanswered.
770-
test("disposing with a request still queued records it as denied, not approved", async () => {
756+
// opposite outcome. Coverage is the deny itself; neither path writes a
757+
// recap row.
758+
test("disposing with a request still queued denies it without a recap", async () => {
771759
await withTestRenderer(async (h) => {
772760
const shell = createAppShell(h.renderer, {
773761
terminal: { columns: 80, rows: 24 },
774762
run: "idle",
775763
})
776764
const emitter = new EventEmitter()
777765
// The currently-open request has no accept/cancel/autoDeny call site
778-
// triggered before teardown either, so dispose must record it too —
779-
// both entries go through the same no-call-site fallback as the
780-
// queued one.
766+
// triggered before teardown either, so dispose must settle it too —
767+
// both entries go through drain() without writing a recap.
781768
let openResolveCount = 0
782769
let queuedResolveCount = 0
783770
let queuedResolved: unknown
@@ -804,11 +791,7 @@ describe("each gate decision appends exactly one transcript row", () => {
804791
expect(openResolveCount).toBe(1)
805792
expect(queuedResolveCount).toBe(1)
806793
expect(queuedResolved).toEqual({ allow: false })
807-
expect(shell.streamLog.length - before).toBe(2)
808-
for (const row of shell.streamLog.slice(-2)) {
809-
expect(row.text).toContain("Denied (session ended)")
810-
expect(row.text).not.toContain("Auto-approved")
811-
}
794+
expect(shell.streamLog.length - before).toBe(0)
812795
shell.dispose()
813796
})
814797
})
@@ -1148,7 +1131,7 @@ describe("operator.gate auto-cancel", () => {
11481131
})
11491132
})
11501133

1151-
test("each terminal path writes exactly one transcript row", async () => {
1134+
test("each terminal path settles without writing a transcript row", async () => {
11521135
await withTestRenderer(async (h) => {
11531136
const shell = createAppShell(h.renderer, {
11541137
terminal: { columns: 80, rows: 24 },
@@ -1165,7 +1148,7 @@ describe("operator.gate auto-cancel", () => {
11651148
timeoutMs: 5,
11661149
})
11671150
await new Promise((r) => setTimeout(r, 20))
1168-
expect(shell.streamLog.length - before).toBe(1)
1151+
expect(shell.streamLog.length - before).toBe(0)
11691152
} finally {
11701153
shell.dispose()
11711154
}
@@ -1208,10 +1191,7 @@ describe("operator.gate auto-cancel", () => {
12081191

12091192
expect(openResolved).toEqual(operatorCancelResult())
12101193
expect(queuedResolved).toEqual(operatorCancelResult())
1211-
expect(shell.streamLog.length - before).toBe(2)
1212-
for (const row of shell.streamLog.slice(-2)) {
1213-
expect(row.text).toContain("Cancelled (session ended)")
1214-
}
1194+
expect(shell.streamLog.length - before).toBe(0)
12151195
shell.dispose()
12161196
})
12171197
})

0 commit comments

Comments
 (0)