From 2c98f35a86e70ef943b05c11cfdf42d5fc4897bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 19:52:58 +0800 Subject: [PATCH] feat(tui): bare /details opens a keyboard selector DetailsDialog is a centered SelectList over the five transcript-detail states (three tool-card phases, reasoning shown/hidden); it preselects the current phase, marks both current values, applies on Enter, and cancels on Esc/Ctrl+C. Width is the new detailsDialogWidth config key. The argument grammar is unchanged and shares the same setters. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 11 +-- .../2026-07-30-tui-details-command.zh.md | 11 +-- docs/config-catalog.md | 4 +- packages/ui/tui/src/components/dialogs.ts | 58 ++++++++++++++++ packages/ui/tui/src/config.ts | 7 ++ packages/ui/tui/src/index.ts | 32 ++++++++- .../snapshots/details-command.expected.txt | 11 ++- .../snapshots/details-selector.expected.txt | 66 ++++++++++++++++++ .../snapshots/disposed-terminal.expected.txt | 6 +- .../snapshots/errors-and-help.expected.txt | 6 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 68 +++++++++++++++++-- 13 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/details-selector.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index df7079c9b8..33278b66d2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: f17c168cb87d546ca158eddef7dd96fb0ab8be2d -2026-07-30-tui-details-command.zh.md: 48e9a8d3f18c51982b8678e39d9315ffe08b015f +2026-07-30-tui-details-command.md: c14461aa08790033026eead38f53d4b8e686bbbb +2026-07-30-tui-details-command.zh.md: e9c9787a4911f29f49e7709d1ba673dc1a25e8a1 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index f17c168cb8..c14461aa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` reports the current state in one notice. 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. The command 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. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector over the five detail states — the three tool-card phases and reasoning shown/hidden — that preselects the current phase, marks both current values, applies the highlighted state on Enter, and cancels on Esc or Ctrl+C; 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. @@ -18,7 +18,9 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## 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 a state report. +**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. @@ -26,6 +28,7 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## Consequences -- A user can jump to any detail mode, set both dimensions at once, and query the state — including on terminals that intercept Ctrl+O/Ctrl+R. +- 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. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the command surface and the fixed replay together. +- The selector applies one dimension per confirm; a combined change still needs the argument form. Enter on the already-current row re-applies it idempotently and repeats its notice. +- 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 selector with its current-state markers. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index 48e9a8d3f1..e9c9787a49 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 用一条通知报告当前状态。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。命令改动的是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,列出五个细节状态——三个工具卡片阶段与 reasoning 显示/隐藏——预选当前阶段并标记两个当前值,Enter 应用高亮状态并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -18,7 +18,9 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Alternatives considered -**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为状态报告更有用。 +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 **拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 @@ -26,6 +28,7 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Consequences -- 用户可以跳到任意细节模式、一次设置两个维度并查询状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照同时固定命令表面与修复后的重放。 +- 选择器每次确认只应用一个维度;组合变更仍需参数形式。在已是当前值的行上按 Enter 会幂等地重新应用并重复其通知。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定带当前值标记的打开选择器。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2288d7ffbe..0f04e0a955 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2034,6 +2034,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -2067,7 +2069,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` diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..f59ff747be 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/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,63 @@ export class ModelDialog implements Component { } } +/** One transcript-detail state the details selector applies on confirm. */ +export type DetailsSelection = + | { readonly kind: 'tools'; readonly visibility: ToolCardVisibility } + | { readonly kind: 'reasoning'; readonly show: boolean } + +/** + * Keyboard selector over the transcript detail states: the three tool-card + * visibility phases and reasoning-block display. Enter applies the highlighted + * state and closes; Esc or Ctrl+C closes without changing anything. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + + constructor( + visibility: ToolCardVisibility, + showReasoning: boolean, + private readonly palette: Palette, + done: (selection: DetailsSelection) => void, + private readonly cancel: () => void, + ) { + const current = (isCurrent: boolean): string => isCurrent ? ' — current' : '' + const items: SelectItem[] = [ + { value: 'collapsed', label: 'Tool cards · collapsed', description: `head/tail preview${current(visibility === 'collapsed')}` }, + { value: 'expanded', label: 'Tool cards · expanded', description: `full bodies${current(visibility === 'expanded')}` }, + { value: 'hidden', label: 'Tool cards · hidden', description: `conversation only${current(visibility === 'hidden')}` }, + { value: 'reasoning-shown', label: 'Reasoning · shown', description: `show reasoning blocks${current(showReasoning)}` }, + { value: 'reasoning-hidden', label: 'Reasoning · hidden', description: `omit reasoning blocks${current(!showReasoning)}` }, + ] + this.list = new SelectList(items, items.length, dialogSelectTheme(palette)) + this.list.setSelectedIndex(items.findIndex(item => item.value === visibility)) + this.list.onSelect = (item) => { + done(item.value === 'reasoning-shown' || item.value === 'reasoning-hidden' + ? { kind: 'reasoning', show: item.value === 'reasoning-shown' } + : { kind: 'tools', visibility: item.value as ToolCardVisibility }) + } + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() + 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 • Enter apply • Esc cancel'), + ], width, this.palette) + } +} + /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index def548861f..59a12c0b96 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -46,6 +46,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -70,6 +72,7 @@ const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) 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 detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) 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) @@ -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 = 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)], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 6470e70114..7fd76a1a6f 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -104,6 +104,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -112,6 +113,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -1037,12 +1039,38 @@ export function createTuiChat( 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, + (selection: DetailsSelection) => { + void session.close() + if (selection.kind === 'reasoning') setReasoning(selection.show) + else 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) { - appendNotice(`Tool and context cards ${toolsVisibility}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + showDetailsSelector() return { kind: 'success' } } let visibility: ToolCardVisibility | undefined @@ -1255,7 +1283,7 @@ export function createTuiChat( }) commandCtx.commands.register({ name: 'details', - description: 'Show or set tool-card visibility and reasoning display', + description: 'Select tool-card visibility and reasoning display', input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, handler: ({ rawInput }) => runDetails(rawInput), }) diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt index 4e84695616..4d971c88df 100644 --- a/packages/ui/tui/tests/snapshots/details-command.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -1,7 +1,7 @@ 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=19 bufferRow=19 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,17 +29,14 @@ buffer 14| "Tool cards hidden. " style 0-17 dim 15| -16| "Tool and context cards hidden; reasoning blocks hidden. " - style 0-54 dim -17| -18| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +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 -19| " dsh > " +17| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -20-39| +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..ad6514d4e9 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,66 @@ +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| +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| +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| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| " ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 14-85 fg=bright-magenta +16| "/workspace/pro│ Tool cards · collapsed head/tail preview │ " + style 0-13 fg=bright-magenta bold + style 14-14 fg=bright-magenta + style 40-66 dim + style 85-85 fg=bright-magenta +17| " dsh > │ Tool cards · expanded full bodies │ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse + style 14-14 fg=bright-magenta + style 39-60 dim + style 85-85 fg=bright-magenta +18| " │ → Tool cards · hidden conversation only — current │ " + style 14-14 fg=bright-magenta + style 16-76 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning · shown show reasoning blocks │ " + style 14-14 fg=bright-magenta + style 35-70 dim + style 85-85 fg=bright-magenta +20| " │ Reasoning · hidden omit reasoning blocks — current │ " + style 14-14 fg=bright-magenta + style 36-80 dim + style 85-85 fg=bright-magenta +21| " │ │ " + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +22| " │ ↑/↓ move • Enter apply • Esc cancel │ " + style 14-14 fg=bright-magenta + style 16-50 dim + style 85-85 fg=bright-magenta +23| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 14-85 fg=bright-magenta +24-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index aa41ca137d..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 48c25cf8fa..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index abea27e69e..c0b6d44f88 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -46,6 +46,7 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'tool-cards-hidden-folded', 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -661,11 +662,14 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('/details hidden reasoning off') harness.terminal.send('\r') }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the selector, preselecting and marking the current + // hidden/reasoning-off state. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') }) - await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) nowSpy.mockRestore() await disposeSnapshot(harness) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c532c5a67d..093d8ad285 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -186,6 +186,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -210,6 +211,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -226,6 +228,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2510,7 +2513,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('/details reports and sets card visibility and reasoning display', async () => { + it('/details sets card visibility and reasoning display from arguments', async () => { const result = await setup() const run = async (line: string): Promise => { result.terminal.send(line) @@ -2518,9 +2521,6 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() } - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks shown.') - await run('/details hidden') expect(result.terminal.output).toContain('Tool cards hidden.') @@ -2531,11 +2531,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await run('/details reasoning on') expect(result.terminal.output).toContain('Reasoning blocks shown.') - // Bare `reasoning` toggles: shown -> hidden, confirmed by the status line. + // 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') - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks hidden.') + 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"') @@ -2543,6 +2544,59 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('bare /details opens the transcript-details selector and applies the confirmed state', async () => { + const result = await setup() + const open = async (): Promise => { + 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 + } + + await open() + expect(result.terminal.output).toContain('Tool cards · collapsed') + expect(result.terminal.output).toContain('head/tail preview — current') + expect(result.terminal.output).toContain('show reasoning blocks — current') + + // 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() + + // Esc cancels without touching the state. + const cancelOutput = result.terminal.output.length + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') + + // Enter on the next visibility row applies it and closes. + await open() + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + + // The reopened selector preselects the current phase and marks it. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('full bodies — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Ctrl+C also cancels. + const ctrlCOutput = result.terminal.output.length + await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks shown.') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup()