diff --git a/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.i18n.yaml
new file mode 100644
index 0000000000..bdd45027ed
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.i18n.yaml
@@ -0,0 +1,6 @@
+# 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/architecture/2026-07-28-tui-palette-single-source.md
+2026-07-28-tui-palette-single-source.md: 7027e650bfb94dca7608342b10c994b255f48d2d
+2026-07-28-tui-palette-single-source.zh.md: a956a916c01feceedc7b6b6715bee6ceb8b80e7f
diff --git a/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.md b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.md
new file mode 100644
index 0000000000..7027e650bf
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.md
@@ -0,0 +1,57 @@
+# Agent Note: One palette table, one role per meaning, printable by `/palette`
+
+Status: implemented
+
+English | [中文](2026-07-28-tui-palette-single-source.zh.md)
+
+## Problem
+
+The palette had fifteen roles built from escape codes written inline in `createPalette`, and three pairs of them resolved to the same SGR parameters: `added`/`success` were both `32`, `removed`/`error` both `31`, and on a light scheme `muted`/`dim` were both `90`. A reader picking `added` or `muted` believed they had chosen a distinct tone; they had chosen an alias. Nothing enumerated the roles, so no reader could compare them, and the codes existed only as literals at their use site.
+
+That hid a real defect for as long as it took a user to notice it. `dim` deliberately substituted ANSI 90 on light schemes, on the recorded theory that "SGR 2 lightens text on a light background." But lightening is precisely what a recessed tone must do, and ANSI 90 is a fixed hue: on a light theme whose default foreground is a soft gray, bright black is *heavier* than ordinary text. Every surface the TUI called dim — tool-card bodies, the timing footer, injected context, dialog chrome — therefore rendered as the most prominent text on screen, inverting the one relationship the role exists to express. The role named `dim` was the least dim thing in the transcript.
+
+Nothing prevented the inversion from being introduced, and nothing revealed it afterwards. There was no listing to compare tones in, and terminal captures report escape codes rather than rendered luminance, so reading `\x1b[90m` and concluding "gray, therefore recessed" is a mistake a reader repeats indefinitely without a rendered sample to check against.
+
+A second hazard was latent in the same design. SGR has no color stack: a nested span's close emits `39` (default foreground), not the enclosing color, so wrapping colored text in a second color silently drops the outer color for the remainder of the line. Only the type `(text: string) => string` guarded that, which is to say nothing guarded it.
+
+## Decision
+
+`paletteSpec(scheme)` is the single table of every SGR code the TUI may emit. Each entry carries `open`, `close`, and a `purpose` string. `createPalette` derives its wrappers by iterating `COLOR_ROLES` and `ATTRIBUTE_ROLES` over that table, and the `/palette` command prints it, so a role cannot exist in the palette without appearing in the listing or the reverse. No component writes an escape sequence of its own. The startup banner's brand gradient stays the one deliberate exception, since fixed brand color is its point.
+
+`close` MUST reset every SGR group `open` sets. This is stated on `ansi` because the roles that violated it did so silently: `dim` opened `2;39` while closing only `22`, leaking a foreground reset past its own span.
+
+Roles that resolved to the same escape are merged rather than kept as aliases. `muted` folds into `dim`, `added` into `success`, and `removed` into `error`, leaving seven colors and five attributes. `accent` becomes ANSI 95, the tone this terminal actually reads as emphasis, and the single-use `accent2` is deleted rather than kept as a second accent.
+
+`dim` is `2;39` closing `22;39` on both schemes. SGR 2 fades relative to whatever foreground the terminal is using, which is the only way to land *below* `text` on a light and a dark theme with one code; `39` pins the starting foreground to the default so the tone does not inherit a caller's color. Only `code` still varies by scheme, because ANSI 36 is genuinely hard to read on a light background.
+
+Colors and attributes are separately typed. A `ColorRole` takes `Colorable` and returns `Colored`; an `AttributeRole` is generic and preserves its argument's type. So `bold(accent(x))` and `accent(bold(x))` both compile, `accent(error(x))` does not, and — because the brand survives an attribute layer — neither does `dim(bold(success(x)))`.
+
+## Alternatives considered
+
+**Keep ANSI 90 for `dim` and adjust the surfaces that looked wrong.** Rejected: the surfaces were right and the tone was wrong. Every consumer of `dim` wanted the same thing, so the fix belongs in the one role, not in each caller.
+
+**Plain SGR 2 without the `39`.** Considered; the user compared both rendered against their own background. Both read as genuinely dim, and `2` alone is the smaller change, but `2;39` guarantees the span starts from the default foreground rather than inheriting a caller's color, which matters wherever `dim` wraps content that may already be styled.
+
+**Keep `muted` and `dim` as separate names against future differentiation.** Rejected: they resolved to one escape on one of the two supported schemes, and a name that sometimes aliases another is worse than one name. Reintroducing a second recessed tone is a new decision, to be made when a consumer needs it.
+
+**Enforce the no-nested-color rule by convention and review.** Rejected: the rule is mechanically checkable and the failure is invisible — a dropped outer color looks like a rendering quirk, not a bug. The brands cost two type aliases and caught four deliberate violations in a compile check.
+
+**A runtime guard that strips or rejects nested colors.** Rejected: it moves a statically decidable error to run time and would have to allocate on every styled span, in the render hot path.
+
+**Print the palette to stdout from a script instead of a TUI command.** Rejected: the point is to see the tones the *running* TUI produces in the *user's* terminal, under the scheme it actually resolved. A script cannot report the live scheme, and this specific bug was invisible outside the real terminal.
+
+## Consequences
+
+The listing is now the fastest way to find a palette defect: `/palette` shows every role painted by its own code beside the SGR pair it reports, so a tone that misbehaves is visible next to its neighbours instead of inferred from escape numbers. It also reports the resolved scheme, which surfaced that scheme detection is not stable across launches — an unrelated bug this change makes observable but does not fix.
+
+Four role names disappear from the palette, and `muted` leaves the public extension `TuiTheme`; an extension that wants a recessed tone uses `dim`. Merging the diff pair means `success` and `error` each carry two meanings, which is honest about there being one green and one red rather than implying a diff-specific palette.
+
+The brands introduce a small friction: an array literal seeded with a colored string infers `Colored[]`, so two call sites now annotate `string[]` explicitly. That is the cost of the guarantee, and it appears at declaration sites rather than in styling expressions.
+
+`accent` moving to 95 repaints twenty-four call sites, including Markdown headings and links, dialog borders, and the prompt. That is a visible change to surfaces beyond the reported defect, made because a single emphasis color is the point of the reduction.
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` asserts `/palette` prints every name and `purpose` in `paletteSpec` and that each row carries the spec's own open code, so a role added to the table without a listing entry, or a listing that reports one code while rendering another, fails. Verified by construction: truncating the attribute loop makes the test fail rather than pass silently.
+
+The scheme-detection test previously pinned `dim` changing between schemes, which is no longer true; it now pins scheme-independent `dim` alongside the `code` role that does vary. The blank-row and running-glyph tests carry the new SGR pairs. The no-nested-color rule is verified by compiling deliberate violations against the project's own tsconfig, where the four color-over-color expressions are rejected and the five legal attribute compositions are not.
diff --git a/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.zh.md b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.zh.md
new file mode 100644
index 0000000000..a956a916c0
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-tui-palette-single-source.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: 单一调色板表、每种含义一个角色,并可通过 `/palette` 打印
+
+Status: implemented
+
+[English](2026-07-28-tui-palette-single-source.md) | 中文
+
+## Problem
+
+调色板原有十五个角色,其转义码直接写在 `createPalette` 中,其中三对角色解析为相同的 SGR 参数:`added`/`success` 都是 `32`,`removed`/`error` 都是 `31`,而在浅色配色方案中,`muted`/`dim` 都是 `90`。读者选择 `added` 或 `muted` 时,会以为自己选了不同的色调,实际上选到的只是别名。没有任何地方枚举这些角色,因此读者无法比较它们,而这些转义码也仅以字面量存在于使用处。
+
+这个设计一直掩盖着一个真实缺陷,直到用户亲眼发现。`dim` 在浅色配色方案中刻意改用 ANSI 90,依据是记录中「SGR 2(dim)会让浅色背景上的文本变淡」的理由。但变淡恰恰是弱化色调必须做的事,而 ANSI 90 是固定色相:如果浅色主题的默认前景色是柔和的灰色,亮黑色就会比普通文本*更重*。因此,TUI 中所有称作 dim 的界面——工具卡片正文、计时页脚、注入的上下文、对话框边框——都会渲染为屏幕上最醒目的文本,颠倒了该角色本应表达的唯一关系。名为 `dim` 的角色反而成了 transcript(文本记录)中最不弱化的内容。
+
+这个颠倒既没有防范机制,发生后也没有显现机制。没有可供比较色调的清单,而终端捕获记录的是转义码而非渲染亮度,因此看到 `\x1b[90m` 就断定「这是灰色,所以会弱化」,是读者在没有渲染样例可供核对时会不断重复的错误。
+
+同一设计中还潜藏着第二个风险。SGR 没有颜色栈:嵌套区段的关闭码会发出 `39`(默认前景色),而不是外层颜色,因此用第二种颜色包裹已经着色的文本,会悄然丢弃该行剩余部分的外层颜色。唯一的防护只是类型 `(text: string) => string`,也就是说实际上毫无防护。
+
+## Decision
+
+`paletteSpec(scheme)` 是 TUI 可以发出的所有 SGR 码的唯一表。每个条目都包含 `open`、`close` 和一个 `purpose` 字符串。`createPalette` 遍历该表的 `COLOR_ROLES` 与 `ATTRIBUTE_ROLES` 来派生包装函数,`/palette` 命令则打印该表,因此一个角色不可能只存在于调色板中却不出现在清单里,反之亦然。任何组件都不会自行写入转义序列。启动横幅的品牌渐变色是唯一的有意例外,因为固定品牌色本来就是它的目的。
+
+`close` 必须重置 `open` 设置的每一个 SGR 组。该要求在 `ansi` 上明确说明,因为此前违反要求的角色没有显露问题:`dim` 以 `2;39` 开启,却只以 `22` 关闭,导致前景色重置越过其自身区段而泄漏。
+
+解析为同一转义码的角色会合并,而不是作为别名保留。`muted` 并入 `dim`,`added` 并入 `success`,`removed` 并入 `error`,最终留下七种颜色和五种属性。`accent` 改为 ANSI 95,这是该终端中实际呈现为强调色的色调;仅使用一次的 `accent2` 则直接删除,不再作为第二种强调色保留。
+
+在两种配色方案中,`dim` 都以 `2;39` 开启、以 `22;39` 关闭。SGR 2 会相对于终端当前使用的前景色减淡,这是仅用一个代码就在浅色和深色主题中落到 `text` *之下*的唯一方式;`39` 将起始前景色固定为默认值,使该色调不会继承调用方的颜色。只有 `code` 仍然随配色方案而变化,因为 ANSI 36 在浅色背景上确实难以辨认。
+
+颜色与属性分别采用不同的类型。`ColorRole` 接受 `Colorable` 并返回 `Colored`;`AttributeRole` 是泛型,会保留实参的类型。因此 `bold(accent(x))` 和 `accent(bold(x))` 都能编译,`accent(error(x))` 不能;而且——由于品牌标记可以穿过一层属性——`dim(bold(success(x)))` 同样不能编译。
+
+## Alternatives considered
+
+**保留 ANSI 90 作为 `dim`,并调整显示异常的界面。**否决:界面没有问题,错的是色调。`dim` 的所有消费方都需要同一种效果,因此修复应落在这个角色本身,而不是每个调用方中。
+
+**不带 `39` 的纯 SGR 2。**考虑过;用户在自己的背景上比较了两者的渲染效果。两者看起来都确实弱化,单独使用 `2` 也是更小的改动,但 `2;39` 可以保证区段从默认前景色开始,而不是继承调用方的颜色;当 `dim` 包裹可能已有样式的内容时,这一点很重要。
+
+**保留 `muted` 与 `dim` 两个名称,以备日后加以区分。**否决:它们在两种受支持的配色方案之一中解析为同一个转义码,而一个有时成为其他名称别名的名称,还不如只保留一个名称。重新引入第二种弱化色调是一项新决策,应等到消费方确有需要时再作出。
+
+**通过约定和评审来执行禁止嵌套颜色的规则。**否决:这条规则可以机械检查,而且故障不可见——外层颜色丢失看起来会像渲染异常,而不是缺陷。品牌类型的代价只是两个类型别名,却在编译检查中捕获了四处刻意构造的违规。
+
+**在运行时剥离或拒绝嵌套颜色的防护。**否决:这会把一个可静态判定的错误推迟到运行时,并且必须在渲染热路径中为每个带样式的区段分配内存。
+
+**通过脚本将调色板打印到 stdout,而不是使用 TUI 命令。**否决:目的是在*运行中的* TUI 所实际解析出的配色方案下,查看它在*用户自己的*终端中产生的色调。脚本无法报告实时配色方案,而这个特定缺陷在真实终端之外不可见。
+
+## Consequences
+
+现在,清单是发现调色板缺陷最快的方式:`/palette` 会用每个角色自身的代码绘制该角色,并在旁边显示其报告的 SGR 码对,因此表现异常的色调会直接出现在邻近角色旁边,而不必根据转义码数字推断。它还会报告解析出的配色方案,由此揭示配色方案检测在多次启动之间并不稳定——这是本次变更令其可观察、但并未修复的无关缺陷。
+
+调色板中消失了四个角色名称,`muted` 也从公共扩展 `TuiTheme` 中移除;需要弱化色调的扩展应使用 `dim`。合并差异对意味着 `success` 和 `error` 各自承载两种含义,这如实反映了系统只有一种绿色和一种红色,而不会暗示另有差异专用调色板。
+
+品牌类型带来了一点不便:如果数组字面量以着色字符串开头,便会推断为 `Colored[]`,因此现在有两个调用点需要显式标注 `string[]`。这是该保证的代价,而且它出现在声明位置,而不是样式表达式中。
+
+`accent` 改为 95 后,二十四个调用点随之重新着色,包括 Markdown 标题和链接、对话框边框以及提示行。这是报告缺陷之外其他界面的可见变化;作出这一改动,是因为精简的目的正是只保留一种强调色。
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` 断言 `/palette` 会打印每个名称和 `purpose`(均来自 `paletteSpec`),并且每一行都包含规格自身的开启码,因此在表中添加角色却没有清单条目,或清单报告的代码与渲染使用的代码不同,都会导致测试失败。构造验证也已完成:截断属性循环会让测试失败,而不会静默通过。
+
+配色方案检测测试此前固定了 `dim` 会随配色方案变化的行为,但现在已不再如此;该测试现在同时固定与配色方案无关的 `dim`,以及确实会变化的 `code` 角色。空白行测试与运行状态字形测试包含新的 SGR 码对。禁止嵌套颜色的规则通过项目自身的 tsconfig 编译刻意构造的违规来验证,其中四个颜色叠加颜色的表达式会被拒绝,五个合法的属性组合则不会。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.i18n.yaml
new file mode 100644
index 0000000000..af1e8ea8d3
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.i18n.yaml
@@ -0,0 +1,6 @@
+# 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
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.md b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.md
new file mode 100644
index 0000000000..7e5e6e9ed8
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.md
@@ -0,0 +1,37 @@
+# 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 `` and escapes only ``, 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 `` 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 ``.
+
+## 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.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.zh.md
new file mode 100644
index 0000000000..48e329d4d6
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-card-content-independent-fold.zh.md
@@ -0,0 +1,37 @@
+# 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` 用 `` 包裹指令,并且只转义 ``,因此正文中任何裸的 `&` 或 `<` 都构成无效实体引用或非法标签,进而让整篇文档解析失败。徽章 URL 中的 `&logo=` 会触发这一情况,任何 `a < b` 也会。在一份真实会话日志中观察到:两张 `workspace-instructions` 卡片分别渲染出 254 行和 85 行,折叠态与展开态完全一致,且每张卡片正文首行都是字面量 ``;而一张文本恰好能解析的 `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),这同时也消除了本次修复无法抑制的残留外框行:未解析的正文没有可去掉的已解析根元素,因此其首行仍是字面量 ``。
+
+## 考虑过的替代方案
+
+**在 `workspace-context` 中转义 `&` 与 `<`,使文档总能解析。** 作为本缺陷的修复方案不予采纳:它是靠让解析成功来让折叠生效,从而使折叠仍取决于内容。任何注入任意文本的其他插件,或任何转义遗漏,都会重新引入同一症状。这件事本身值得做——它还会顺带去掉外框行——但卡片无论如何都必须能折叠。
+
+**先做预解析修复,或改用宽松的 HTML 式解析器。** 不予采纳:`renderUnknownXml` 是有意拒绝的,这样部分或混合文本才会原样渲染,而不是走一棵猜出来的树。为了满足呈现限额而放宽它,等于用一次正确的拒绝换来一棵错误的树,并且会连未知工具结果一起波及。
+
+**只在 `undefined` 分支里做截断。** 不予采纳:它修掉了症状,却留下两份彼此一致性未经验证的折叠实现。导出 `preview` 同时消除了工具卡片中的那份重复。
+
+## 后果
+
+每张上下文卡片都会折叠到 `maxToolOutputLines`,并响应 `Ctrl+O`,无论其文本包含什么。树形正文含大量子项的工具卡片现在可能在原先不折叠的情况下折叠,因为受约束的是卡片总行数而非每个子项。
+
+## 测试
+
+`packages/ui/tui/tests/tui.spec.ts` 固定了一张文本带有裸 `&` 的上下文卡片(徽章 URL 中的 `&logo=`,即观察到的真实触发条件):它默认处于折叠态并带有 `Ctrl+O to expand` 标记,隐藏了中间某一行,按下 `Ctrl+O` 后将其显示出来。该测试在本次修复的父提交上曾失败,当时的卡片不会输出标记。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.i18n.yaml
new file mode 100644
index 0000000000..417d09e167
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.i18n.yaml
@@ -0,0 +1,6 @@
+# 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
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.md b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.md
new file mode 100644
index 0000000000..0b87e6b5e2
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.md
@@ -0,0 +1,49 @@
+# 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:
+
+- `` 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///`, `-t `, `Branded`. 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 `` expands to a control character the raw-source escaping never saw. Unparsed text cannot expand, so `` 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/<group>/<pkg>/` and `&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 `` 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 (`` … ``), header-only rendering for an empty frame, and a body carrying both a raw `&logo=` and `packages///` that folds and survives verbatim through a `Ctrl+O` round trip. The one-line frame case pins that `` 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.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.zh.md
new file mode 100644
index 0000000000..461466be14
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-context-cards-render-prose-not-xml.zh.md
@@ -0,0 +1,49 @@
+# 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。三个事实可以确立这一点:
+
+- `` 是一种提示词约定,而非标记语言。没有任何模型是针对该标签训练的([外框设计依据](../simplification/2026-07-20-unwrap-injected-content-envelopes.md));它传达的是「这是注入内容,不是用户在说话」。
+- 真实的指令正文包含一些对严格解析致命、但在普通文本中再正常不过的字符。徽章 URL 中 `&logo=` 里的裸 `&` 就是一个无效实体引用;在一次真实会话上测得,某个 `workspace-instructions` 载荷携带了四个。
+- 这些正文里的尖括号并不是元素。本仓库自身指令中的每一个内层「标签」都是路径或命令模板中的占位符——`packages///`、`-t `、`Branded`。把它们按元素做树形渲染会曲解这段文本。
+
+这种脆弱性从来不局限于工作区:`dsh-tool-skill` 构造的是同一套外框,它今天还能解析成功,仅仅因为至今没有哪个 skill(技能)描述里出现过 `&`。
+
+## 决策
+
+卡片不做解析。它按整行精确匹配剥掉生产方的外层外框,把剩余文本渲染为暗色文本行,并交由共享的 `preview` 折叠。
+
+`stripReminderFrame` 只在首行与末行构成一对匹配的开闭标签、且各自独占一行(`REMINDER_FRAME_LINE`)时才将其移除。落单的标签行、不匹配的标签对、以及在文本中间提及的标签都原样保留,因此不会有正文因为首行形似标签而被静默截断。生产方以整行形式输出外框,这正是精确匹配足够可靠的原因。
+
+面向模型的文本不受影响。被否决的替代方案(在生产方做转义以使文档可解析)会改变模型读到的内容。
+
+是否为空由剥离后的文本判定,而不是由已套样式的行判定。调色板会给每一行都包上转义序列,因此空正文若先套样式,就会产出一行只含转义序列的行,看上去像标题下方多出一条无端的空行;改为检测 `stripped === ''` 后,卡片只渲染标题。
+
+去掉解析同时也去掉了它带来的 C1 展开隐患。`renderUnknownXml` 必须对解析出的文本做转义,因为形如 `` 的字符引用会展开为一个控制字符,而针对原始源文本的转义根本看不到它。未经解析的文本无法展开,因此 `` 现在按字面渲染,终端注入安全性完全由 `displayText` 承担,并由 `untrusted-controls` 快照固定。
+
+`renderUnknownXml` 保留了它唯一剩下的消费方:未知工具结果。在那里真正的 XML 结果是有可能出现的,根元素也确有含义。与上下文卡片共享的只有 `preview`。
+
+## 考虑过的替代方案
+
+**在每个生产方中把 `&` 与 `<` 做 XML 转义,使外框可解析。** 不予采纳:它为了迁就一个呈现细节而破坏面向模型的契约。模型会读到 `packages/<group>/<pkg>/` 和 `&logo=deepseek`——而那个徽章 URL 正是一条要求模型逐字复制到 PR(Pull Request)描述里的指令。它还需要在每个现有和未来的生产方中重复实现,且没有任何机制保证它们彼此一致。
+
+**只在展示侧转义,保持模型文本不变。** 不予采纳:为让解析成功而在展示侧转义、再把解析结果渲染成树,会重新引入把占位符当作元素的误读。这段文本无论如何都是普通文本,解析它换不来任何收益。
+
+**改用宽松的 HTML 式解析器。** 不予采纳:`renderUnknownXml` 是有意拒绝的,这样部分或混合文本才会原样渲染,而不是走一棵猜出来的树。放宽它等于用一次正确的拒绝换来一棵错误的树,并且会连未知工具结果一起波及。
+
+## 后果
+
+上下文卡片无论文本包含什么都渲染一致:外框行在所有情形下都消失,普通文本逐字保留,折叠只取决于行数。正文失去了树形渲染的两空格缩进与嵌套元素结构,重新录制的 `surface-after-compaction-{narrow,wide}` 快照体现了这一点;诸如 `` 这类嵌套块,现在按它在面向模型载荷中的字面文本出现。空外框只渲染标题,而不是留下一条空行。
+
+上下文中的字符引用不再为展示而解码,这才是对模型逐字接收到的载荷的正确读法。
+
+## 测试
+
+`packages/ui/tui/tests/tui.spec.ts` 固定了以下几点:多行提醒的外框被剥掉;标签对落单(`` … ``)时普通文本得以保留;空外框只渲染标题;以及一段同时含有裸 `&logo=` 和 `packages///` 的正文会折叠,并在 `Ctrl+O` 的一次往返中逐字存活。单行外框的用例固定了 `` 保持字面量,同时裸的 C1 字节永远不会到达终端。`surface-after-compaction-{narrow,wide}` 无密钥快照因缩进丢失和现已录制的暗色样式而被重新录制;`untrusted-controls` 未变,继续持有转义契约。`examples/tui-agent` 的 `multi-turn-conversation` 终端快照也一并刷新:自最初的可折叠上下文改动以来,它一直因一行无关的陈旧内容而失败——那里它仍期望一个按宽度补齐的 `Context · plan-mode` 标题,而自标题不再经由 `Text` 渲染之后,就没有卡片再输出过这种标题。两个改动文件都保持 100% 的语句与分支覆盖率。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.i18n.yaml
new file mode 100644
index 0000000000..9ac8345ba9
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.i18n.yaml
@@ -0,0 +1,6 @@
+# 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
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.md b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.md
new file mode 100644
index 0000000000..e082308a9a
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.md
@@ -0,0 +1,46 @@
+# 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.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.zh.md
new file mode 100644
index 0000000000..dc8aa0a6c2
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-terminal-card-double-exit-status.zh.md
@@ -0,0 +1,46 @@
+# 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 的修复,它能复现重复渲染。
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
new file mode 100644
index 0000000000..60285a26b0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
@@ -0,0 +1,6 @@
+# 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/feature/2026-07-28-cross-workspace-resume.md
+2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df
+2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
new file mode 100644
index 0000000000..09b638398e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
@@ -0,0 +1,52 @@
+# Agent Note: Cross-workspace session resume
+
+Status: implemented
+
+English | [中文](2026-07-28-cross-workspace-resume.zh.md)
+
+## Problem
+
+`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing.
+
+Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it.
+
+The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused.
+
+Finally, resume never changed directory. The host re-execs `dsh --resume=` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project.
+
+## Decision
+
+The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory.
+
+**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`.
+
+`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment.
+
+**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.
+
+`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact.
+
+**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace.
+
+## Alternatives considered
+
+**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact.
+
+**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up.
+
+**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume ` from that directory, but no longer appear in `/resume`.
+
+**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones.
+
+**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam.
+
+## Consequences
+
+- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration.
+- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope.
+- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
+- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
+
+## Testing
+
+TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace.
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
new file mode 100644
index 0000000000..5a2e7d2535
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
@@ -0,0 +1,52 @@
+# Agent Note: 跨 workspace 会话恢复
+
+Status: implemented
+
+[English](2026-07-28-cross-workspace-resume.md) | 中文
+
+## Problem
+
+`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。
+
+存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。
+
+接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。
+
+最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。
+
+## Decision
+
+dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
+
+**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。
+
+`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。
+
+**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
+
+因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
+
+**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。
+
+## Alternatives considered
+
+**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。
+
+**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。
+
+**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume ` 仍可恢复,只是不再出现在 `/resume` 中。
+
+**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
+
+**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。
+
+## Consequences
+
+- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
+- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。
+- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
+- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
+
+## Testing
+
+TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。
diff --git a/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.i18n.yaml
new file mode 100644
index 0000000000..07fbaed171
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.i18n.yaml
@@ -0,0 +1,6 @@
+# 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/feature/2026-07-28-tui-dim-tool-result-output.md
+2026-07-28-tui-dim-tool-result-output.md: 3eab3e57158c1bbcbadf233e27e50360601c84d5
+2026-07-28-tui-dim-tool-result-output.zh.md: cac06245f6c9d71a358f789099512a5a4a84eb49
diff --git a/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.md b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.md
new file mode 100644
index 0000000000..3eab3e5715
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.md
@@ -0,0 +1,39 @@
+# Agent Note: Dim tool-result output inside TUI tool cards
+
+Status: implemented
+
+English | [中文](2026-07-28-tui-dim-tool-result-output.zh.md)
+
+## Problem
+
+After the [fixed `Tool / ` header](2026-07-27-tui-tool-card-header.md) moved every tool-specific detail into the card body, that body became a flat block at the terminal's default foreground: a presenter title, a terminal `$` command, its cwd, and the tool's own output all read as one undifferentiated run of text. A transcript of several calls gave no visual cue for where the card's framing ended and what the tool actually produced began, and long command output competed with the surrounding conversation for attention even though it is reference material a reader skims rather than reads.
+
+## Decision
+
+The framing/output split below is superseded by [one dim tone for the whole card body](2026-07-28-tui-uniform-dim-card-body.md), which keeps dim output but extends it over the framing rows; that note owns the current rule and the reason the split read as scatter. What remains current here is why tool output is recessed at all, and the diff-card and blank-row carve-outs both notes share.
+
+Inside a tool card, the tool's own output renders in the `dim` palette role while the card's framing keeps its existing color. Framing is the presenter title, a terminal card's `$` command line and cwd row, and a diff card's per-file path headers and `+`/`-` lines; output is a terminal card's captured stdout/stderr and a generic card's result text.
+
+`ToolCardComponent.renderBody` in `packages/ui/tui/src/components/transcript.ts` returns a `CardBody` of `{ prelude, lines }` instead of one flat string array. `prelude` holds already-styled framing rows that render verbatim; `lines` holds the tool's text. A terminal card dims its output rows through `dimOutput`, which leaves a blank row as the empty string so the branch's existing blank-row filter still drops it rather than keeping an ANSI-wrapped empty value. A diff card returns its hunks and change footer entirely as `prelude`: the `+`/`-` colors already carry the diff's meaning, and dimming them would fight that signal.
+
+A generic card renders its title and result as one Markdown document and dims only the rows past the title's, in `dimPastPrelude`. Rendering the title alone at the same width yields its row count, so the split survives wrapping and the document keeps its own block spacing — notably the blank row pi-tui's Markdown places between a leading paragraph and a following heading, which a two-document split would drop. A whitespace-only row is left unwrapped so Markdown's line padding stays out of the styled ranges. Markdown role colors (headings, inline code) still apply over the dim base, so a dim result keeps its internal structure.
+
+Exit and signal markers keep their existing roles (`dim [exit N]`, `error [signal …]`), and the collapsed-preview marker stays dim, so the change adds no new palette role and no configuration.
+
+## Alternatives considered
+
+**Dim the whole card body.** Rejected: it flattens a diff card's `+`/`-` green and red, which is the one place color carries meaning rather than emphasis, and it dims the `$` command a reader scans for to identify what ran.
+
+**Change the generic card's Markdown base color and keep the title inside the same document.** Rejected: `DefaultTextStyle.color` applies uniformly to every row, so the title would dim along with the result. Splitting the title into its own document instead loses the blank row Markdown inserts before a heading, which visibly closed the gap between title and result in the `run_code` and `cordis_inspect` cards.
+
+**Introduce a dedicated `toolOutput` palette role.** Rejected: no consumer needs it distinct from `dim`, and the palette's role set is the contract other components read; adding a role that resolves to the same SGR pair buys nothing.
+
+**Dim every row unconditionally in `dimOutput`.** Rejected: wrapping an empty string yields a non-empty ANSI value, which defeats the terminal branch's `filter(Boolean)` and adds a blank row to every card whose output ends in a newline — that is, nearly every real bash result.
+
+## Consequences
+
+A card now reads as framing plus output at a glance, and a transcript of many calls scans as a column of headers with recessed detail beneath each. The cost is that `dimPastPrelude` renders a generic card's Markdown twice per frame — once for the prelude alone to count its rows, once for the whole document — which is acceptable at card scale and keeps the row split correct under wrapping. Because dim is an SGR attribute rather than a color, a result's Markdown role colors survive underneath it, so a dim body is still structured rather than uniformly gray. The treatment is TUI-local: ACP and JSON-RPC bridges keep their own tool-call presentation, and no presenter or `presentation.ts` type changed.
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` pins the blank-row guard with color enabled, where the dim wrapper is what makes an empty row non-empty; the assertion fails if `dimOutput` wraps unconditionally. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` were re-recorded and carry the new `dim` style ranges for bash output, read output, `run_code`, `workflow`, `subagent`, `todo_write`, and `cordis_*` results, while the diff cards' `+`/`-` ranges and the `$` command rows are unchanged.
diff --git a/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.zh.md b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.zh.md
new file mode 100644
index 0000000000..cac06245f6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-tui-dim-tool-result-output.zh.md
@@ -0,0 +1,39 @@
+# Agent Note: Dim tool-result output inside TUI tool cards
+
+Status: implemented
+
+[English](2026-07-28-tui-dim-tool-result-output.md) | 中文
+
+## Problem
+
+[固定的 `Tool / ` 表头](2026-07-27-tui-tool-card-header.md)把每一项工具专属的细节都移入卡片正文后,正文成为使用终端默认前景色的扁平文本块:presenter 标题、终端的 `$` 命令及其 cwd,以及工具自身的输出都成了一段无法区分的文本。包含多次调用的 transcript(文本记录)无法在视觉上区分卡片框架到哪里结束、工具实际产生的内容从哪里开始;而且命令输出较长时会与周围的对话争夺注意力,尽管它只是供读者扫读而非细读的参考资料。
+
+## Decision
+
+下文所述的框架/输出划分已由[整个卡片正文统一使用一种暗色调](2026-07-28-tui-uniform-dim-card-body.md)取代:后者保留输出的暗色样式,并将其扩展到框架行;当前规则以及这种划分为何呈现为颜色散乱,均由该说明负责记录。本文仍然有效的是工具输出为何需要弱化,以及两篇说明共同保留的 diff 卡片和空白行例外。
+
+在工具卡片中,工具自身的输出使用调色板的 `dim` 角色渲染,而卡片框架保留既有颜色。框架包括 presenter 标题、终端卡片的 `$` 命令行与 cwd 行,以及 diff 卡片各文件的路径表头和 `+`/`-` 行;输出包括终端卡片捕获的 stdout/stderr,以及 generic 卡片的结果文本。
+
+`ToolCardComponent.renderBody` 在 `packages/ui/tui/src/components/transcript.ts` 中返回 `CardBody`,其内容为 `{ prelude, lines }`,而非单一的扁平字符串数组。`prelude` 保存已设置样式并按原样渲染的框架行;`lines` 保存工具文本。终端卡片通过 `dimOutput` 将输出行变暗;该函数会把空白行保留为空字符串,使此分支既有的空白行过滤逻辑仍会丢弃它,而不会保留一个带 ANSI 包装的空值。diff 卡片将其变更块与变更页脚全部作为 `prelude` 返回:`+`/`-` 的颜色已经承载了 diff 的含义,再将它们变暗会干扰这一信号。
+
+对于 generic 卡片,标题与结果作为同一个 Markdown 文档渲染,再由 `dimPastPrelude` 仅将标题之后的行变暗。以相同宽度单独渲染标题即可得到它的行数,因此即使发生折行,也能正确划分两部分,同时文档仍可保留自身的块间距,尤其是 pi-tui 的 Markdown 在开头段落与后续标题之间插入的空白行;拆成两个文档会丢失该空白行。仅含空白字符的行不会添加样式包装,因此 Markdown 的行填充不会进入样式范围。Markdown 的角色颜色(标题、内联代码)仍会叠加于暗色基础样式之上,因此变暗的结果仍保留内部结构。
+
+退出和信号标记保留既有角色(`dim [exit N]`、`error [signal …]`),折叠预览标记也保持变暗,因此此变更没有新增调色板角色或配置。
+
+## Alternatives considered
+
+**将整个卡片正文变暗。** 已否决:这会削弱 diff 卡片中 `+`/`-` 的绿色和红色,而这里的颜色承载的是含义而非强调;同时还会把读者用来确认所运行命令的 `$` 命令变暗。
+
+**更改 generic 卡片的 Markdown 基础颜色,并将标题留在同一个文档中。** 已否决:`DefaultTextStyle.color` 会统一应用于每一行,因此标题会随结果一起变暗。改为把标题拆成单独的文档,又会丢失 Markdown 在标题前插入的空白行,从视觉上消除 `run_code` 和 `cordis_inspect` 卡片中标题与结果之间的间隔。
+
+**引入专用的 `toolOutput` 调色板角色。** 已否决:没有消费方需要将其与 `dim` 区分,而调色板的角色集合是其他组件读取的契约;新增一个解析为相同 SGR 组合的角色没有收益。
+
+**在 `dimOutput` 中无条件将每一行变暗。** 已否决:包装空字符串会得到一个非空的 ANSI 值,使终端分支的 `filter(Boolean)` 失效,并为输出以换行符结尾的每张卡片都增加一个空白行,而几乎每条真实 bash 结果都以换行符结尾。
+
+## Consequences
+
+卡片现在一眼就能看出框架与输出,包含大量调用的 transcript 也呈现为一列表头,每个表头下方是弱化的细节。代价是 `dimPastPrelude` 每帧会渲染 generic 卡片的 Markdown 两次:第一次只渲染 prelude 以统计行数,第二次渲染整个文档;在卡片规模下,这一开销可以接受,并能在折行时保持正确的行划分。由于变暗效果是 SGR 属性而非颜色,结果中的 Markdown 角色颜色仍能在其下保留,因此变暗的正文仍有结构,而不是统一的灰色。此处理仅限 TUI:ACP 和 JSON-RPC 桥接层保留各自的工具调用呈现方式,所有 presenter 与 `presentation.ts` 类型均未改变。
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` 在启用颜色的情况下固定了空白行守卫,此时正是 dim 包装层使空白行成为非空值;如果 `dimOutput` 无条件包装,该断言就会失败。`packages/ui/tui/tests/snapshots/` 与 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照已重新录制,并加入新的 `dim` 样式范围,覆盖 bash 输出、read 输出、`run_code`、`workflow`、`subagent`、`todo_write` 和 `cordis_*` 结果,同时 diff 卡片的 `+`/`-` 范围与 `$` 命令行保持不变。
diff --git a/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.i18n.yaml
new file mode 100644
index 0000000000..506e343d61
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.i18n.yaml
@@ -0,0 +1,6 @@
+# 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/feature/2026-07-28-tui-foldable-context-cards.md
+2026-07-28-tui-foldable-context-cards.md: ff96c30db231c682a5a4d6a9aa3b94a283b8501b
+2026-07-28-tui-foldable-context-cards.zh.md: ceb9f28834d26abc5717174dd34ab316e3754284
diff --git a/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.md b/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.md
new file mode 100644
index 0000000000..ff96c30db2
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-tui-foldable-context-cards.md
@@ -0,0 +1,35 @@
+# Agent Note: Foldable injected-context cards in the TUI
+
+Status: implemented
+
+English | [中文](2026-07-28-tui-foldable-context-cards.zh.md)
+
+## Problem
+
+The TUI rendered every injected-context message (a non-`user` `user/message` source: `workspace-context`, `goal`, and other plugins) as three loose transcript children — a dim `Context ·