Files
deepseek-harness/packages/ui/tui/src/index.ts
T

1806 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { brandText, 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,
formatStatusDuration,
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:<name>` — 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<typeof setInterval>
/** 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<typeof setInterval>
}
/** Lifecycle handle for a mounted interactive terminal channel. */
export interface TuiController {
/** Stop rendering, restore the terminal, and reject pending questions. */
dispose(): Promise<void>
}
/**
* 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)
const compactionStatusLine = new Text('', 0, 0)
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
/**
* Live standalone compaction observed by this process. Never derive this
* state from history: a resumed log may contain a stale orphaned start.
*/
let compacting: {
startedAt: number
timer: ReturnType<typeof setInterval>
} | 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<MessageId>()
let disposed = false
let shuttingDown: Promise<void> | 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<string, ToolCardComponent>()
const allToolCards = new Set<ToolCardComponent>()
const contextCards = new Set<ContextCardComponent>()
const liveErrors = new Set<string>()
const commandControllers = new Set<AbortController>()
const referenceControllers = new Set<AbortController>()
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 => {
const renderTime = now()
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')))
compactionStatusLine.setText(compacting === undefined
? ''
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
// `${indicator}` owns the caret column and its trailing gap before the
// cursor. The active status glyph replaces the `>` caret in place — same
// width every frame — fading in when work 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 statusGlyph = runningPhaseGlyph(
agent.session.events,
runningStatus !== undefined,
compacting !== 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 && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
// The fade envelope gates appear/disappear; the active throb breathes the
// glyph throughout the operation. 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 activeSince = runningStatus?.startedAt ?? compacting?.startedAt
const envelope = activeSince !== undefined && statusGlyph !== undefined
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
: fadingStatus !== undefined
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - 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(renderTime),
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(compactionStatusLine)
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),
brand: (value: string) => resolved.theme.color
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
: 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 turn-phase running and fade-out timers and drop both states. */
const clearTurnStatus = (): void => {
if (runningStatus !== undefined) {
clearInterval(runningStatus.timer)
runningStatus = undefined
}
if (fadingStatus !== undefined) {
clearInterval(fadingStatus.timer)
fadingStatus = undefined
}
runtime.terminal.setProgress(compacting !== undefined)
}
/** Hard clear: drop every indicator, including a live compaction bracket. */
const clearStatus = (): void => {
if (compacting !== undefined) {
clearInterval(compacting.timer)
compacting = undefined
}
clearTurnStatus()
}
/**
* Hand the last active 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 => {
clearTurnStatus()
const fading: FadingStatus = {
glyph,
endedAt: now(),
timer: setInterval(() => {
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
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') clearTurnStatus()
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
else clearTurnStatus()
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<SessionEvent, { type: 'tool/call' }>): 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<void> => {
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:<name> [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<void> => {
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<MessageId>()
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<void> } }> } | undefined
if (loader === undefined) {
appendNotice('/reload needs the cordis Loader; this runtime has none.', 'warning')
return
}
const refreshes: Promise<void>[] = []
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:<name>` 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:<name> [instructions]', 'warning')
else invokeSkill(skillName, instructions)
return
}
if (value.startsWith('/')) {
editor.addToHistory(text)
editor.setText('')
runCommand(value)
return
}
let parsed: ReturnType<typeof parseSessionReferenceText>
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
// Track live standalone compaction state.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {
const startedAt = now()
compacting = {
startedAt,
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
}
runtime.terminal.setProgress(true)
}
requestRender()
return
}
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
clearInterval(compacting.timer)
compacting = undefined
if (event.data.error !== undefined) {
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
}
// A concurrently running turn owns the indicator. Keep its timer and
// progress bit instead of letting the compaction fade clear that state.
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
requestRender()
return
}
// 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.
// The hard clear also retires live compaction. A later compact/end is
// intentionally presentation-silent: this disposal notice owns the
// terminal outcome, and no animation may survive agent detachment.
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()
modelController.detach()
}
// 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<typeof setInterval> | 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:<name>` 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<void> {
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 */