fix(tui): land deferred shell-mode output in the transcript - #4039
Conversation
A `!cmd` typed while the agent was Working ran, then vanished: completion detached the block from the pending container without ever re-parenting it, so it lived on in a plain array with no parent and was never drawn. Ordinary mid-turn events made it worse — `updatePendingMessagesDisplay()` and the transcript rebuild both called `Container.clear()`, which disposes children, tearing down a still-streaming block and dropping its buffered output. Completion now moves the block into the chat transcript, and both rebuild paths detach-and-reattach parked execution components instead of disposing them. `Container.clear()` keeps its disposing contract; the retention lives in a private helper on the coding-agent side. Lore-id: 5b81e40c Constraint: Container.clear() must keep disposing children -- other callers depend on it Constraint: a running block must stay parented while it streams, not be flushed early into chat Rejected: flush parked components on the streaming submit path | an in-flight block would jump into the transcript before it finished Rejected: change clear() to detach instead of dispose | silently leaks every other container's children Confidence: high Scope-risk: narrow Reversibility: easy Tested: `!` submitted mid-turn is parented while streaming and lands in the transcript on completion Tested: pending-queue refresh and transcript rebuild no longer dispose a running execution block Not-tested: interactive TUI smoke on a real terminal
|
Self-review: REQUEST_CHANGES on my own PR. A red-team pass found that my guard checks the wrong thing, and I verified it in source. Blocker: the guard tests ARRAY MEMBERSHIP, not PARENTAGE
const parkedIndex = this.ctx.pendingBashComponents.indexOf(bashComponent);
// "A parked component is only ours to move while it is still parked"
if (parkedIndex !== -1) {
this.ctx.pendingBashComponents.splice(parkedIndex, 1);
this.ctx.pendingMessagesContainer.detachChild(bashComponent);
addChatChild(this.ctx, bashComponent);
}
So: start a long This is my own comment claiming an invariant the code does not establish — exactly the kind of thing I said in the PR body I had covered. What the fix has to beThe check must be parentage, not bookkeeping: ask the container whether it still owns the child (or have the clear paths keep the array and the container in sync — one of the two, not both half-done). Given The reviewer additionally flagged ordering and double-render concerns downstream of the same confusion; I am re-checking those once parentage is authoritative. Not merging until that is in with a regression test that runs |
…wns it The completion guard checked array membership, not parentage. Several clearing paths (command-controller /clear flows, extension-ui, selector) clear pendingMessagesContainer without resetting pendingBashComponents, so a deferred command finishing after /clear passed the stale index check and a DISPOSED component was re-parented into a transcript it was never part of. The container now answers ownership itself: a non-disposing liveness query on Container reports whether a child is still live under it, and the completion path treats disposed as terminal. Container.clear() keeps its disposing contract for every other caller. Lore-id: 6a1c8f35 Constraint: Container.clear() semantics unchanged for existing callers Constraint: a disposed component is never re-parented Rejected: syncing the array at every clear site | five call sites today, the sixth would miss it the same way Confidence: high Scope-risk: narrow Reversibility: easy Tested: /clear during an in-flight deferred command re-parents nothing and does not crash Tested: a deferred command completing normally still lands exactly once Tested: a pending-queue refresh still does not dispose a running block Not-tested: a live TUI session under manual /clear stress
|
Blocker fixed in |
Rebuild reconciliation matched parked execution components by command text and occurrence count, so at the history cap an older persisted execution and a currently running one sharing the same command collapsed into each other: the live block could be dropped, or a finished one revived. Components are now reconciled by their own identity, which the rebuild already has, so identical command text is no longer load-bearing. Lore-id: 5a9d3f28 Constraint: a disposed component is still never re-parented Constraint: Container.clear() semantics unchanged for every other caller Rejected: hashing command text plus timestamp | two executions can legitimately share both Confidence: high Scope-risk: narrow Reversibility: easy Tested: identical commands across the history cap stay distinct through a rebuild Tested: /clear during an in-flight deferred command still re-parents nothing Tested: a completed deferred command still lands exactly once Not-tested: a live TUI under sustained rebuild pressure
|
Local codex-pro review gate: APPROVE, no blockers, after Rebuild reconciliation matched parked execution components by command text and occurrence count, so at the history cap an older persisted execution and a currently running one sharing the same command collapsed into each other — the live block could be dropped, or a finished one revived. Components are reconciled by their own identity now; identical command text is no longer load-bearing. 408 pass / 0 fail across |
Scoped replacement for the shell-mode portion of the closed #4021. One commit, 5 files.
Fixes #3639.
The problem
A
!cmdtyped while the agent is Working runs, but its execution block flashes and disappears: no command header, no output, no exit status. The same!pwdtyped while idle renders fine.$(python/eval) commands typed mid-turn stay visible — only!disappears.Mechanism
Three defects on the deferred branch:
1. Completion detached the block without ever re-parenting it.
CommandController.handleBashCommandparks the component inpendingMessagesContainer+pendingBashComponentswhile streaming, and on completion callspendingMessagesContainer.detachChild(bashComponent)and never adds it tochatContainer. It stays inpendingBashComponents, which is a plain array, not a rendered container — so it has no parent and is never drawn. For a fast command likepwdthat happens within milliseconds of submit, which is why nothing is visible.handlePythonCommandhas no equivalent detach, which is exactly why$behaves and!does not.2. The only flush ran on the next non-streaming submit.
flushPendingBashComponents()is the only place that moves parked components into the transcript, andInputControllerreturns early on the streaming path before reaching it — so output stayed invisible until the user sent another prompt after the turn ended.3. The pending bar disposed live components mid-turn.
updatePendingMessagesDisplay()starts withpendingMessagesContainer.clear(), andContainer.clear()disposes its children. That method has ~25 callsites and fires on ordinary mid-turn events (queued/dequeued user messages), so a still-streaming!command was torn down mid-flight, killing its loader and dropping buffered output.flushPendingBashComponentsexplicitly documents that these components must be detached rather than disposed;clear()violated that.renderInitialMessages()also cleared both the container and the array, permanently dropping anything parked if a transcript rebuild happened before the flush.The fix
Completion moves the block into the chat transcript, and both the pending-refresh and transcript-rebuild paths detach-and-reattach parked execution components instead of disposing them.
Container.clear()keeps its disposing contract — other callers depend on it — so the retention lives in a private helper on the coding-agent side.Verification
Load-bearing proof — restoring
packages/coding-agent/srcfromorigin/devand re-running:Coverage:
!submitted mid-turn is parented and visible during streaming and lands in the transcript on completion;$mid-turn keeps working; a pending-queue refresh during an in-flight!neither disposes it nor drops its output; a transcript rebuild does not lose it; and a completed deferred command appears exactly once.Relationship to #4021
#4021 bundled twelve unrelated defects into 53 files and was closed with the instruction to open fresh, scoped PRs. This is one of those, alongside #4031, #4033, #4035, #4036, #4037 and #4038.
Closes #3639