/** * Interactive pi-tui front door for DeepSeek Harness agents. It renders the * durable session transcript, drives one configured agent, and provides * keyboard-driven user-interaction dialogs without owning agent lifecycle. * @module @deepseek-ai/dsh-tui */ import { homedir } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, Editor, Input, Key, Loader, Markdown, Spacer, Text, TUI, ProcessTerminal, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type EditorTheme, type Focusable, type MarkdownTheme, type SelectItem, type SelectListTheme, type SlashCommand, type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' import z from 'schemastery' import { installAgentLlmTarget, type Agent, type AgentLlmTarget, type AgentLlmTargetRef, type AgentStatus, type HookContext, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type {} from '@deepseek-ai/dsh-commands' import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId, StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { displayPromptContent, SessionId, type JsonValue, type Session, type SessionEvent, type SessionHeader, type TodoItem, } from '@deepseek-ai/dsh-session' import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, TerminalCallView, ToolCallView, ToolDefinition, ToolResultView, } from '@deepseek-ai/dsh-tools' import { UserInteractionError, type AskUserQuestionAnswer, type AskUserQuestionAnswerItem, type AskUserQuestionItem, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, } from './overlay-manager.ts' import type { TuiOverlayRequest, TuiOverlaySession, TuiTheme, } from './extension.ts' export type { TuiComponent, TuiFocusable, TuiOverlayAnchor, TuiOverlayCloseReason, TuiOverlayHost, TuiOverlayMargin, TuiOverlayOptions, TuiOverlayOutcome, TuiOverlayRequest, TuiOverlaySession, TuiOverlayState, TuiTheme, TuiViewport, } from './extension.ts' declare module 'cordis' { interface Context { /** Terminal-only interaction service, available only while a TUI is mounted. */ tui: TuiExtensionService /** Optional process host that can replace this TUI with a resumed session. */ tuiResumeHost: TuiResumeHost } } /** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ export interface TuiResumeHost { /** * Dispose the current app and replace it with a runtime for `sessionId`. * Success does not return. A host may reject before it commits teardown; * after commit it owns fatal reporting and process exit. * @param sessionId - validated persisted session selected by the user. */ handoff(sessionId: SessionId): Promise } /** * Optional terminal-local interaction service provided by one mounted TUI. * * The concrete provider retains pi-tui, focus, and terminal lifecycle state. * Plugins receive only effect-owned overlay sessions. */ export abstract class TuiExtensionService extends Service { /** Exact agent driven by this terminal instance. */ abstract readonly agent: Agent /** * Queue an interactive overlay owned by the calling plugin fiber. * * The TUI displays one overlay at a time in FIFO order. Disposing the caller * removes a queued overlay or closes an active one before plugin teardown * settles. This live presentation is neither logged nor replayed. * * @param request - component factory, layout constraints, and cancellation. * @returns the effect-owned overlay session. * @throws when the TUI has begun shutting down. */ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession } import { activeAtToken, DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, formatFileMention, WorkspaceFileSearch, } from './file-autocomplete.ts' export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, } from './file-autocomplete.ts' export const name = 'ui-tui' export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' /** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number /** Maximum sessions visible at once in the resume selector. */ maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ fileSearchMaxEntries?: number /** Directory basenames excluded from `@` traversal and completion. */ fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ color?: boolean /** * Paint the startup banner's product name in the DeepSeek brand gradient * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the * flat accent color when either is off. Unset auto-detects `COLORTERM` at the * process boundary, so most deployments leave it unset. */ truecolor?: boolean /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string } const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) // No default: an unset value auto-detects truecolor from COLORTERM in `apply`. const truecolorSchema = z.boolean() const titleSchema = z.string().default('DeepSeek Harness') const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, truecolor: truecolorSchema, title: titleSchema, } /** Schemastery schema for presentation settings embedded by app bundles. */ export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) /** Serializable plugin configuration. */ export interface Config extends TuiConfig { /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ welcome?: string /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** * Shell command fallback printed on exit or after selecting a session when * the host cannot hand off in place. Every `{session}` becomes the selected * id; the TUI never executes this text. Absent disables only the fallback, * not the interactive selector. */ resumeCommand?: string } export const Config: z = z.object({ welcome: z.string(), sessionId: z.string().default('main'), resumeCommand: z.string(), showReasoning: tuiConfigSchemaFields.showReasoning, maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, color: tuiConfigSchemaFields.color, truecolor: tuiConfigSchemaFields.truecolor, title: tuiConfigSchemaFields.title, }) /** Fully defaulted TUI presentation settings. */ export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number questionDialogWidth: number questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] showHardwareCursor: boolean color: boolean truecolor: boolean title: string } /** Runtime boundary used by the interactive TUI. */ export interface TuiRuntime { /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void /** * Override the footer's logical working-directory label without changing the session directory used by tools. * @param cwd - Operational working directory from the session header. * @returns Unescaped label; the TUI makes terminal controls visible. */ formatCwd?: (cwd: string | undefined) => string /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ handoffResume?: TuiResumeHost['handoff'] } /** * Apply direct-call defaults after Loader schema validation has normally run. * * @param config - Deployment-provided terminal presentation settings. * @returns Complete settings consumed by the TUI renderer. */ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { return { showReasoning: config?.showReasoning ?? true, maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, truecolor: config?.truecolor ?? false, title: config?.title ?? 'DeepSeek Harness', } } interface Palette { accent: (text: string) => string accent2: (text: string) => string text: (text: string) => string muted: (text: string) => string dim: (text: string) => string success: (text: string) => string warning: (text: string) => string error: (text: string) => string code: (text: string) => string added: (text: string) => string removed: (text: string) => string bold: (text: string) => string italic: (text: string) => string underline: (text: string) => string strike: (text: string) => string /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ selected: (text: string) => string } function ansi(open: string, close: string, enabled: boolean): (text: string) => string { return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text } const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu const BRACKETED_PASTE_START = '\u001B[200~' const BRACKETED_PASTE_END = '\u001B[201~' /** * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. * Line feeds remain structural so transcript and tool output retain their layout. */ function displayText(text: string): string { return text.replace(TERMINAL_CONTROL_PATTERN, control => `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) } /** Escape external controls for terminal fields that must remain on one line. */ function displayInlineText(text: string): string { return displayText(text).replaceAll('\n', '\\x0a') } /** Remove terminal controls from clipboard text before an editable field stores it. */ function sanitizePastedText(text: string): string { return text .replace(TERMINAL_OSC_PATTERN, '') .replace(TERMINAL_CSI_PATTERN, '') .replace(TERMINAL_ESCAPE_PATTERN, '') .replace(TERMINAL_CONTROL_PATTERN, '') } /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body * `text` stays the terminal's default foreground so it reads on light and dark * backgrounds alike; grouping uses foreground-only gutter bars and reverse * video rather than fixed background fills. */ function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { return { accent: ansi('94', '39', enabled), accent2: ansi('95', '39', enabled), text: text => text, muted: ansi('90', '39', enabled), // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 // (bright black / gray) which renders as a readable muted tone on any scheme. dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), success: ansi('32', '39', enabled), warning: ansi('33', '39', enabled), error: ansi('31', '39', enabled), // ANSI 36 (cyan) is difficult to read on a light background — use // ANSI 34 (blue) which is legible on both light and dark schemes. code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), added: ansi('32', '39', enabled), removed: ansi('31', '39', enabled), bold: ansi('1', '22', enabled), italic: ansi('3', '23', enabled), underline: ansi('4', '24', enabled), strike: ansi('9', '29', enabled), selected: ansi('7', '27', enabled), } } /** * DeepSeek brand gradient stops (indigo → light blue) taken from the * deepseek.com logo, painted across the startup banner's product name on * truecolor terminals. Fixed brand identity, deliberately outside the * theme-adaptive {@link Palette}. */ const BRAND_GRADIENT = [ [77, 107, 254], // #4D6BFE [57, 130, 255], // #3982FF [36, 152, 255], // #2498FF ] as const /** * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear * interpolation across its stops. * * @param t - Position along the gradient; clamped to [0, 1]. * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. */ function brandColorAt(t: number): readonly [number, number, number] { const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) const local = span - index // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. const from = BRAND_GRADIENT[index] as readonly [number, number, number] const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] return [ Math.round(from[0] + (to[0] - from[0]) * local), Math.round(from[1] + (to[1] - from[1]) * local), Math.round(from[2] + (to[2] - from[2]) * local), ] } /** * Paint `text` left-to-right in the DeepSeek brand gradient with per-character * 24-bit foreground codes, resetting to the default foreground at the end. * Foreground-only, so it stays legible on any terminal background; the caller * gates it on truecolor support and wraps it in bold. * * @param text - Text to colorize; sampled once per character. * @returns `text` wrapped in truecolor SGR foreground codes. */ function gradientText(text: string): string { // The sole caller passes the ASCII product name, so UTF-16 unit iteration // samples exactly one color per visible letter. const last = Math.max(1, text.length - 1) let painted = '' for (let index = 0; index < text.length; index += 1) { const [r, g, b] = brandColorAt(index / last) painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` } return `${painted}\x1b[39m` } function markdownTheme(palette: Palette): MarkdownTheme { return { heading: text => palette.accent(text), link: text => palette.accent(text), // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. /* v8 ignore next */ linkUrl: text => palette.dim(text), code: text => palette.code(text), codeBlock: text => palette.text(text), codeBlockBorder: text => palette.dim(text), quote: text => palette.muted(text), quoteBorder: text => palette.accent2(text), hr: text => palette.dim(text), listBullet: text => palette.accent(text), bold: text => palette.bold(text), italic: text => palette.italic(text), strikethrough: text => palette.strike(text), underline: text => palette.underline(text), } } function selectTheme(palette: Palette): SelectListTheme { return { selectedPrefix: palette.accent, selectedText: palette.accent, description: palette.muted, scrollInfo: palette.dim, noMatch: palette.warning, } } function dialogSelectTheme(palette: Palette): SelectListTheme { return { ...selectTheme(palette), selectedText: text => palette.selected(palette.accent(text)), } } function contentText(content: readonly ContentBlock[]): string { const parts: string[] = [] for (const block of content) { switch (block.type) { case 'text': case 'reasoning': parts.push(block.text) break case 'tool-call': parts.push(`${block.name}(${block.arguments})`) break case 'tool-result': parts.push(contentText(block.content)) break default: { const rawType = (block as { type?: unknown }).type parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) break } } } return parts.join('') } function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { return content .filter((block): block is Extract => block.type === type) .map(block => block.text) .join('\n\n') } interface ModelChoice extends AgentLlmTarget { modelName: string description?: string reasoning?: LlmModelReasoningInfo } interface ModelDialogSelection { choice: ModelChoice reasoningEffort: ReasoningEffortId | undefined } function targetLabel(target: AgentLlmTarget): string { return `${target.provider}/${target.model}` } function compactTargetLabel(target: AgentLlmTarget): string { return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` } 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 } 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 } } async function readModelChoices( ctx: Context, current: AgentLlmTarget | undefined, ): Promise { const providers = ctx.llm.listProviders() const groups = await Promise.all(providers.map(async (provider) => { const advertised = await ctx.llm.listModels(provider.id) const models: LlmModelInfo[] = [...advertised] if ( current?.provider === provider.id && !models.some(model => model.id === current.model) ) { models.push({ provider: provider.id, id: current.model, name: current.model }) } return Promise.all(models.map(async (model): Promise => { const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning return { provider: provider.id, model: model.id, modelName: model.name, ...model.description === undefined ? {} : { description: model.description }, ...reasoning === undefined ? {} : { reasoning }, } })) })) return groups.flat() } /** Milliseconds between banner sweep-reveal frames (~60 fps). */ const BANNER_REVEAL_INTERVAL_MS = 15 /** Number of sweep frames the banner reveal spreads the terminal width over. */ const BANNER_REVEAL_STEPS = 24 /** * Borderless startup banner: product title, an optional configured subtitle, * and the model/session detail line. No box frame — each line renders as plain * left-padded text (matching transcript notices) so it reads on any theme. */ class HeaderComponent implements Component { /** Columns of the banner currently revealed; `undefined` renders it whole. */ private revealWidth: number | undefined constructor( private readonly agent: Agent, private readonly subtitle: () => string | undefined, private readonly palette: Palette, private readonly gradient: boolean, private readonly currentModel: () => string | undefined, ) {} /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */ setRevealWidth(width: number | undefined): void { this.revealWidth = width } invalidate(): void {} render(width: number): string[] { const usable = Math.max(1, width - 2) const name = this.gradient ? this.palette.bold(gradientText('DEEPSEEK')) : this.palette.bold(this.palette.accent('DEEPSEEK')) const title = `${name} ${this.palette.bold('HARNESS')}` const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` const subtitle = this.subtitle() const lines = [ title, ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], this.palette.dim(detail), ] .flatMap(line => wrapTextWithAnsi(line, usable)) .map(line => ` ${truncateToWidth(line, usable, '')}`) if (this.revealWidth === undefined) return lines const revealed = this.revealWidth return lines.map(line => truncateToWidth(line, revealed, '')) } } /** Milliseconds between elapsed-time refreshes of the running status line. */ const STATUS_ELAPSED_INTERVAL_MS = 1000 /** Steering/cancel affordance shown on every running status line. */ const STATUS_HINT = 'Enter sends steering, Esc cancels' /** * Fine-grained activity of a running turn, derived in the TUI from session * lifecycle events for the status line. It is presentation-only, not a durable * agent state: `waiting` spans a step from its `step/start` until the first * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas, * and `executing` covers tool calls until the next step begins. */ type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing' /** * Live controller for the running status line: its {@link Loader}, the derived * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer * that refreshes it. Present only while the turn runs; `undefined` when idle. */ interface RunningStatus { loader: Loader phase: TurnPhase phaseStartedAt: number stepStartedAt: number timer: ReturnType } /** Status-line label for each {@link TurnPhase}. */ const TURN_PHASE_LABELS: Record = { waiting: 'Waiting for the first token', thinking: 'Thinking', responding: 'Responding', executing: 'Executing tools', } /** * Format a non-negative elapsed span as a compact status duration: whole * seconds under a minute (`8s`), else minutes and zero-padded seconds * (`1m05s`). * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero. * @returns The compact duration string. */ function formatStatusDuration(elapsedMs: number): string { const total = Math.floor(Math.max(0, elapsedMs) / 1000) if (total < 60) return `${total}s` return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s` } /** * Compose the running status-line text from the current phase, its timers, and * the queued-steering badge. The waiting phase spans the whole step so it shows * one duration; later phases show time in the phase plus the running step * total, and a non-zero `queued` count surfaces as a badge before the hint. * @param phase - The current turn phase. * @param phaseMs - Elapsed time in the current phase, in milliseconds. * @param stepMs - Elapsed time in the current step, in milliseconds. * @param queued - Count of pending steering messages; zero hides the badge. * @returns The status-line text, including the steering/cancel hint. */ function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string { const timing = phase === 'waiting' ? formatStatusDuration(stepMs) : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}` const badge = queued > 0 ? `${queued} queued · ` : '' return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}` } /** * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so * it renders legibly on any terminal background — unlike a filled block whose * body text would collide with the theme's default foreground. */ class GutterBox implements Component { protected readonly children: Component[] = [] constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {} addChild(child: Component): void { this.children.push(child) } invalidate(): void { for (const child of this.children) child.invalidate() } render(width: number): string[] { const inner = Math.max(1, width - 2) const body: string[] = [] for (const child of this.children) for (const line of child.render(inner)) body.push(line) // Every caller adds a non-empty title/label child, so an all-empty box is unreachable; // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars. /* v8 ignore next */ if (body.length === 0) return [] const bar = this.barFn('▌') const pad = Array.from({ length: this.paddingY }, () => '') return [...pad, ...body, ...pad].map(line => `${bar} ${line}`) } } class UserMessageComponent extends GutterBox { constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { super(value => palette.accent(value)) this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0)) this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { preserveOrderedListMarkers: true, preserveBackslashEscapes: true, })) } } class AssistantMessageComponent extends Container { constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { super() const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) if (reasoning && showReasoning) { this.addChild(new Spacer(1)) this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) this.addChild(new Markdown(reasoning, 1, 0, mdTheme, { color: value => palette.muted(value), italic: true, })) } if (text) { this.addChild(new Spacer(1)) this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0)) this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) })) } } } interface StreamingBlock { type: string text: string } class StreamingAssistantComponent extends Container { private readonly blocks = new Map() constructor( private showReasoning: boolean, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { super() } update(chunk: StreamChunk): void { if (chunk.type === 'block-start') { this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' const block = this.blocks.get(chunk.index) ?? { type, text: '' } block.text += chunk.text this.blocks.set(chunk.index, block) } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) } this.rebuild() } setShowReasoning(show: boolean): void { this.showReasoning = show this.rebuild() } private rebuild(): void { this.clear() const content: ContentBlock[] = [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) for (const child of component.children) this.addChild(child) } } interface ParsedArguments { value: unknown valid: boolean } function parseArguments(raw: string): ParsedArguments { try { return { value: JSON.parse(raw), valid: true } } catch { return { value: raw, valid: false } } } function pretty(value: unknown): string { if (typeof value === 'string') return displayText(value) // The lib declaration narrows `unknown` to a string-returning overload, but // JSON.stringify returns undefined for runtime values such as symbols. const serialized = JSON.stringify(value, null, 2) as string | undefined return displayText(serialized ?? String(value)) } function diffLines(diff: FileDiff, palette: Palette): string[] { const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) } for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) return lines } class ToolCardComponent implements Component { private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined private expanded = false private callView: ToolCallView private resultView: ToolResultView | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, private readonly palette: Palette, ) { this.callView = this.presentCall() } private presentCall(): ToolCallView { if (this.parsed.valid && this.definition?.presentCall) { try { const view = this.definition.presentCall(this.parsed.value) if (view !== undefined) return view } catch (error: unknown) { return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } } } return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } } updateResult(event: Extract['data']): void { this.result = { content: [...event.content], isError: event.isError, ...event.meta !== undefined ? { meta: event.meta } : {}, } if (this.parsed.valid && this.definition?.presentResult) { try { const view = this.definition.presentResult(this.parsed.value, this.result) if (view !== undefined) this.resultView = view } catch (error: unknown) { this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } } } } setExpanded(expanded: boolean): void { this.expanded = expanded } invalidate(): void {} render(width: number): string[] { const isError = this.result?.isError ?? false const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') const body = this.renderBody() const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') const headLines = Math.ceil(this.maxOutputLines / 2) const tailLines = this.maxOutputLines - headLines const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body : [ ...body.slice(0, headLines), this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), ...body.slice(body.length - tailLines), ] const barFn = this.result === undefined ? this.palette.warning : isError ? this.palette.error : this.palette.success const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0) box.addChild(new Text(this.palette.bold(title), 0, 0)) if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) return box.render(width) } private title(): string { return this.resultView?.title ?? this.callView.title } private renderBody(): string[] { const view = this.resultView ?? this.callView if (view.card === 'terminal') { const pending = this.callView.card === 'terminal' ? this.callView : undefined const lines: string[] = [] if (pending?.description) lines.push(this.palette.muted(displayText(pending.description))) if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd))) if (this.resultView?.card === 'terminal') { if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) if (this.resultView.signal !== undefined) { lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) } } else if (this.result === undefined) { // A pending terminal view is the call view itself; TerminalCallView requires a title. lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`)) } else { lines.push(...displayText(contentText(this.result.content)).split('\n')) } return lines.filter(Boolean) } if (view.card === 'diff') { return view.diffs.flatMap((diff, index) => [ ...index > 0 ? [''] : [], ...diffLines(diff, this.palette), ]) } const content = view.content ?? this.result?.content const lines: string[] = [] if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) const rawInput = this.result === undefined && this.callView.card === 'generic' ? this.callView.rawInput : undefined if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) } } class TodoComponent implements Component { private todos: readonly TodoItem[] = [] constructor(private readonly palette: Palette) {} update(todos: readonly TodoItem[]): void { this.todos = todos } invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return [] const lines = [this.palette.bold(this.palette.accent('Plan'))] for (const todo of this.todos) { const prefix = todo.status === 'completed' ? this.palette.success('✓') : todo.status === 'in_progress' ? this.palette.warning('●') : this.palette.dim('○') const content = displayText(todo.content) const text = todo.status === 'completed' ? this.palette.muted(content) : content lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) } return ['', ...lines] } } function formatTokens(value: number): string { if (value < 1_000) return String(value) if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` if (value < 1_000_000) return `${Math.round(value / 1_000)}k` return `${(value / 1_000_000).toFixed(1)}m` } function formatCwd(cwd: string | undefined): string { if (cwd === undefined) return 'cwd unset' const home = homedir() const rel = relative(resolve(home), resolve(cwd)) if (rel === '') return '~' /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ if (isAbsolute(rel)) return cwd if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` return cwd } /** * Running token totals for the footer, keyed per turn/step so replayed or * re-emitted usage replaces rather than double-counts; `input` is uncached * input, cache buckets are disjoint. */ interface SessionTokenTotals { input: number output: number cacheRead: number cacheWrite: number readonly byStep: Map } function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { const key = `${turn}:${step}` const previous = totals.byStep.get(key) if (previous !== undefined) { totals.input -= previous.inputTokens totals.output -= previous.outputTokens totals.cacheRead -= previous.cacheReadTokens ?? 0 totals.cacheWrite -= previous.cacheWriteTokens ?? 0 } totals.byStep.set(key, usage) totals.input += usage.inputTokens totals.output += usage.outputTokens totals.cacheRead += usage.cacheReadTokens ?? 0 totals.cacheWrite += usage.cacheWriteTokens ?? 0 } function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) } } /** * Share of billed input (prompt) tokens served from the provider cache, as an * integer percent, or `undefined` before any input is billed (avoids 0/0 and a * meaningless rate on an empty session). */ function cacheHitRate(totals: SessionTokenTotals): number | undefined { const billedInput = totals.input + totals.cacheRead + totals.cacheWrite if (billedInput === 0) return undefined return Math.round((totals.cacheRead / billedInput) * 100) } function sessionTokens(session: Session): SessionTokenTotals { const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } for (const event of session.events) { recordEventUsage(totals, event) } return totals } function formatDiagnosticNumber(value: number): string { return value.toLocaleString('en-US') } function formatDiagnosticTime(value: number): string { return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') } function formatDiagnosticCount(value: number, singular: string): string { return `${String(value)} ${singular}${value === 1 ? '' : 's'}` } 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)}]`)}` } type StatusCardRow = readonly [label: string, value: string] /** Bordered, grouped field card for one point-in-time status snapshot. */ 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 } } class FooterComponent implements Component { constructor( private readonly agent: Agent, private readonly palette: Palette, private readonly toolsExpanded: () => boolean, private readonly tokens: () => SessionTokenTotals, private readonly cwdFormatter: TuiRuntime['formatCwd'], private readonly currentModel: () => string | undefined, private readonly contextPercent: () => number | undefined, ) {} invalidate(): void {} render(width: number): string[] { const totals = this.tokens() const model = displayText(this.currentModel() ?? 'model unset') const rate = cacheHitRate(totals) const cache = rate === undefined ? '' : ` cache ${rate}%` const formattedCwd = displayText( this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd), ) const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}` const contextPercent = this.contextPercent() const context = contextPercent === undefined ? '' : `${contextPercent}% context ` const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}` const leftStyled = this.palette.dim(left) const available = Math.max(0, width - visibleWidth(left) - 2) const rightClipped = truncateToWidth(right, available, '') const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] } } interface QuestionSelection { selected: string[] custom?: string } 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 } class ModelDialog implements Component { private readonly list: SelectList private readonly items: Map private readonly choices: Map private readonly efforts: Map 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 selectedEffort = this.efforts.get(targetLabel(choice)) const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) const effortLabel = selectedEffort === undefined ? choice.reasoning === undefined ? undefined : 'provider default' : effort?.name ?? selectedEffort return [ displayText(choice.modelName), ...choice.description === undefined ? [] : [displayText(choice.description)], ...effortLabel === undefined ? [] : [displayText(effortLabel)], ...isCurrent ? ['current'] : [], ].join(' — ') } private cycleReasoningEffort(): void { const selectedItem = this.list.getSelectedItem() /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ if (selectedItem === null) return const choice = this.choices.get(selectedItem.value) if (choice?.reasoning === undefined) return const current = this.efforts.get(selectedItem.value) const efforts: Array = [ ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], ...choice.reasoning.efforts.map(effort => effort.id), ] const currentIndex = efforts.indexOf(current) const next = efforts[(currentIndex + 1) % efforts.length] this.efforts.set(selectedItem.value, next) const item = this.items.get(selectedItem.value) /* v8 ignore next -- items and choices are constructed from the same values. */ if (item === undefined) return item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) } invalidate(): void { this.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) } } interface ResumeRoute { provider: string model: string } 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 } function summarizeResumeCandidate( record: SessionRecord, snapshot: SessionLogSnapshot, currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, ): 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 }, ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, ...disabledReason === undefined ? {} : { disabledReason }, } } /** Full-viewport keyboard selector over detached, preflighted resume summaries. */ 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}` 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) } } class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() private mode: 'options' | 'custom' private error = '' private readonly input = new Input() private readonly options: NonNullable focused = false constructor( private readonly question: AskUserQuestionItem, private readonly position: number, private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, ) { this.options = question.options ?? [] this.mode = this.options.length > 0 ? 'options' : 'custom' this.input.onSubmit = (value) => { this.submitCustom(value) } this.input.onEscape = () => { if (this.options.length > 0) { this.mode = 'options' this.error = '' } else { this.cancel() } } } invalidate(): void { this.input.invalidate() } handleInput(data: string): void { this.invalidate() if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) return } const options = this.options if (matchesKey(data, Key.up)) { this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] if (indices.length === 0) { this.error = 'Select at least one option, or press Tab for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() } } private submitCustom(value: string): void { const custom = value.trim() if (custom === '') { this.error = 'Enter an answer before submitting.' return } this.done({ selected: [], custom }) } render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` const lines = [ this.palette.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[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 hint = this.palette.dim(this.question.multiSelect ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') 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)))} ` }) } } interface PendingQuestion { request: AskUserQuestionRequest index: number answers: AskUserQuestionAnswerItem[] resolve(answer: AskUserQuestionAnswer): void reject(error: unknown): void onAbort: () => void overlay: TuiOverlaySession | undefined } /** Merge path-only file candidates and optional session snapshots with commands. */ class ReferenceAutocompleteProvider implements AutocompleteProvider { constructor( private readonly base: CombinedAutocompleteProvider, private readonly files: WorkspaceFileSearch, private readonly sessions: SessionReferenceService | undefined, private readonly agent: Agent, ) {} async getSuggestions( lines: string[], cursorLine: number, cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise { const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) const currentLine = lines[cursorLine] /* v8 ignore next -- Editor always supplies its current state line. */ if (currentLine === undefined) return basePromise const token = activeAtToken(currentLine, cursorCol) if (token === undefined) { this.files.invalidate() return basePromise } const filePromise = this.files.list(token.query, options.signal).catch(() => []) const sessionPromise = this.sessions === undefined || token.quoted ? Promise.resolve([]) : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) const [base, fileCandidates, sessionCandidates] = await Promise.all([ basePromise, filePromise, sessionPromise, ]) if (options.signal.aborted) return base const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { const value = formatFileMention(candidate, token.quoted) if (value === undefined) return [] const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) const directory = candidate.kind === 'directory' return [{ value, label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, description: displayInlineText(candidate.path), }] }) const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { const mentionLabel = displayInlineText(candidate.label) const sessionId = displayInlineText(candidate.sessionId) const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` return { value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), label: `Session · ${mentionLabel}`, description, } }) const items = [...fileItems, ...sessionItems] if (items.length === 0) return base return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } } applyCompletion( lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) } shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) } } /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ dispose(): Promise } /** Prefix that marks an editor submission as a manual skill invocation. */ const SKILL_COMMAND_PREFIX = '/skill:' /** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ interface ParsedSkillCommand { /** Skill name typed after `/skill:`, up to the first space. */ name: string /** Trimmed text after the name; empty when none was typed. */ instructions: string } /** * Split a `/skill: [instructions]` submission into its name and trailing instructions. * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. * @returns the skill name and any trailing instructions. */ function parseSkillCommand(text: string): ParsedSkillCommand { const rest = text.slice(SKILL_COMMAND_PREFIX.length) const spaceIndex = rest.indexOf(' ') if (spaceIndex === -1) return { name: rest, instructions: '' } return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } } /** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { if (base === undefined) return undefined switch (base.kind) { case 'directory': return `References in this skill are relative to ${base.path}.` case 'url': return `References in this skill are relative to ${base.url}.` case 'opaque': return base.description default: return assertNever(base, 'SkillResourceBase.kind') } } /** * Render a manually invoked skill into the model-visible user-message text. The * `` block carries the body and, when the provider supplies one, its * resource base; the trimmed `instructions` follow the block as the user's * request for this turn. The name is registry-validated kebab-case * ({@link SkillService} rejects any other) and the resource base is trusted * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` * result, which escapes for a tool channel — this user turn is assembled raw. * @param skill - the loaded skill definition. * @param instructions - trimmed text typed after `/skill:`; empty when absent. * @returns the user-message text delivered to the agent. */ export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { const lines = [``] const reference = skillResourceReference(skill.resourceBase) if (reference !== undefined) lines.push(reference, '') lines.push(skill.content, '') const block = lines.join('\n') return instructions === '' ? block : `${block}\n\n${instructions}` } function activeSurfaceSeqs(session: Session): Set { return new Set(session.surface.nodes) } function sessionReferenceCard(meta: unknown): string[] | undefined { if (typeof meta !== 'object' || meta === null) return undefined const record = meta as Record if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined const references = record['references'] as unknown[] const labels: string[] = [] for (const reference of references) { if (typeof reference !== 'object' || reference === null) return undefined const entry = reference as Record const sessionId = entry['sessionId'] const label = entry['label'] if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) } return labels } function promptReferenceCards(event: Extract): string[][] { return event.data.envelope?.prefixContexts.flatMap((context) => { const card = sessionReferenceCard(context.meta) return card === undefined ? [] : [card] }) ?? [] } function activeToolCallIds(session: Session, active: ReadonlySet): Set { const ids = new Set() for (const event of session.events) { if (event.type !== 'assistant/message' || !active.has(event.seq)) continue for (const block of event.data.content) { if (block.type === 'tool-call') ids.add(block.id) } } return ids } /** * Start the interactive pi-tui channel for an already-created target agent. * @param ctx - agent, tools, session-event, and user-interaction context. * @param config - target agent, banner, and TUI presentation config. * @param runtime - terminal and process-exit boundary. * @returns lifecycle controller used by the Cordis effect disposer. */ export function createTuiChat( ctx: Context, config: Config, runtime: TuiRuntime, ): TuiController { const sessionId = SessionId(config.sessionId ?? 'main') const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) const persistence = ctx.get('sessionPersistence') const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.color) const mdTheme = markdownTheme(palette) const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() const statusContainer = new Container() const editor = new Editor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), } satisfies EditorTheme, { paddingX: 1 }) const todo = new TodoComponent(palette) let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined // Steering messages queued during the running turn (`agent/inbox/enqueue` // with `info.steering`) that the loop has not yet drained, shown as a badge on // the status line. Each entry is the queued message's serialized source: a // drain (`steering/message`) removes one MATCHING entry, so a loop-authored // continuation reason (which enqueues and drains under its own source) pushes // and pops its own slot and cannot consume a pending user message's slot. // Cleared on leaving `running`, which also absorbs a cancellation that // discards the queue without logging drains; the status line exists only // while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise | undefined // Optional: skills mount conditionally, so read the global service store // rather than declaring an injection that would make the TUI require them. const skills = ctx.get('skills') const cwd = agent.session.header.cwd ?? process.cwd() const fileSearch = new WorkspaceFileSearch(cwd, { maxResults: resolved.fileSearchMaxResults, maxEntries: resolved.fileSearchMaxEntries, excludedDirectories: resolved.fileSearchExcludedDirectories, }) const skillAbort = new AbortController() const tokens = sessionTokens(agent.session) const toolCards = new Map() const allToolCards = new Set() const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() const referenceControllers = new Set() let activeQuestion: PendingQuestion | undefined let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } let contextWindow: number | undefined let contextResolution: Promise< | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } | { readonly kind: 'error'; readonly error: unknown } > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). let sessionTitle = foldSessionTitle(agent.session.events)?.title const header = new HeaderComponent( agent, () => sessionTitle ?? config.welcome, palette, resolved.color && resolved.truecolor, () => target.current === undefined ? undefined : compactTargetLabel(target.current), ) const footer = new FooterComponent( agent, palette, () => toolsExpanded, () => tokens, runtime.formatCwd, () => target.current === undefined ? undefined : compactTargetLabel(target.current), () => contextWindow === undefined ? undefined : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), ) ui.addChild(header) ui.addChild(chat) ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) ui.addChild(footer) ui.setFocus(editor) const updateTerminalTitle = (): void => { runtime.terminal.setTitle(displayText( sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`, )) } updateTerminalTitle() const requestRender = (): void => { footer.invalidate() ui.requestRender() } const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted chat.addChild(new Spacer(1)) chat.addChild(new Text(color(displayText(message)), 1, 0)) requestRender() } const extensionTheme: TuiTheme = Object.freeze({ text: (value: string) => palette.text(value), muted: (value: string) => palette.muted(value), dim: (value: string) => palette.dim(value), accent: (value: string) => palette.accent(value), success: (value: string) => palette.success(value), warning: (value: string) => palette.warning(value), error: (value: string) => palette.error(value), bold: (value: string) => palette.bold(value), }) const overlayManager = new TuiOverlayManager({ viewport: () => Object.freeze({ columns: runtime.terminal.columns, rows: runtime.terminal.rows, }), theme: () => extensionTheme, display: displayText, show: (component, options) => ui.showOverlay(component, options === undefined ? undefined : { ...options, ...typeof options.margin === 'object' ? { margin: { ...options.margin } } : {}, }), invalidate: requestRender, reportError: (error) => { const message = errorChain(error) ctx.logger.warn(`ui-tui: overlay failed: ${message}`) /* v8 ignore next -- shutdown removes overlays before the terminal stops */ if (disposed) return appendNotice(`TUI overlay failed: ${message}`, 'error') }, }) const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { contextWindow = undefined const resolution = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), (error: unknown) => ({ kind: 'error', error } as const), ) contextResolution = resolution void resolution.then((result) => { if (contextResolution !== resolution) return if (result.kind === 'error') { appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') return } contextWindow = result.contextWindow requestRender() }) } resolveContextWindow(target.current) const selectModel = ( selected: ModelChoice, explicitReasoning?: { effort: ReasoningEffortId | undefined }, ): void => { const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model const reasoningEffort = explicitReasoning === undefined ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) : explicitReasoning.effort if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { const reasoning = targetReasoningLabel(selected, reasoningEffort) appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) return } target.current = { provider: selected.provider, model: selected.model, ...reasoningEffort === undefined ? {} : { reasoningEffort }, } resolveContextWindow(target.current) const reasoning = targetReasoningLabel(selected, reasoningEffort) appendNotice([ `Model selected: ${targetLabel(selected)}.`, ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], 'New steps will use it.', ].join(' ')) } const showModelSelector = (choices: readonly ModelChoice[]): void => { const current = target.current === undefined ? 'unset' : targetLabel(target.current) if (choices.length === 0) { appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') return } void modelOverlay?.close() const session = overlayManager.open({ create: () => new ModelDialog( choices, target.current, resolved.maxModelOptions, palette, (selection) => { void session.close() selectModel(selection.choice, { effort: selection.reasoningEffort }) }, () => { void session.close() }, ), options: { width: resolved.modelDialogWidth, maxHeight: resolved.modelDialogMaxHeight, anchor: 'center', margin: 1, }, }) modelOverlay = session void session.closed.then(() => { if (modelOverlay === session) modelOverlay = undefined }) requestRender() } const handleModelCommand = async (raw: string): Promise => { const choices = await readModelChoices(ctx, target.current) if (disposed) return const argument = raw.trim() if (argument === '') { showModelSelector(choices) return } const parts = argument.split(/\s+/u) if (parts.length > 2) { appendNotice('Usage: /model [provider/]model', 'warning') return } let matches: ModelChoice[] if (parts.length === 2) { matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) } else { const value = argument const qualified = choices.filter(choice => targetLabel(choice) === value) matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) } if (matches.length === 0) { appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') return } if (matches.length > 1) { appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') return } const selected = matches[0] /* v8 ignore next -- a non-empty matches array always has index zero. */ if (selected === undefined) return selectModel(selected) } const queueModelCommand = (raw: string): void => { modelCommands = modelCommands.then(async () => { await handleModelCommand(raw) }).catch((error: unknown) => { if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') }) } const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) runningStatus.loader.stop() runningStatus = undefined } statusContainer.clear() runtime.terminal.setProgress(false) } // Refresh the status line's elapsed timers and queued badge from the // controller's phase and the current steering count. const renderStatus = (running: RunningStatus): void => { const at = now() running.loader.setMessage( formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length), ) } // Move to a derived phase, resetting the phase timer on a genuine change and // the step timer when a new step begins; ignored unless a turn is running. const enterPhase = (phase: TurnPhase, resetStep: boolean): void => { const running = runningStatus if (running === undefined) return const at = now() if (resetStep) running.stepStartedAt = at if (phase !== running.phase || resetStep) running.phaseStartedAt = at running.phase = phase renderStatus(running) } const setStatus = (status: AgentStatus): void => { // A running→running rebuild (a mid-turn palette swap re-derives the border) // carries the derived phase and both elapsed baselines across; only a fresh // idle→running turn starts at `waiting`. const prior = runningStatus clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) if (status === 'running') { const at = now() const phase = prior?.phase ?? 'waiting' const phaseStartedAt = prior?.phaseStartedAt ?? at const stepStartedAt = prior?.stepStartedAt ?? at const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length) const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) statusContainer.addChild(loader) const running: RunningStatus = { loader, phase, phaseStartedAt, stepStartedAt, timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS), } runningStatus = running runtime.terminal.setProgress(true) } requestRender() } // Refresh the running status line's queued-steering badge from the current // count; a no-op when idle because the controller only exists while running. const refreshStatus = (): void => { if (runningStatus !== undefined) renderStatus(runningStatus) requestRender() } // Derive the status-line phase from live session lifecycle events. The event // map is merge-extensible, so unhandled types fall through the default. const advanceTurnPhase = (event: SessionEvent): void => { switch (event.type) { case 'step/start': enterPhase('waiting', true) break case 'assistant/chunk': { const chunk = event.data.chunk if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { enterPhase('thinking', false) } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { enterPhase('responding', false) } break } case 'tool/call': enterPhase('executing', false) break default: break } } const parsedTool = (event: Extract): ToolCardComponent => { const parsed = parseArguments(event.data.arguments) const card = new ToolCardComponent( event.data.name, parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, palette, ) card.setExpanded(toolsExpanded) toolCards.set(event.data.callId, card) allToolCards.add(card) return card } const clearStreaming = (): void => { if (streaming === undefined) return const index = chat.children.indexOf(streaming) /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ if (index >= 0) chat.children.splice(index, 1) streaming = undefined } const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { // Injected context (plugin/goal source) renders as a dim context card, // not a human bubble; only a direct human prompt is a user message. The // boolean avoids narrowing `source`, so the label keeps its full union. const source = event.data.source if (source.kind !== 'user') { const references = sessionReferenceCard(event.data.meta) if (references !== undefined) { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) break } const text = displayText(contentText(event.data.content).trim()) if (text) { // The tui type view lacks plugin-augmented source kinds (e.g. goal), // so read the display label without narrowing on `kind`. const labelled = source as { kind: string; plugin?: string } const label = labelled.plugin ?? labelled.kind chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) chat.addChild(new Text(palette.muted(text), 1, 0)) } break } const text = displayText(contentText(displayPromptContent(event.data)).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme)) if (options.addHistory) editor.addToHistory(text) } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) } break } case 'steering/message': { const text = displayText(contentText(displayPromptContent(event.data)).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering')) } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) } break } case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break case 'assistant/chunk': if (options.renderChunks) { if (streaming === undefined) { streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme) chat.addChild(streaming) } streaming.update(event.data.chunk) } break case 'assistant/message': { clearStreaming() const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) if (component.children.length > 0) chat.addChild(component) break } case 'llm/retry': { clearStreaming() appendNotice( `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', ) break } case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) break case 'tool/result': { let card = toolCards.get(event.data.callId) if (card === undefined) { card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette) chat.addChild(new Spacer(1)) chat.addChild(card) allToolCards.add(card) } card.updateResult(event.data) toolCards.delete(event.data.callId) break } case 'todo/write': todo.update(event.data.todos) break case 'session/title': sessionTitle = event.data.title header.invalidate() updateTerminalTitle() break case 'turn/end': clearStreaming() if (event.data.reason.kind === 'error') { const key = `${event.data.turn}:${event.data.reason.step}` const message = 'failure' in event.data.reason ? event.data.reason.failure.message : event.data.reason.message if (!liveErrors.delete(key)) appendNotice(message, 'error') } else if (event.data.reason.kind === 'aborted') { appendNotice('Turn cancelled.', 'warning') } else if (event.data.reason.kind === 'max-tokens') { appendNotice('The model reached its output-token limit.', 'warning') } else if (event.data.reason.kind === 'rejected') { appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning') } else if (event.data.reason.kind === 'interrupted') { appendNotice('The previous process ended during this turn.', 'warning') } break default: break } } const rebuildTranscript = (populateHistory: boolean): void => { chat.clear() toolCards.clear() allToolCards.clear() streaming = undefined const active = activeSurfaceSeqs(agent.session) const activeCalls = activeToolCallIds(agent.session, active) for (const event of agent.session.events) { const isSurface = event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' || event.type === 'steering/message' if (isSurface && !active.has(event.seq)) continue if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue renderEvent(event, { addHistory: populateHistory, renderChunks: false }) } requestRender() } const removeAbortListener = (pending: PendingQuestion): void => { pending.request.signal?.removeEventListener('abort', pending.onAbort) } const rejectQuestion = (pending: PendingQuestion): void => { void pending.overlay?.close() pending.overlay = undefined removeAbortListener(pending) pending.reject(new UserInteractionError( 'ask_user_question was interrupted before the user answered', 'ASK_ABORTED', )) } const startNextQuestion = (): void => { if (activeQuestion !== undefined || disposed) return const pending = questionQueue.shift() if (pending === undefined) return activeQuestion = pending const show = (): void => { const question = pending.request.questions[pending.index] if (question === undefined) { activeQuestion = undefined removeAbortListener(pending) pending.resolve({ answers: pending.answers }) startNextQuestion() return } const session = overlayManager.open({ ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, create: () => new QuestionDialog( question, pending.index + 1, pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, palette, (selection) => { pending.overlay = undefined void session.close() pending.answers.push({ id: question.id, ...selection }) pending.index += 1 show() }, () => { activeQuestion = undefined rejectQuestion(pending) startNextQuestion() }, ), options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, anchor: 'bottom-left', margin: { bottom: 1 }, }, }) pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return pending.overlay = undefined /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ if (result.reason !== 'error') return activeQuestion = undefined removeAbortListener(pending) pending.reject(new UserInteractionError( `ask_user_question TUI failed: ${errorChain(result.error)}`, 'ASK_ABORTED', )) startNextQuestion() }) requestRender() } show() } const disposeUserInteraction = ctx.userInteraction.registerProvider({ ask(request) { return new Promise((resolveAnswer, reject) => { const pending: PendingQuestion = { request, index: 0, answers: [], resolve: resolveAnswer, reject, overlay: undefined, onAbort: () => { if (activeQuestion === pending) { activeQuestion = undefined rejectQuestion(pending) startNextQuestion() return } // A non-active pending ask remains in the queue until this listener settles it. questionQueue.splice(questionQueue.indexOf(pending), 1) rejectQuestion(pending) }, } request.signal?.addEventListener('abort', pending.onAbort, { once: true }) questionQueue.push(pending) startNextQuestion() }) }, }) /** * Persisted sessions for this workspace, newest first. Empty when no * persistence backend is mounted or a listing failure would otherwise block * exit or crash `/resume`; the resume hint is best-effort convenience. */ const listWorkspaceSessions = async (): Promise => { if (persistence === undefined) return [] let all: readonly SessionHeader[] try { all = await persistence.list() } catch { // A listing failure must never block terminal exit or crash `/resume`. return [] } return all .filter(header => header.cwd === agent.session.header.cwd) } /** * The resume command for the current session — the configured template with * every `{session}` filled — but only once the session is durably persisted, * so a session abandoned before its first flush yields no hint (resuming that * id would fail to load). */ const currentResumeCommand = async (): Promise => { if (config.resumeCommand === undefined) return undefined const sessions = await listWorkspaceSessions() if (!sessions.some(header => header.id === agent.session.id)) return undefined return config.resumeCommand.replaceAll('{session}', agent.session.id) } const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() for (const controller of referenceControllers) controller.abort(new Error('TUI disposed')) referenceControllers.clear() await tuiServiceFiber?.dispose() tuiServiceFiber = undefined if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined rejectQuestion(pending) } for (const pending of questionQueue.splice(0)) rejectQuestion(pending) await overlayManager.dispose() modelOverlay = undefined disposeUserInteraction() await runtime.terminal.drainInput(100, 20) ui.stop() if (exitProcess) { const command = await currentResumeCommand() if (command !== undefined) { runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`) } runtime.exit(0) } })() return shuttingDown } const requestExit = (): void => { if (agent.status === 'running') { agent.cancel({ kind: 'user' }) appendNotice('Cancelling the active turn before exit…', 'warning') void agent.whenIdle().then(() => shutdown(true)) return } void shutdown(true) } /** Swap the palette and all derived themes for the given terminal color scheme. */ const applyColorScheme = (scheme: TerminalColorScheme): void => { if (scheme === currentScheme) return currentScheme = scheme Object.assign(palette, createPalette(resolved.color, scheme)) Object.assign(mdTheme, markdownTheme(palette)) // `setStatus` below re-derives `editor.borderColor` from the new palette. rebuildTranscript(false) setStatus(agent.status) requestRender() } let currentScheme: TerminalColorScheme = 'dark' // Apply any color scheme the terminal reports. Registering before the query // below means even a synchronous reply reaches `applyColorScheme`; in practice // the startup query's reply is the only report, since dsh-tui leaves // unsolicited color-scheme notifications disabled. const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme) // Ask the terminal for its color scheme via device-status report; the reply, // if any, arrives through the listener above. Most terminals do not respond, // so we keep the dark-optimised palette. Swallow a query-write failure for the // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) const toggleTools = (): void => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`) } const toggleReasoning = (): void => { showReasoning = !showReasoning const activeStreaming = streaming rebuildTranscript(false) if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) chat.addChild(activeStreaming) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` return `/${command.name}${input} — ${command.description}` }) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', '', ...commandLines, '/skill: [instructions] — load a skill into the conversation', ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } const showStatus = (): void => { const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` if (contextWindow !== undefined) { const contextPercent = Math.round(usedContext / contextWindow * 100) context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` } const rate = cacheHitRate(tokens) const turns = events.filter(event => event.type === 'turn/start').length const steps = events.filter(event => event.type === 'step/start').length const toolCalls = events.filter(event => event.type === 'tool/call').length const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current)) const effort = target.current === undefined ? 'unset' : target.current.reasoningEffort === undefined ? 'default' : displayText(target.current.reasoningEffort) const groups: readonly (readonly StatusCardRow[])[] = [ [ ['Session', displayText(agent.session.id)], ['Title', displayText(sessionTitle ?? 'untitled')], ['Directory', displayText(cwd)], ['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`], ], [ ['Agent', [ agent.status, formatDiagnosticCount(events.length, 'event'), formatDiagnosticCount(turns, 'turn'), formatDiagnosticCount(steps, 'step'), formatDiagnosticCount(toolCalls, 'tool call'), ].join(' · ')], ], [ ['Tokens', `${formatDiagnosticNumber(tokens.input)} input + ${formatDiagnosticNumber(tokens.output)} output`], ['KV cache', rate === undefined ? `n/a (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)` : `${diagnosticMeter(rate, palette)} ${String(rate)}% hit (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`], ['Context', context], ], [ ['Created', formatDiagnosticTime(agent.session.header.createdAt)], ['Active', formatDiagnosticTime(latestActivity)], ], ] const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) requestRender() } // Skill listing is async while `createTuiChat` is synchronous, so the // completions rebuild once the catalog resolves. Disabled-for-model skills // are absent from `list()`, so they never appear as completions; a user can // still invoke one by typing its exact name. let skillCommands: SlashCommand[] = [] const refreshCommandAutocomplete = (): void => { const base = new CombinedAutocompleteProvider( [ ...ctx.commands.list(agent).map(command => ({ name: command.name, description: command.description, ...(command.input === undefined ? {} : { argumentHint: command.input.hint }), })), ...skillCommands, ], agent.session.header.cwd ?? process.cwd(), ) const sessionReferences = ctx.get('sessionReferences') editor.setAutocompleteProvider(new ReferenceAutocompleteProvider( base, fileSearch, sessionReferences, agent, )) } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() const loadSkillCommands = (service: SkillService): void => { service.list({ cwd, signal: skillAbort.signal }).then( (summaries) => { if (disposed || summaries.length === 0) return skillCommands = summaries.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, argumentHint: '[instructions]', })) refreshCommandAutocomplete() requestRender() }, () => { // Discovery failed or was aborted on dispose; keep the base slash // commands so autocomplete still works without skill entries. }, ) } if (skills !== undefined) loadSkillCommands(skills) // The agent scope is minted by agent-loop and intentionally inherits only // that core plugin's dependencies. A child command producer declares its own // UI-service dependency while retaining the parent agent scope and lifetime. const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ name: 'help', description: 'Show keyboard shortcuts and commands', handler: () => { showHelp(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'model', description: 'Show or switch this session\'s model', input: { hint: '[[provider/]model]' }, handler: ({ rawInput }) => { queueModelCommand(rawInput) return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'clear', description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'reasoning', description: 'Toggle reasoning blocks', handler: () => { toggleReasoning(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'tools', description: 'Expand or collapse all tool cards', handler: () => { toggleTools(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'redraw', description: 'Invalidate components and redraw the terminal', handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'reload', description: 'EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)', handler: () => { runReload(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'resume', description: 'List this workspace\'s resumable sessions', handler: () => { showResume(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'status', description: 'Show detailed session diagnostics', handler: () => { showStatus(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', handler: () => { requestExit(); return { kind: 'success' } }, }) }) const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => { promptCtx.systemPrompt.section({ name: 'ui:tui-file-reference', order: 99, // Tool visibility can change dynamically or by agent scope. Empty // sections are omitted by renderPrompt, so guidance never names a tool // that this agent cannot call. text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, }) }) const runCommand = (text: string): void => { const controller = new AbortController() commandControllers.add(controller) void ctx.commands.execute(agent, text, controller.signal).then( (result) => { if (disposed) return if (result === undefined) { appendNotice(`Unknown command: ${text}`, 'warning') } else if (result.text !== undefined && result.text !== '') { appendNotice(result.text, result.kind === 'error' ? 'error' : 'info') } }, (error: unknown) => { if (!disposed) { appendNotice(`Command failed: ${errorChain(error)}`, 'error') } }, ).finally(() => { commandControllers.delete(controller) }) } const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => { if (agent.status === 'disposed') { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { agent.steer(content, { contexts }) } else { agent.followup(content, { contexts }) } } /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */ const deliver = (payload: string): void => { dispatchMessage([{ type: 'text', text: payload }], []) } /** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */ const invokeSkill = (name: string, instructions: string): void => { if (skills === undefined) { appendNotice('Skills are not available in this session.', 'warning') return } skills.get(name, { cwd, signal: skillAbort.signal }).then( (skill) => { if (disposed) return if (skill === undefined) { appendNotice(`Unknown skill: ${name}`, 'warning') return } deliver(renderSkillInvocation(skill, instructions)) }, (error: unknown) => { if (disposed) return appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error') }, ) } // EXPERIMENTAL, dev-only: manually re-read every file-backed loader config // tree and apply the diff to the running app — the same path the HMR // watcher's config-change branch drives, minus the watcher. Useful when the // watcher misses an edit (replace-by-rename saves) or HMR is not mounted. // Module-source hot reload stays watcher-owned; this refreshes configs only. let reloadInFlight = false const runReload = (): void => { // Idle-only: a reload can dispose and re-mount entries mid-flight; doing // that under an active turn could tear tools or the adapter out from // under in-flight calls. Idleness is advisory (a send can race in after // the check), but it removes the common footgun. if (agent.status !== 'idle') { appendNotice(`/reload requires an idle agent (status: ${agent.status}).`, 'warning') return } // Re-entrancy guard: concurrent refreshes over a genuinely changed file // would race unmutexed tree updates (create/remove interleaving); one // reload at a time keeps the update pass single-writer. if (reloadInFlight) { appendNotice('A config reload is already running.', 'warning') return } // Optional-service lookup: the TUI must not depend on the Loader (tests // and embedders run without one), so `loader` stays out of `inject` and // is read through the non-throwing `ctx.get` accessor — a bare `ctx.loader` // proxy read would throw `cannot get property without inject` in a fiber. const loader = ctx.get('loader') as { entries(): Iterable<{ subtree?: { refresh?(): Promise } }> } | undefined if (loader === undefined) { appendNotice('/reload needs the cordis Loader; this runtime has none.', 'warning') return } const refreshes: Promise[] = [] for (const entry of loader.entries()) { if (entry.subtree?.refresh !== undefined) refreshes.push(entry.subtree.refresh()) } reloadInFlight = true appendNotice(`Reloading ${refreshes.length} config tree(s)… (experimental)`) // refresh() never rejects (it warns and keeps the running tree), so the // join can only fulfill; the catch arm guards a future contract change. void Promise.all(refreshes).then(() => { appendNotice('Config reload complete. Unchanged files were skipped; invalid files keep the running tree (see logs).') }).catch((error: unknown) => { appendNotice(`Config reload failed: ${errorChain(error)}`, 'error') }).finally(() => { reloadInFlight = false }) } /** Build one display candidate without letting a corrupt neighbor abort the selector. */ const readResumeCandidate = async ( record: SessionRecord, providers: ReadonlySet, ): Promise => { try { let snapshot: SessionLogSnapshot const live = ctx.sessions.get(record.header.id) if (live !== undefined) { snapshot = { session: structuredClone(live.header), events: live.events.map(event => structuredClone(event)), } } else { /* v8 ignore next -- caller checks the optional service before mapping records */ if (sessionQuery === undefined) throw new Error('session query is unavailable') snapshot = await sessionQuery.readSession(record.header.id) } return summarizeResumeCandidate( record, snapshot, agent.session.id, agent.session.header.cwd, providers, ) } catch (error: unknown) { return { record, title: 'Unreadable session', lastActivityAt: record.header.createdAt, lastTurn: 'log unavailable', disabledReason: `session cannot be loaded: ${errorChain(error)}`, } } } /** Re-read every mutable precondition immediately before terminal handoff. */ const preflightResume = async (sessionId: SessionId): Promise => { /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') const initialStatus = agentStatus() if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) const candidate = await readResumeCandidate( record, new Set(ctx.llm.listProviders().map(provider => provider.id)), ) if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) const finalStatus = agentStatus() if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) return candidate } const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { if (resumeInFlight) return resumeInFlight = true let terminalReleased = false try { const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume if (hostHandoff === undefined) { const template = config.resumeCommand const fallback = template?.replaceAll('{session}', checked.record.header.id) await overlay.close() resumeOverlay = undefined appendNotice(fallback === undefined ? 'Session is resumable, but this host cannot hand it off in place.' : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ if (disposed) return await ctx.sessions.flush(agent.session) // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (disposed) return if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) await overlay.close() resumeOverlay = undefined await runtime.terminal.drainInput(100, 20) // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (disposed) return ui.stop() terminalReleased = true await hostHandoff(checked.record.header.id) throw new Error('resume host returned without replacing the process') } catch (error: unknown) { if (!disposed) { if (terminalReleased) { ui.start() ui.setFocus(editor) appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') } else { await overlay.close() resumeOverlay = undefined appendNotice(`Resume failed: ${errorChain(error)}`, 'error') } } } finally { resumeInFlight = false } } /** Open the current-workspace searchable session selector. */ const showResume = (): void => { if (agent.status !== 'idle') { appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') return } if (sessionQuery === undefined) { appendNotice('Resume is not available: session query is not mounted.', 'warning') return } const scan = ++resumeScan void resumeOverlay?.close() void sessionQuery.listSessions().then(async (records) => { if (isDisposed() || scan !== resumeScan) return const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (isDisposed() || scan !== resumeScan) return const session = overlayManager.open({ create: host => new ResumePicker( candidates, resolved.maxResumeOptions, runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, () => { void session.close() }, ), options: { width: '100%', maxHeight: '100%', anchor: 'top-left', margin: 0, }, }) resumeOverlay = session void session.closed.then(() => { /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ if (resumeOverlay === session) resumeOverlay = undefined }) requestRender() }, (error: unknown) => { if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) } editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return const restoreSubmittedInput = (): void => { if (editor.getText() === '') editor.setText(value) } // `/skill:` carries a colon, which the command registry's name // grammar rejects, so it is intercepted before generic command routing. if (text.startsWith(SKILL_COMMAND_PREFIX)) { editor.addToHistory(text) editor.setText('') const { name, instructions } = parseSkillCommand(text) if (name === '') appendNotice('Usage: /skill: [instructions]', 'warning') else invokeSkill(name, instructions) return } if (value.startsWith('/')) { editor.addToHistory(text) editor.setText('') runCommand(value) return } let parsed: ReturnType try { parsed = parseSessionReferenceText(text) } catch (error: unknown) { restoreSubmittedInput() appendNotice(`Invalid session reference: ${errorChain(error)}`, 'error') return } if (parsed.references.length === 0) { editor.addToHistory(text) editor.setText('') dispatchMessage([{ type: 'text', text: parsed.text }], []) return } const sessionReferences = ctx.get('sessionReferences') if (sessionReferences === undefined) { restoreSubmittedInput() appendNotice('Session reference capability unavailable.', 'error') return } const controller = new AbortController() referenceControllers.add(controller) editor.disableSubmit = true void sessionReferences.prepare( agent, [{ type: 'text', text: parsed.text }], parsed.references, controller.signal, ).then((prepared) => { if (disposed) return editor.addToHistory(text) if (editor.getText() === value) editor.setText('') dispatchMessage(prepared.content, prepared.contexts) }, (error: unknown) => { if (!disposed && !controller.signal.aborted) { restoreSubmittedInput() appendNotice(`Session reference failed: ${errorChain(error)}`, 'error') } }).finally(() => { referenceControllers.delete(controller) editor.disableSubmit = false requestRender() }) } const removeInputListener = ui.addInputListener((data) => { if (overlayManager.hasActiveOverlay()) return undefined if (matchesKey(data, Key.ctrl('o'))) { toggleTools() return { consume: true } } if (matchesKey(data, Key.ctrl('r'))) { toggleReasoning() return { consume: true } } if (matchesKey(data, Key.ctrl('l'))) { ui.invalidate() ui.requestRender(true) return { consume: true } } if (matchesKey(data, Key.escape) && agent.status === 'running') { agent.cancel({ kind: 'user' }) return { consume: true } } if (matchesKey(data, Key.ctrl('c'))) { if (agent.status === 'running') { agent.cancel({ kind: 'user' }) } else if (editor.getText() !== '') { editor.setText('') } else { requestExit() } return { consume: true } } if (matchesKey(data, Key.ctrl('d'))) { if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning') else requestExit() return { consume: true } } return undefined }) const disposeSessionEvents = ctx.on('session/event', (session, event) => { if (session !== agent.session) return if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) advanceTurnPhase(event) if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its // entry from the badge. Matching by source keeps a loop-authored // continuation reason popping its own enqueued slot rather than a pending // user message's slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) refreshStatus() } } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return } renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return // Leaving 'running' ends the turn's status line; clear any badge so the // next running turn starts from zero (and a cancellation, which discards // the queue without logging drains, cannot strand a stale count). if (status !== 'running') pendingSteering.length = 0 setStatus(status) }) const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { if (subject !== agent) return liveErrors.add(`${turn}:${step}`) // Full cause chain: wrapper messages like `fetch failed` carry the // actionable transport detail on `cause`. appendNotice(errorChain(error), 'error') }) const disposeAgent = ctx.on('agent/disposed', (subject) => { if (subject !== agent) return clearStatus() appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') }) const detachListeners = (): void => { skillAbort.abort() fileSearch.dispose() removeInputListener() disposeCommandChanges() stopBannerReveal() disposeSessionEvents() disposeQueued() disposeStatus() disposeError() disposeAgent() disposeSchemeListener() disposeTargetListeners() } // Sweep reveal of the whole banner: the header wipes in left-to-right over // ~BANNER_REVEAL_STEPS frames (started after `ui.start()` succeeds). // Configured subtitles skip it so deployments (and snapshot fixtures) stay // frame-deterministic. let revealTimer: ReturnType | undefined const stopBannerReveal = (): void => { if (revealTimer === undefined) return clearInterval(revealTimer) revealTimer = undefined header.setRevealWidth(undefined) } const startBannerReveal = (): void => { if (config.welcome !== undefined) return const total = Math.max(1, runtime.terminal.columns) const step = Math.max(1, Math.ceil(total / BANNER_REVEAL_STEPS)) let shown = 0 header.setRevealWidth(0) revealTimer = setInterval(() => { shown += step if (shown >= total) { stopBannerReveal() } else { header.setRevealWidth(shown) } requestRender() }, BANNER_REVEAL_INTERVAL_MS) } rebuildTranscript(true) const restoredGoal = foldGoal(agent.session.events).goal if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { appendNotice( `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` + 'Human confirmation is required; send “继续” or run /goal resume.', 'warning', ) } setStatus(agent.status) try { ui.start() } catch (error: unknown) { disposed = true detachListeners() void Promise.all([ commandFiber.dispose(), fileReferencePromptFiber.dispose(), ]).catch( /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */ (cleanupError: unknown) => { ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`) }, ) clearStatus() disposeUserInteraction() ui.stop() throw error } tuiServiceFiber = ctx.inject([], (serviceCtx) => { new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager) }) startBannerReveal() return { async dispose(): Promise { detachListeners() await shutdown(false) await Promise.all([ commandFiber.dispose(), fileReferencePromptFiber.dispose(), ]) }, } } /** * Open the pi-tui channel once its configured agent exists. * * @param ctx - Context supplying the agent registry, tools, and event stream. * @param config - Target agent and presentation configuration. * @param runtime - Terminal and process-exit boundary. */ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void { const sessionId = SessionId(config.sessionId ?? 'main') const matchesConfiguredIdentity = (agent: Agent): boolean => agent.id === sessionId && ctx.agents.roots().includes(agent) let settled = false const stopWaiting = (): void => { disposeCreated() disposeFailure() } const start = (agent: Agent): void => { if (settled || !matchesConfiguredIdentity(agent)) return settled = true stopWaiting() ctx.effect(() => { const controller = createTuiChat(ctx, config, runtime) return () => controller.dispose() }, 'ui-tui') } const fail = (failedSessionId: SessionId, error: unknown): void => { if (settled || failedSessionId !== sessionId) return settled = true stopWaiting() runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`)) runtime.exit(1) } const disposeCreated = ctx.on('agent/created', start) const disposeFailure = ctx.on('agent-loop/config-start-failed', fail) const existing = ctx.agents.roots().find(agent => agent.id === sessionId) if (existing !== undefined) start(existing) } /** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */ /* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat, and the tui-agent PTY smoke covers the real entry */ export function apply(ctx: Context, config: Config): void { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes') } // Truecolor is a terminal capability, so detect it here at the process // boundary from COLORTERM; an explicit `truecolor` config value still wins. const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') const resumeHost = ctx.get('tuiResumeHost') mountTui(ctx, Object.assign({}, config, { truecolor }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, }) } /* v8 ignore stop */