/** * 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 { CombinedAutocompleteProvider, Container, Key, Spacer, Text, TUI, ProcessTerminal, matchesKey, visibleWidth, type EditorTheme, type SlashCommand, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber, type FiberState } from 'cordis' import { assembleContextFor, installAgentLlmTarget, type Agent, type AgentLlmTargetRef, type AgentStatus, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type { CommandResult } from '@deepseek-ai/dsh-commands' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { isReplacementSurfaceEvent, lastActivityTime, SessionId, type SessionEvent, type UserMessage, } from '@deepseek-ai/dsh-session' import { foldGoal } from '@deepseek-ai/dsh-goal' import { parseSessionReferenceText, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' // 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 { SkillService } from '@deepseek-ai/dsh-skill' // Type import declaration-merges the `userInteraction` service onto `Context`; // the ask-user-question queue is registered by ./chat/questions. import type {} from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, } from './extension/overlay-manager.ts' import { parseTuiPromptTemplate, renderTuiPromptTemplate, type TuiPromptValueHandle, } from './prompt.ts' import type { TuiOverlayRequest, TuiOverlaySession, TuiTheme, } from './extension/types.ts' import { displayInlineText, displayText } from './components/text.ts' import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts' import { contentText, parseArguments } from './components/content.ts' import { cacheHitRate, formatTokens, recordEventUsage, sessionTokens, } from './chat/tokens.ts' import { fadeGlyph, formatQueuedStatus, openStepPhase, openTurn, pulseLevel, runningPhaseGlyph, STATUS_ANIMATION_INTERVAL_MS, STATUS_FADE_MS, TIMING_BUCKET_GLYPHS, type StepPosition, } from './chat/timing.ts' import { resolveTuiConfig, type Config, } from './config.ts' import { ContextCardComponent, type ToolCardVisibility, HeaderComponent, StreamingAssistantComponent, ToolCardComponent, TodoComponent, UserMessageComponent, } from './components/transcript.ts' import { compactTargetLabel, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, formatDiagnosticTime, initialTarget, StatusCardComponent, PromptContextComponent, targetLabel, type StatusCardRow, } from './components/dialogs.ts' import { parseSkillCommand, renderSkillInvocation, SKILL_COMMAND_PREFIX, } from './chat/skill-invocation.ts' import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts' import { BANNER_REVEAL_INTERVAL_MS, BANNER_REVEAL_STEPS, formatCwd, gitBranch, HintEditor, isCompactCheckpoint, sessionReferenceCard, transcriptToolCallIds, } from './chat/helpers.ts' import { createModelController, type ModelController, } from './chat/model-command.ts' import { createQuestionQueue } from './chat/questions.ts' import { createResumeController } from './chat/resume.ts' import type { TuiResumeHost, TuiRuntime } from './runtime.ts' import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' export { renderSkillInvocation } from './chat/skill-invocation.ts' export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, TuiConfigSchema, Config, type ResolvedTuiConfig, type ResolvedTuiThemeConfig, type TuiConfig, type TuiThemeConfig, } from './config.ts' export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, } from './chat/file-autocomplete.ts' export type { TuiComponent, TuiFocusable, TuiOverlayAnchor, TuiOverlayCloseReason, TuiOverlayHost, TuiOverlayMargin, TuiOverlayOptions, TuiOverlayOutcome, TuiOverlayRequest, TuiOverlaySession, TuiOverlayState, TuiTheme, TuiViewport, } from './extension/types.ts' /** First terminal Cordis state: FAILED, DISPOSED, and UNLOADING are unusable. */ const FIBER_FAILED = 3 as FiberState.FAILED 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 /** Launcher-owned `main` session identity; absent lets the app mint one. */ mainSessionId: MainSessionIdentity | undefined /** Line the launcher wants printed on exit; absent prints nothing. */ tuiGoodbyeMessage: string | undefined /** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */ tuiInitialSkill: string | undefined } } /** Launcher-chosen identity for the app's `main` session. */ export interface MainSessionIdentity { /** Exact session id `main` binds to. */ readonly id: SessionId /** * Whether that session already has persisted history to load. `true` requires * an existing log and fails loud when absent; `false` creates it fresh. */ readonly resume: boolean } /** * Context key a launcher sets before any Loader entry mounts * (`ctx.provide(MAIN_SESSION_ID_KEY, identity)`) to fix the `main` agent's * session identity, so an app bundle mounted from a `cordis.yml` binds a * launcher-selected session without a config key. `ctx.provide` is the only * channel from launcher argv into a Loader-mounted plugin, because config * `!!js` expressions evaluate against the entry's context. Absent leaves the * choice to the app. */ export const MAIN_SESSION_ID_KEY = 'mainSessionId' /** * Context key a launcher sets before any Loader entry mounts * (`ctx.provide(TUI_GOODBYE_MESSAGE_KEY, line)`) to supply the line the TUI * prints once the terminal is released on exit — for the shipped CLI, the * command that resumes this session. The launcher owns the wording because only * it knows how it was invoked; the TUI escapes terminal controls before * rendering. Absent prints nothing. */ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage' /** * Context key a launcher sets before any Loader entry mounts * (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user * turn with `/skill:` — the `dsh migrate`/`dsh upgrade` guided-session * entry. The launcher sets it only when minting a fresh session, so it never * re-fires on a resumed one. Absent leaves the first turn to the user. */ export const INITIAL_SKILL_KEY = 'tuiInitialSkill' /** * 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 } export const name = 'ui-tui' export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter', 'tuiPrompt'] /** 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.' /** * Transcript row standing in for one compacted range. The conversation the * compaction replaced stays rendered above it: the marker reports where the * model stopped seeing that history, not that the history is gone. */ const COMPACTION_MARKER = '… earlier context was compacted …' interface RunningStatus { turn: number | undefined timer: ReturnType /** Render clock when the turn began; origin of the glyph fade-in. */ startedAt: number /** The most recently rendered phase glyph, handed to the fade-out. */ lastGlyph: string } /** A running glyph fading out after its turn ended, before the caret returns. */ interface FadingStatus { glyph: string /** Render clock when the turn ended; origin of the glyph fade-out. */ endedAt: number timer: ReturnType } /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ dispose(): Promise } /** * 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 resolved = resolveTuiConfig(config) const palette = createPalette(resolved.theme.color) const mdTheme = markdownTheme(palette) const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() const editor = new HintEditor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), } satisfies EditorTheme, { paddingX: 1, frame: 'none', prompt: { first: initialInputPrompt, continuation: ' '.repeat(visibleWidth(initialInputPrompt)), }, }) editor.hintPrefix = initialInputPrompt const todo = new TodoComponent(palette) let showReasoning = resolved.showReasoning // Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops // tool cards entirely, collapsed previews, expanded shows full bodies. let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined // TUI steering submissions that the inbox has not yet claimed or discarded. // Correlation ids avoid guessing whether a running-state submission actually // joined steering or fell back to the queued-turn FIFO during turn close. const pendingSteering = new Set() 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 contextCards = new Set() const liveErrors = new Set() const commandControllers = new Set() const referenceControllers = new Set() let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } // `updatePromptValues` (defined below) closes over the model controller, but // the controller needs `appendNotice`/`overlayManager`, defined after that // closure. Declare here, assign once after those exist, and defer the first // `updatePromptValues()` call until after the assignment so no read precedes it. // oxlint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. let modelController!: ModelController 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.theme.color && resolved.theme.truecolor, ) const formattedCwd = displayText(runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd)) const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd) const promptValues: TuiPromptValueHandle[] = [ ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))), ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`)), ctx.tuiPrompt.register('token_meter/cache_hit_rate'), ctx.tuiPrompt.register('model'), ctx.tuiPrompt.register('context'), ctx.tuiPrompt.register('queued'), ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))), ctx.tuiPrompt.register('indicator', palette.dim('> ')), ] const [cwdValue, gitValue, tokenValue, modelValue, contextValue, queuedValue, symbolValue, indicatorValue] = promptValues /* v8 ignore next -- the fixed built-in registration list always supplies each handle. */ if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined || contextValue === undefined || queuedValue === undefined || symbolValue === undefined || indicatorValue === undefined) { throw new Error('TUI prompt built-ins failed to initialize') } const updatePromptValues = (): void => { cwdValue.set(palette.bold(palette.accent(formattedCwd))) gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`)) const rate = cacheHitRate(tokens) const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` modelValue.set(` ${palette.dim(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) tokenValue.set(` ${palette.dim(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) const contextWindow = modelController.contextWindow() contextValue.set(contextWindow === undefined ? undefined : ` ${palette.dim( `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, )}`) const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size) queuedValue.set(queued === undefined ? undefined : palette.dim(queued)) symbolValue.set(palette.bold(palette.accent('dsh'))) // `${indicator}` owns the caret column and its trailing gap before the // cursor. The phase glyph replaces the `>` caret in place — same width // every frame — fading in as a turn starts, throbbing while it runs, and // fading out after it ends before the plain `>` returns. Only the gray // brightness changes, so the cursor never shifts. const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined) // Remember the live phase glyph so the fade-out shows it, not the ttft // fallback the derivation returns once the closing turn's step has ended. if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph // The fade envelope gates appear/disappear; the running throb breathes the // glyph the whole turn. Truecolor opacity is envelope × throb; the // non-truecolor fallback keys visibility off the envelope alone, so the // throb never blinks it. `envelope` clamps to [0, 1]. const envelope = runningStatus !== undefined && runningGlyph !== undefined ? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) } : fadingStatus !== undefined ? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) } : undefined const caret = envelope === undefined ? palette.dim('>') : fadeGlyph( envelope.glyph, palette, resolved.theme.color, resolved.theme.color && resolved.theme.truecolor, envelope.level * pulseLevel(now()), envelope.level >= 0.5, ) indicatorValue.set(`${caret}${palette.dim(' ')}`) } const promptContext = new PromptContextComponent( parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), valueName => ctx.tuiPrompt.get(valueName), ) ui.addChild(header) ui.addChild(chat) ui.addChild(new Spacer(1)) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(promptContext) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { runtime.terminal.setTitle(displayText( sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`, )) } updateTerminalTitle() const requestRender = (): void => { if (disposed) return updatePromptValues() const inputPrompt = renderInputPrompt() editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) }) editor.hintPrefix = inputPrompt promptContext.invalidate() ui.requestRender() } // A prompt value that changes on its own schedule (e.g. a plugin-owned // `${custom}` fragment) redraws through the registry's coalesced notification; // built-ins are already covered by the state-change callers of requestRender. const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender) const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.dim chat.addChild(new Spacer(1)) chat.addChild(new Text(color(displayText(message)), 0, 0)) requestRender() } const extensionTheme: TuiTheme = Object.freeze({ text: (value: string) => palette.text(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) modelController = createModelController({ ctx, resolved, palette, overlayManager, target, appendNotice, requestRender, isDisposed, }) updatePromptValues() const renderStatus = (): void => { streaming?.invalidate() requestRender() } /** Stop the running and fade-out timers and drop both states at once. */ const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) runningStatus = undefined } if (fadingStatus !== undefined) { clearInterval(fadingStatus.timer) fadingStatus = undefined } runtime.terminal.setProgress(false) } /** * On the running → non-running edge, hand the last rendered glyph to a * fade-out that re-renders until it settles on the `>` caret, then stops its * own timer. A hard clear (teardown) skips this via {@link clearStatus}. */ const beginFadeOut = (glyph: string): void => { clearStatus() const fading: FadingStatus = { glyph, endedAt: now(), timer: setInterval(() => { if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus() renderStatus() }, STATUS_ANIMATION_INTERVAL_MS), } fadingStatus = fading } const setStatus = (status: AgentStatus): void => { const priorTurn = runningStatus?.turn const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined if (status === 'running') clearStatus() else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph) else clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined if (status === 'running') { const turn = priorTurn ?? openTurn(agent.session.events) const running: RunningStatus = { turn, startedAt: now(), // Seed with the current phase (ttft before the first step opens) so the // fade-out always has a glyph, even for a turn that ends before a render. lastGlyph: TIMING_BUCKET_GLYPHS[openStepPhase(agent.session.events) ?? 'ttft'], // Refresh every tick so the fading prompt phase glyph animates even // before the first token, when no streaming component exists yet. timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), } runningStatus = running runtime.terminal.setProgress(true) } requestRender() } const refreshStatus = (): void => { renderStatus() } 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, mdTheme, ) card.setVisibility(toolsVisibility) toolCards.set(event.data.callId, card) allToolCards.add(card) return card } const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { const index = chat.children.indexOf(child) /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } } /** * Move the running step's timing footer to the tail of the chat so it trails * the tool cards the step just appended. A completed footer (its step ended, * so `streaming` is cleared) stays pinned where it is. */ const trailStreamingTiming = (): void => { /* v8 ignore next -- every replayed tool event follows its step/start, so an open step always owns an attached footer here. */ if (streaming === undefined) return const footer = streaming.timing const index = chat.children.indexOf(footer) /* v8 ignore next -- the open step's footer is attached to the chat whenever a tool event of that step renders. */ if (index < 0) return chat.children.splice(index, 1) chat.addChild(footer) } const clearStreaming = (): void => { removeStreaming(streaming) streaming = undefined } const retractFailedStreaming = (): void => { removeStreaming(streaming ?? completedStreaming) streaming = undefined completedStreaming = undefined } const startAssistantStep = (position: StepPosition): void => { streaming = new StreamingAssistantComponent( position, () => agent.session.events, now, showReasoning, palette, mdTheme, ) chat.addChild(streaming) chat.addChild(streaming.timing) } 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.source) if (references !== undefined) { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) break } const text = contentText(event.data.content).trim() /* v8 ignore next -- context events with empty content are rejected by their owning producers. */ if (text) { // The tui type view lacks plugin-augmented source kinds (e.g. goal), // so read the display label without narrowing on `kind`. The session // log is a durable/replay boundary: a corrupt or foreign injected // source may not match the typed shape, so fall back to `context`. const labelled = source as { kind?: unknown; plugin?: unknown } const label = typeof labelled.plugin === 'string' ? labelled.plugin : typeof labelled.kind === 'string' ? labelled.kind : 'context' const card = new ContextCardComponent(label, text, resolved.maxToolOutputLines, palette) card.setExpanded(toolsVisibility === 'expanded') contextCards.add(card) chat.addChild(new Spacer(1)) chat.addChild(card) } break } const text = displayText(contentText(event.data.content).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme)) if (options.addHistory) editor.addToHistory(text) } break } case 'steering/message': { const text = displayText(contentText(event.data.message.content).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering')) } break } case 'step/start': startAssistantStep(event.data) break case 'assistant/chunk': if (options.renderChunks) streaming?.update(event.data.chunk) break case 'assistant/message': completedStreaming = undefined if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) streaming?.settle(event.data.message.content) break case 'llm/retry': { retractFailedStreaming() const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) appendNotice( `Retrying model request (${event.data.retry}/${retryLimit}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', ) break } // No external Spacer for tool cards: the card renders its own leading // gap, so the hidden state removes the row and the gap together. case 'tool/call': chat.addChild(parsedTool(event)) trailStreamingTiming() break case 'tool/result': { const callId = event.data.message.source.callId let card = toolCards.get(callId) if (card === undefined) { card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) card.setVisibility(toolsVisibility) chat.addChild(card) allToolCards.add(card) } card.updateResult(event.data) toolCards.delete(callId) trailStreamingTiming() break } case 'todo/write': todo.update(event.data.todos) break case 'turn/start': // Plan strip is turn-scoped: keep it after turn/end for reading, clear on the next turn. todo.update([]) break case 'session/title': sessionTitle = event.data.title header.invalidate() updateTerminalTitle() break case 'step/end': if (streaming === undefined) startAssistantStep(event.data) streaming?.complete(event.time) completedStreaming = streaming streaming = undefined break // Every turn/end kind presents why the agent stopped: `completed` is // presented by the settled assistant message and its Completed timing // header; every other kind appends an explicit notice. case 'turn/end': { clearStreaming() const reason = event.data.reason switch (reason.kind) { case 'completed': break case 'error': { const key = `${event.data.turn}:${reason.step}` const message = 'failure' in reason ? reason.failure.message : reason.message if (!liveErrors.delete(key)) appendNotice(message, 'error') break } case 'aborted': appendNotice('Turn cancelled.', 'warning') break case 'max-tokens': appendNotice('The model reached its output-token limit.', 'warning') break case 'disposed': appendNotice('Turn stopped: the agent was disposed.', 'warning') break case 'interrupted': appendNotice('The previous process ended during this turn.', 'warning') break default: // TurnEndReasonMap is merge-extensible: a plugin-added outcome // still names why the agent stopped rather than ending silently. appendNotice(`Turn ended: ${(reason as { kind: string }).kind}.`, 'warning') break } break } default: break } } const renderCompactionMarker = (): void => { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0)) } /** * Replay the human transcript from the append-only log. The model-visible * surface shadows compacted ranges, so it is not the source here: every * append-origin message stays rendered, and a replacement contributes at most * the compaction marker at its own log position. * * The `tool/call` pairing check has no live counterpart, because only replay * can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it * inherits transcript membership from the `assistant/message` that advertised * it, which the live listener has necessarily just rendered. A loaded log is a * replay boundary, so the pairing is re-derived here instead of assumed. */ const rebuildTranscript = (populateHistory: boolean): void => { chat.clear() toolCards.clear() allToolCards.clear() contextCards.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) for (const event of agent.session.events) { if (isReplacementSurfaceEvent(event)) { if (isCompactCheckpoint(event)) renderCompactionMarker() continue } if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue renderEvent(event, { addHistory: populateHistory, renderChunks: false }) } requestRender() } const questions = createQuestionQueue({ ctx, resolved, palette, overlayManager, requestRender, isDisposed, }) const resume = createResumeController({ ctx, agent, runtime, resolved, palette, overlayManager, // Optional and independently mounted. Cordis transiently leaves this sibling // non-ACTIVE during command callbacks, so the non-strict read is intentional; // terminal fiber states still exclude failed, closing, and closed providers. sessionQuery: () => { const implementation = ctx.reflect._getImpl('sessionQuery', false) if (implementation === undefined || implementation.fiber.state >= FIBER_FAILED) return undefined return ctx.get('sessionQuery', false) }, ui, editor, appendNotice, requestRender, isDisposed, agentStatus, }) const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() modelController.resetContextResolution() 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 questions.rejectAll() await overlayManager.dispose() modelController.clearOverlay() questions.unregister() await runtime.terminal.drainInput(100, 20) ui.stop() if (exitProcess) { if (runtime.goodbyeMessage !== undefined) { runtime.terminal.write(`${palette.dim(displayText(runtime.goodbyeMessage))}\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.theme.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 => { // The cycle order puts the two common reading modes adjacent: preview -> // full detail -> conversation-only, then back to the preview default. toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } const toggleReasoning = (): void => { showReasoning = !showReasoning const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } 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')), 0, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L redraw', '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.dim(line)).join('\n'), 0, 0)) requestRender() } const showPalette = (): void => { chat.addChild(new Spacer(1)) chat.addChild(new Text( renderPalette(palette, currentScheme, resolved.theme.color).join('\n'), 0, 0, )) requestRender() } const showStatus = async (signal: AbortSignal): Promise => { const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) /* v8 ignore next -- disposal during the awaited assembly is covered by command-owner teardown tests. */ if (disposed) return /* v8 ignore next -- SystemPrompt always emits at least its required base section. */ const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)' const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)' const events = agent.session.events const latestActivity = lastActivityTime(events) ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` const contextWindow = modelController.contextWindow() 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) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('System prompt')), 0, 0)) chat.addChild(new Text(systemPrompt, 0, 0)) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Registered tools')), 0, 0)) chat.addChild(new Text(registeredTools, 0, 0)) requestRender() } // Skill listing is async while `createTuiChat` is synchronous, so the TUI // retains the last complete invocation-neutral catalog for synchronous // editor completion, filters it for user invocation, and refreshes it after // registry invalidation. let skillCommands: SlashCommand[] = [] let skillCommandScan = 0 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 refreshVisibleSlashAutocomplete = (): void => { const cursor = editor.getCursor() const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col) if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) { // pi-tui's provider setter closes an existing menu but does not query // the replacement for the current draft. Tab in a slash-name context // only requests suggestions, so it refreshes without editing the text. editor.handleInput('\t') } } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() const refreshSkillCommands = (service: SkillService): void => { const scan = ++skillCommandScan service.snapshot({ cwd, signal: skillAbort.signal }).then( (snapshot) => { if (disposed || scan !== skillCommandScan || !snapshot.complete) return const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable) // The argument-hint slot shows in the menu but is never inserted on // selection, so it carries the skill's scope instead of an // instructions placeholder. `SkillSource` is open-ended; every // non-project source (user, custom, bundled, runtime, …) collapses // to `(user)`. skillCommands = invocable.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', })) refreshCommandAutocomplete() refreshVisibleSlashAutocomplete() requestRender() }, () => { // Discovery failed or was aborted on dispose; keep the base slash // commands so autocomplete still works without skill entries. }, ) } const disposeSkillChanges = skills === undefined ? () => {} : ctx.on('skills/change', () => { refreshSkillCommands(skills) }) if (skills !== undefined) refreshSkillCommands(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 }) => { modelController.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: 'palette', description: 'Show every color and attribute role this terminal renders', handler: () => { showPalette(); 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: () => { resume.showResume(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'status', description: 'Show session diagnostics, system prompt, and registered tools', handler: async ({ signal }) => { await showStatus(signal); return { kind: 'success' } }, }) const exitHandler = (): CommandResult => { requestExit() return { kind: 'success' } } commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', handler: exitHandler, }) commandCtx.commands.register({ name: 'quit', description: 'Exit after the active turn reaches idle', handler: exitHandler, }) }) 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( (execution) => { if (disposed) return if (execution === undefined) { appendNotice(`Unknown command: ${text}`, 'warning') } else if (execution.result.text !== undefined && execution.result.text !== '') { appendNotice(execution.result.text, execution.result.kind === 'error' ? 'error' : 'info') } }, (error: unknown) => { if (!disposed) { appendNotice(`Command failed: ${errorChain(error)}`, 'error') } }, ).finally(() => { commandControllers.delete(controller) }) } const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessage): void => { if (disposed) { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') return } if (agent.acceptsNextStep) { // Steering is never subject to prompt admission; an attached snapshot // drains beside it at the same step boundary through the outbox. if (attachedContext !== undefined) { agent.inject(attachedContext) } const message = createUserMessage({ content, source: { kind: 'user' } }) agent.steer(message) pendingSteering.add(message.id) refreshStatus() return } if (attachedContext === undefined) { agent.followup(createUserMessage({ content, source: { kind: 'user' } })) return } // Idle: the snapshot rides the prompt's admission transaction so a // blocking hook discards both together. let cleanedUp = false const message: UserMessage = createUserMessage({ content, source: { kind: 'user' } }) const acceptedId = message.id const discarded = new Set() const cleanup = (): void => { // Every completion path detaches both listeners. Keep this // idempotent so later cleanup paths cannot double-release them. /* v8 ignore next -- unreachable idempotence guard, see above */ if (cleanedUp) return cleanedUp = true detachSubmit() detachDiscard() } // Prepended so this wrapper is outermost: it observes the exact accepted // message identity whether a downstream hook allows or blocks, then detaches. const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => { if (subject !== agent || submitted.id !== message.id) return next() cleanup() const decision = await next() if (decision.kind !== 'allow') return decision return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] } }, { prepend: true }) // Installed before followup(): an enqueue listener can synchronously // cancel and discard before followup() returns its id. const detachDiscard = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return for (const item of items) discarded.add(item.message.id) if (discarded.has(acceptedId)) cleanup() }) // followup() accepts any typed input and contains listener failures; // this guards a future synchronous throw so the wrapper cannot leak. /* v8 ignore start -- future-proofing guard, see above */ try { agent.followup(message) if (discarded.has(acceptedId)) cleanup() } catch (error: unknown) { cleanup() throw error } /* v8 ignore stop */ } /** 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 } const lookup = { cwd, signal: skillAbort.signal } const reportFailure = (error: unknown): void => { if (disposed) return appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error') } skills.list(lookup).then( (summaries) => { if (disposed) return const summary = summaries.find(skill => skill.name === name) if (summary === undefined) { appendNotice(`Unknown skill: ${name}`, 'warning') return } if (!summary.invocation.userInvocable) { appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') return } skills.get(name, lookup).then( (skill) => { if (disposed) return if (skill === undefined) { appendNotice(`Unknown skill: ${name}`, 'warning') return } if (!skill.invocation.userInvocable) { appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning') return } deliver(renderSkillInvocation(skill, instructions)) }, reportFailure, ) }, reportFailure, ) } // 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 }) } 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: skillName, instructions } = parseSkillCommand(text) if (skillName === '') appendNotice('Usage: /skill: [instructions]', 'warning') else invokeSkill(skillName, 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('') // The snapshot travels with the prompt so a blocking admission hook // discards them together — see dispatchMessage's attached-context path. dispatchMessage(prepared.content, prepared.additionalContext) }, (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) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // A replacement mutates only the model surface, so the rendered transcript // keeps what it already showed; a landed summary checkpoint adds its marker. if (isReplacementSurfaceEvent(event)) { if (isCompactCheckpoint(event)) renderCompactionMarker() requestRender() return } renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) const settlePendingSteering = (id: MessageId): void => { if (pendingSteering.delete(id)) refreshStatus() } const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, item) => { if (subject === agent) settlePendingSteering(item.message.id) }) const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return let changed = false for (const item of items) changed = pendingSteering.delete(item.message.id) || changed if (changed) 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.clear() 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 // The agent left the registry (e.g. an agent-loop-only reload) while the // TUI stays mounted. Retained agents accept deliveries after detachment, so // without this a later send would drive a zombie agent/session; mark // disposed so dispatchMessage reports it instead. clearStatus() appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') disposed = true }) const detachListeners = (): void => { skillAbort.abort() fileSearch.dispose() removeInputListener() disposeCommandChanges() disposeSkillChanges() disposePromptChanges() for (const value of promptValues) value.dispose() stopBannerReveal() disposeSessionEvents() disposeDequeued() disposeDiscarded() 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 /* v8 ignore next -- goal replay coverage lives with the goal seam; the TUI only formats its startup notice. */ 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() questions.unregister() ui.stop() throw error } tuiServiceFiber = ctx.inject([], (serviceCtx) => { new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager) }) startBannerReveal() // A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the // named skill exactly as a typed `/skill:` would, once the chat is live // and the agent is idle. The launcher sets this only for a fresh session, so // there is no prior turn to collide with; invokeSkill reports an unknown skill // as a notice. if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '') 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) } const ROOT_DISPOSE_TIMEOUT_MS = 5_000 /** * Dispose the whole application before process exit, with a bounded fallback. * @param ctx - The TUI plugin context whose root owns sibling resources. * @param code - Process status to report. * @param exit - Exit boundary, replaceable by tests. */ export function disposeRootAndExit( ctx: Context, code: number, exit: (status: number) => void = (status) => { process.exit(status) }, ): void { let exited = false const exitOnce = (): void => { if (exited) return exited = true exit(code) } const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS) void ctx.root.fiber.dispose().then( () => { clearTimeout(timeout); exitOnce() }, () => { clearTimeout(timeout); exitOnce() }, ) } /** 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 apps/cli PTY smokes cover 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 theme value still wins. const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') const resumeHost = ctx.get('tuiResumeHost') const goodbyeMessage = ctx.get('tuiGoodbyeMessage') // The launcher seeds a guided fresh session's first turn through this key; a // config value still wins. Consumed in createTuiChat via config.initialSkill. const initialSkill = config.initialSkill ?? ctx.get('tuiInitialSkill') mountTui(ctx, Object.assign( {}, config, { theme: Object.assign({}, config.theme, { truecolor }) }, initialSkill === undefined ? {} : { initialSkill }, ), { terminal: new ProcessTerminal(), exit: (code) => { disposeRootAndExit(ctx, code) }, ...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) }, ...goodbyeMessage === undefined ? {} : { goodbyeMessage }, }) } /* v8 ignore stop */