Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
T
Chinesezjc f378873b22 fix(web): replay cursor movements the way a terminal paints them
Carriage return and backspace only MOVE the cursor; neither erases. Both of
my earlier approximations were wrong, and I checked each case against a real
terminal rather than reasoning about it:

`100%\rOK` shows `OK0%`, not `OK` — the redraw is shorter than the frame
beneath it, so the tail stands. `abc\b` still shows `abc`, not `ab` — a
trailing backspace has nothing to overwrite. `\x1b[31mgone\rkept` paints
`kept` RED, because a carriage return does not reset the graphic state, which
one of my own tests had asserted the opposite of.

Both now replay into a per-line column buffer with SGR state stamped per
column, as a terminal stores it per cell. That gives the partial-overwrite
case its real result too: red `bad`, three backspaces, then `ok` shows `okd`
with the `d` still red, since `ok` reached only two of the three cells.

The presenter description now also renders at every site. An expanded row
draws it itself — the collapsed summary is hidden while open, so otherwise
the description was visible only collapsed, the opposite of "above the card"
— and the details panel draws it above the card as well.

Three of my own tests encoded the wrong semantics and were corrected with
their behavior, and the emit loop's gap-filling arm was removed as
unreachable: `\r` and backspace only move left, so no column can be unwritten.
2026-07-29 14:39:21 +08:00

18 KiB

Agent Note: Web terminal card — the bash render intent reaches the browser

Status: implemented

English | 中文

Problem

The bash tool declares card: 'terminal' for both its call and its result (render-intent union): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto ConversationSnapshot as callView/resultView — and the TUI already renders it as a $-prompt card with an exit line and a head/tail height cap.

The Web client ignored it. packages/client/ui-conversation/src/client/contract/tool-call-model.ts derived every row from raw tool args, and skeleton/DetailsPanel.tsx flattened every tool's content blocks into one <pre> with white-space: pre-wrap; word-break: break-word. Two defects followed from soft-wrapping and from having no height bound: multi-column output (ls, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.

Decision

TerminalBlock is a ui-primitives component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. ui-conversation/src/client/contract/terminal-card-model.ts is the single place that turns the snapshot's callView/resultView pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares card: 'terminal', including a card value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's title REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no workdir. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare $ rather than naming a directory it cannot know. The resolved path also normalizes its ./.. segments, because the bash executor resolves the workdir before running: a .. against /w/app runs in /w, so the prompt label has to read w rather than ... A UNC path's server and share are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's description rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.

The component's contract:

  • Prompt lines, one per command line. Each line of the command gets its own row: label, then that line verbatim. A command carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or ~ when the cwd equals the home prop — a browser has no $HOME, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain $. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a cd in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare $ so they still read as prompts.
  • One run-state dot for the call, on the first row. StateDot in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since StateDot is aria-hidden and one label per row would read to assistive technology as several distinct outcomes.
  • No soft wrapping. Output lines are white-space: pre inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
  • Height cap with an expand control. Output longer than DEFAULT_TERMINAL_MAX_LINES (16) lines shows ceil(max/2) head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (packages/ui/tui/src/components/transcript.ts), so one command's head and tail slices agree between the two front ends.
  • ANSI color. anser splits the SGR runs; ui-primitives/src/ansi.ts resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto --dsw-* theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. 100% then a carriage return and OK shows OK0%, since the redraw is shorter than the frame beneath it; a trailing abc plus a backspace still shows abc, since nothing overwrote the c; abc plus two backspaces and XY shows aXY. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red bad, three backspaces, then ok shows okd with the d still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color.
  • Exit status and copy. A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.

Geometry, radius, and fonts mirror CodeBlock, so a terminal card and a fenced code block match visually; white-space: pre plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of CodeBlock into a package-internal src/clipboard.ts, unexported so it stays an implementation detail of the two blocks.

Inline output in the chat row reverses a stated convention

chat/ToolRow.tsx and contract/tool-call-model.ts asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.

The reason the reversal holds: for a shell command the output is the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.

The remaining bound: the row caps at CHAT_TERMINAL_MAX_LINES (8), half the primitive's default, which the panel keeps — the message flow is a summary surface read across many calls, the panel is the single-call reading surface. Only the terminal intent renders inline; a generic tool's content is still panel-only.

One premise of that split has since weakened: tool rows stopped being details-panel click targets and nothing replaced the gesture, so the panel is currently unreachable in the assembled application. The in-row cap is therefore the only surface a reader actually has for a long output, which the expand control covers. Restoring a panel entry point is that change's follow-up, not this one's — but until it lands, "the panel stays the place for the full output" is not true, and the row's expand control carries that load alone.

Alternatives considered

Render the terminal block only in the details panel. This keeps the stated no-inline-output convention and needs no reversal to record. Rejected by the owner's explicit decision: a shell command's output is what the user came to read, and putting it one click away costs more than the convention buys. Recorded here as the owner's call, not as a conclusion derived from the codebase.

Reuse CodeBlock with a console language instead of a new primitive. Rejected: CodeBlock soft-wraps, which is the defect being fixed, and it has no exit status, no cwd prompt line, no height cap, and no ANSI handling. Adding four terminal-specific concerns to the shared code-fence component would impose them on every markdown fence. The two components share their geometry and font tokens instead, which is the only part where one implementation is correct for both.

Hand-roll the SGR parser. Rejected: an SGR parser is exactly the surface prefer maintained dependencies over hand-rolling says not to own — its edge cases (256-palette and truecolor forms, reverse, multi-parameter runs, unterminated sequences) each fail on output nobody produces in a test, so a hand-rolled version stays subtly wrong for a long time. Stated honestly against that policy's bar: anser does not delete existing owned code. It is a capability addition, which that note distinguishes from a net-deletion simplification; the health and boundary-fit halves of the bar are what it clears. What stays hand-rolled is the part anser does not cover: the theme-token color mapping, the non-CSI sanitizing, the carriage-return redraw, and the per-line span folding the height cap slices.

Consequences

anser is a new runtime dependency of packages/client/ui-primitives, so every consumer of that package pays for it once. A bash row in the Web chat carries output, which is a deliberate density increase over a summary-only row; the cap is what keeps it bounded, and a tighter cap is a props change, not a redesign.

TerminalBlock reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed.

A run_code sub-dispatch does not reach a terminal card on the shipped wire: session.ts folds tool/code-dispatch(-start) with callView: null/resultView: null, and the host's viewFor presents only top-level tool/call/tool/result, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that seam's own change.

Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own.

Testing

packages/client/ui-primitives/tests/ansi.spec.ts pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the textDecoration collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws and backspace overwrites (including a backspace stopping at a line start and resolving after a redraw), and CRLF preservation. packages/client/ui-primitives/tests/terminal-block.spec.tsx pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its aria-expanded toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus writeClipboard directly.

packages/client/ui-conversation/tests/terminal-card.spec.tsx pins the wiring at every render site: terminalCardModel's derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, BashRow's resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — packages/client/ui-conversation/src/* sits on the coverage exclude list in vitest.config.ts, so a coverage run over this package measures none of these files.

apps/web/tests/terminal-card.snapshot.ts pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed BashRow registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named bash and turn 60 left as fx-bash so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (dotsPerPromptRow: [1, 0]). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next turn/start, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker.

apps/web/tests/navigation-panes.e2e.ts adds the real-browser scenario over its existing echo NAVIGATION_OK bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a --dsw-* var has no computed value at all without the real theme stylesheet) and starts to the left of the card surface itself, and the copy control reaches the page's own async Clipboard API rather than the execCommand fallback. Its terminal-card.expected.md golden records the resolved workspace in the prompt row, which is what a bash call with no workdir must show instead of a bare $.