Przeglądaj źródła

Merge branch 'master' into codex/fork-real-turn-tail

imccyu 1 miesiąc temu
rodzic
commit
a8a97d0202
27 zmienionych plików z 977 dodań i 141 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml
  5. 25 0
      .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md
  6. 25 0
      .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml
  8. 34 0
      .agents/notes/implemented/feature/2026-07-30-tui-details-command.md
  9. 34 0
      .agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md
  10. 34 30
      apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt
  11. 3 1
      docs/config-catalog.md
  12. 1 1
      docs/cordis-catalog/services.md
  13. 2 2
      packages/ui/tui/README.i18n.yaml
  14. 0 0
      packages/ui/tui/README.md
  15. 0 0
      packages/ui/tui/README.zh.md
  16. 74 0
      packages/ui/tui/src/components/dialogs.ts
  17. 54 13
      packages/ui/tui/src/components/transcript.ts
  18. 7 0
      packages/ui/tui/src/config.ts
  19. 139 11
      packages/ui/tui/src/index.ts
  20. 42 0
      packages/ui/tui/tests/snapshots/details-command.expected.txt
  21. 72 0
      packages/ui/tui/tests/snapshots/details-selector.expected.txt
  22. 29 25
      packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
  23. 28 24
      packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
  24. 46 0
      packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt
  25. 25 30
      packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
  26. 68 0
      packages/ui/tui/tests/tui.snapshot.ts
  27. 219 0
      packages/ui/tui/tests/tui.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml

@@ -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

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md

@@ -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
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md

@@ -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)负责。
 
 ### 跨工作区恢复
 

+ 6 - 0
.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/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

+ 25 - 0
.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md

@@ -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.

+ 25 - 0
.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md

@@ -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` 固定折叠后的帧。

+ 6 - 0
.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-tui-details-command.md
+2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c
+2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6

+ 34 - 0
.agents/notes/implemented/feature/2026-07-30-tui-details-command.md

@@ -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.

+ 34 - 0
.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md

@@ -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` 后仍打开的开关,包括其背后恢复显示的工具卡片。

+ 34 - 30
apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt

@@ -1,7 +1,7 @@
-terminal 100x36 buffer=normal length=66 base=30 viewport=30
+terminal 100x36 buffer=normal length=68 base=32 viewport=32
 lifecycle started=1 stopped=0 progress=inactive
 title "Reply with exactly the word: — DSH TUI snapshot"
-cursor hidden column=7 viewportRow=35 bufferRow=65
+cursor hidden column=7 viewportRow=35 bufferRow=67
 buffer
 0| " DEEPSEEK HARNESS"
   style 1-8 fg=bright-magenta bold
@@ -73,56 +73,60 @@ buffer
   style 0-64 dim
 37| "/compact — Compact older conversation history                                                       "
   style 0-44 dim
-38| "/exit — Exit after the active turn reaches idle                                                     "
+38| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and         "
+  style 0-99 dim
+39| "reasoning display                                                                                   "
+  style 0-16 dim
+40| "/exit — Exit after the active turn reaches idle                                                     "
   style 0-46 dim
-39| "/help — Show keyboard shortcuts and commands                                                        "
+41| "/help — Show keyboard shortcuts and commands                                                        "
   style 0-43 dim
-40| "/model [[provider/]model] — Show or switch this session's model                                     "
+42| "/model [[provider/]model] — Show or switch this session's model                                     "
   style 0-62 dim
-41| "/palette — Show every color and attribute role this terminal renders                                "
+43| "/palette — Show every color and attribute role this terminal renders                                "
   style 0-67 dim
-42| "/quit — Exit after the active turn reaches idle                                                     "
+44| "/quit — Exit after the active turn reaches idle                                                     "
   style 0-46 dim
-43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)            "
+45| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)            "
   style 0-87 dim
-44| "/resume — List this workspace's resumable sessions                                                  "
+46| "/resume — List this workspace's resumable sessions                                                  "
   style 0-49 dim
-45| "/status — Show session diagnostics, system prompt, and registered tools                             "
+47| "/status — Show session diagnostics, system prompt, and registered tools                             "
   style 0-70 dim
-46| "/skill:<name> [instructions] — load a skill into the conversation                                   "
+48| "/skill:<name> [instructions] — load a skill into the conversation                                   "
   style 0-64 dim
-47| <blank>
-48| "Context · snapshot-injector"
+49| <blank>
+50| "Context · snapshot-injector"
   style 0-26 dim
-49| "Injected while compaction was running.                                                              "
+51| "Injected while compaction was running.                                                              "
   style 0-37 dim
-50| <blank>
-51| "… earlier context was compacted …                                                                   "
-  style 0-32 dim
 52| <blank>
-53| "You                                                                                                 "
+53| "… earlier context was compacted …                                                                   "
+  style 0-32 dim
+54| <blank>
+55| "You                                                                                                 "
   style 0-2 fg=bright-magenta bold underline
-54| "Reply with exactly the word: TWO. No tools.                                                         "
-55| <blank>
-56| "Compacted 2 history items (~387 tokens).                                                            "
-  style 0-39 dim
+56| "Reply with exactly the word: TWO. No tools.                                                         "
 57| <blank>
-58| "Assistant                                                                                           "
+58| "Compacted 2 history items (~387 tokens).                                                            "
+  style 0-39 dim
+59| <blank>
+60| "Assistant                                                                                           "
   style 0-8 fg=bright-magenta bold underline
-59| "Reasoning                                                                                           "
+61| "Reasoning                                                                                           "
   style 0-8 dim italic
-60| "The user wants me to reply with exactly the word \"TWO\" and no tools.                                "
+62| "The user wants me to reply with exactly the word \"TWO\" and no tools.                                "
   style 0-67 dim italic
-61| "TWO                                                                                                 "
-62| "Model wait 0.0s · Completed 2026-07-21 12:00:00                                                     "
+63| "TWO                                                                                                 "
+64| "Model wait 0.0s · Completed 2026-07-21 12:00:00                                                     "
   style 0-46 dim
-63| <blank>
-64| "/workspace/project  deepseek-v4-flash  ↑2.9k ↓41  cache 49%  3% cont"
+65| <blank>
+66| "/workspace/project  deepseek-v4-flash  ↑2.9k ↓41  cache 49%  3% cont"
   style 0-49 fg=bright-magenta bold
   style 52-68 dim
   style 71-90 dim
   style 93-99 dim
-65| " dsh ◍                                                                                              "
+67| " dsh ◍                                                                                              "
   style 1-3 fg=bright-magenta bold
   style 5-6 dim
   style 7-7 inverse

+ 3 - 1
docs/config-catalog.md

@@ -2081,6 +2081,8 @@ export interface TuiConfig {
   modelDialogWidth?: number
   /** Model-selector maximum height in terminal rows. */
   modelDialogMaxHeight?: number
+  /** Transcript-details selector width in terminal columns. */
+  detailsDialogWidth?: number
   /** Maximum fuzzy file candidates displayed for one `@` query. */
   fileSearchMaxResults?: number
   /** Maximum paths retained in one `@` workspace index. */
@@ -2112,7 +2114,7 @@ export interface TuiThemeConfig {
 }
 ```
 
-Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts)
+Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts)
 
 ## `@deepseek-ai/dsh-typert-loader`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
 abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
 ```
 
-Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts)
 
 ## `ctx.typert` — `TypertRegistry`
 

+ 2 - 2
packages/ui/tui/README.i18n.yaml

@@ -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: c81cac891403e5294c4456ce4d4048ecd74666ce
-README.zh.md: 01055619f4df460284564f0a1816de366d809e01
+README.md: a577fb2f858f61eb765d4a1a9564f452d01d92c1
+README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2

Plik diff jest za duży
+ 0 - 0
packages/ui/tui/README.md


Plik diff jest za duży
+ 0 - 0
packages/ui/tui/README.zh.md


+ 74 - 0
packages/ui/tui/src/components/dialogs.ts

@@ -34,6 +34,7 @@ import type {
 import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
 import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
 import { dialogSelectTheme, type Palette } from './theme.ts'
+import type { ToolCardVisibility } from './transcript.ts'
 import {
   renderTuiPromptTemplate,
   type TuiPromptTemplateToken,
@@ -432,6 +433,79 @@ export class ModelDialog implements Component {
   }
 }
 
+/** Both transcript-detail dimensions, applied immediately on each Tab. */
+export interface DetailsSelection {
+  readonly visibility: ToolCardVisibility
+  readonly showReasoning: boolean
+}
+
+const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden']
+
+/**
+ * Keyboard toggle over the two transcript-detail entries — tool-card
+ * visibility and reasoning display. Tab cycles the highlighted entry's value
+ * and applies it immediately, so the transcript behind the dialog is the live
+ * preview; Enter, Esc, or Ctrl+C closes.
+ */
+export class DetailsDialog implements Component {
+  private readonly list: SelectList
+  private readonly toolsItem: SelectItem
+  private readonly reasoningItem: SelectItem
+
+  constructor(
+    private visibility: ToolCardVisibility,
+    private showReasoning: boolean,
+    private readonly palette: Palette,
+    private readonly apply: (selection: DetailsSelection) => void,
+    private readonly close: () => void,
+  ) {
+    this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility }
+    this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() }
+    this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette))
+    this.list.onSelect = close
+  }
+
+  private reasoningLabel(): string {
+    return this.showReasoning ? 'shown' : 'hidden'
+  }
+
+  /** Cycle the highlighted entry one step and apply the new state. */
+  private cycle(): void {
+    const selected = this.list.getSelectedItem()
+    /* v8 ignore next -- the two-entry list always has a selection. */
+    if (selected === null) return
+    if (selected.value === 'tools') {
+      const index = TOOL_CARD_PHASES.indexOf(this.visibility)
+      this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility
+      this.toolsItem.description = this.visibility
+    } else {
+      this.showReasoning = !this.showReasoning
+      this.reasoningItem.description = this.reasoningLabel()
+    }
+    this.apply({ visibility: this.visibility, showReasoning: this.showReasoning })
+  }
+
+  invalidate(): void {
+    this.list.invalidate()
+  }
+
+  handleInput(data: string): void {
+    if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close()
+    else if (matchesKey(data, Key.tab)) this.cycle()
+    else this.list.handleInput(data)
+    this.invalidate()
+  }
+
+  render(width: number): string[] {
+    const innerWidth = Math.max(1, width - 4)
+    return renderDialog('Transcript details', [
+      ...this.list.render(innerWidth),
+      '',
+      this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'),
+    ], width, this.palette)
+  }
+}
+
 /** The provider/model route recovered from a resume candidate's log. */
 export interface ResumeRoute {
   provider: string

+ 54 - 13
packages/ui/tui/src/components/transcript.ts

@@ -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)
   }
 }
 

+ 7 - 0
packages/ui/tui/src/config.ts

@@ -48,6 +48,8 @@ export interface TuiConfig {
   modelDialogWidth?: number
   /** Model-selector maximum height in terminal rows. */
   modelDialogMaxHeight?: number
+  /** Transcript-details selector width in terminal columns. */
+  detailsDialogWidth?: number
   /** Maximum fuzzy file candidates displayed for one `@` query. */
   fileSearchMaxResults?: number
   /** Maximum paths retained in one `@` workspace index. */
@@ -71,6 +73,7 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
 const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
 const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
 const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
+const detailsDialogWidthSchema = z.number().step(1).min(20).default(72)
 const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
 const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
 const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
@@ -102,6 +105,7 @@ const tuiConfigSchemaFields = {
   questionDialogMaxHeight: questionDialogMaxHeightSchema,
   modelDialogWidth: modelDialogWidthSchema,
   modelDialogMaxHeight: modelDialogMaxHeightSchema,
+  detailsDialogWidth: detailsDialogWidthSchema,
   fileSearchMaxResults: fileSearchMaxResultsSchema,
   fileSearchMaxEntries: fileSearchMaxEntriesSchema,
   fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
@@ -142,6 +146,7 @@ export const Config: z<Config> = z.object({
   questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
   modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
   modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
+  detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth,
   fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
   fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
   fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
@@ -171,6 +176,7 @@ export interface ResolvedTuiConfig {
   questionDialogMaxHeight: number
   modelDialogWidth: number
   modelDialogMaxHeight: number
+  detailsDialogWidth: number
   fileSearchMaxResults: number
   fileSearchMaxEntries: number
   fileSearchExcludedDirectories: string[]
@@ -196,6 +202,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
     questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
     modelDialogWidth: config?.modelDialogWidth ?? 76,
     modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
+    detailsDialogWidth: config?.detailsDialogWidth ?? 72,
     fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
     fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
     fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],

+ 139 - 11
packages/ui/tui/src/index.ts

@@ -105,6 +105,7 @@ import {
 } from './components/transcript.ts'
 import {
   compactTargetLabel,
+  DetailsDialog,
   diagnosticMeter,
   formatDiagnosticCount,
   formatDiagnosticNumber,
@@ -113,6 +114,7 @@ import {
   StatusCardComponent,
   PromptContextComponent,
   targetLabel,
+  type DetailsSelection,
   type StatusCardRow,
 } from './components/dialogs.ts'
 import {
@@ -337,6 +339,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
   /**
@@ -646,6 +652,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]) {
@@ -653,6 +688,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)
   }
 
   /**
@@ -691,6 +735,7 @@ export function createTuiChat(
       palette,
       mdTheme,
     )
+    registerAssistantStep(streaming)
     chat.addChild(streaming)
     chat.addChild(streaming.timing)
   }
@@ -754,12 +799,22 @@ 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)
+        // A settled component stays attached but never absorbs a later message
+        // of the same step; both the live and replay paths start a new one.
+        if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data)
+        if (streaming !== undefined) {
+          streaming.settle(event.data.message.content)
+          applyTurnFolding(streaming.position.turn)
+        }
         break
       case 'llm/retry': {
         retractFailedStreaming()
@@ -870,6 +925,7 @@ export function createTuiChat(
     toolCards.clear()
     allToolCards.clear()
     contextCards.clear()
+    assistantSteps.clear()
     streaming = undefined
     todo.update([])
     const transcriptCalls = transcriptToolCallIds(agent.session)
@@ -979,32 +1035,99 @@ export function createTuiChat(
   // same reason.
   ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
 
-  const toggleTools = (): void => {
-    // The cycle order puts the two common reading modes adjacent: preview ->
-    // full detail -> conversation-only, then back to the preview default.
-    toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded'
-      : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed'
+  const setToolsVisibility = (next: ToolCardVisibility): void => {
+    toolsVisibility = next
     for (const card of allToolCards) card.setVisibility(toolsVisibility)
     // 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}.`)
   }
 
-  const toggleReasoning = (): void => {
-    showReasoning = !showReasoning
+  const toggleTools = (): void => {
+    // The cycle order puts the two common reading modes adjacent: preview ->
+    // full detail -> conversation-only, then back to the preview default.
+    setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded'
+      : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed')
+  }
+
+  const setReasoning = (show: boolean): void => {
+    showReasoning = show
     const activeStreaming = streaming
     rebuildTranscript(false)
     /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */
     if (activeStreaming !== undefined) {
       streaming = activeStreaming
       streaming.setShowReasoning(showReasoning)
+      registerAssistantStep(activeStreaming)
       chat.addChild(activeStreaming)
       chat.addChild(activeStreaming.timing)
     }
     appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
   }
 
+  const toggleReasoning = (): void => { setReasoning(!showReasoning) }
+
+  // The selector and the argument grammar mutate the same closure state the
+  // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges.
+  let detailsOverlay: TuiOverlaySession | undefined
+  const showDetailsSelector = (): void => {
+    void detailsOverlay?.close()
+    const session = overlayManager.open({
+      create: () => new DetailsDialog(
+        toolsVisibility,
+        showReasoning,
+        palette,
+        // Each Tab applies immediately; one dimension changes per call.
+        (selection: DetailsSelection) => {
+          if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning)
+          if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility)
+        },
+        () => { void session.close() },
+      ),
+      options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 },
+    })
+    detailsOverlay = session
+    void session.closed.then(() => {
+      if (detailsOverlay === session) detailsOverlay = undefined
+    })
+    requestRender()
+  }
+
+  // `/details` names the same transcript-detail state the Ctrl+O cycle and
+  // Ctrl+R toggle mutate, so a user can jump to a mode without cycling.
+  const runDetails = (rawInput: string): CommandResult => {
+    const tokens = rawInput.split(/\s+/u).filter(token => token !== '')
+    if (tokens.length === 0) {
+      showDetailsSelector()
+      return { kind: 'success' }
+    }
+    let visibility: ToolCardVisibility | undefined
+    let reasoning: boolean | undefined
+    for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) {
+      if (token === 'collapsed' || token === 'expanded' || token === 'hidden') {
+        visibility = token
+      } else if (token === 'reasoning') {
+        const value = tokens[0]
+        if (value === 'on' || value === 'off') {
+          tokens.shift()
+          reasoning = value === 'on'
+        } else {
+          reasoning = !showReasoning
+        }
+      } else {
+        return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` }
+      }
+    }
+    // Reasoning first: its transcript rebuild would drop the visibility notice.
+    if (reasoning !== undefined) setReasoning(reasoning)
+    if (visibility !== undefined) setToolsVisibility(visibility)
+    return { kind: 'success' }
+  }
+
   const showHelp = (): void => {
     const commandLines = ctx.commands.list(agent).map((command) => {
       const input = command.input === undefined ? '' : ` ${command.input.hint}`
@@ -1190,6 +1313,12 @@ export function createTuiChat(
       description: 'Clear the transcript view (session history is unchanged)',
       handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
     })
+    commandCtx.commands.register({
+      name: 'details',
+      description: 'Select tool-card visibility and reasoning display',
+      input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' },
+      handler: ({ rawInput }) => runDetails(rawInput),
+    })
     commandCtx.commands.register({
       name: 'palette',
       description: 'Show every color and attribute role this terminal renders',
@@ -1529,7 +1658,6 @@ export function createTuiChat(
     if (event.type === 'tool/result') fileSearch.invalidate()
     recordEventUsage(tokens, event)
     if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
-    if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
     // Track live standalone compaction state.
     if (event.type === 'compact/start' && event.data.turn === null) {
       if (compacting === undefined) {

+ 42 - 0
packages/ui/tui/tests/snapshots/details-command.expected.txt

@@ -0,0 +1,42 @@
+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=17 bufferRow=17
+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| "Running the check now.                                                                              "
+6| "Model wait 0.0s                                                                                     "
+  style 0-14 dim
+7| <blank>
+8| "You                                                                                                 "
+  style 0-2 fg=bright-magenta bold underline
+9| "Inspect the renderer.                                                                               "
+10| "Model wait 0.0s · Completed 2026-07-30 18:00:00                                                     "
+  style 0-46 dim
+11| <blank>
+12| "Reasoning blocks hidden.                                                                            "
+  style 0-23 dim
+13| <blank>
+14| "Tool cards hidden.                                                                                  "
+  style 0-17 dim
+15| <blank>
+16| "/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
+17| " dsh >                                                                                              "
+  style 1-3 fg=bright-magenta bold
+  style 5-6 dim
+  style 7-7 inverse
+18-39| <blank>

+ 72 - 0
packages/ui/tui/tests/snapshots/details-selector.expected.txt

@@ -0,0 +1,72 @@
+terminal 100x40 buffer=normal length=40 base=0 viewport=0
+lifecycle started=1 stopped=0 progress=inactive
+title "DSH snapshot"
+cursor hidden column=0 viewportRow=39 bufferRow=39
+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| "Running the check now.                                                                              "
+6| "Model wait 0.0s                                                                                     "
+  style 0-14 dim
+7| <blank>
+8| "You                                                                                                 "
+  style 0-2 fg=bright-magenta bold underline
+9| "Inspect the renderer.                                                                               "
+10| <blank>
+11| "Assistant                                                                                           "
+  style 0-8 fg=bright-magenta bold underline
+12| <blank>
+13| "● Tool / bash / Run the coverage gate"
+  style 0-36 fg=green
+14| "$ pnpm run test:coverage                                                                            "
+  style 0-23 dim
+15| "/workspace/project                                                                                  "
+  style 0-17 dim
+16| "… +4 lines (Ctrl+O to expand)                                                                       "
+  style 0-28 dim
+17| "[exit 0]      ╭ Transcript details ──────────────────────────────────────────────────╮              "
+  style 0-7 dim
+  style 14-85 fg=bright-magenta
+18| "Model wait 0.0│ → Tool cards                      collapsed                          │              "
+  style 0-13 dim
+  style 14-14 fg=bright-magenta
+  style 16-58 fg=bright-magenta inverse
+  style 85-85 fg=bright-magenta
+19| "              │   Reasoning                       hidden                             │              "
+  style 14-14 fg=bright-magenta
+  style 27-55 dim
+  style 85-85 fg=bright-magenta
+20| "Reasoning bloc│                                                                      │              "
+  style 0-13 dim
+  style 14-14 fg=bright-magenta
+  style 85-85 fg=bright-magenta
+21| "              │ ↑/↓ move • Tab toggle • Enter/Esc close                              │              "
+  style 14-14 fg=bright-magenta
+  style 16-54 dim
+  style 85-85 fg=bright-magenta
+22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯              "
+  style 0-13 dim
+  style 14-85 fg=bright-magenta
+23| <blank>
+24| "Tool and context cards collapsed.                                                                   "
+  style 0-32 dim
+25| <blank>
+26| "/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
+27| " dsh >                                                                                              "
+  style 1-3 fg=bright-magenta bold
+  style 5-6 dim
+  style 7-7 inverse
+28-39| <blank>

+ 29 - 25
packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt

@@ -1,7 +1,7 @@
-terminal 92x32 buffer=normal length=37 base=5 viewport=5
+terminal 92x32 buffer=normal length=39 base=7 viewport=7
 lifecycle started=1 stopped=1 progress=inactive
 title "DSH snapshot"
-cursor visible column=0 viewportRow=31 bufferRow=36
+cursor visible column=0 viewportRow=31 bufferRow=38
 buffer
 0| " DEEPSEEK HARNESS"
   style 1-8 fg=bright-magenta bold
@@ -29,48 +29,52 @@ buffer
 12| "                                                                                            "
 13| "/clear — Clear the transcript view (session history is unchanged)                           "
   style 0-64 dim
-14| "/exit — Exit after the active turn reaches idle                                             "
+14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
+  style 0-91 dim
+15| "reasoning display                                                                           "
+  style 0-16 dim
+16| "/exit — Exit after the active turn reaches idle                                             "
   style 0-46 dim
-15| "/help — Show keyboard shortcuts and commands                                                "
+17| "/help — Show keyboard shortcuts and commands                                                "
   style 0-43 dim
-16| "/model [[provider/]model] — Show or switch this session's model                             "
+18| "/model [[provider/]model] — Show or switch this session's model                             "
   style 0-62 dim
-17| "/palette — Show every color and attribute role this terminal renders                        "
+19| "/palette — Show every color and attribute role this terminal renders                        "
   style 0-67 dim
-18| "/quit — Exit after the active turn reaches idle                                             "
+20| "/quit — Exit after the active turn reaches idle                                             "
   style 0-46 dim
-19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)    "
+21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)    "
   style 0-87 dim
-20| "/resume — List this workspace's resumable sessions                                          "
+22| "/resume — List this workspace's resumable sessions                                          "
   style 0-49 dim
-21| "/status — Show session diagnostics, system prompt, and registered tools                     "
+23| "/status — Show session diagnostics, system prompt, and registered tools                     "
   style 0-70 dim
-22| "/skill:<name> [instructions] — load a skill into the conversation                           "
+24| "/skill:<name> [instructions] — load a skill into the conversation                           "
   style 0-64 dim
-23| <blank>
-24| "provider stream failed after partial output                                                 "
-  style 0-42 fg=red
 25| <blank>
-26| "The previous process ended during this turn.                                                "
-  style 0-43 fg=yellow
+26| "provider stream failed after partial output                                                 "
+  style 0-42 fg=red
 27| <blank>
-28| "Turn stopped: the agent was disposed.                                                       "
-  style 0-36 fg=yellow
+28| "The previous process ended during this turn.                                                "
+  style 0-43 fg=yellow
 29| <blank>
-30| "Turn ended: plugin-policy.                                                                  "
-  style 0-25 fg=yellow
+30| "Turn stopped: the agent was disposed.                                                       "
+  style 0-36 fg=yellow
 31| <blank>
-32| "Unknown command: /unknown-advanced-command                                                  "
-  style 0-41 fg=yellow
+32| "Turn ended: plugin-policy.                                                                  "
+  style 0-25 fg=yellow
 33| <blank>
-34| "/workspace/project (tui-staging)  deepseek-v4-flash  ↑0 ↓0  0% context"
+34| "Unknown command: /unknown-advanced-command                                                  "
+  style 0-41 fg=yellow
+35| <blank>
+36| "/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
-35| " dsh >                                                                                      "
+37| " dsh >                                                                                      "
   style 1-3 fg=bright-magenta bold
   style 5-6 dim
   style 7-7 inverse
-36| <blank>
+38| <blank>

+ 28 - 24
packages/ui/tui/tests/snapshots/errors-and-help.expected.txt

@@ -1,7 +1,7 @@
-terminal 92x32 buffer=normal length=36 base=4 viewport=4
+terminal 92x32 buffer=normal length=38 base=6 viewport=6
 lifecycle started=1 stopped=0 progress=inactive
 title "DSH snapshot"
-cursor hidden column=7 viewportRow=31 bufferRow=35
+cursor hidden column=7 viewportRow=31 bufferRow=37
 buffer
 0| " DEEPSEEK HARNESS"
   style 1-8 fg=bright-magenta bold
@@ -29,47 +29,51 @@ buffer
 12| "                                                                                            "
 13| "/clear — Clear the transcript view (session history is unchanged)                           "
   style 0-64 dim
-14| "/exit — Exit after the active turn reaches idle                                             "
+14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and "
+  style 0-91 dim
+15| "reasoning display                                                                           "
+  style 0-16 dim
+16| "/exit — Exit after the active turn reaches idle                                             "
   style 0-46 dim
-15| "/help — Show keyboard shortcuts and commands                                                "
+17| "/help — Show keyboard shortcuts and commands                                                "
   style 0-43 dim
-16| "/model [[provider/]model] — Show or switch this session's model                             "
+18| "/model [[provider/]model] — Show or switch this session's model                             "
   style 0-62 dim
-17| "/palette — Show every color and attribute role this terminal renders                        "
+19| "/palette — Show every color and attribute role this terminal renders                        "
   style 0-67 dim
-18| "/quit — Exit after the active turn reaches idle                                             "
+20| "/quit — Exit after the active turn reaches idle                                             "
   style 0-46 dim
-19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)    "
+21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)    "
   style 0-87 dim
-20| "/resume — List this workspace's resumable sessions                                          "
+22| "/resume — List this workspace's resumable sessions                                          "
   style 0-49 dim
-21| "/status — Show session diagnostics, system prompt, and registered tools                     "
+23| "/status — Show session diagnostics, system prompt, and registered tools                     "
   style 0-70 dim
-22| "/skill:<name> [instructions] — load a skill into the conversation                           "
+24| "/skill:<name> [instructions] — load a skill into the conversation                           "
   style 0-64 dim
-23| <blank>
-24| "provider stream failed after partial output                                                 "
-  style 0-42 fg=red
 25| <blank>
-26| "The previous process ended during this turn.                                                "
-  style 0-43 fg=yellow
+26| "provider stream failed after partial output                                                 "
+  style 0-42 fg=red
 27| <blank>
-28| "Turn stopped: the agent was disposed.                                                       "
-  style 0-36 fg=yellow
+28| "The previous process ended during this turn.                                                "
+  style 0-43 fg=yellow
 29| <blank>
-30| "Turn ended: plugin-policy.                                                                  "
-  style 0-25 fg=yellow
+30| "Turn stopped: the agent was disposed.                                                       "
+  style 0-36 fg=yellow
 31| <blank>
-32| "Unknown command: /unknown-advanced-command                                                  "
-  style 0-41 fg=yellow
+32| "Turn ended: plugin-policy.                                                                  "
+  style 0-25 fg=yellow
 33| <blank>
-34| "/workspace/project (tui-staging)  deepseek-v4-flash  ↑0 ↓0  0% context"
+34| "Unknown command: /unknown-advanced-command                                                  "
+  style 0-41 fg=yellow
+35| <blank>
+36| "/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
-35| " dsh >                                                                                      "
+37| " dsh >                                                                                      "
   style 1-3 fg=bright-magenta bold
   style 5-6 dim
   style 7-7 inverse

+ 46 - 0
packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt

@@ -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:30:00                                                     "
+  style 0-46 dim
+11| <blank>
+12| "The renderer is sound; no refactor needed.                                                          "
+13| "Model wait 0.0s · Completed 2026-07-29 22:30:00                                                     "
+  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>

+ 25 - 30
packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt

@@ -13,46 +13,41 @@ buffer
 3| <blank>
 4| "Assistant                                                                                           "
   style 0-8 fg=bright-magenta bold underline
-5| <blank>
-6| "You                                                                                                 "
+5| "Reasoning                                                                                           "
+  style 0-8 dim italic
+6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                      "
+  style 0-61 dim italic
+7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                      "
+8| "Model wait 0.0s                                                                                     "
+  style 0-14 dim
+9| <blank>
+10| "You                                                                                                 "
   style 0-2 fg=bright-magenta bold underline
-7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                           "
-8| <blank>
-9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
+11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                           "
+12| <blank>
+13| "Assistant                                                                                           "
+  style 0-8 fg=bright-magenta bold underline
+14| <blank>
+15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
   style 0-81 fg=green
-10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                        "
+16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                        "
   style 0-59 dim
-11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                               "
+17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                               "
   style 0-52 dim
-12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                         "
+18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                         "
   style 0-58 dim
-13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m]                                           "
+19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m]                                           "
   style 0-56 fg=red
-14| "Model wait 0.0s · Completed 2026-07-21 15:00:00                                                     "
+20| "Model wait 0.0s · Completed 2026-07-21 15:00:00                                                     "
   style 0-46 dim
-15| <blank>
-16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
+21| <blank>
+22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
   style 0-61 dim
-17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                        "
+23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                        "
   style 0-59 dim
-18| <blank>
-19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                     "
+24| <blank>
+25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m                                     "
   style 0-62 fg=red
-20-21| <blank>
-22| "Plan"
-  style 0-3 fg=bright-magenta bold
-23| "  ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
-  style 2-2 fg=yellow
-24| "/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
-25| " dsh >                                                                                              "
-  style 1-3 fg=bright-magenta bold
-  style 5-6 dim
-  style 7-7 inverse
 26| "                                                                                                    "
 27| "  Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m         "
   style 2-90 dim

+ 68 - 0
packages/ui/tui/tests/tui.snapshot.ts

@@ -44,6 +44,9 @@ const CHECKPOINTS = [
   'cordis-tools-pending',
   'advanced-cards-collapsed',
   'advanced-cards-expanded',
+  'tool-cards-hidden-folded',
+  'details-command',
+  'details-selector',
   'untrusted-controls',
   'question-dialog',
   'question-dialog-single-option',
@@ -609,6 +612,71 @@ describe('TUI terminal-state snapshots', () => {
     await disposeSnapshot(harness)
   })
 
+  it('pins the hidden phase folding a multi-step turn into one assistant message', async () => {
+    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime())
+    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 })
+    nowSpy.mockRestore()
+    await disposeSnapshot(harness)
+  })
+
+  it('pins /details jumping card visibility and reasoning display to named states', async () => {
+    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime())
+    const harness = await setupSnapshot({
+      tools: ADVANCED_CARD_TOOLS,
+      config: { maxToolOutputLines: 3 },
+    }, { columns: 100, rows: 40 })
+    await renderAfter(harness, () => {
+      appendUser(harness.session, 'Inspect the renderer.')
+      appendAssistant(harness.session, [
+        { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' },
+        { type: 'text', text: 'Running the check now.' },
+      ])
+      appendToolCalls(harness.session, [
+        { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } },
+      ])
+      appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }])
+      harness.session.append('step/end', { turn: 1, step: 1 })
+      harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    })
+    await renderAfter(harness, () => {
+      harness.terminal.send('/details hidden reasoning off')
+      harness.terminal.send('\r')
+    })
+    await checkpoint('details-command', harness.terminal, { includeScrollback: true })
+    // Bare /details opens the two-entry toggle seeded with the current
+    // hidden/reasoning-off state; one Tab immediately cycles tool cards
+    // hidden -> collapsed, so the frame pins the applied notice, the restored
+    // tool card behind the dialog, and the updated entry value together.
+    await renderAfter(harness, () => {
+      harness.terminal.send('/details')
+      harness.terminal.send('\r')
+      harness.terminal.send('\t')
+    })
+    await checkpoint('details-selector', harness.terminal, { includeScrollback: true })
+    nowSpy.mockRestore()
+    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 = {

+ 219 - 0
packages/ui/tui/tests/tui.spec.ts

@@ -192,6 +192,7 @@ describe('TUI config', () => {
       questionDialogMaxHeight: 20,
       modelDialogWidth: 76,
       modelDialogMaxHeight: 20,
+      detailsDialogWidth: 72,
       fileSearchMaxResults: 20,
       fileSearchMaxEntries: 10_000,
       fileSearchExcludedDirectories: ['.git', 'node_modules'],
@@ -216,6 +217,7 @@ describe('TUI config', () => {
       questionDialogMaxHeight: 14,
       modelDialogWidth: 64,
       modelDialogMaxHeight: 16,
+      detailsDialogWidth: 44,
       fileSearchMaxResults: 7,
       fileSearchMaxEntries: 123,
       fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -232,6 +234,7 @@ describe('TUI config', () => {
       questionDialogMaxHeight: 14,
       modelDialogWidth: 64,
       modelDialogMaxHeight: 16,
+      detailsDialogWidth: 44,
       fileSearchMaxResults: 7,
       fileSearchMaxEntries: 123,
       fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -2687,6 +2690,94 @@ describe('pi-tui chat lifecycle and transcript', () => {
     await dispose(result)
   })
 
+  it('/details sets card visibility and reasoning display from arguments', async () => {
+    const result = await setup()
+    const run = async (line: string): Promise<void> => {
+      result.terminal.send(line)
+      result.terminal.send('\r')
+      await tick()
+    }
+
+    await run('/details hidden')
+    expect(result.terminal.output).toContain('Tool cards hidden.')
+
+    await run('/details expanded reasoning off')
+    expect(result.terminal.output).toContain('Tool and context cards expanded.')
+    expect(result.terminal.output).toContain('Reasoning blocks hidden.')
+
+    await run('/details reasoning on')
+    expect(result.terminal.output).toContain('Reasoning blocks shown.')
+
+    // Bare `reasoning` toggles: shown -> hidden.
+    const toggleOutput = result.terminal.output.length
+    await run('/details reasoning')
+    expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.')
+    await run('/details collapsed')
+    expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.')
+
+    await run('/details bogus')
+    expect(result.terminal.output).toContain('Unknown /details argument "bogus"')
+
+    await dispose(result)
+  })
+
+  it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => {
+    const result = await setup()
+    const open = async (): Promise<number> => {
+      const from = result.terminal.output.length
+      result.terminal.send('/details')
+      result.terminal.send('\r')
+      await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') })
+      return from
+    }
+
+    const opened = await open()
+    expect(result.terminal.output.slice(opened)).toContain('Tool cards')
+    expect(result.terminal.output.slice(opened)).toContain('Reasoning')
+
+    // A second /details while the selector is open replaces the overlay
+    // instead of stacking a second one behind it.
+    await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal)
+    await tick()
+
+    // Each Tab applies one step immediately while the dialog stays open:
+    // collapsed -> expanded -> hidden -> collapsed (wraparound).
+    result.terminal.send('\t')
+    await tick()
+    expect(result.terminal.output).toContain('Tool and context cards expanded.')
+    result.terminal.send('\t')
+    await tick()
+    expect(result.terminal.output).toContain('Tool cards hidden.')
+    result.terminal.send('\t')
+    await tick()
+    expect(result.terminal.output).toContain('Tool and context cards collapsed.')
+
+    // The reasoning entry toggles the same way.
+    result.terminal.send('\x1b[B')
+    result.terminal.send('\t')
+    await tick()
+    expect(result.terminal.output).toContain('Reasoning blocks hidden.')
+
+    // Enter closes without further changes.
+    const entered = result.terminal.output.length
+    result.terminal.send('\r')
+    await tick()
+    expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks')
+
+    // Esc and Ctrl+C also close; the reopened dialog shows the live values.
+    const reopened = await open()
+    expect(result.terminal.output.slice(reopened)).toContain('collapsed')
+    expect(result.terminal.output.slice(reopened)).toContain('hidden')
+    result.terminal.send('\x1b')
+    await tick()
+    const ctrlCOutput = await open()
+    result.terminal.send('\x03')
+    await tick()
+    expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks')
+
+    await dispose(result)
+  })
+
   it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
     const result = await setup()
 
@@ -5167,6 +5258,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', () => {

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików