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