Files
deepseek-harness/packages/ui/tui/src/components/dialogs.ts
2026-08-03 17:19:40 +08:00

1264 lines
50 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 { lastActivityTime } from '@deepseek-ai/dsh-session'
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 type { ToolCardVisibility } from './transcript.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, `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 : '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)
}
}
/** Both transcript-detail dimensions, applied immediately on each Tab. */
export interface DetailsSelection {
readonly visibility: ToolCardVisibility
readonly showReasoning: boolean
}
const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden']
/**
* Keyboard toggle over the two transcript-detail entries — tool-card
* visibility and reasoning display. Tab cycles the highlighted entry's value
* and applies it immediately, so the transcript behind the dialog is the live
* preview; Enter, Esc, or Ctrl+C closes.
*/
export class DetailsDialog implements Component {
private readonly list: SelectList
private readonly toolsItem: SelectItem
private readonly reasoningItem: SelectItem
constructor(
private visibility: ToolCardVisibility,
private showReasoning: boolean,
private readonly palette: Palette,
private readonly apply: (selection: DetailsSelection) => void,
private readonly close: () => void,
) {
this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility }
this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() }
this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette))
this.list.onSelect = close
}
private reasoningLabel(): string {
return this.showReasoning ? 'shown' : 'hidden'
}
/** Cycle the highlighted entry one step and apply the new state. */
private cycle(): void {
const selected = this.list.getSelectedItem()
/* v8 ignore next -- the two-entry list always has a selection. */
if (selected === null) return
if (selected.value === 'tools') {
const index = TOOL_CARD_PHASES.indexOf(this.visibility)
this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility
this.toolsItem.description = this.visibility
} else {
this.showReasoning = !this.showReasoning
this.reasoningItem.description = this.reasoningLabel()
}
this.apply({ visibility: this.visibility, showReasoning: this.showReasoning })
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close()
else if (matchesKey(data, Key.tab)) this.cycle()
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 • Tab toggle • Enter/Esc close'),
], 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,
// Excludes a prior pickup's boundary, or every browsed session floats up.
lastActivityAt: lastActivityTime(snapshot.events) ?? 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)
}
}
interface SelectedBlockPage {
offset: number
size: number
maxOffset: number
}
/** Inline 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 headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
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 maxHeight: () => 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 (matchesKey(data, Key.pageUp)) {
this.pageBackward()
return
}
if (matchesKey(data, Key.pageDown)) {
this.pageForward()
return
}
if (this.mode === 'custom') {
this.input.focused = this.focused
this.input.handleInput(data)
return
}
const options = this.options
if (matchesKey(data, Key.up)) {
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
} else if (matchesKey(data, Key.down)) {
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
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.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
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 })
}
/** Page backward through an oversized option, then through question detail. */
private pageBackward(): void {
if (this.mode === 'options' && this.selectedBlockPage.offset > 0) {
this.selectedBlockPage = {
...this.selectedBlockPage,
offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size),
}
return
}
this.headerPage = {
...this.headerPage,
offset: Math.max(0, this.headerPage.offset - this.headerPage.size),
}
}
/** Page forward through question detail, then through an oversized option. */
private pageForward(): void {
if (this.headerPage.offset < this.headerPage.maxOffset) {
this.headerPage = {
...this.headerPage,
offset: Math.min(
this.headerPage.maxOffset,
this.headerPage.offset + this.headerPage.size,
),
}
return
}
if (this.mode === 'custom') return
this.selectedBlockPage = {
...this.selectedBlockPage,
offset: Math.min(
this.selectedBlockPage.maxOffset,
this.selectedBlockPage.offset + this.selectedBlockPage.size,
),
}
}
render(width: number): string[] {
this.input.focused = this.focused
const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2)))
const innerWidth = Math.max(1, width - horizontalPadding * 2)
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
const questionLines = wrapTextWithAnsi(
this.palette.text(displayText(this.question.question)),
innerWidth,
)
const contentLines = [...questionLines]
const headerLines: string[] = [
...wrapTextWithAnsi(this.palette.dim(header), innerWidth),
...questionLines,
]
// 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) {
headerLines.push('')
contentLines.push('')
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) {
headerLines.push(line)
contentLines.push(line)
}
}
headerLines.push('')
const customHint = this.palette.dim(this.options.length > 0
? 'Enter submit • Esc options'
: 'Enter submit • Esc cancel')
const footerLines: string[] = []
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) footerLines.push(line)
for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line)
} else {
const controls = [
'Tab custom answer',
...(this.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)) footerLines.push(line)
}
if (this.error) {
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line)
}
const positionLines = this.mode === 'options' && this.options.length > this.maxVisible
? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)]
: []
// Options receive only the rows left after fixed chrome and outer padding.
// The final height window handles fixed chrome that cannot fit even alone.
const paddingRows = 2
const maxHeight = this.maxHeight()
const availableForOptions = Math.max(
this.mode === 'options' ? 4 : 1,
maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length,
)
const body: string[] = [...headerLines]
const optionLines: string[] = []
if (this.mode === 'custom') {
for (const line of footerLines) body.push(line)
} else {
const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth))
const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth)
if (hiddenBefore > 0) optionLines.push(this.palette.dim(`${hiddenBefore} more`))
for (const block of visibleBlocks) {
for (const line of block) optionLines.push(line)
}
if (hiddenAfter > 0) optionLines.push(this.palette.dim(`${hiddenAfter} more`))
for (const line of optionLines) body.push(line)
for (const line of positionLines) body.push(line)
for (const line of footerLines) body.push(line)
}
const rows = ['', ...body, '']
let visibleRows = rows
if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 }
if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) {
const headerBudget = Math.max(
0,
maxHeight - optionLines.length - (this.error === '' ? 1 : 2),
)
const compactFooter = [
...this.error === ''
? []
: [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')],
this.compactOptionControls(
innerWidth,
headerBudget === 1 && contentLines.length > headerBudget,
),
]
const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth)
visibleRows = [...compactHeader, ...optionLines, ...compactFooter]
} else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) {
const compactFooterSource = [
...this.input.render(innerWidth),
this.compactCustomControls(innerWidth),
...this.error === ''
? []
: [truncateToWidth(this.palette.error(this.error), innerWidth, '…')],
]
const footerBudget = Math.max(1, maxHeight - 1)
const compactFooter = compactFooterSource.length <= footerBudget
? compactFooterSource
: footerBudget === 1
? compactFooterSource.slice(0, 1)
: [
...compactFooterSource.slice(0, 1),
...compactFooterSource.slice(-(footerBudget - 1)),
]
const compactHeader = this.compactQuestionHeader(
contentLines,
Math.max(0, maxHeight - compactFooter.length),
innerWidth,
)
visibleRows = [...compactHeader, ...compactFooter]
}
if (visibleRows.length > maxHeight) {
visibleRows = maxHeight === 1
? [this.palette.dim(`${visibleRows.length} lines hidden`)]
: [
this.palette.dim(`${visibleRows.length - maxHeight + 1} lines hidden`),
...visibleRows.slice(-(maxHeight - 1)),
]
}
return visibleRows.map((line) => {
const bounded = truncateToWidth(line, innerWidth, '…')
const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded)))
const outerPad = ' '.repeat(horizontalPadding)
return `${outerPad}${bounded}${pad}${outerPad}`
})
}
/** Render one option as wrapped label and indented description lines. */
private renderOptionBlock(
option: NonNullable<AskUserQuestionItem['options']>[number],
index: number,
innerWidth: number,
): string[] {
const cursor = index === this.selectedIndex ? '' : ' '
const number = `${index + 1}. `
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
const labelPrefixPlain = ` ${cursor} ${number}${mark}`
const labelPrefixWidth = visibleWidth(labelPrefixPlain)
const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth)
const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth)
const continuation = ' '.repeat(labelPrefixWidth)
const lines: string[] = []
for (const [lineIndex, labelLine] of labelLines.entries()) {
const prefix = lineIndex === 0 ? labelPrefixPlain : continuation
const composed = `${prefix}${labelLine}`
lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed)
}
if (option.description !== undefined) {
const descIndent = ' '.repeat(labelPrefixWidth)
const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth)
const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth)
for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`)
}
return lines
}
/** Keep the question visible when fixed chrome must be compacted. */
private compactQuestionHeader(
contentLines: readonly string[],
budget: number,
innerWidth: number,
): string[] {
if (budget <= 0) return []
if (contentLines.length <= budget) {
this.headerPage = { offset: 0, size: 1, maxOffset: 0 }
return [...contentLines]
}
const pageSize = Math.max(1, budget - 1)
const maxOffset = Math.max(0, contentLines.length - pageSize)
const offset = Math.min(this.headerPage.offset, maxOffset)
this.headerPage = { offset, size: pageSize, maxOffset }
const keptLines = contentLines.slice(offset, offset + pageSize)
if (budget === 1) {
// A page is non-empty because pageSize is one and offset is clamped inside contentLines.
return [keptLines[0] as string]
}
return [
...keptLines,
this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth),
]
}
/** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */
private pagerStatus(first: number, last: number, total: number, innerWidth: number): string {
const full = `… lines ${first}-${last}/${total} • PgUp/PgDn`
const compact = `PgUp/PgDn ${first}/${total}`
return this.palette.dim(truncateToWidth(
visibleWidth(full) <= innerWidth ? full : compact,
innerWidth,
'…',
))
}
/** Render custom-mode controls on one row when the header must compact. */
private compactCustomControls(innerWidth: number): string {
const controls = this.options.length > 0
? 'Enter submit • Esc options'
: 'Enter submit • Esc cancel'
const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel'
const line = visibleWidth(controls) <= innerWidth ? controls : fallback
return this.palette.dim(truncateToWidth(line, innerWidth, '…'))
}
/** Render a one-row option footer that retains every mode-specific control. */
private compactOptionControls(innerWidth: number, showPager = false): string {
const controls = [
...(this.options.length > 1 ? ['↑/↓'] : []),
'Tab custom',
...(this.question.multiSelect ? ['Space toggle'] : []),
'Enter',
'Esc interrupt',
...(showPager ? ['PgUp/PgDn'] : []),
].join(' • ')
const optionNavigation = this.options.length > 1 ? '↑↓ ' : ''
const fallback = showPager
? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc`
: this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc`
const line = visibleWidth(controls) <= innerWidth ? controls : fallback
return this.palette.dim(truncateToWidth(line, innerWidth, '…'))
}
/**
* Choose option blocks that fit while keeping the selected option visible.
* Omitted blocks are counted at each end for explicit overflow markers.
*/
private windowBlocks(
blocks: readonly string[][],
budget: number,
innerWidth: number,
): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } {
const totalLines = blocks.reduce((sum, block) => sum + block.length, 0)
if (totalLines <= budget && blocks.length <= this.maxVisible) {
return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 }
}
// `blocks` is dense and selectedIndex is derived from the same options.
let start = this.selectedIndex
let end = this.selectedIndex + 1
/* v8 ignore next -- selectedIndex stays inside [0, options.length). */
let used = blocks[this.selectedIndex]?.length ?? 0
const markerLines = (before: number, after: number): number =>
(before > 0 ? 1 : 0) + (after > 0 ? 1 : 0)
const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean =>
nextEnd - nextStart <= this.maxVisible
&& nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget
const selectedMarkers = markerLines(start, blocks.length - end)
if (used + selectedMarkers > budget) {
/* v8 ignore next -- selectedIndex stays inside [0, options.length). */
const selectedBlock = blocks[this.selectedIndex] ?? []
const hiddenBefore = start
const hiddenAfter = blocks.length - end
const pageSize = budget - selectedMarkers - 1
const maxOffset = Math.max(0, selectedBlock.length - pageSize)
const offset = Math.min(this.selectedBlockPage.offset, maxOffset)
this.selectedBlockPage = { offset, size: pageSize, maxOffset }
const keptLines = selectedBlock.slice(offset, offset + pageSize)
const first = offset + 1
const last = offset + keptLines.length
const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth)
return {
visibleBlocks: [[...keptLines, overflow]],
hiddenBefore,
hiddenAfter,
}
}
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
let expanded = true
while (expanded && (start > 0 || end < blocks.length)) {
expanded = false
if (end < blocks.length) {
/* v8 ignore next -- guarded by `end < blocks.length` above. */
const next = blocks[end]?.length ?? 0
if (fits(start, end + 1, used + next)) {
used += next
end += 1
expanded = true
continue
}
}
if (start > 0) {
/* v8 ignore next -- guarded by `start > 0` above. */
const previous = blocks[start - 1]?.length ?? 0
if (fits(start - 1, end, used + previous)) {
used += previous
start -= 1
expanded = true
}
}
}
return {
visibleBlocks: blocks.slice(start, end),
hiddenBefore: start,
hiddenAfter: blocks.length - end,
}
}
}