Files
deepseek-harness/packages/ui/tui/src/components/dialogs.ts

897 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<ModelChoice[]> {
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<ModelChoice> => {
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<string, SelectItem>
private readonly choices: Map<string, ModelChoice>
private readonly efforts: Map<string, ReasoningEffortId | undefined>
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<ReasoningEffortId | undefined> = [
...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<string>,
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<number>()
private mode: 'options' | 'custom'
private error = ''
private readonly input = new Input()
private readonly options: NonNullable<AskUserQuestionItem['options']>
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<AskUserQuestionItem['options']>[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)))} `
})
}
}