mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Review follow-up: the llm/adapters-updated listener's disposer was discarded, leaving it firing (harmlessly, behind isDisposed()) between TUI shutdown and fiber disposal, asymmetric with the sibling channel listeners. The controller now exposes detach(), and the channel's detachListeners() calls it on both the dispose() and startup-failure paths.
1745 lines
69 KiB
TypeScript
1745 lines
69 KiB
TypeScript
/**
|
||
* 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:<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)
|
||
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<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 => {
|
||
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<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
|
||
// 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()
|
||
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 */
|