Skip to content

fix(tui): land deferred shell-mode output in the transcript - #4039

Merged
probepark merged 3 commits into
Yeachan-Heo:devfrom
probepark:pr/tui-deferred-shell-output
Aug 9, 2026
Merged

fix(tui): land deferred shell-mode output in the transcript#4039
probepark merged 3 commits into
Yeachan-Heo:devfrom
probepark:pr/tui-deferred-shell-output

Conversation

@probepark

Copy link
Copy Markdown
Collaborator

Scoped replacement for the shell-mode portion of the closed #4021. One commit, 5 files.

Fixes #3639.

The problem

A !cmd typed while the agent is Working runs, but its execution block flashes and disappears: no command header, no output, no exit status. The same !pwd typed 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.handleBashCommand parks the component in pendingMessagesContainer + pendingBashComponents while streaming, and on completion calls pendingMessagesContainer.detachChild(bashComponent) and never adds it to chatContainer. It stays in pendingBashComponents, which is a plain array, not a rendered container — so it has no parent and is never drawn. For a fast command like pwd that happens within milliseconds of submit, which is why nothing is visible. handlePythonCommand has 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, and InputController returns 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 with pendingMessagesContainer.clear(), and Container.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. flushPendingBashComponents explicitly 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/src from origin/dev and re-running:

with fix:     4 pass / 5 fail  →  34 pass / 0 fail
bash-command + input-controller-skill-queue + render-initial-messages-dedupe   34 pass / 0 fail
bun --cwd=packages/coding-agent run check                                      exit 0

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

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
@probepark

Copy link
Copy Markdown
Collaborator Author

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

packages/coding-agent/src/modes/controllers/command-controller.ts:1186-1193:

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);
}

indexOf(...) !== -1 proves the component is in the tracking array. It does not prove it is still a child of pendingMessagesContainer. Those two facts diverge, because several production call sites clear the container without resetting the array:

command-controller.ts:993     (/clear → #runNewSessionFlow)   chatContainer.clear(); pendingMessagesContainer.clear();  ← no pendingBashComponents reset
command-controller.ts:1033                                     same shape
extension-ui-controller.ts:679, :993                           same shape
selector-controller.ts:2681                                    same shape

So: start a long !cmd mid-turn, run /clear, let the command finish. The component was disposed and evicted by clear(), the array still lists it, the guard passes, and a dead block is re-parented into the fresh transcript it was never part of.

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 be

The 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 Container.clear() disposes, a disposed component must never be re-parented at all, so the completion path also needs to treat "disposed" as a terminal state rather than something recoverable.

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 /clear while a deferred block is in flight.

…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
@probepark

Copy link
Copy Markdown
Collaborator Author

Blocker fixed in e9b8a34eb: the completion path asks the container for live ownership (non-disposing query on Container), disposed is terminal, and Container.clear() semantics are unchanged for every other caller. Regression runs /clear during an in-flight deferred command. 406 pass / 0 fail across test/modes; 10 pass/6 fail without src.

@probepark
probepark requested a review from Yeachan-Heo August 8, 2026 19:09
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
@probepark

Copy link
Copy Markdown
Collaborator Author

Local codex-pro review gate: APPROVE, no blockers, after 1027a89c0.

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 test/modes; 17 pass / 1 fail with src stashed; check exit 0 for both coding-agent and tui.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant