1806 lines
71 KiB
TypeScript
1806 lines
71 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 { 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 */
|