/** * pi-tui dialog and selector components for the terminal front door: the status * card, prompt-context line, model selector, resume picker, and user-question * dialog, plus the model-choice and resume-candidate data they present. * @module @deepseek-ai/dsh-tui/components/dialogs */ import { Input, Key, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type SelectItem, } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import { type Agent, type AgentLlmTarget, } from '@deepseek-ai/dsh-agent' import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' 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 { renderTuiPromptTemplate, type TuiPromptTemplateToken, } from '../prompt.ts' /** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */ export interface ModelChoice extends AgentLlmTarget { modelName: string description?: string reasoning?: LlmModelReasoningInfo } /** * The provider/model route and selected reasoning effort resolved from a model dialog. */ export interface ModelDialogSelection { choice: ModelChoice reasoningEffort: ReasoningEffortId | undefined } /** * Format a provider/model target as its `provider/model` label. * @param target - The LLM target. * @returns The `provider/model` label. */ export function targetLabel(target: AgentLlmTarget): string { return `${target.provider}/${target.model}` } /** * Format a target compactly as its model name with any selected reasoning effort appended. * @param target - The LLM target. * @returns The compact `model [effort]` label. */ export function compactTargetLabel(target: AgentLlmTarget): string { return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` } /** * Resolve the display label for a choice's reasoning effort. * @param choice - The model choice carrying advertised reasoning metadata. * @param effort - The selected effort, or `undefined` for provider default. * @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata. */ export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort } /** * Derive the agent's initial LLM target from its logged request header or options. * @param agent - The driven agent. * @returns The initial target, or `undefined` when unset. */ export function initialTarget(agent: Agent): AgentLlmTarget | undefined { const logged = agent.session.requestHeader()?.config if (logged !== undefined) { if (logged.reasoningEffort === undefined) { return { provider: logged.provider, model: logged.model } } return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort } } if (agent.options.provider === undefined || agent.options.model === undefined) return undefined return { provider: agent.options.provider, model: agent.options.model } } /** * List every advertised model across registered providers, appending the current * target when a provider does not advertise it. * @param ctx - Context supplying the LLM service. * @param current - The current target, appended when unadvertised. * @returns The model choices, flattened across providers. */ export async function readModelChoices( ctx: Context, current: AgentLlmTarget | undefined, ): Promise { const providers = ctx.llm.listProviders() const groups = await Promise.all(providers.map(async (provider) => { const advertised = await ctx.llm.listModels(provider.id) const models: LlmModelInfo[] = [...advertised] if ( current?.provider === provider.id && !models.some(model => model.id === current.model) ) { models.push({ provider: provider.id, id: current.model, name: current.model }) } return Promise.all(models.map(async (model): Promise => { const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning return { provider: provider.id, model: model.id, modelName: model.name, ...model.description === undefined ? {} : { description: model.description }, ...reasoning === undefined ? {} : { reasoning }, } })) })) return groups.flat() } /** * Format a diagnostic integer with grouping separators. * @param value - Integer to format. * @returns The grouped decimal string. */ export function formatDiagnosticNumber(value: number): string { return value.toLocaleString('en-US') } /** * Format a diagnostic timestamp as an ISO date-time in UTC. * @param value - Epoch milliseconds. * @returns The formatted UTC timestamp. */ export function formatDiagnosticTime(value: number): string { return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') } /** * Format a pluralized count for a diagnostic row. * @param value - Count. * @param singular - Singular noun; an `s` is appended for other counts. * @returns The formatted count. */ export function formatDiagnosticCount(value: number, singular: string): string { return `${String(value)} ${singular}${value === 1 ? '' : 's'}` } /** * Render a fixed-width filled meter bar for a percentage. * @param percent - Percentage in [0, 100]. * @param palette - Active role palette. * @returns The rendered meter. */ export function diagnosticMeter(percent: number, palette: Palette): string { const width = 16 const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` } /** One `label: value` row of a status card group. */ export type StatusCardRow = readonly [label: string, value: string] /** Bordered, grouped field card for one point-in-time status snapshot. */ export class StatusCardComponent implements Component { constructor( private readonly groups: readonly (readonly StatusCardRow[])[], private readonly palette: Palette, ) {} invalidate(): void {} render(width: number): string[] { const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) const naturalLabelWidth = Math.max(...labels.map(label => label.length)) const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => 1 + naturalLabelWidth + 2 + visibleWidth(value)))) const cardWidth = Math.min( Math.max(8, width), Math.max('Session status'.length + 5, naturalBodyWidth + 4), ) const innerWidth = Math.max(1, cardWidth - 4) const labelWidth = Math.min( naturalLabelWidth, Math.max(1, Math.floor(innerWidth / 3)), ) const body: string[] = [] for (const [groupIndex, group] of this.groups.entries()) { if (groupIndex > 0) body.push('') for (const [label, value] of group) { const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') const prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} ` const continuation = ' '.repeat(1 + labelWidth + 2) const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) const wrapped = wrapTextWithAnsi(value, valueWidth) for (const [lineIndex, line] of wrapped.entries()) { body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) } } } const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` const lines = [top] for (const line of body) { const clipped = truncateToWidth(line, innerWidth, '') lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) } lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) return lines } } /** The left/right template line rendered above the editor. */ export class PromptContextComponent implements Component { constructor( private readonly leftTemplate: readonly TuiPromptTemplateToken[], private readonly rightTemplate: readonly TuiPromptTemplateToken[], private readonly resolve: (name: string) => string | undefined, ) {} invalidate(): void {} render(width: number): string[] { const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '') const rightWidth = visibleWidth(right) const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2)) const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '') if (rightWidth === 0) return [left] const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth)) return [`${left}${gap}${right}`] } } /** A user's answer to one question: chosen option labels and an optional custom answer. */ export interface QuestionSelection { selected: string[] custom?: string } /** * Render a bordered dialog frame around body lines with a titled top edge. * @param title - Dialog title shown in the top border. * @param body - Body lines. * @param width - Dialog width in columns. * @param palette - Active role palette. * @returns The framed dialog lines. */ export function renderDialog( title: string, body: readonly string[], width: number, palette: Palette, ): string[] { const innerWidth = Math.max(1, width - 4) const topLabel = ` ${displayText(title)} ` const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` const lines: string[] = [palette.accent(top)] for (const line of body) { const clipped = truncateToWidth(line, innerWidth, '') lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) } lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) return lines } /** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */ export class ModelDialog implements Component { private list: SelectList private readonly filter = new Input() private readonly items: Map private readonly choices: Map private readonly efforts: Map private readonly currentValue: string | undefined constructor( choices: readonly ModelChoice[], current: AgentLlmTarget | undefined, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: ModelDialogSelection) => void, private readonly cancel: () => void, ) { this.items = new Map() this.choices = new Map() this.efforts = new Map() this.currentValue = current === undefined ? undefined : targetLabel(current) for (const choice of choices) { const value = targetLabel(choice) const isCurrent = current?.provider === choice.provider && current.model === choice.model this.choices.set(value, choice) this.efforts.set( value, isCurrent ? current.reasoningEffort ?? choice.reasoning?.defaultEffort : choice.reasoning?.defaultEffort, ) this.items.set(value, { value, label: displayText(value), description: this.describeChoice(choice, isCurrent), }) } this.list = this.buildList(this.currentValue) } /** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */ private buildList(selectValue: string | undefined): SelectList { const items = this.filteredItems() const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette)) const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue) list.setSelectedIndex(Math.max(0, index)) list.onSelect = (item) => { this.confirm(item) } list.onCancel = this.cancel return list } /** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */ private filteredItems(): SelectItem[] { const query = this.filter.getValue().trim().toLocaleLowerCase() if (query === '') return [...this.items.values()] return [...this.items.values()].filter((item) => { const choice = this.choices.get(item.value) /* v8 ignore next -- items and choices share the same keys. */ if (choice === undefined) return false return [item.value, choice.modelName, choice.description ?? ''] .some(field => field.toLocaleLowerCase().includes(query)) }) } private confirm(item: SelectItem): void { const selected = this.choices.get(item.value) /* v8 ignore next -- SelectList only returns values built from `choices`. */ if (selected === undefined) return this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) } private describeChoice(choice: ModelChoice, isCurrent: boolean): string { const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice))) return [ displayText(choice.modelName), ...choice.description === undefined ? [] : [displayText(choice.description)], ...effortLabel === undefined ? [] : [displayText(effortLabel)], ...isCurrent ? ['current'] : [], ].join(' — ') } private cycleReasoningEffort(): void { const selectedItem = this.list.getSelectedItem() /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ if (selectedItem === null) return const choice = this.choices.get(selectedItem.value) if (choice?.reasoning === undefined) return const current = this.efforts.get(selectedItem.value) const efforts: Array = [ ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], ...choice.reasoning.efforts.map(effort => effort.id), ] const currentIndex = efforts.indexOf(current) const next = efforts[(currentIndex + 1) % efforts.length] this.efforts.set(selectedItem.value, next) const item = this.items.get(selectedItem.value) /* v8 ignore next -- items and choices are constructed from the same values. */ if (item === undefined) return item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) } invalidate(): void { this.filter.invalidate() this.list.invalidate() } handleInput(data: string): void { if (matchesKey(data, Key.shift(Key.tab))) { this.cycleReasoningEffort() } else if (matchesKey(data, Key.escape)) { if (this.filter.getValue() === '') this.cancel() else { this.filter.setValue('') this.list = this.buildList(undefined) } } else if ( matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.enter) ) { this.list.handleInput(data) } else { const previous = this.filter.getValue() this.filter.focused = true this.filter.handleInput(data) if (this.filter.getValue() !== previous) { const selected = this.list.getSelectedItem() this.list = this.buildList(selected?.value) } } this.invalidate() } render(width: number): string[] { const innerWidth = Math.max(1, width - 4) this.filter.focused = true const results = this.filteredItems() const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '') return renderDialog('Select model', [ filterContent, '', ...results.length === 0 ? [this.palette.dim(' No models match the filter')] : this.list.render(innerWidth), '', this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'), ], width, this.palette) } } /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string model: string } /** A preflighted resume selector row summarizing one persisted session. */ export interface ResumeCandidate { record: SessionRecord title: string lastActivityAt: number lastTurn: string /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ currentWorkspace: boolean /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ workspaceLabel: string route?: ResumeRoute goalPhase?: GoalPhase disabledReason?: string } function resumeTurnLabel(snapshot: SessionLogSnapshot): string { const event = snapshot.events.findLast(item => item.type === 'turn/end') if (event === undefined) return 'no completed turn' const reason = event.data.reason switch (reason.kind) { case 'completed': return `turn ${event.data.turn}: completed` case 'aborted': return `turn ${event.data.turn}: cancelled` case 'error': return `turn ${event.data.turn}: error` case 'disposed': return `turn ${event.data.turn}: disposed` case 'max-tokens': return `turn ${event.data.turn}: max tokens` case 'interrupted': return `turn ${event.data.turn}: interrupted` default: return `turn ${event.data.turn}: unknown result` } } function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { const header = snapshot.events.findLast(item => item.type === 'request/header') if (header?.type === 'request/header') { return { provider: header.data.header.config.provider, model: header.data.header.config.model } } const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') return assistant?.type === 'assistant/message' ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } : undefined } /** * Build one resume selector row from a record and its log snapshot, deriving the * title, route, goal phase, workspace scope, and any reason the session cannot * be resumed here. A workspace other than the current one is a scope, not a * disabled reason: resuming it hands the process off into that directory. * @param record - The session record. * @param snapshot - The session's log snapshot. * @param currentId - The current session id. * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. * @param availableProviders - Providers registered in this runtime. * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. * @returns The summarized resume candidate. */ export function summarizeResumeCandidate( record: SessionRecord, snapshot: SessionLogSnapshot, currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' const route = resumeRoute(snapshot) const foldedGoal = foldGoal(snapshot.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' else if (route !== undefined && !availableProviders.has(route.provider)) { disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` } return { record, title, lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), ...route === undefined ? {} : { route }, /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, ...disabledReason === undefined ? {} : { disabledReason }, } } /** Which workspaces the resume picker currently lists. */ export type ResumeScope = 'workspace' | 'all' /** * Full-viewport keyboard selector over detached, preflighted resume summaries. * * Two scopes over one candidate set: `workspace` (the default) lists only the * current session's workspace, `all` lists every workspace and labels each row * with its own. Tab toggles between them; the search query and selection reset * on a scope change so the highlighted row always belongs to the visible list. */ export class ResumePicker implements Component, Focusable { private readonly search = new Input() private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' private scope: ResumeScope = 'workspace' focused = false constructor( private readonly candidates: readonly ResumeCandidate[], private readonly maxVisible: number, private readonly workspaceLabel: string, private readonly viewportRows: () => number, private readonly palette: Palette, private readonly done: (candidate: ResumeCandidate) => void, private readonly cancel: () => void, ) {} invalidate(): void { this.search.invalidate() } /** Candidates in the active scope, before the search query narrows them. */ private scoped(): ResumeCandidate[] { return this.scope === 'all' ? [...this.candidates] : this.candidates.filter(candidate => candidate.currentWorkspace) } private filtered(): ResumeCandidate[] { const query = this.search.getValue().trim().toLocaleLowerCase() const scoped = this.scoped() if (query === '') return scoped // The workspace label only distinguishes rows once it is on screen, so it // joins the searchable text exactly in the scope that shows it. return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) || candidate.record.header.id.toLocaleLowerCase().includes(query) || (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query))) } private visibleCandidateCount(): number { // The all-workspaces scope adds a per-row workspace line, so a row costs // one more terminal row there than in the single-workspace scope. const rowHeight = this.scope === 'all' ? 5 : 4 const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) return Math.min(this.maxVisible, candidateBudget) } private handleBracketedPaste(data: string): boolean { const start = data.indexOf(BRACKETED_PASTE_START) if (this.pasteBuffer === undefined && start < 0) return false if (this.pasteBuffer === undefined) { const prefix = data.slice(0, start) if (prefix !== '') this.handleInput(prefix) this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) } else { this.pasteBuffer += data } const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) if (end < 0) return true const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) this.pasteBuffer = undefined const previous = this.search.getValue() this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) if (this.search.getValue() !== previous) { this.selectedIndex = 0 this.error = '' } if (remaining !== '') this.handleInput(remaining) this.invalidate() return true } handleInput(data: string): void { if (this.handleBracketedPaste(data)) return const filtered = this.filtered() if (matchesKey(data, Key.ctrl('c'))) { this.cancel() return } if (matchesKey(data, Key.escape)) { if (this.search.getValue() === '') this.cancel() else { this.search.setValue('') this.selectedIndex = 0 this.error = '' } } else if (matchesKey(data, Key.up)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + filtered.length - 1) % filtered.length } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length } else if (matchesKey(data, Key.pageUp)) { this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) } else if (matchesKey(data, Key.pageDown)) { this.selectedIndex = Math.min( Math.max(0, filtered.length - 1), this.selectedIndex + this.visibleCandidateCount(), ) } else if (matchesKey(data, Key.tab)) { this.scope = this.scope === 'workspace' ? 'all' : 'workspace' this.search.setValue('') this.selectedIndex = 0 this.error = '' } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' else if (selected.disabledReason !== undefined) this.error = selected.disabledReason else this.done(selected) } else { const previous = this.search.getValue() this.search.focused = this.focused this.search.handleInput(data) if (this.search.getValue() !== previous) { this.selectedIndex = 0 this.error = '' } } this.invalidate() } /** * The scope line under the search box: the active scope with the current * workspace it means, and the inactive scope with the count Tab would reveal. */ private renderScopeLine(): string { const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length const active = this.scope === 'workspace' ? `this workspace ${displayText(this.workspaceLabel)}` : `all workspaces (${this.candidates.length})` const other = this.scope === 'workspace' ? `all workspaces (${this.candidates.length})` : `this workspace (${inWorkspace})` return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}` } render(width: number): string[] { this.search.focused = this.focused const height = Math.max(1, this.viewportRows()) const horizontalPadding = width >= 12 ? 2 : 0 const contentWidth = Math.max(1, width - horizontalPadding * 2) const indent = ' '.repeat(horizontalPadding) const filtered = this.filtered() if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) const selected = filtered[this.selectedIndex] const position = selected === undefined ? 0 : this.selectedIndex + 1 const lines: string[] = [ '', `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, '', ] const searchInnerWidth = Math.max(1, contentWidth - 4) lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') lines.push( `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, '', `${indent}${this.renderScopeLine()}`, '', ) const visibleCount = this.visibleCandidateCount() const start = Math.max(0, Math.min( this.selectedIndex - Math.floor(visibleCount / 2), filtered.length - visibleCount, )) const end = Math.min(filtered.length, start + visibleCount) const push = (line: string): void => { lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) } for (let index = start; index < end; index += 1) { const candidate = filtered[index] as ResumeCandidate const active = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : undefined, candidate.record.live ? 'live' : undefined, candidate.record.persisted ? 'persisted' : undefined, ].filter((value): value is string => value !== undefined).join(' · ') const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` push(active ? this.palette.bold(this.palette.accent(lead)) : lead) const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) // Only the all-workspaces scope mixes directories, so the per-row // workspace is redundant in the scope that already names one. if (this.scope === 'all') { push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`)) } if (candidate.disabledReason !== undefined) { push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } } if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) if (this.error !== '') { lines.push('') push(this.palette.error(displayText(this.error))) } const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}` while (lines.length < height - 2) lines.push('') lines.push(footer, '') return lines.slice(0, height) } } /** Bottom-anchored dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() private mode: 'options' | 'custom' private error = '' private readonly input = new Input() private readonly options: NonNullable focused = false constructor( private readonly question: AskUserQuestionItem, private readonly position: number, private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, ) { this.options = question.options ?? [] this.mode = this.options.length > 0 ? 'options' : 'custom' this.input.onSubmit = (value) => { this.submitCustom(value) } this.input.onEscape = () => { if (this.options.length > 0) { this.mode = 'options' this.error = '' } else { this.cancel() } } } invalidate(): void { this.input.invalidate() } handleInput(data: string): void { this.invalidate() if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) return } const options = this.options if (matchesKey(data, Key.up)) { this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] if (indices.length === 0) { this.error = 'Select at least one option, or press Tab for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() } } private submitCustom(value: string): void { const custom = value.trim() if (custom === '') { this.error = 'Enter an answer before submitting.' return } this.done({ selected: [], custom }) } render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` const lines = [ this.palette.dim(header), ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), ] const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { push('') for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) } push('') if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) } else { const options = this.options const start = Math.max(0, Math.min( this.selectedIndex - Math.floor(this.maxVisible / 2), options.length - this.maxVisible, )) const end = Math.min(options.length, start + this.maxVisible) const optionRows = options.slice(start, end).map((option, offset) => { const index = start + offset const mark = this.question.multiSelect ? this.selected.has(index) ? '[x] ' : '[ ] ' : '' return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` }) const descriptionColumn = Math.min( Math.max(...optionRows.map(row => visibleWidth(row))) + 2, Math.max(1, Math.floor(innerWidth * 0.55)), ) for (let index = start; index < end; index += 1) { // `index < end <= options.length`; the options array is borrowed immutably for this dialog. const option = options[index] as NonNullable[number] const mark = this.question.multiSelect ? this.selected.has(index) ? '[x] ' : '[ ] ' : '' const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` const leftStyled = index === this.selectedIndex ? this.palette.bold(this.palette.accent(left)) : left const description = option.description === undefined ? '' : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) const controls = [ 'Tab custom answer', ...(options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) } if (this.error) { for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) } return ['', ...lines, ''].map((line) => { const clipped = truncateToWidth(line, innerWidth, '') return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` }) } }