Web terminal card — the bash render intent reaches the browser
Web 终端卡片:bash 渲染意图抵达浏览器
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): 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 `Conv
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 former TUI rendered it as a $-prompt card with an exit line and a head/tail height cap.
The Web client ignored it. packages/client/ui-tool/src/client/tool/models/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. packages/client/ui-tool/src/client/tool/models/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 in this UI bridge; 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
commandcarrying 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 thehomeprop — 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 acdin 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.
StateDotin 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 the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewritesmarginwholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. 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, sinceStateDotisaria-hiddenand one label per row would read to assistive technology as several distinct outcomes. - No soft wrapping. Output lines are
white-space: preinside 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 showsceil(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 preserves the former TUI transcript's collapsed-card behavior, so the established head/tail selection stays stable. - ANSI color.
ansersplits the SGR runs;ui-primitives/src/ansi.tsresolves 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 andOKshowsOK0%, since the redraw is shorter than the frame beneath it; a trailingabcplus a backspace still showsabc, since nothing overwrote thec;abcplus two backspaces andXYshowsaXY. These semantics match a real terminal; truncate-and-delete approximations look right and are 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: redbad, three backspaces, thenokshowsokdwith thedstill 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, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because\r\x1b[Kis the single idiom every spinner and progress bar writes — modelling the\ralone left the previous frame's tail standing, which is text the terminal never showed. Onlymaccumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and aRangeErrorwell under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (39,49,22,24, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in\x1b[0mleaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block:\u2713, the check every progress line writes, is one column, so treating 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:a\tbthen a redraw ofXYshowsXY b, since a two-character redraw cannot reach column 8. - 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 — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a
trim()yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. 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
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx and packages/client/ui-tool/src/client/tool/models/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. 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 boundary'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.client.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, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. packages/client/ui-primitives/tests/terminal-block.client.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-tool/tests/terminal-card.client.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-tool/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 is named bash and turn 60 stays fx-bash so one fixture covers both shapes, and turn 60's command is 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 two prompt rows cannot — SGR runs resolved to --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO [exit code: N] line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce.
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 sits inside the card box yet left of the prompt label, which is the invariant the gutter padding owns, 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 $.
Related
- Tagged render-intent union for tool-call presentation — the
card-tagged vocabulary this consumes; the Web client is now a full consumer of theterminalarm rather than of args alone. - Web client syntax highlighting — owns
CodeBlockand its shiki arm, and records why tool output deliberately stays unhighlighted; ANSI color here is authored color, not guessed grammar. - Web client architecture — the slot and snapshot layering the two render sites sit in.
中文
问题
bash 工具的调用与结果都声明 card: 'terminal'(渲染意图联合类型):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 ConversationSnapshot 的 callView/resultView 上——原 TUI 曾把它渲染为带 $ 提示符的卡片,附退出行与首尾高度上限。
Web client 却对它视而不见。packages/client/ui-tool/src/client/tool/models/tool-call-model.ts 仅从原始工具参数推导每一行,skeleton/DetailsPanel.tsx 则把所有工具的内容块压平进一个 <pre>,样式为 white-space: pre-wrap; word-break: break-word。软换行加上没有高度约束,带来两个缺陷:多列输出(ls、表格、框线图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
决策
TerminalBlock 是 ui-primitives 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts 是把快照上的 callView/resultView 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 card: 'terminal' 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 card 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图约定交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 title 替换待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则就是 workspace,而这正是不带 workdir 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这个 UI 桥接层;两个渲染点各自从会话列表行取出 cwd 传入。只有存在的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 $,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 ./.. 段,因为 bash 执行器在运行前就已解析 workdir:相对 /w/app 的 .. 实际运行在 /w,因此提示标签必须读作 w 而不是 ..。UNC 路径的 server 与 share 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 description 走同一处推导,因为约定把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
该组件的约定:
- 提示符行,每条命令行一行。 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的
command就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于homeprop 时取~——浏览器没有$HOME,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯$。末尾换行是终止符,不是一条空的末命令。只有第一行携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个cd就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸$,因此它们仍读作提示符。 - 整次调用一枚运行状态点,位于第一行。 它是
StateDot四种状态中的三种:运行期间为追逐动画,会渲染状态徽章的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以自身左内边距预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写margin来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为StateDot是aria-hidden,而每行一个标签会被辅助技术读成好几个各自独立的结果。 - 不软换行。 输出行使用
white-space: pre,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - 高度上限与展开控件。 输出超过
DEFAULT_TERMINAL_MAX_LINES(16)行时,显示ceil(max/2)行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法保留原 TUI transcript(文本记录)折叠卡片的行为,因此既有的首尾选择保持稳定。 - ANSI 颜色。
anser切分 SGR 分段;ui-primitives/src/ansi.ts把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到--dsw-*主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格只移动光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。100%后接回车再接OK显示为OK0%,因为这次重绘比它下面的帧更短;末尾abc加一个退格仍显示abc,因为没有任何东西覆盖过那个c;abc加两个退格再接XY显示aXY。这些语义与真实终端一致;「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色bad、三个退格、再写ok,显示为okd且那个d仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为\r\x1b[K是每个 spinner 与进度条都会写的同一个惯用法——只建模\r会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有m会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以归一化记录保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB,并最终触发RangeError,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(39、49、22、24等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须收束:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以\x1b[0m结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji presentation 而非 U+2600–U+27BF 整个区块:\u2713——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:a\tb之后用XY重绘显示为XY b,因为两个字符的重绘到不了第 8 列。 - 退出状态与复制。 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过
trim()却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
几何尺寸、圆角与字体沿用 CodeBlock,因此终端卡片与围栏代码块在视觉上一致;white-space: pre 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 CodeBlock 中提取到包内部的 src/clipboard.ts,不对外导出,因此它仍是这两个块的实现细节。
<a id="inline-output-in-the-chat-row-reverses-a-stated-convention"></a>
聊天行内嵌输出推翻了一条既有约定
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx 与 packages/client/ui-tool/src/client/tool/models/tool-call-model.ts 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
这次推翻成立的理由:对 shell 命令而言,输出就是用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
余下的约束:行内上限为 CHAT_TERMINAL_MAX_LINES(8),是组件默认值的一半,而面板沿用默认值——消息流是跨多次调用阅读的摘要表面,面板才是单次调用的阅读表面。只有 terminal 意图内嵌渲染;generic 工具的内容依旧只在面板中。
这一划分的一个前提此后被削弱了:工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此该面板在组装后的应用中当前不可达。于是行内上限成为读者实际拥有的唯一长输出表面,由展开控件承担。恢复面板入口属于那次改动的后续,而非本次改动——但在它落地之前,「面板仍是查看完整输出的地方」并不成立,行内的展开控件独自承担了这一职责。
考虑过的替代方案
只在详情面板渲染终端块。 这样保留既有的「不内嵌输出」约定,也不需要记录任何推翻。已被 owner 的明确决定否决:shell 命令的输出正是用户来读的东西,把它挪到一次点击之外,代价高于该约定带来的收益。此处记录的是 owner 的裁决,而非从代码库推导出的结论。
复用 CodeBlock 并传入 console 语言,而不新建组件。 已否决:CodeBlock 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
手写 SGR 解析器。 已否决:SGR 解析器恰是优先采用维护良好的依赖而非手写所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、reverse、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛:anser 并未删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它达到的是健康度与边界契合这两方面的门槛。anser 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
后果
anser 成为 packages/client/ui-primitives 的一项新运行时依赖,因此该包的每个消费方都为它支付一次。Web 聊天中的 bash 行现在承载输出,相比只有摘要的行,这是有意提高的信息密度;上限是维持其有界的机制,而调紧上限是改一个 prop,不是重新设计。
TerminalBlock 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
在当前已交付的 wire 上,run_code 子派发不会得到终端卡片:session.ts 把 tool/code-dispatch(-start) 折叠为 callView: null/resultView: null,而 host 的 viewFor 只呈现顶层的 tool/call/tool/result,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于该边界自身的改动。
内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
测试
packages/client/ui-primitives/tests/ansi.client.spec.ts 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 textDecoration 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。packages/client/ui-primitives/tests/terminal-block.client.spec.tsx 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 aria-expanded 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 writeClipboard 的直接固定。
packages/client/ui-tool/tests/terminal-card.client.spec.tsx 固定每个渲染点上的接线:terminalCardModel 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、BashRow 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——packages/client/ui-tool/src/* 位于 vitest.config.ts 的覆盖率 exclude 列表中,因此覆盖率运行不会统计其中任何文件。
apps/web/tests/terminal-card.snapshot.ts 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 BashRow 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮名为 bash、第 60 轮保持 fx-bash,于是一份 fixture 覆盖两种形态,而第 60 轮的命令为两行,使构建产物快照钉住逐行提示区及其单枚状态点(dotsPerPromptRow: [1, 0])。该终端轮有意排在 todo 轮之前:站立计划会在下一次 turn/start 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 --dsw-* token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意不含 [exit code: N] 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
apps/web/tests/navigation-panes.e2e.ts 在其既有的 echo NAVIGATION_OK bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,--dsw-* 变量根本不产生计算值),且它位于卡片盒之内、提示标签之左——这正是那道 gutter 内边距所拥有的不变量;复制控件走的是页面自身的异步 Clipboard API,而非 execCommand 兜底路径。其 terminal-card.expected.md 基准记录了提示行中已解析的 workspace——这正是不带 workdir 的 bash 调用应当显示的内容,而非一个裸 $。
相关
- Tagged render-intent union for tool-call presentation——本次消费的
card标签词汇;Web client 现在是terminal分支的完整消费方,而不再只消费参数。 - Web client syntax highlighting——它拥有
CodeBlock及其 shiki 分支,并记录了工具输出为何有意不做语法高亮;这里的 ANSI 颜色是作者指定的颜色,不是猜出来的语法。 - Web client architecture——两个渲染点所处的 slot 与快照分层。