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

This commit is contained in:
imccyu
2026-08-03 16:23:46 +08:00
committed by GitHub
27 changed files with 981 additions and 143 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
README.zh.md: 01055619f4df460284564f0a1816de366d809e01
README.md: a577fb2f858f61eb765d4a1a9564f452d01d92c1
README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2

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. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). 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`, `/details`, `/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. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). 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. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice.
`/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.
@@ -57,6 +57,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `76` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `detailsDialogWidth` | `72` | Transcript-details selector width in columns |
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |

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 ·` 徽标每条消息排空后随即清除。在实时独立压缩compaction标记对处于开启状态期间提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)。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``/details``/palette``/reload``/resume``/status``/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help``/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具它显示该阶段已经过时间和运行中的步骤总数每秒刷新并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标每条消息排空后随即清除。在实时独立压缩compaction标记对处于开启状态期间提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题之后的步骤渲染为无标题的续段没有可见正文的步骤则不渲染任何内容离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本并去掉生产方的外层提醒外框因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoningCtrl+L 重绘Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards``Reasoning`——显示实时值Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning使其 transcript 重建不会丢掉卡片通知
`/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 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
@@ -57,6 +57,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) |
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename |

View File

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

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

View File

@@ -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)],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {