The tail-header cap fix pushed SearchBlock's head/tail slicing arithmetic and
its copy-feedback hook over the duplication gate's threshold against the
byte-identical logic in TerminalBlock. Extract both into head-tail-cap.ts
(headTailCap) and use-copy-feedback.ts (useCopyFeedback) and consume them from
both blocks, deleting the clone rather than nudging it under the limit.
Every expandable tool row shares one interaction (whole-row toggle,
icon-to-chevron hover preview) and one expanded body: an IN/OUT
gutter-labeled card with per-section 150px scroll caps and sticky labels.
toolRowModel derives result output and the error first line, terminalFailed
surfaces a failing exit as the collapsed row's red dot, a hover Inspect
pill jumps to the call's trajectory record through a one-shot store
handoff, and the chat view keeps its scroll offset across view switches.
Nine findings, one critical. Terminal cases verified in a real terminal first.
CRITICAL: cells held the accumulated SGR history, so every state boundary
re-emitted the whole chain — output switching color without a full reset
emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the
reviewer's ~90 KB alternating-color case is well under bash's own output cap.
State is now a normalized record (foreground, background, attribute set) with
one canonical sequence per boundary, so the emitted text is linear in cells;
the 90 KB case parses in 36 ms. That also makes the attribute closers every
chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the
following write PLAIN, which a real terminal confirms.
Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn
with `XY` shows `XYB`, so the check every progress line writes is ONE column.
Taking the block as wide misaligned exactly the output this card exists for.
Writing over either half of a wide pair blanks the other, since a terminal
cannot leave one cell of a two-cell glyph standing.
`line\n\x1b[0m` does not end in a newline as a string yet its last parsed line
holds nothing visible, so the terminator check now reads the parsed lines — it
had added a blank row and inflated the collapse count.
A line with no cursor movement no longer builds a column buffer at all; only
its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per
character.
The `.terminalDescription` rule had been inserted into an existing grouped
selector, silently giving `.codeBody` description typography and changing its
bottom margin from 4px to 0 — a pre-existing surface this PR does not own.
Split out, `.codeBody`'s margin restored.
Three comments contradicted their code: the fixture's exit-marker line (still
claiming recovery from a marker deliberately removed), `bash-sample`'s header
(still routing a click to the details panel, and calling the consumer's cap the
block's own), and a DetailsPanel comment stacked above the wrong rule. The
ui-primitives README documented only the CR/BS half of the replay, so a reader
would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`.
Six findings. Each terminal-semantics case was checked in a real terminal
(tmux, reading back the painted screen) before changing anything:
`100%\r\x1b[KOK` shows `OK`. Modelling the `\r` without its erase left the
previous frame's tail standing — a regression against the old truncate, since
`\r\x1b[K` is the single idiom every spinner and progress bar writes. Erase is
now part of the same replay, in all three parameter forms.
`a\tb\rXY` shows `XY b`. Counting a tab as one column produced `XYb` and
destroyed the alignment this card exists to hold, so the cursor now advances
by terminal columns: tabs reach the next 8-column stop and a wide character
takes two cells.
`\x1b[31mabc\rX\nnext` paints BOTH lines red. A newline does not reset the
graphic state, so state threads from one replayed line to the next instead of
closing at each line end.
Only `m` accumulates into a cell's style now. Folding cursor and erase
sequences in grew the state string per redraw and emitted boundaries anser
had to discard.
The empty check reads the parsed lines the card renders rather than the raw
text: output that is only escapes or control bytes survives `trim()` yet
parses to nothing, and drew blank rows plus a copy control for invisible
bytes instead of the placeholder.
The gutter is the card's own left padding rather than a margin. Every render
site rewrites `margin` wholesale for its own indent, which silently cancelled
the reservation and let a container clip the dot.
The fixture sample no longer carries an `[exit code: 1]` line: the real bash
presenter consumes that marker precisely because the card shows the exit as
its own pill, so the built-bundle snapshot had pinned a frame showing it
twice — one the product path cannot produce.
A multi-line command repeated the cwd label on every prompt row, which
states something the view does not know: it carries ONE working directory —
where the call started — and a `cd` in the command moves later lines
elsewhere. `cd ~` then `ls` rendered both rows labelled with the session
workspace while `ls` actually listed the home directory.
The label now appears on the first row only, and later rows keep a bare `$`
so they still read as prompts. Same reasoning as the run-state dot: neither
a per-line directory nor a per-line exit status exists to report.
The built-bundle snapshot records the effect on fixture turn 60's two-line
command (`fixture echo done` becomes `$ echo done`).
A `command` carrying two shell commands on two lines rendered as one row:
`.command` had `white-space: nowrap`, so the two collapsed into a single
ellipsized line that read as one command with stray arguments.
Render one prompt row per command line, and move the run-state dot out of
flow into a gutter reserved to the left of the card surface, so it neither
indents its command nor depends on the command's text metrics to line up.
The dot stays exactly one per card, on the first row. 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 label keeps the same
scope, since one label per row would read to assistive technology as several
distinct outcomes.
Fixture turn 60's command becomes two lines, so the built-bundle snapshot
pins the layout and its dot distribution (`dotsPerPromptRow: [1, 0]`), and
the e2e adds that the dot starts left of the card surface — geometry jsdom
cannot compute. Both READMEs now also record that this package's
user-facing copy is inline Chinese, since zero-cordis atoms have no route to
`ctx.locale`; extracting it belongs to the repo-wide localization work.
Master replaced StateDot's ongoing ring with a pixel-art chase, so the
prompt line's run-state description named an indicator that no longer
exists. Same fix in the note, both READMEs, and the test name.
The terminal card showed no run state: a running command and a settled
command that produced no output rendered the same prompt line, so whether
a command was still running had to be inferred from the absence of output.
Lead the prompt line with a StateDot in three of its states — the spinning
ring while running, red for the same exit status that renders the status
pill, green for a clean settle. That is the same indicator a tool row's
leading icon carries, so a row and its own card cannot disagree about one
command; the row/card agreement is pinned in the ui-conversation spec.
StateDot is aria-hidden, so a visually hidden text label rides beside it,
which is what the refreshed aria goldens now record.
The e2e adds what jsdom cannot compute: the dot's color resolves to the
green success token through the real theme stylesheet, and the dot precedes
the prompt label in document order.
The bash tool already declares the `card: 'terminal'` render intent for
both its call and its result, and host/connection/runtime already deliver
it to the browser as callView/resultView. The Web client ignored it:
rows derived from raw args, and the details panel flattened every tool's
content into one soft-wrapping `<pre>`. Column-aligned output folded into
a paragraph and a long listing stretched the panel without bound.
`TerminalBlock` (ui-primitives) renders a command as a terminal surface:
a shortened-cwd prompt line, output at `white-space: pre` in a
horizontally scrolling box, a head/tail height cap with an expand
control, an exit-code/signal status pill, and a copy control for the raw
output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*`
theme tokens, with literal rgb kept for values the design system has no
token for. Geometry and fonts mirror CodeBlock; the clipboard write both
need moved into a package-internal `clipboard.ts`.
Both Web render sites for a bash call consume the intent through one
derivation (`terminal-card-model.ts`), so they cannot disagree about a
command, its cwd, or its exit status: the keyed BashRow carries the card
resident below its summary row, and the render-site fallback row keeps it
behind its existing expand control. Rows cap at 8 lines against the
panel's 16.
Inline output in the chat row reverses this package's stated
no-inline-output convention, on the owner's explicit decision; the Agent
Note records the reversal and its bound.
Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring
specs at every render site, a built-client-graph snapshot covering both
chat-row shapes, and a real-browser e2e asserting the no-wrap layout and
the page's own Clipboard API.