Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 19:02:29 +08:00
188 changed files with 5000 additions and 660 deletions

View File

@@ -2,5 +2,5 @@
# 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-27-tui-diff-card-redundant-path-header.md
2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a
2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9
2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554
2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4

View File

@@ -1,6 +1,7 @@
# Agent Note: TUI diff card dropped the duplicated file path
Status: implemented
Archived: 2026-07-31
English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md)

View File

@@ -1,6 +1,7 @@
# Agent Note: TUI diff 卡片重复打印文件路径
Status: implemented
Archived: 2026-07-31
[English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文

View File

@@ -46,6 +46,9 @@
"bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81",
"bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373",
"bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",

View File

@@ -2,5 +2,5 @@
# 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-consolidated-tui-presentation.md
2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64
2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1
2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00
2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b

View File

@@ -26,7 +26,7 @@ A tool card has one colored `Tool / <name>` status header over one dim body. Pre
Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `<system-reminder>` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters.
`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic.
`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule.
### Cross-workspace resume

View File

@@ -26,7 +26,7 @@ Status: implemented
注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `<system-reminder>` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。
`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。
`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。
### 跨工作区恢复

View File

@@ -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-30-multi-select-custom-answer-composition.md
2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22
2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480

View File

@@ -0,0 +1,25 @@
# Agent Note: Multi-select custom answer composition
Status: implemented
English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md)
## Problem
The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields.
## Decision
For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation.
Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes.
## Alternatives considered
**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom.
**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers.
## Consequences
Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule.

View File

@@ -0,0 +1,25 @@
# Agent Note: 多选题自定义答案组合
Status: implemented
[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文
## 问题
用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本但最初的语义要求每个问题的这两个字段互斥。对于多选题打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。
## 决策
对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本Web 草稿都会保留两个值TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本并在任一模式提交时将其与已勾选的标签一同投影Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。
单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。
## 考虑过的替代方案
**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。
**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。
## 后果
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。

View File

@@ -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-31-tui-diff-context-line-accounting.md
2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba
2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96

View File

@@ -0,0 +1,33 @@
# Agent Note: TUI diff context lines stay neutral
Status: implemented
English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md)
## Problem
Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals.
## Decision
The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object.
When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row.
This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation.
## Alternatives considered
**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI.
**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer.
**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks.
**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer.
## Consequences
TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering.
The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states.

View File

@@ -0,0 +1,33 @@
# Agent Note: TUI diff 上下文行保持中性
Status: implemented
[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文
## 问题
文件系统 diff 返回结果时,每个 `FileDiff.oldText``FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。
## 决策
TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。
`oldText``null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。
该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff`package无需引入第二套逐行 diff 实现。
## 考虑过的替代方案
**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。
**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。
**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。
**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。
## 后果
TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。
聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。

View File

@@ -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-08-02-message-fork-actions-require-completed-turn-tail.md
2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234
2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8

View File

@@ -0,0 +1,29 @@
# Agent Note: Message fork actions require a completed turn tail
Status: implemented
English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md)
## Problem
The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix.
## Decision
`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged.
This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation.
## Alternatives considered
**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript.
**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact.
**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind.
**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request.
## Consequences
An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows.

View File

@@ -0,0 +1,29 @@
# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部
Status: implemented
[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文
## 问题
Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理reasoning节点或终态错误这些行也不会接管操作因为它们没有内容文本 IconActions。因此分支图标可能出现在 assistant 响应下方而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。
## 决策
`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript文本记录节点仅当边界的最后一个节点是用户消息、持久 steering中途引导消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用Host 按已完成轮次 fork 的语义保持不变。
本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。
## 考虑过的替代方案
**在点击的 assistant 消息处截断事件日志。** 不予采纳assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。
**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering 轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。
**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。
**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。
## 后果
启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中响应仍保留复制、时钟以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。

View File

@@ -1,6 +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
2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281
2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76
2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859

View File

@@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed
## Untrusted output policy
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML).
Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML).
Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
@@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
**Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies.
**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md).
**Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract.

View File

@@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
## 不受信任输出策略
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML
assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML
围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
@@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S
**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界因此它们保持禁用,无需增加净化器与图片策略依赖。
**通过净化启用原始 HTML** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。

View File

@@ -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-24-tui-question-dialog-multiline.md
2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224
2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf

View File

@@ -0,0 +1,41 @@
# Agent Note: TUI QuestionDialog renders options across multiple lines
Status: implemented
English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md)
## Problem
`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it.
## Decision
The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays:
- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize.
- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit.
- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp.
- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit.
- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls.
Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state.
## Alternatives considered
**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary.
**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable.
**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership.
**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules.
**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together.
## Consequences
- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts.
- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging.
- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page.
- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available.
- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged.

View File

@@ -0,0 +1,41 @@
# Agent Note: TUI QuestionDialog 以多行方式渲染选项
Status: implemented
[English](2026-07-24-tui-question-dialog-multiline.md) | 中文
## 问题
`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript文本记录和后续输入。
## 决策
TUI 将待处理问题渲染为位于 transcript状态区域与编辑器之间的内联模态框同时仍与模型浮层和插件浮层共享 FIFO
- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth``questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。
- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。
- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。
-`windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more``↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。
- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。
package测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。
## 备选方案
**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。
**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。
**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。
**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。
**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。
## 后果
- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。
- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。
- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。
- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。
- 面向模型的 schema、选中的标签、中止取消行为以及 ACPAgent Client Protocol的 elicitation 路径均保持不变。

View File

@@ -2,5 +2,5 @@
# 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-27-web-session-fork-actions.md
2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047
2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004
2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf
2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848

View File

@@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session
## Decision
The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `N` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list.
The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current.
The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `N` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list.
`forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation.
@@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays
## Consequences
Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls.
Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls.
Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application.
Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application.

View File

@@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We
## Decision
Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费子会话进入本地列表后client 把源会话持久化标题尾部的 `(N)``N` 递增并保留括号样式,无编号时追加 ` (1)`没有持久化标题时不改名Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中
本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效
Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费子会话进入本地列表后client 把源会话持久化标题尾部的 `(N)``N` 递增并保留括号样式,无编号时追加 ` (1)`没有持久化标题时不改名Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。
`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。
@@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se
## Consequences
用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)``(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。
用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)``(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。
Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。
Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。

View File

@@ -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-29-tui-hidden-mode-assistant-fold.md
2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109
2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013

View File

@@ -0,0 +1,25 @@
# Agent Note: TUI hidden mode folds a turn's assistant steps into one message
Status: implemented
English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md)
## Problem
Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn.
## Decision
Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them.
Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected.
## Alternatives considered
- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer.
- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped.
- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step.
## Consequences
Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame.

View File

@@ -0,0 +1,25 @@
# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息
Status: implemented
[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文
## 问题
Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript文本记录读作一段对话但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。
## 决定
隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。
机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算以及失败流被撤回可能把标题移交给下一个步骤时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。
## 考虑过的替代方案
- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。
- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。
- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。
## 后果
隐藏模式现在每轮次读作一条 assistant 消息轮次之间仍由各自的标题分隔。折叠是重新计算的状态从不存储因此会话与持久化格式没有变化。覆盖TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。

View File

@@ -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-30-tui-details-command.md
2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c
2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6

View File

@@ -0,0 +1,34 @@
# Agent Note: /details command for transcript detail state
Status: implemented
English | [中文](2026-07-30-tui-details-command.zh.md)
## Problem
The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all.
## Decision
`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged.
A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice.
The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present.
## Alternatives considered
**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target.
**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes.
**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation.
**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts.
## Consequences
- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R.
- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README.
- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value.
- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it.

View File

@@ -0,0 +1,34 @@
# Agent Note: 用于 transcript 细节状态的 /details 命令
Status: implemented
[English](2026-07-30-tui-details-command.md) | 中文
## Problem
TUI 的 transcript文本记录细节状态——工具卡片可见性`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。
## Decision
`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards``Reasoning`——显示实时值Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。
组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript而重建会丢弃非持久的通知组件若最后才应用它会抹掉刚追加的可见性通知。
reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent``assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。
## Alternatives considered
**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。
**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。
**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决两个维度同属一个展示关注点“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。
**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。
## Consequences
- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。
- 解析器接受无序 token因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。
- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。
- 当一个步骤携带多条 `assistant/message` 事件时transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。

View File

@@ -2,5 +2,5 @@
# 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-30-web-diff-card.md
2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c
2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d
2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679
2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755

View File

@@ -16,13 +16,13 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff`
`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate.
- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
- **Path grouping.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The TUI keeps a path header on every hunk, but both front ends count distinct paths in the `N file(s)` footer, so two hunks in one file read as `1 file`.
- **Whole-side change colors.** Every old-side line is `- ` on the error token and every new-side line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` and the TUI use: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives.
- **Footer and copy.** A dim `└ +A -R · N file(s)` footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.

View File

@@ -16,13 +16,13 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行
`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
组件的契约遵循 TUI 的 `diffLines``packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。
- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk分散编辑`replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`
- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
- **路径分组。** 新文件开启一个粗体路径头;同文件的第二个 hunk分散编辑`replace_all`)以一个 `⋯` gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 `N file(s)` 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`
- **整侧改动配色。** 旧侧每一行都以 error token 上的 `- ` 显示,新侧每一行都以 success token 上的 `+ ` 显示,并在横向滚动的盒子里以 `white-space: pre` 逐字绘制源码行靠缩进阅读,因此滚动而不折行。新建(`oldText: null`)没有删除侧。
- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`16的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap使多文件复制保持可归属。
- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap使多文件复制保持可辨别归属。
几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner只有页脚。
@@ -32,7 +32,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX
**并排双栏diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PRPull Request保持为对既有契约的纯 Web 消费。
**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token那是唯一一处一个实现对两者都正确的部分。

View File

@@ -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-30-web-remote-markdown-images.md
2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e
2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac

View File

@@ -0,0 +1,29 @@
# Agent Note: Remote Web Markdown images
Status: implemented
English | [中文](2026-07-30-web-remote-markdown-images.zh.md)
## Problem
Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior.
## Decision
`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `<img>` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored `<img>`.
The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior.
## Alternatives considered
**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline.
**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure.
**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled.
**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs.
## Consequences
Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text.

View File

@@ -0,0 +1,29 @@
# Agent Note: Web 中的远程 Markdown 图片
Status: implemented
[English](2026-07-30-web-remote-markdown-images.md) | 中文
## 问题
assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL也无法获得普通的 Markdown 图片行为。
## 决策
`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `<img>` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `<img>` 绕过 Markdown 图片组件。
图片组件复用渲染器的绝对 URL 策略不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。
## 考虑过的替代方案
**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。
**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址但这会让主机执行任意出站请求并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。
**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。
**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript文本记录。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。
## 后果
assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。