fix(tui): complete launcher integration and rationale
This commit is contained in:
@@ -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
|
||||
@@ -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=<id>` 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 <id>` 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.
|
||||
@@ -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=<id>`,而它会继承 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 <id>` 仍可恢复,只是不再出现在 `/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。
|
||||
@@ -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
|
||||
@@ -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 / <name>` 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.
|
||||
@@ -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 / <name>` 表头](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 卡片的 `+`/`-` 范围与 `$` 命令行保持不变。
|
||||
@@ -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
|
||||
@@ -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 · <label>` header, the message's XML root element name (`system-reminder`), and the body — always fully expanded. Two things read badly. First, unlike tool-call cards, a context card could not be collapsed, so a large `workspace-context` reminder occupied the transcript permanently with no `Ctrl+O` fold. Second, the XML root element rendered as its own literal line (`system-reminder`) directly under the `Context · workspace-context` header that already names the source, so the frame element read as raw-XML noise duplicating the header.
|
||||
|
||||
## Decision
|
||||
|
||||
Injected context renders as a `ContextCardComponent` (`packages/ui/tui/src/components/transcript.ts`), a collapsible dim card that shares the tool-card `Ctrl+O` toggle and starts collapsed. Its header is the same dim `Context · <label>` line. Its body is the message text as muted prose rows with the redundant frame lines stripped: the source label already names the context, so the body starts at the instruction content and the `system-reminder` line is gone. The body folds to the card's `maxToolOutputLines` budget through the shared `preview` helper, showing the `… +N lines (Ctrl+O to expand)` marker. Neither the fold nor the frame stripping depends on the payload's syntax ([content-independent fold](../bug-fix/2026-07-28-context-card-content-independent-fold.md), [prose rendering](../bug-fix/2026-07-28-context-cards-render-prose-not-xml.md)); this note's original implementation routed both through `renderUnknownXml`.
|
||||
|
||||
`Ctrl+O` cycles the shared `toggleTools` handler through three states — collapsed preview, expanded, hidden — matching Codex's behavior: the hidden phase removes tool cards (and their leading gap, which the card renders itself) from the transcript entirely. Context cards carry injected instructions rather than tool traffic, so they never hide; the hidden phase reads as their collapsed preview. The notice names the state (`Tool and context cards {collapsed|expanded}.` / `Tool cards hidden.`) and the `/help` shortcut line reads `Ctrl+O cycle cards (collapse/expand/hide)`. `renderEvent` tracks each card in a `contextCards` set alongside `allToolCards`, cleared by `rebuildTranscript`.
|
||||
|
||||
The label derivation tolerates a non-object source shape (an invalid injected source that is neither a session-reference card nor an object): it falls back to the generic `context` heading rather than dereferencing `plugin`/`kind` off a non-object. The `sessionReferenceCard` branch (the single-line referenced-sessions row) is unchanged — it has nothing to fold.
|
||||
|
||||
The change is TUI-only. It touches `ContextCardComponent`/`ToolCardComponent` wiring in `transcript.ts` and the `renderEvent`/`toggleTools`/help paths in `packages/ui/tui/src/index.ts`. No producer, session event, or other UI bridge (ACP, JSON-RPC) changes; the fold shape is TUI-local and not a cross-package contract.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep context cards fully expanded, only drop the root line.** Rejected: the primary complaint was that a large `workspace-context` reminder cannot be folded like a tool card. Dropping the redundant frame line alone leaves the transcript-occupancy problem unsolved.
|
||||
|
||||
**A dedicated shortcut for context cards, independent of tool cards.** Rejected in favor of sharing `Ctrl+O`: one key matches the existing mental model and adds nothing to learn or document, and the two card kinds fold for the same reason (transcript noise). The cycle's hidden phase applies only to tool cards for the same reason a shared key works: hiding injected instructions would remove content the user cannot recover from any other card.
|
||||
|
||||
**Suppress the root line generically inside `renderUnknownXml`.** Rejected: `renderUnknownXml` also renders unknown tool results, where the root element is meaningful. Root suppression is a context-card presentation choice and lives in `ContextCardComponent`, keeping the tool-card path unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
A `workspace-context` reminder now collapses with the same `Ctrl+O` that folds tool cards, and its body no longer prints the redundant `system-reminder` frame line under the header. The cost is a second card kind tracked for the shared cycle and a small label-shape fallback for invalid injected sources. Because the change is confined to the TUI transcript, ACP and JSON-RPC bridges keep their own injected-context presentation.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins that a context card is collapsed by default with the `Ctrl+O to expand` marker, expands on `Ctrl+O` with the `Tool and context cards expanded.` notice, survives the hidden phase at its collapsed preview while tool cards disappear from a repaint, returns to `collapsed` on the fourth press, drops the `system-reminder` frame line, renders unframed context as muted prose, and renders an empty frame header-only. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` — rendered through the real assembled TUI and a pseudo-terminal — were re-recorded and show the frame-less context card, the updated toggle notice, and the updated `/help` shortcut line.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: TUI 中的可折叠注入上下文卡片
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-tui-foldable-context-cards.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
TUI 会将每条注入上下文消息(即一条来源不是 `user` 的 `user/message`:`workspace-context`、`goal` 及其他插件)渲染为 transcript(文本记录)中的三个零散子项:暗色的 `Context · <label>` 标题、消息的 XML 根元素名称(`system-reminder`)和正文,并且始终完全展开。这种呈现有两处影响阅读。第一,与工具调用卡片不同,上下文卡片无法折叠,因此较大的 `workspace-context` 提醒会一直占据 transcript,无法通过 `Ctrl+O` 折叠。第二,XML 根元素会直接显示在已经注明来源的 `Context · workspace-context` 标题下方,单独形成一行字面值(`system-reminder`);这使外框元素成为重复标题信息的原始 XML 噪声。
|
||||
|
||||
## 决策
|
||||
|
||||
注入上下文渲染为 `ContextCardComponent`(`packages/ui/tui/src/components/transcript.ts`)。这是一个可折叠的暗色卡片,与工具卡片共用 `Ctrl+O` 切换操作,初始状态为折叠。其标题仍是暗色的 `Context · <label>` 行。正文是去掉多余外框行后、以暗色文本按行渲染的消息内容:来源标签已经指明上下文,因此正文直接从指令内容开始,不再显示 `system-reminder` 行。正文通过共用的 `preview` 辅助函数折叠到卡片的 `maxToolOutputLines` 行数限额,并显示 `… +N lines (Ctrl+O to expand)` 标记。折叠与去外框都不依赖载荷的语法([与内容无关的折叠](../bug-fix/2026-07-28-context-card-content-independent-fold.md)、[渲染为文本](../bug-fix/2026-07-28-context-cards-render-prose-not-xml.md));本记录最初的实现是把两者都走 `renderUnknownXml`。
|
||||
|
||||
`Ctrl+O` 让共享的 `toggleTools` 处理器在三种状态间循环——折叠预览、展开、隐藏——与 Codex 的行为一致:隐藏阶段把工具卡片(连同卡片自渲染的前导空行)从 transcript 中完全去掉。上下文卡片承载的是注入指令而非工具流量,因此从不隐藏;隐藏阶段对它们呈现为折叠预览。通知文本按状态命名(`Tool and context cards {collapsed|expanded}.` / `Tool cards hidden.`),`/help` 中的快捷键说明为 `Ctrl+O cycle cards (collapse/expand/hide)`。`renderEvent` 将每张上下文卡片记录在 `contextCards` 集合中,与 `allToolCards` 并列;`rebuildTranscript` 会清空该集合。
|
||||
|
||||
标签派生逻辑可以处理非对象形态的来源(既不是会话引用卡片也不是对象的无效注入来源):系统会回退到通用的 `context` 标题,而不会从非对象值中解引用 `plugin`/`kind`。`sessionReferenceCard` 分支(引用会话的单行条目)保持不变,其中没有可折叠内容。
|
||||
|
||||
此变更仅限于 TUI。它涉及 `transcript.ts` 中 `ContextCardComponent`/`ToolCardComponent` 的连接,以及 `packages/ui/tui/src/index.ts` 中的 `renderEvent`/`toggleTools`/帮助信息路径。生产方、会话事件和其他 UI 桥接层(ACP、JSON-RPC)均无需变更;折叠方式属于 TUI 的局部实现,并非跨包契约。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保持上下文卡片完全展开,仅去掉根元素行。** 不予采纳:最主要的问题是较大的 `workspace-context` 提醒无法像工具卡片一样折叠。只去掉多余的外框行,仍无法解决 transcript 占用问题。
|
||||
|
||||
**为上下文卡片设置独立快捷键,与工具卡片分开。** 不予采纳,选择共享 `Ctrl+O`:统一按键符合现有认知模式,无需学习或记录新的按键;两类卡片的折叠目的相同,都是减少 transcript 噪声。循环的隐藏阶段只作用于工具卡片,理由与共享按键成立的理由相同:隐藏注入指令会移除用户无法从其他卡片找回的内容。
|
||||
|
||||
**在 `renderUnknownXml` 内统一隐藏根元素行。** 不予采纳:`renderUnknownXml` 还用于渲染未知的工具结果,此时根元素具有实际意义。隐藏根元素是上下文卡片的呈现选择,应由 `ContextCardComponent` 负责,从而保持工具卡片路径不变。
|
||||
|
||||
## 后果
|
||||
|
||||
`workspace-context` 提醒可以通过折叠工具卡片的同一个 `Ctrl+O` 折叠,其正文也不再在标题下方显示多余的 `system-reminder` 外框行。代价是共享循环逻辑需要额外跟踪第二种卡片,并为无效注入来源增加一小段标签形态回退逻辑。由于此变更仅限于 TUI transcript,ACP 和 JSON-RPC 桥接层会保留各自的注入上下文呈现方式。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:上下文卡片默认折叠并显示 `Ctrl+O to expand` 标记;按下 `Ctrl+O` 后展开,并显示 `Tool and context cards expanded.` 通知;在隐藏阶段以折叠预览存活、而工具卡片在重绘后消失;第四次按键回到 `collapsed`;不显示 `system-reminder` 外框行;将不带外框的上下文渲染为暗色文本;空外框只渲染标题。`packages/ui/tui/tests/snapshots/` 下的无密钥终端快照通过实际组装的 TUI 和伪终端渲染,并已重新录制;其中展示了不带外框元素的上下文卡片、更新后的切换通知,以及更新后的 `/help` 快捷键说明。
|
||||
@@ -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-uniform-dim-card-body.md
|
||||
2026-07-28-tui-uniform-dim-card-body.md: 571ae517b508e0ca49571237dfe96d9b710f8721
|
||||
2026-07-28-tui-uniform-dim-card-body.zh.md: 4b776d3ade411e1ef6e18c527907457ea719bfba
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: One dim tone for the whole TUI tool-card body
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-tui-uniform-dim-card-body.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Dimming tool-result output](2026-07-28-tui-dim-tool-result-output.md) split a tool card into framing that keeps its own color and output that renders dim. In a real transcript that split read as scatter rather than structure, because the framing rows are not one color: a generic card's presenter title (`Read src/foo.ts (95 - 149)`, `Update todo list`) stayed at the terminal's default foreground, a terminal card's `$` command was cyan, its cwd was dim, and the output below was dim. Three tones in four consecutive rows, and the brightest one — plain default foreground, which reads as solid black on a light scheme — sat on the least informative row.
|
||||
|
||||
An unknown tool's XML tree was worse, and is what surfaced the problem. `renderUnknownXml` styled element names through its `label` callback but emitted text content with no styling at all, while the card's collapsed `… +N lines (Ctrl+O to expand)` marker was dim. The card body therefore mixed unstyled black content rows with a dim fold hint, so the hint looked like it belonged to a different element than the text it summarized. The same undifferentiated-body complaint the earlier note set out to fix had reappeared one level down, inside the body.
|
||||
|
||||
The underlying reason is that the two roles are indistinguishable on one of the two supported schemes. `dim` is SGR 2 on a dark scheme but ANSI 90 on a light one, exactly what `muted` always is, so a body that mixes `dim`, `muted`, and unstyled rows collapses to two tones on light terminals and three on dark — a difference that reads as inconsistency rather than as meaning.
|
||||
|
||||
## Decision
|
||||
|
||||
A tool card's body is one dim tone end to end. The card's only colored row is its `Tool / <name>` header, which carries call status (warning pending, success ok, error). The presenter title, a terminal card's `$` command line and its cwd, the tool's own output, an XML tree's text content, and the collapsed fold marker all render through the `dim` role.
|
||||
|
||||
Two exceptions keep color where color is the meaning rather than emphasis: a diff card's `+`/`-` lines and per-file path headers, whose red and green *are* the diff, and a terminal card's `[signal …]` marker, which reports abnormal termination. An XML tree's element names stay `muted`, one shade off body text, because they are structure a reader navigates by rather than content.
|
||||
|
||||
`renderUnknownXml` takes a `body` styler beside its existing `label` styler and applies it to every text row — `textBlock`'s lines and the single-line `<tag>: value` form's value. Its one caller, the unknown-tool card, passes the card's body tone (`dim`), so tree content matches the rows around it instead of falling back to the default foreground.
|
||||
|
||||
`ContextCardComponent` renders [injected context as prose](../bug-fix/2026-07-28-context-cards-render-prose-not-xml.md) rather than parsing it, and its body moves from `muted` to `dim` for the same reason the tool cards changed: the card's header and fold marker are already `dim`, so a `muted` body was the one row group that did not match.
|
||||
|
||||
`ToolCardComponent.dimPastPrelude` becomes `dimBody`: it still renders prelude and result as one Markdown document so the document's own block spacing survives, but dims every row rather than only those past the prelude's row count. That deletes the second Markdown render the old row-counting split needed, since there is no longer a boundary to locate. A whitespace-only row stays unwrapped, keeping Markdown's padding out of the styled ranges, and the terminal branch's blank rows stay the empty string so its `filter(Boolean)` still drops them.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the framing/output split and just fix the XML tree.** Rejected: it addresses the reported symptom and leaves the cause. The split's premise is that framing shares one color, which was never true — the presenter title, the cyan `$` command, and the dim cwd are three tones — so every future framing row reopens the same question.
|
||||
|
||||
**Give the presenter title its own role (bold, or accent).** Rejected: it adds a fourth tone to the row the user identified as noise. The header already names the tool, so the title is a detail line, not a heading; emphasizing it competes with the status color directly above it.
|
||||
|
||||
**Keep the `$` command cyan as a scan anchor.** Considered and explicitly declined by the user in favor of a uniform body. The `$ ` prefix and the header's `/ <description>` segment already locate the command, so the color was redundant with two other cues.
|
||||
|
||||
**Make `dim` and `muted` visually distinct on light schemes so the existing three-tone body reads as structure.** Rejected: the palette is built from the 16-color ANSI set precisely so terminals remap it, and the only tones reliably dimmer than default foreground on both schemes are the two that already collapse. Manufacturing a third would mean fixed shades, which the palette contract forbids.
|
||||
|
||||
**Dim diff `+`/`-` lines too, for a fully monochrome transcript.** Rejected: a diff's colors carry its semantics, and the leading `+`/`-` character alone is a weaker signal that a reader must parse rather than see.
|
||||
|
||||
## Consequences
|
||||
|
||||
A card now reads as one colored header over a recessed block, and a transcript of many calls scans as a column of status headers. The user-visible change is that presenter titles, `$` commands, and XML tree content lost their distinct tones; the reported scatter goes with them.
|
||||
|
||||
`dimBody` renders a generic card's Markdown once per frame instead of twice, so the earlier note's stated cost is gone. Because `dim` is an SGR attribute on dark schemes rather than a color, a result's Markdown role colors (headings, inline code) still show through underneath it, so a dim body keeps its internal structure on dark terminals; on light schemes `dim` resolves to ANSI 90 and those roles read against gray instead.
|
||||
|
||||
The `renderUnknownXml` signature gained a required parameter, so its caller and its unit test pass a body styler; there is no default, because a caller that forgets one is exactly the bug this note fixes. The treatment stays TUI-local: no presenter, palette role, or `presentation.ts` type changed, and the ACP and JSON-RPC bridges keep their own tool-call presentation.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/xml-tool-output.spec.ts` asserts the body tone through a distinct `[body]…[/body]` marker on every text row, so a regression that drops the styler fails rather than silently rendering unstyled text. It also pins that an interior blank line stays the empty string, since styling it would emit an escape-only row that reads as a stray indented blank.
|
||||
|
||||
The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` were refreshed from committed replay scripts. Their diffs are style ranges only: `fg=cyan` and `fg=bright-black` rows become `dim`, and rows that previously carried no style gain it — covering bash output, read output, `run_code`, `workflow`, `subagent`, `todo_write`, `cordis_*`, and injected-context prose, while the diff cards' `+`/`-` ranges are unchanged.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Agent Note: TUI 工具卡片正文统一使用暗色调
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-tui-uniform-dim-card-body.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[将工具结果输出变暗](2026-07-28-tui-dim-tool-result-output.md)把工具卡片划分为保留自身颜色的框架和以暗色渲染的输出。在真实 transcript(文本记录)中,这种划分呈现的是颜色散乱而非结构层次,因为各框架行并非同一种颜色:generic 卡片的 presenter 标题(`Read src/foo.ts (95 - 149)`、`Update todo list`)保留终端的默认前景色,终端卡片的 `$` 命令为青色,cwd 为暗色,下方输出也是暗色。连续四行中出现三种色调,而最亮的默认前景色在浅色方案中呈现为纯黑色,却落在信息量最少的一行。
|
||||
|
||||
未知工具的 XML 树问题更为明显,也正是它暴露了这个缺陷。`renderUnknownXml` 通过 `label` 回调设置元素名的样式,却完全不为文本内容设置样式,而卡片折叠后的 `… +N lines (Ctrl+O to expand)` 标记为暗色。因此,卡片正文混合了无样式的黑色内容行和暗色的折叠提示,使提示看起来与它所概括的文本不属于同一个元素。此前说明要解决的正文无法区分问题又在正文内部重现了一层。
|
||||
|
||||
根本原因在于,这两种角色在两套受支持的方案之一中无法区分。在深色方案中,`dim` 是 SGR 2;在浅色方案中则是 ANSI 90,与 `muted` 始终使用的样式完全相同。因此,混合 `dim`、`muted` 和无样式行的正文在浅色终端上呈现两种色调,在深色终端上呈现三种色调;这种差异体现为不一致,而非含义。
|
||||
|
||||
## Decision
|
||||
|
||||
工具卡片的正文从头到尾统一使用一种暗色调。卡片中唯一带颜色的行是 `Tool / <name>` 表头,它承载调用状态:挂起时为 warning、成功时为 success、错误时为 error。presenter 标题、终端卡片的 `$` 命令行及 cwd、工具自身的输出、XML 树的文本内容和折叠标记均通过 `dim` 角色渲染。
|
||||
|
||||
有两项例外会保留颜色,因为颜色在这些位置承载含义,而非强调:diff 卡片的 `+`/`-` 行和各文件路径表头,其红色与绿色本身就是 diff;终端卡片的 `[signal …]` 标记则报告异常终止。XML 树的元素名保持 `muted`,与正文文本相差一档,因为它们是供读者定位的结构,而非内容。
|
||||
|
||||
`renderUnknownXml` 接收一个 `body` 样式函数,与既有的 `label` 样式函数并列,并将其应用于每一个文本行,包括 `textBlock` 的各行以及单行 `<tag>: value` 形式中的值。它唯一的调用方——未知工具卡片——传入卡片的正文色调(`dim`),使树的内容与周围各行保持一致,而不会回退到默认前景色。
|
||||
|
||||
`ContextCardComponent` 将[注入上下文渲染为散文](../bug-fix/2026-07-28-context-cards-render-prose-not-xml.md)而不对其解析,其正文也从 `muted` 改为 `dim`,原因与工具卡片相同:该卡片的表头与折叠标记本已是 `dim`,因此 `muted` 正文是卡片中唯一不匹配的行组。
|
||||
|
||||
`ToolCardComponent.dimPastPrelude` 更名为 `dimBody`:它仍将 prelude 与结果作为同一个 Markdown 文档渲染,使文档自身的块间距得以保留,但现在会将每一行变暗,而非只处理 prelude 行数之后的内容。由于不再需要定位边界,旧的行数划分所需的第二次 Markdown 渲染也随之删除。仅含空白字符的行仍不添加样式包装,使 Markdown 的填充不会进入样式范围;终端分支的空白行仍为空字符串,因此其 `filter(Boolean)` 仍会将其丢弃。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留框架/输出划分,只修复 XML 树。** 已否决:这只处理了报告的症状,未解决根因。该划分的前提是框架共用一种颜色,但实际从来不是如此:presenter 标题、青色的 `$` 命令和暗色的 cwd 使用三种色调,因此以后每增加一种框架行都会再次引出同一个问题。
|
||||
|
||||
**为 presenter 标题设置专属角色(粗体或强调色)。** 已否决:这会在用户认为形成噪声的行上增加第四种色调。表头已经给出工具名称,因此标题是细节行而非标题行;强调它会与正上方的状态色争夺注意力。
|
||||
|
||||
**保留青色的 `$` 命令,将其作为扫读定位点。** 用户考虑过此方案,但明确选择统一正文并否决了它。`$ ` 前缀和表头中的 `/ <description>` 段已经可以定位命令,因此该颜色与另外两个提示重复。
|
||||
|
||||
**使 `dim` 和 `muted` 在浅色方案中呈现明显差异,让既有的三色正文体现结构。** 已否决:调色板特意基于 16 色 ANSI 色集构建,以便终端重新映射颜色;在两种方案中都能可靠弱于默认前景色的只有目前已经重合的两种色调。制造第三种色调需要使用固定色值,而调色板契约禁止这样做。
|
||||
|
||||
**也将 diff 的 `+`/`-` 行变暗,使 transcript 完全单色。** 已否决:diff 的颜色承载其语义,而仅依靠开头的 `+`/`-` 字符是更弱的信号,读者必须解析字符才能理解,无法直接从颜色识别。
|
||||
|
||||
## Consequences
|
||||
|
||||
卡片现在呈现为一行带颜色的表头,下接一个弱化的正文块;包含大量调用的 transcript 扫读起来则是一列状态表头。用户可见的变化是 presenter 标题、`$` 命令和 XML 树内容不再使用各自独立的色调,此前报告的颜色散乱也随之消失。
|
||||
|
||||
`dimBody` 每帧只渲染一次 generic 卡片的 Markdown,而非两次,因此此前说明中记录的代价已经消除。由于深色方案中的 `dim` 是 SGR 属性而非颜色,结果中的 Markdown 角色颜色(标题、内联代码)仍能在其下显示,所以暗色正文在深色终端上仍保留内部结构;在浅色方案中,`dim` 解析为 ANSI 90,这些角色则以灰色为基础呈现。
|
||||
|
||||
`renderUnknownXml` 的签名新增了一个必需参数,因此其调用方及其单元测试均传入正文样式函数;这里不提供默认值,因为调用方忘记传入该参数,正是本文要修复的缺陷。此处理仍仅限 TUI:没有更改任何 presenter、调色板角色或 `presentation.ts` 类型,ACP 和 JSON-RPC 桥接层继续使用各自的工具调用呈现方式。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/xml-tool-output.spec.ts` 通过每个文本行上的 `[body]…[/body]` 专用标记断言正文色调,因此遗漏样式函数的回归会导致测试失败,而不会静默渲染为无样式文本。它还固定了内部空行仍为空字符串,因为为其设置样式会产生一个只含转义序列的行,读起来像一个多余的缩进空行。
|
||||
|
||||
`packages/ui/tui/tests/snapshots/` 和 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照已通过提交到仓库的回放脚本刷新。差异仅涉及样式范围:将 `fg=cyan` 和 `fg=bright-black` 行改为 `dim`,并为之前没有样式的行添加 `dim`;覆盖 bash 输出、read 输出、`run_code`、`workflow`、`subagent`、`todo_write`、`cordis_*` 以及注入上下文散文,而 diff 卡片的 `+`/`-` 范围保持不变。
|
||||
Reference in New Issue
Block a user