feat(tui): hidden mode folds a turn's assistant steps into one message

The Ctrl+O hidden phase keeps one Assistant header per turn: the first
step with visible text/reasoning owns it, later steps render as
headerless continuations, and bodiless (tool-only) steps render
nothing. Leaving hidden restores per-step headers. Pure TUI
presentation; the session log is unchanged.
This commit is contained in:
Turtle
2026-07-31 16:12:05 +08:00
parent 9e11f438f3
commit 0c9e529f74
14 changed files with 376 additions and 23 deletions
@@ -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
@@ -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
@@ -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)负责。
### 跨工作区恢复
@@ -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
@@ -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.
@@ -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` 固定折叠后的帧。
+2 -2
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 packages/ui/tui/README.md
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
README.md: a65d72b3b80b992fabcb33d4b4345942b58e9147
README.zh.md: fd056da59335af5cb9fc63fd2fb681266fadeb62
+1 -1
View File
@@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
+1 -1
View File
@@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()``agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help``/model``/clear``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘,Ctrl+D 在空闲时退出。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help``/model``/clear``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘,Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}``{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
+54 -13
View File
@@ -149,20 +149,28 @@ export class UserMessageComponent extends Container {
}
}
/** Children of a settled assistant message: optional reasoning block then the response text. */
/**
* Children of a settled assistant message: optional reasoning block then the
* response text. A folded continuation (a later step of a turn while tool cards
* are hidden) drops the `Assistant` header and renders nothing when it has no
* visible body, so tool-only steps leave no blank segment behind.
*/
function assistantMessageChildren(
content: readonly ContentBlock[],
showReasoning: boolean,
foldedContinuation: boolean,
palette: Palette,
mdTheme: MarkdownTheme,
): Component[] {
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
const text = displayText(textBlocks(content, 'text').trim())
const children: Component[] = [
new Spacer(1),
new Text(messageHeader('Assistant', palette.accent, palette), 0, 0),
]
if (reasoning && showReasoning) {
const showsReasoning = reasoning !== '' && showReasoning
if (foldedContinuation && !showsReasoning && text === '') return []
const children: Component[] = [new Spacer(1)]
if (!foldedContinuation) {
children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0))
}
if (showsReasoning) {
children.push(
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
@@ -220,6 +228,7 @@ interface StreamingBlock {
export class StreamingAssistantComponent extends Container {
private readonly blocks = new Map<number, StreamingBlock>()
private settledContent: readonly ContentBlock[] | undefined
private foldedContinuation = false
/**
* The step's timing footer. The renderer keeps it at the tail of the chat so
* it trails any tool cards the step appends after this assistant message; it
@@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container {
readonly timing: StepTimingComponent
constructor(
position: StepPosition,
/** The step's turn/step coordinates, used to group steps into their turn. */
readonly position: StepPosition,
events: () => readonly SessionEvent[],
now: () => number,
private showReasoning: boolean,
@@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container {
this.rebuild()
}
private rebuild(): void {
this.clear()
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
/**
* Mark this step as a folded continuation of its turn: no `Assistant` header,
* and no output at all while the step has no visible body. Used while tool
* cards are hidden so a turn reads as one assistant message.
* @param folded - Whether to render as a headerless continuation.
*/
setFoldedContinuation(folded: boolean): void {
if (this.foldedContinuation === folded) return
this.foldedContinuation = folded
this.rebuild()
}
/**
* Whether the step currently renders visible reasoning or text.
* @returns `true` when a header-owning render would show a body.
*/
hasVisibleBody(): boolean {
const content = this.presentedContent()
return textBlocks(content, 'text').trim() !== ''
|| (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '')
}
/** The settled content when available, otherwise the streamed blocks in model order. */
private presentedContent(): readonly ContentBlock[] {
return this.settledContent ?? [...this.blocks.entries()]
.sort(([left], [right]) => left - right)
.flatMap<ContentBlock>(([, block]) => {
if (block.type === 'text') return [{ type: 'text', text: block.text }]
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
return []
})
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
this.addChild(child)
}
}
private rebuild(): void {
this.clear()
const children = assistantMessageChildren(
this.presentedContent(),
this.showReasoning,
this.foldedContinuation,
this.palette,
this.mdTheme,
)
for (const child of children) this.addChild(child)
}
}
+58 -2
View File
@@ -335,6 +335,10 @@ export function createTuiChat(
let toolsVisibility: ToolCardVisibility = 'collapsed'
let streaming: StreamingAssistantComponent | undefined
let completedStreaming: StreamingAssistantComponent | undefined
// Assistant step components in model order per turn, for hidden-mode folding:
// with tool cards hidden, a turn keeps one Assistant header and later steps
// render as headerless continuations (see applyTurnFolding).
const assistantSteps = new Map<number, StreamingAssistantComponent[]>()
let runningStatus: RunningStatus | undefined
let fadingStatus: FadingStatus | undefined
// TUI steering submissions that the inbox has not yet claimed or discarded.
@@ -614,6 +618,35 @@ export function createTuiChat(
return card
}
/**
* Re-derive hidden-mode folding for one turn: the first step with a visible
* body owns the turn's single Assistant header, every other step renders as a
* headerless continuation (empty ones render nothing). Any other visibility
* restores the per-step headers.
*/
const applyTurnFolding = (turn: number): void => {
const steps = assistantSteps.get(turn)
if (steps === undefined) return
let headerSeen = false
for (const step of steps) {
if (toolsVisibility !== 'hidden') {
step.setFoldedContinuation(false)
} else if (!headerSeen && step.hasVisibleBody()) {
headerSeen = true
step.setFoldedContinuation(false)
} else {
step.setFoldedContinuation(true)
}
}
}
const registerAssistantStep = (component: StreamingAssistantComponent): void => {
const steps = assistantSteps.get(component.position.turn) ?? []
steps.push(component)
assistantSteps.set(component.position.turn, steps)
applyTurnFolding(component.position.turn)
}
const removeStreaming = (current: StreamingAssistantComponent | undefined): void => {
if (current === undefined) return
for (const child of [current, current.timing]) {
@@ -621,6 +654,15 @@ export function createTuiChat(
/* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */
if (index >= 0) chat.children.splice(index, 1)
}
const steps = assistantSteps.get(current.position.turn)
/* v8 ignore next -- every attached streaming component is registered in the fold map. */
if (steps === undefined) return
const index = steps.indexOf(current)
/* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */
if (index < 0) return
steps.splice(index, 1)
// A retracted step may have owned the turn's hidden-mode header.
applyTurnFolding(current.position.turn)
}
/**
@@ -659,6 +701,7 @@ export function createTuiChat(
palette,
mdTheme,
)
registerAssistantStep(streaming)
chat.addChild(streaming)
chat.addChild(streaming.timing)
}
@@ -722,12 +765,20 @@ export function createTuiChat(
startAssistantStep(event.data)
break
case 'assistant/chunk':
if (options.renderChunks) streaming?.update(event.data.chunk)
if (options.renderChunks && streaming !== undefined) {
streaming.update(event.data.chunk)
// The first streamed text/reasoning may make this step the turn's
// hidden-mode header owner (or a continuation with a visible body).
applyTurnFolding(streaming.position.turn)
}
break
case 'assistant/message':
completedStreaming = undefined
if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data)
streaming?.settle(event.data.message.content)
if (streaming !== undefined) {
streaming.settle(event.data.message.content)
applyTurnFolding(streaming.position.turn)
}
break
case 'llm/retry': {
retractFailedStreaming()
@@ -838,6 +889,7 @@ export function createTuiChat(
toolCards.clear()
allToolCards.clear()
contextCards.clear()
assistantSteps.clear()
streaming = undefined
todo.update([])
const transcriptCalls = transcriptToolCallIds(agent.session)
@@ -956,6 +1008,9 @@ export function createTuiChat(
// Context cards carry injected instructions rather than tool traffic, so
// they never hide: the hidden phase reads as their collapsed preview.
for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded')
// Hidden mode folds each turn's steps into one assistant message; other
// modes restore the per-step Assistant headers.
for (const turn of assistantSteps.keys()) applyTurnFolding(turn)
appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`)
}
@@ -967,6 +1022,7 @@ export function createTuiChat(
if (activeStreaming !== undefined) {
streaming = activeStreaming
streaming.setShowReasoning(showReasoning)
registerAssistantStep(activeStreaming)
chat.addChild(activeStreaming)
chat.addChild(activeStreaming.timing)
}
@@ -0,0 +1,46 @@
terminal 100x40 buffer=normal length=40 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=20 bufferRow=20
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Inspecting the renderer first. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
style 0-2 fg=bright-magenta bold underline
9| "Refactor the renderer. "
10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 "
style 0-46 dim
11| <blank>
12| "The renderer is sound; no refactor needed. "
13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 "
style 0-46 dim
14| <blank>
15| "Tool and context cards expanded. "
style 0-31 dim
16| <blank>
17| "Tool cards hidden. "
style 0-17 dim
18| <blank>
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-57 dim
style 60-69 dim
20| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
21-39| <blank>
+26
View File
@@ -44,6 +44,7 @@ const CHECKPOINTS = [
'cordis-tools-pending',
'advanced-cards-collapsed',
'advanced-cards-expanded',
'tool-cards-hidden-folded',
'untrusted-controls',
'question-dialog',
'question-dialog-single-option',
@@ -609,6 +610,31 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins the hidden phase folding a multi-step turn into one assistant message', async () => {
const harness = await setupSnapshot({
tools: ADVANCED_CARD_TOOLS,
config: { maxToolOutputLines: 3 },
}, { columns: 100, rows: 40 })
await renderAfter(harness, () => {
appendUser(harness.session, 'Refactor the renderer.')
appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }])
appendToolCalls(harness.session, [
{ id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } },
])
appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }])
harness.session.append('step/end', { turn: 1, step: 1 })
harness.session.append('step/start', { turn: 1, step: 2 })
appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 })
harness.session.append('step/end', { turn: 1, step: 2 })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
// collapsed -> expanded -> hidden: one Assistant header, no tool card.
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime())
const tools = {
+128
View File
@@ -4989,6 +4989,134 @@ describe('tool cards and surface replay', () => {
expect(mounted).not.toContain('stored model-only payload')
await dispose(result)
})
/** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */
const lastFrame = (terminal: FakeTerminal): string => terminal.output
.slice(terminal.output.lastIndexOf('\x1b[2J'))
.replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '')
const countAssistantHeaders = (frame: string): number => frame.split('\n')
.filter(row => row.trim() === 'Assistant').length
/** One turn with text -> tool call/result -> text across two steps. */
const appendTwoStepTurn = (session: Awaited<ReturnType<typeof setup>>['session']): void => {
appendUser(session, 'fold me')
appendAssistant(session, [{ type: 'text', text: 'first step text' }])
session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}
it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => {
const result = await setup({ tools })
appendTwoStepTurn(result.session)
await tick()
// Collapsed (default): each step keeps its own header.
result.terminal.send('\x0c')
await tick()
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
// collapsed -> expanded -> hidden.
result.terminal.send('\x0f')
result.terminal.send('\x0f')
await tick()
result.terminal.send('\x0c')
await tick()
const hidden = lastFrame(result.terminal)
expect(countAssistantHeaders(hidden)).toBe(1)
expect(hidden).toContain('first step text')
expect(hidden).toContain('second step text')
expect(hidden).not.toContain('Tool / bash')
// The fold keeps model order: header text precedes the continuation.
expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text'))
// hidden -> collapsed restores per-step headers.
result.terminal.send('\x0f')
await tick()
result.terminal.send('\x0c')
await tick()
expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2)
await dispose(result)
})
it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => {
const result = await setup({ tools })
// Turn 1, step 1 is tool-only; step 2 carries the turn's text.
appendUser(result.session, 'tool-only first step')
appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }])
result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('step/start', { turn: 1, step: 2 })
appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 })
result.session.append('step/end', { turn: 1, step: 2 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Turn 2 keeps its own header.
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
appendUser(result.session, 'next turn')
result.session.append('step/start', { turn: 2, step: 1 })
appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 })
result.session.append('step/end', { turn: 2, step: 1 })
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await tick()
result.terminal.send('\x0f')
result.terminal.send('\x0f')
await tick()
result.terminal.send('\x0c')
await tick()
const hidden = lastFrame(result.terminal)
// One header per turn: the tool-only step neither renders a blank segment
// nor consumes turn one's header, which the late text step owns.
expect(countAssistantHeaders(hidden)).toBe(2)
expect(hidden).toContain('late turn-one text')
expect(hidden).toContain('turn-two text')
const rows = hidden.split('\n').map(row => row.trim())
const turnOneHeader = rows.indexOf('Assistant')
expect(rows[turnOneHeader + 1]).toBe('late turn-one text')
await dispose(result)
})
it('folds live hidden-mode streaming once a later step shows text', async () => {
const result = await setup({ tools, status: 'running' })
result.terminal.send('\x0f')
result.terminal.send('\x0f')
await tick()
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } })
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('step/start', { turn: 1, step: 2 })
result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } })
await tick()
result.terminal.send('\x0c')
await tick()
const hidden = lastFrame(result.terminal)
expect(countAssistantHeaders(hidden)).toBe(1)
expect(hidden).toContain('live first')
expect(hidden).toContain('live second')
// A transcript rebuild (resize) recomputes the same fold from the log.
result.terminal.resize(89)
await tick()
const rebuilt = lastFrame(result.terminal)
expect(countAssistantHeaders(rebuilt)).toBe(1)
expect(rebuilt).toContain('live second')
await dispose(result)
})
})
describe('TUI user-interaction dialogs', () => {