|
|
@@ -0,0 +1,790 @@
|
|
|
+/**
|
|
|
+ * 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.muted(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 per-model reasoning-effort cycling. */
|
|
|
+export class ModelDialog implements Component {
|
|
|
+ private readonly list: SelectList
|
|
|
+ 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,
|
|
|
+ maxVisible: number,
|
|
|
+ private readonly palette: Palette,
|
|
|
+ done: (selection: ModelDialogSelection) => void,
|
|
|
+ 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 = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
|
|
|
+ const currentIndex = current === undefined
|
|
|
+ ? 0
|
|
|
+ : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
|
|
+ this.list.setSelectedIndex(currentIndex)
|
|
|
+ this.list.onSelect = (item) => {
|
|
|
+ const selected = choices.find(choice => targetLabel(choice) === item.value)
|
|
|
+ /* v8 ignore next -- SelectList only returns values built from `choices`. */
|
|
|
+ if (selected === undefined) return
|
|
|
+ done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
|
|
+ }
|
|
|
+ this.list.onCancel = cancel
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.list.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ handleInput(data: string): void {
|
|
|
+ if (matchesKey(data, Key.shift(Key.tab))) {
|
|
|
+ this.cycleReasoningEffort()
|
|
|
+ } else {
|
|
|
+ this.list.handleInput(data)
|
|
|
+ }
|
|
|
+ this.invalidate()
|
|
|
+ }
|
|
|
+
|
|
|
+ render(width: number): string[] {
|
|
|
+ const innerWidth = Math.max(1, width - 4)
|
|
|
+ return renderDialog('Select model', [
|
|
|
+ ...this.list.render(innerWidth),
|
|
|
+ '',
|
|
|
+ this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
|
|
+ ], 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
|
|
|
+ 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 'rejected': return `turn ${event.data.turn}: rejected`
|
|
|
+ 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.provenance.provider, model: assistant.data.provenance.model }
|
|
|
+ : undefined
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Build one resume selector row from a record and its log snapshot, deriving the
|
|
|
+ * title, route, goal phase, and any reason the session cannot be resumed here.
|
|
|
+ * @param record - The session record.
|
|
|
+ * @param snapshot - The session's log snapshot.
|
|
|
+ * @param currentId - The current session id.
|
|
|
+ * @param cwd - The current workspace directory.
|
|
|
+ * @param availableProviders - Providers registered in this runtime.
|
|
|
+ * @returns The summarized resume candidate.
|
|
|
+ */
|
|
|
+export function summarizeResumeCandidate(
|
|
|
+ record: SessionRecord,
|
|
|
+ snapshot: SessionLogSnapshot,
|
|
|
+ currentId: SessionId,
|
|
|
+ cwd: string | undefined,
|
|
|
+ availableProviders: ReadonlySet<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 !== cwd) disabledReason = 'different 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),
|
|
|
+ ...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 },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
|
|
|
+export class ResumePicker implements Component, Focusable {
|
|
|
+ private readonly search = new Input()
|
|
|
+ private pasteBuffer: string | undefined
|
|
|
+ private selectedIndex = 0
|
|
|
+ private error = ''
|
|
|
+ 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()
|
|
|
+ }
|
|
|
+
|
|
|
+ private filtered(): ResumeCandidate[] {
|
|
|
+ const query = this.search.getValue().trim().toLocaleLowerCase()
|
|
|
+ if (query === '') return [...this.candidates]
|
|
|
+ return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
|
|
+ || candidate.record.header.id.toLocaleLowerCase().includes(query))
|
|
|
+ }
|
|
|
+
|
|
|
+ private visibleCandidateCount(): number {
|
|
|
+ const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
|
|
|
+ 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.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()
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.palette.muted(displayText(this.workspaceLabel))}`,
|
|
|
+ '',
|
|
|
+ )
|
|
|
+
|
|
|
+ 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.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
|
|
+ push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
|
|
+ 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 • 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.muted(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.muted(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)))} `
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|