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:
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user