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.
This commit is contained in:
Turtle
2026-07-30 19:52:58 +08:00
parent 3899a26c87
commit 2c98f35a86
13 changed files with 256 additions and 34 deletions

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

View File

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

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

View File

@@ -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| <blank>
16| "Tool and context cards hidden; reasoning blocks hidden. "
style 0-54 dim
17| <blank>
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| <blank>
18-39| <blank>

View File

@@ -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| <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| " ╭ 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| <blank>

View File

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

View File

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

View File

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

View File

@@ -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<void> => {
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<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
}
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()