docs: consolidate TUI Agent Notes

This commit is contained in:
Turtle
2026-07-29 10:30:27 +08:00
parent c7e6cb5753
commit 7a3295e0bf
26 changed files with 129 additions and 777 deletions

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.md
2026-07-28-context-card-content-independent-fold.md: 7e5e6e9ed8df317b1f1ade8f6935a18903937d6f
2026-07-28-context-card-content-independent-fold.zh.md: 48e329d4d64395e3bcfa9ecb6085474d70eddac0

View File

@@ -1,37 +0,0 @@
# Agent Note: Context-card folding is independent of whether the text parses as XML
Status: implemented
English | [中文](2026-07-28-context-card-content-independent-fold.zh.md)
## Problem
[Foldable injected-context cards](../feature/2026-07-28-tui-foldable-context-cards.md) folded only the tree-rendered body. `ContextCardComponent.render` passed `expanded` and `maxOutputLines` into `renderUnknownXml`, which returns `undefined` when the text is not one complete XML document; the `undefined` branch then rendered the whole message as a single muted blob that consulted neither field. Unparsed context was therefore permanently expanded and inert under `Ctrl+O`.
The parser rejects far more real context than the fallback implied. `workspace-context` frames instructions in `<system-reminder>` and escapes only `</system-reminder>`, so any raw `&` or `<` inside the prose is an invalid entity reference or bogus tag that fails the whole document. A badge URL's `&logo=` does it, and so does any `a < b`. Observed on a live session log: two `workspace-instructions` cards rendered 254 and 85 identical rows collapsed and expanded, each with a literal `<system-reminder>` as the first body row, while a `dsh-tool-skill` card whose text happened to parse folded 113 rows to 46.
The user-visible symptom read as two bugs — the frame line was back, and nothing folded — but both are this one branch.
## Decision
Folding is a property of the card, not of the text. `preview` is exported from `packages/ui/tui/src/components/xml-tool-output.ts` and applied to the assembled body in `ContextCardComponent.render`.
`ToolCardComponent` had an inline copy of that head/tail/marker arithmetic; it now calls the same `preview`, so one function owns the fold rule for every transcript card. Where a tool card's tree body was already folded per child, re-applying the limit to the assembled rows is deliberate: the per-child budget bounds each child, not their sum, so many small children could still exceed the card's budget.
This note's fix kept the parse and made the fold independent of it. The card [no longer parses context at all](2026-07-28-context-cards-render-prose-not-xml.md), which also removed the residual frame row this fix could not suppress: an unparsed body had no parsed root to drop, so its first row stayed the literal `<system-reminder>`.
## Alternatives considered
**Escape `&` and `<` in `workspace-context` so the document always parses.** Rejected as the fix for this defect: it makes the fold work by making the parse succeed, leaving folding contingent on content. Any other plugin injecting arbitrary text, or any escaping gap, reintroduces the same symptom. Worth doing on its own merits — it would also drop the frame row — but the card must fold regardless.
**Pre-parse repair, or a lenient HTML-style parser.** Rejected: `renderUnknownXml` declines on purpose so partial or mixed text renders unchanged rather than through a guessed tree. Loosening it to satisfy a presentation budget trades a correct decline for a wrong tree, and affects unknown tool results too.
**Truncate in the `undefined` branch alone.** Rejected: it fixes the symptom while leaving two fold implementations whose agreement is unverified. Exporting `preview` removes the tool card's duplicate at the same time.
## Consequences
Every context card folds to `maxToolOutputLines` and responds to `Ctrl+O`, whatever its text contains. Tool cards whose tree body has many children can now fold where they did not, since the card total is bounded rather than each child.
## Testing
`packages/ui/tui/tests/tui.spec.ts` pins a context card whose text carries a raw `&` (a badge URL's `&logo=`, the observed real-world trigger): it is collapsed by default with the `Ctrl+O to expand` marker, hides a middle line, and reveals it on `Ctrl+O`. That test failed on this fix's parent commit, where the card emitted no marker.

View File

@@ -1,37 +0,0 @@
# Agent Note: 上下文卡片的折叠与文本能否按 XML 解析无关
Status: implemented
[English](2026-07-28-context-card-content-independent-fold.md) | 中文
## 问题
[可折叠的注入上下文卡片](../feature/2026-07-28-tui-foldable-context-cards.md)此前只折叠按树形渲染的正文。`ContextCardComponent.render` 把 `expanded` 和 `maxOutputLines` 传给 `renderUnknownXml`,而当文本不是一个完整的 XML 文档时,该函数返回 `undefined`;`undefined` 分支随后把整条消息渲染成单块暗色文本,这两个字段一个都不看。于是未能解析的上下文永久处于展开状态,且对 `Ctrl+O` 毫无反应。
解析器拒绝的真实上下文远多于该回退分支所暗示的范围。`workspace-context` 用 `<system-reminder>` 包裹指令,并且只转义 `</system-reminder>`,因此正文中任何裸的 `&` 或 `<` 都构成无效实体引用或非法标签,进而让整篇文档解析失败。徽章 URL 中的 `&logo=` 会触发这一情况,任何 `a < b` 也会。在一份真实会话日志中观察到:两张 `workspace-instructions` 卡片分别渲染出 254 行和 85 行,折叠态与展开态完全一致,且每张卡片正文首行都是字面量 `<system-reminder>`;而一张文本恰好能解析的 `dsh-tool-skill` 卡片则把 113 行折叠到了 46 行。
用户可见的症状看起来像两个缺陷——外框行又回来了,而且什么都折不起来——但两者都出自这同一个分支。
## 决策
折叠是卡片的属性,而非文本的属性。`preview` 从 `packages/ui/tui/src/components/xml-tool-output.ts` 导出,并在 `ContextCardComponent.render` 中作用于组装完成的正文。
`ToolCardComponent` 此前内联复制了那套头部/尾部/标记的行数计算;它现在调用同一个 `preview`,因此所有 transcript(文本记录)卡片的折叠规则由一个函数统一持有。对于工具卡片中已经逐子项折叠过的树形正文,再对组装完成的行施加一次限额是有意为之:逐子项的限额只约束单个子项,而非它们的总和,因此大量小子项仍可能超出卡片的限额。
本记录的修复保留了解析,并让折叠不再依赖解析结果。卡片[已完全不再解析上下文](2026-07-28-context-cards-render-prose-not-xml.md),这同时也消除了本次修复无法抑制的残留外框行:未解析的正文没有可去掉的已解析根元素,因此其首行仍是字面量 `<system-reminder>`。
## 考虑过的替代方案
**在 `workspace-context` 中转义 `&` 与 `<`,使文档总能解析。** 作为本缺陷的修复方案不予采纳:它是靠让解析成功来让折叠生效,从而使折叠仍取决于内容。任何注入任意文本的其他插件,或任何转义遗漏,都会重新引入同一症状。这件事本身值得做——它还会顺带去掉外框行——但卡片无论如何都必须能折叠。
**先做预解析修复,或改用宽松的 HTML 式解析器。** 不予采纳:`renderUnknownXml` 是有意拒绝的,这样部分或混合文本才会原样渲染,而不是走一棵猜出来的树。为了满足呈现限额而放宽它,等于用一次正确的拒绝换来一棵错误的树,并且会连未知工具结果一起波及。
**只在 `undefined` 分支里做截断。** 不予采纳:它修掉了症状,却留下两份彼此一致性未经验证的折叠实现。导出 `preview` 同时消除了工具卡片中的那份重复。
## 后果
每张上下文卡片都会折叠到 `maxToolOutputLines`,并响应 `Ctrl+O`,无论其文本包含什么。树形正文含大量子项的工具卡片现在可能在原先不折叠的情况下折叠,因为受约束的是卡片总行数而非每个子项。
## 测试
`packages/ui/tui/tests/tui.spec.ts` 固定了一张文本带有裸 `&` 的上下文卡片(徽章 URL 中的 `&logo=`,即观察到的真实触发条件):它默认处于折叠态并带有 `Ctrl+O to expand` 标记,隐藏了中间某一行,按下 `Ctrl+O` 后将其显示出来。该测试在本次修复的父提交上曾失败,当时的卡片不会输出标记。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.md
2026-07-28-context-cards-render-prose-not-xml.md: 0b87e6b5e20648dc40ce1cde7a6d4c23843f478f
2026-07-28-context-cards-render-prose-not-xml.zh.md: 461466be148ffce6ebc5e7188f88014bf6bac69e

View File

@@ -1,49 +0,0 @@
# Agent Note: Context cards render injected context as prose, not as XML
Status: implemented
English | [中文](2026-07-28-context-cards-render-prose-not-xml.zh.md)
## Problem
`ContextCardComponent` rendered injected context through `renderUnknownXml`, the strict single-document XML tree renderer. That made two user-visible properties depend on whether the payload happened to be well-formed XML: the redundant frame row was dropped only via `xml.slice(1)` on a successful parse, and — before the [content-independent fold](2026-07-28-context-card-content-independent-fold.md) — so was folding.
Injected context is not XML. Three facts establish it:
- `<system-reminder>` is a prompting convention, not markup. No model is trained on the tag ([envelope rationale](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)); it signals "injected, not the user speaking".
- Real instruction bodies contain characters that are fatal to a strict parse but ordinary in prose. A raw `&` in a badge URL's `&logo=` is an invalid entity reference; measured on a live session, one `workspace-instructions` payload carried four.
- The angle brackets inside those bodies are not elements. Every inner "tag" in this repository's own instructions is a placeholder in a path or command template — `packages/<group>/<pkg>/`, `-t <name>`, `Branded<B>`. Tree-rendering them as elements misrepresents the text.
The fragility was never workspace-specific: `dsh-tool-skill` builds the same frame and parses today only because no skill description has yet contained an `&`.
## Decision
The card does not parse. It strips a producer's outer frame by exact line match and renders the remaining text as muted prose rows, folded by the shared `preview`.
`stripReminderFrame` removes the first and last lines only when they are a matched open/close pair, each alone on its line (`REMINDER_FRAME_LINE`). An unpaired tag line, a mismatched pair, or a tag mentioned mid-prose is left intact, so no body is silently truncated by a tag-like first line. Producers emit the frame as whole lines, which is what makes an exact match sufficient.
Model-facing text is untouched. The rejected alternative — escaping at the producer so the document parses — would have changed what the model reads.
Emptiness is decided on the stripped text rather than the styled rows. A palette wraps every row in escapes, so a blank body styled first yields one escape-only row that reads as a stray blank line under the header; testing `stripped === ''` renders the card header-only instead.
Removing the parse also removes the C1-expansion hazard it carried. `renderUnknownXml` had to escape parsed text because a character reference like `&#155;` expands to a control character the raw-source escaping never saw. Unparsed text cannot expand, so `&#155;` now renders literally and terminal-injection safety rests solely on `displayText`, which the `untrusted-controls` snapshot pins.
`renderUnknownXml` keeps its one remaining consumer: unknown tool results, where a genuine XML result is plausible and the root element is meaningful. Only `preview` is shared with the context card.
## Alternatives considered
**XML-escape `&` and `<` in every producer so the frame parses.** Rejected: it corrupts a model-facing contract to satisfy a presentation detail. The model would read `packages/&lt;group&gt;/&lt;pkg&gt;/` and `&amp;logo=deepseek` — and that badge URL is an instruction the model is told to copy verbatim into a pull request description. It also needs repeating in every current and future producer with nothing keeping them in sync.
**Escape only for display, keeping the model text intact.** Rejected: escaping display-side to make a parse succeed, then rendering the parse as a tree, reintroduces the placeholders-as-elements misreading. The text is prose either way, so parsing it buys nothing.
**A lenient HTML-style parser.** Rejected: `renderUnknownXml` declines by design so partial or mixed text renders unchanged rather than as a guessed tree. Loosening it trades a correct decline for a wrong tree, and would affect unknown tool results too.
## Consequences
Context cards render identically whatever their text contains: the frame row is gone in every case, prose survives verbatim, and the fold depends only on row count. Bodies lose the tree's two-space indentation and nested-element structure, which the re-recorded `surface-after-compaction-{narrow,wide}` snapshots show; a nested block such as `<available_skills>` now appears as the literal text it is in the model-facing payload. An empty frame renders header-only rather than leaving a blank row.
A character reference in context is no longer decoded for display, which is the correct reading of a payload the model receives literally.
## Testing
`packages/ui/tui/tests/tui.spec.ts` pins the frame stripped for a multi-line reminder, prose preserved for an unpaired tag pair (`<available_skills>` … `</other-tag>`), header-only rendering for an empty frame, and a body carrying both a raw `&logo=` and `packages/<group>/<pkg>/` that folds and survives verbatim through a `Ctrl+O` round trip. The one-line frame case pins that `&#155;` stays literal while a raw C1 byte never reaches the terminal. The `surface-after-compaction-{narrow,wide}` keyless snapshots were re-recorded for the lost indentation and the now-recorded muted styling; `untrusted-controls` is unchanged, holding the escaping contract. `examples/tui-agent`'s `multi-turn-conversation` terminal snapshot was refreshed too: it had been failing since the original foldable-context change on an unrelated stale row, where it still expected a width-padded `Context · plan-mode` header that no card has emitted since the header stopped passing through `Text`. Both changed files keep 100% statement and branch coverage.

View File

@@ -1,49 +0,0 @@
# Agent Note: 上下文卡片把注入上下文渲染为文本,而非 XML
Status: implemented
[English](2026-07-28-context-cards-render-prose-not-xml.md) | 中文
## 问题
`ContextCardComponent` 此前通过 `renderUnknownXml` 渲染注入上下文,而该函数是严格的单文档 XML 树形渲染器。这使得两个用户可见的属性取决于载荷是否恰好是格式良好的 XML:冗余的外框行只有在解析成功时才会经 `xml.slice(1)` 去掉,而在[与内容无关的折叠](2026-07-28-context-card-content-independent-fold.md)之前,折叠同样如此。
注入上下文并不是 XML。三个事实可以确立这一点:
- `<system-reminder>` 是一种提示词约定,而非标记语言。没有任何模型是针对该标签训练的([外框设计依据](../simplification/2026-07-20-unwrap-injected-content-envelopes.md));它传达的是「这是注入内容,不是用户在说话」。
- 真实的指令正文包含一些对严格解析致命、但在普通文本中再正常不过的字符。徽章 URL 中 `&logo=` 里的裸 `&` 就是一个无效实体引用;在一次真实会话上测得,某个 `workspace-instructions` 载荷携带了四个。
- 这些正文里的尖括号并不是元素。本仓库自身指令中的每一个内层「标签」都是路径或命令模板中的占位符——`packages/<group>/<pkg>/`、`-t <name>`、`Branded<B>`。把它们按元素做树形渲染会曲解这段文本。
这种脆弱性从来不局限于工作区:`dsh-tool-skill` 构造的是同一套外框,它今天还能解析成功,仅仅因为至今没有哪个 skill(技能)描述里出现过 `&`。
## 决策
卡片不做解析。它按整行精确匹配剥掉生产方的外层外框,把剩余文本渲染为暗色文本行,并交由共享的 `preview` 折叠。
`stripReminderFrame` 只在首行与末行构成一对匹配的开闭标签、且各自独占一行(`REMINDER_FRAME_LINE`)时才将其移除。落单的标签行、不匹配的标签对、以及在文本中间提及的标签都原样保留,因此不会有正文因为首行形似标签而被静默截断。生产方以整行形式输出外框,这正是精确匹配足够可靠的原因。
面向模型的文本不受影响。被否决的替代方案(在生产方做转义以使文档可解析)会改变模型读到的内容。
是否为空由剥离后的文本判定,而不是由已套样式的行判定。调色板会给每一行都包上转义序列,因此空正文若先套样式,就会产出一行只含转义序列的行,看上去像标题下方多出一条无端的空行;改为检测 `stripped === ''` 后,卡片只渲染标题。
去掉解析同时也去掉了它带来的 C1 展开隐患。`renderUnknownXml` 必须对解析出的文本做转义,因为形如 `&#155;` 的字符引用会展开为一个控制字符,而针对原始源文本的转义根本看不到它。未经解析的文本无法展开,因此 `&#155;` 现在按字面渲染,终端注入安全性完全由 `displayText` 承担,并由 `untrusted-controls` 快照固定。
`renderUnknownXml` 保留了它唯一剩下的消费方:未知工具结果。在那里真正的 XML 结果是有可能出现的,根元素也确有含义。与上下文卡片共享的只有 `preview`。
## 考虑过的替代方案
**在每个生产方中把 `&` 与 `<` 做 XML 转义,使外框可解析。** 不予采纳:它为了迁就一个呈现细节而破坏面向模型的契约。模型会读到 `packages/&lt;group&gt;/&lt;pkg&gt;/` 和 `&amp;logo=deepseek`——而那个徽章 URL 正是一条要求模型逐字复制到 PR(Pull Request)描述里的指令。它还需要在每个现有和未来的生产方中重复实现,且没有任何机制保证它们彼此一致。
**只在展示侧转义,保持模型文本不变。** 不予采纳:为让解析成功而在展示侧转义、再把解析结果渲染成树,会重新引入把占位符当作元素的误读。这段文本无论如何都是普通文本,解析它换不来任何收益。
**改用宽松的 HTML 式解析器。** 不予采纳:`renderUnknownXml` 是有意拒绝的,这样部分或混合文本才会原样渲染,而不是走一棵猜出来的树。放宽它等于用一次正确的拒绝换来一棵错误的树,并且会连未知工具结果一起波及。
## 后果
上下文卡片无论文本包含什么都渲染一致:外框行在所有情形下都消失,普通文本逐字保留,折叠只取决于行数。正文失去了树形渲染的两空格缩进与嵌套元素结构,重新录制的 `surface-after-compaction-{narrow,wide}` 快照体现了这一点;诸如 `<available_skills>` 这类嵌套块,现在按它在面向模型载荷中的字面文本出现。空外框只渲染标题,而不是留下一条空行。
上下文中的字符引用不再为展示而解码,这才是对模型逐字接收到的载荷的正确读法。
## 测试
`packages/ui/tui/tests/tui.spec.ts` 固定了以下几点:多行提醒的外框被剥掉;标签对落单(`<available_skills>` … `</other-tag>`)时普通文本得以保留;空外框只渲染标题;以及一段同时含有裸 `&logo=` 和 `packages/<group>/<pkg>/` 的正文会折叠,并在 `Ctrl+O` 的一次往返中逐字存活。单行外框的用例固定了 `&#155;` 保持字面量,同时裸的 C1 字节永远不会到达终端。`surface-after-compaction-{narrow,wide}` 无密钥快照因缩进丢失和现已录制的暗色样式而被重新录制;`untrusted-controls` 未变,继续持有转义契约。`examples/tui-agent` 的 `multi-turn-conversation` 终端快照也一并刷新:自最初的可折叠上下文改动以来,它一直因一行无关的陈旧内容而失败——那里它仍期望一个按宽度补齐的 `Context · plan-mode` 标题,而自标题不再经由 `Text` 渲染之后,就没有卡片再输出过这种标题。两个改动文件都保持 100% 的语句与分支覆盖率。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.md
2026-07-28-terminal-card-double-exit-status.md: e082308a9a3bd6071753993572bfca518bd89de5
2026-07-28-terminal-card-double-exit-status.zh.md: dc8aa0a6c26958c1cfe7e4d20b7228260f7a1bff

View File

@@ -1,46 +0,0 @@
# Agent Note: The terminal card reported a non-zero exit twice
Status: implemented
English | [中文](2026-07-28-terminal-card-double-exit-status.zh.md)
## Problem
A failing `bash` call rendered its exit status twice:
```
● Tool / bash / Check merge lock and flock availability
$ … ; grep -n "merge.lock" .gitignore
/opt/homebrew/bin/flock
[exit code: 1]
[exit 1]
```
`renderResult` appends `[exit code: N]` to the model-facing text because the model reads a single string and must see the exit. `presentBashResult` then returned that same string verbatim as the terminal card's `output` while also parsing the marker into `exitCode`, and the TUI renders `output` followed by its own dim `[exit N]` pill. Every non-zero exit and every signal kill therefore printed both forms.
Neither producer was individually wrong: `TerminalResultView` documents `output` as the captured command output and `exitCode`/`signal` as separate structured fields precisely so a capable UI can show a pill. The bug was that `presentBashResult` put the consumed marker in both places. The TUI's own snapshot fixtures hid it — the hand-built card fixture supplies a marker-free `output`, and the one real recorded bash journey (`bash-terminal-card`) runs `echo TERMINAL_OK`, which exits 0 and so emits no marker at all.
## Decision
`parseExitStatus` now returns `{ body, …exit }`: it splits the rendered text at the marker it anchors on, so the caller receives the output body without the status line it consumed. `presentBashResult` passes that body as the card's `output`. Only the exit/signal marker leaves the output; `[output truncated: …]`, `[timed out after Nms]`, and the sandbox denial and escalation lines stay in the body because they carry facts no exit pill shows.
The split lives in `render.ts` next to the marker emission it inverts. Emission, parse, and strip already had to co-evolve in one file, and a round-trip test pins the trio.
## Alternatives considered
- **Drop the `[exit N]` pill in the TUI.** Rejected: the pill is the scannable status, styled and placed independently of command output, and it is the only exit signal a card gets for a `TerminalResultView` produced by a tool other than `bash`.
- **Strip the marker in the TUI renderer.** Rejected: the renderer would have to know `dsh-tool-bash`'s marker vocabulary, and the strip belongs with the parse that already consumes it. A tool's render intent is the tool's to define.
- **Stop emitting the marker from `renderResult`.** Rejected: the marker is the model's only exit signal in a single text result, and the `tool:bash` prompt section teaches the model to check it.
- **Have `execute` return a structured exit alongside the text.** Rejected: `presentResult(args, result)` is deliberately pure over content blocks so it replays from the session log, which retains only the rendered text.
## Consequences
A card body no longer ends in the marker, so a session replayed from the log renders the same single pill as a live run. The pre-existing display-only residual is now slightly larger and is recorded in the package README: output whose final line happens to be exactly `[exit code: N]` or `[killed by signal: …]` is read as the marker, which both shows a wrong pill and drops that line from the card body.
The deliberate treatments in [the tool-card header note](../feature/2026-07-27-tui-tool-card-header.md) are unchanged: terminal exit keeps its existing dim `[exit N]` line rather than moving into a uniform footer.
## Testing
`tools.spec.ts` pins the marker-free body for a non-zero exit, a signal kill, and a clean run; asserts a timeout marker survives alongside the stripped exit; and extends the `renderResult`/`parseExitStatus` round-trip to assert no consumed marker remains in the body.
The defect was invisible to every existing snapshot, so `tui-keyless-smoke.e2e.ts` adds a real-PTY scenario: the scripted adapter calls the real `bash` tool with `printf …; exit 3`, and the test asserts the terminal output contains the command's stdout and `[exit 3]` but never `[exit code: 3]`. It reproduces the double render when the presenter fix is reverted.

View File

@@ -1,46 +0,0 @@
# Agent Note: terminal 卡片把非零退出状态报告了两次
Status: implemented
[English](2026-07-28-terminal-card-double-exit-status.md) | 中文
## Problem
失败的 `bash` 调用会把退出状态渲染两次:
```
● Tool / bash / Check merge lock and flock availability
$ … ; grep -n "merge.lock" .gitignore
/opt/homebrew/bin/flock
[exit code: 1]
[exit 1]
```
`renderResult` 会把 `[exit code: N]` 追加到面向模型的文本上,因为模型读到的是单个字符串,必须能看到退出状态。而 `presentBashResult` 随后把同一个字符串原样作为 terminal 卡片的 `output` 返回,同时又把该标记解析进 `exitCode`,TUI 则在 `output` 之后再渲染自己的暗色 `[exit N]` 徽标。于是每一次非零退出、每一次信号终止都会同时打印两种形式。
两个产出方各自都没有错:`TerminalResultView` 明确约定 `output` 是捕获的命令输出,`exitCode`/`signal` 是独立的结构化字段,正是为了让有能力的 UI 能显示徽标。缺陷在于 `presentBashResult` 把已被消费的标记同时放进了两个位置。TUI 自己的快照 fixture(测试前置数据)掩盖了它:手工构造的卡片 fixture 提供的 `output` 不含标记,而唯一真实录制的 bash 流程(`bash-terminal-card`)执行 `echo TERMINAL_OK`,退出码为 0,因此根本不会发出标记。
## Decision
`parseExitStatus` 现在返回 `{ body, …exit }`:它在自己锚定的标记处切分渲染后的文本,于是调用方拿到的输出正文不再包含它消费掉的那行状态。`presentBashResult` 把该正文作为卡片的 `output` 传出。只有退出/信号标记会离开输出;`[output truncated: …]`、`[timed out after Nms]` 以及沙箱拒绝与升权提示行仍留在正文中,因为它们携带的事实是退出徽标无法体现的。
切分逻辑放在 `render.ts` 中、紧邻它所反演的标记发出处。发出、解析与剥除本就必须在同一个文件里协同演进,一个往返测试把这三者钉在一起。
## Alternatives considered
- **在 TUI 中去掉 `[exit N]` 徽标。** 已否决:该徽标是可快速扫读的状态,其样式与位置独立于命令输出,而且对于由 `bash` 之外的工具产出的 `TerminalResultView`,它是卡片能拿到的唯一退出信号。
- **在 TUI 渲染器中剥除该标记。** 已否决:渲染器将不得不知晓 `dsh-tool-bash` 的标记词汇,而剥除应当与已经消费它的那次解析放在一起。工具的渲染意图应由工具自己定义。
- **不再从 `renderResult` 发出该标记。** 已否决:在单个文本结果中,该标记是模型唯一的退出信号,而且 `tool:bash` 提示词章节会教模型去检查它。
- **让 `execute` 在文本之外一并返回结构化的退出状态。** 已否决:`presentResult(args, result)` 刻意设计为对内容块的纯函数,从而可以从会话日志回放,而日志只保留渲染后的文本。
## Consequences
卡片正文不再以标记结尾,因此从日志回放的会话与实时运行渲染出同样的单个徽标。既有的仅影响展示的残留问题现在稍微扩大,并已记录在包(package)的 README 中:如果输出的最后一行恰好正是 `[exit code: N]` 或 `[killed by signal: …]`,它会被读成标记,既显示错误的徽标,也会把该行从卡片正文中丢掉。
[工具卡片标题 note](../feature/2026-07-27-tui-tool-card-header.md) 中的刻意处理保持不变:terminal 的退出状态仍沿用既有的暗色 `[exit N]` 行,而不是并入统一的页脚。
## Testing
`tools.spec.ts` 针对非零退出、信号终止与正常运行三种情形钉住不含标记的正文;断言超时标记会与被剥除的退出标记并存;并扩展 `renderResult`/`parseExitStatus` 往返测试,断言正文中不再残留被消费的标记。
该缺陷对所有既有快照都不可见,因此 `tui-keyless-smoke.e2e.ts` 新增了一个真实 PTY 场景:脚本化适配器以 `printf …; exit 3` 调用真实的 `bash` 工具,测试断言终端输出包含该命令的 stdout 与 `[exit 3]`,但绝不出现 `[exit code: 3]`。若回退 presenter 的修复,它能复现重复渲染。