index.ts 128 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319
  1. /**
  2. * Interactive pi-tui front door for DeepSeek Harness agents. It renders the
  3. * durable session transcript, drives one configured agent, and provides
  4. * keyboard-driven user-interaction dialogs without owning agent lifecycle.
  5. * @module @deepseek-ai/dsh-tui
  6. */
  7. import { homedir } from 'node:os'
  8. import { isAbsolute, relative, resolve, sep } from 'node:path'
  9. import {
  10. CombinedAutocompleteProvider,
  11. Container,
  12. Editor,
  13. Input,
  14. Key,
  15. Loader,
  16. Markdown,
  17. Spacer,
  18. Text,
  19. TUI,
  20. ProcessTerminal,
  21. SelectList,
  22. matchesKey,
  23. truncateToWidth,
  24. visibleWidth,
  25. wrapTextWithAnsi,
  26. type Component,
  27. type AutocompleteItem,
  28. type AutocompleteProvider,
  29. type AutocompleteSuggestions,
  30. type EditorTheme,
  31. type Focusable,
  32. type MarkdownTheme,
  33. type SelectListTheme,
  34. type SlashCommand,
  35. type Terminal,
  36. type TerminalColorScheme,
  37. } from '@earendil-works/pi-tui'
  38. import { Service, type Context, type Fiber } from 'cordis'
  39. import z from 'schemastery'
  40. import {
  41. installAgentLlmTarget,
  42. type Agent,
  43. type AgentMessageId,
  44. type AgentLlmTarget,
  45. type AgentLlmTargetRef,
  46. type AgentStatus,
  47. } from '@deepseek-ai/dsh-agent'
  48. import type {} from '@deepseek-ai/dsh-agent-loop'
  49. import type {} from '@deepseek-ai/dsh-token-meter'
  50. import type {} from '@deepseek-ai/dsh-commands'
  51. import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
  52. import type {
  53. ContentBlock,
  54. LlmModelInfo,
  55. StreamChunk,
  56. TokenUsage,
  57. } from '@deepseek-ai/dsh-llm'
  58. import type {} from '@deepseek-ai/dsh-llm-retry'
  59. import {
  60. SessionId,
  61. type JsonValue,
  62. type Session,
  63. type SessionEvent,
  64. type SessionHeader,
  65. type TodoItem,
  66. } from '@deepseek-ai/dsh-session'
  67. import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
  68. import {
  69. formatSessionReferenceMention,
  70. parseSessionReferenceText,
  71. type SessionReferenceService,
  72. } from '@deepseek-ai/dsh-session-reference'
  73. import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
  74. import type {
  75. SessionLogSnapshot,
  76. SessionRecord,
  77. } from '@deepseek-ai/dsh-session-query'
  78. // Type import also declaration-merges the optional `sessionPersistence`
  79. // service onto `Context` so `ctx.get('sessionPersistence')` is typed.
  80. import type {} from '@deepseek-ai/dsh-session-persistence'
  81. import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill'
  82. import type {
  83. FileDiff,
  84. TerminalCallView,
  85. ToolCallView,
  86. ToolDefinition,
  87. ToolResultView,
  88. } from '@deepseek-ai/dsh-tools'
  89. import {
  90. UserInteractionError,
  91. type AskUserQuestionAnswer,
  92. type AskUserQuestionAnswerItem,
  93. type AskUserQuestionItem,
  94. type AskUserQuestionRequest,
  95. } from '@deepseek-ai/dsh-user-interaction'
  96. import {
  97. TuiExtensionServiceImpl,
  98. TuiOverlayManager,
  99. } from './overlay-manager.ts'
  100. import type {
  101. TuiOverlayRequest,
  102. TuiOverlaySession,
  103. TuiTheme,
  104. } from './extension.ts'
  105. export type {
  106. TuiComponent,
  107. TuiFocusable,
  108. TuiOverlayAnchor,
  109. TuiOverlayCloseReason,
  110. TuiOverlayHost,
  111. TuiOverlayMargin,
  112. TuiOverlayOptions,
  113. TuiOverlayOutcome,
  114. TuiOverlayRequest,
  115. TuiOverlaySession,
  116. TuiOverlayState,
  117. TuiTheme,
  118. TuiViewport,
  119. } from './extension.ts'
  120. declare module 'cordis' {
  121. interface Context {
  122. /** Terminal-only interaction service, available only while a TUI is mounted. */
  123. tui: TuiExtensionService
  124. /** Optional process host that can replace this TUI with a resumed session. */
  125. tuiResumeHost: TuiResumeHost
  126. }
  127. }
  128. /** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
  129. export interface TuiResumeHost {
  130. /**
  131. * Dispose the current app and replace it with a runtime for `sessionId`.
  132. * Success does not return. A host may reject before it commits teardown;
  133. * after commit it owns fatal reporting and process exit.
  134. * @param sessionId - validated persisted session selected by the user.
  135. */
  136. handoff(sessionId: SessionId): Promise<never>
  137. }
  138. /**
  139. * Optional terminal-local interaction service provided by one mounted TUI.
  140. *
  141. * The concrete provider retains pi-tui, focus, and terminal lifecycle state.
  142. * Plugins receive only effect-owned overlay sessions.
  143. */
  144. export abstract class TuiExtensionService extends Service {
  145. /** Exact agent driven by this terminal instance. */
  146. abstract readonly agent: Agent
  147. /**
  148. * Queue an interactive overlay owned by the calling plugin fiber.
  149. *
  150. * The TUI displays one overlay at a time in FIFO order. Disposing the caller
  151. * removes a queued overlay or closes an active one before plugin teardown
  152. * settles. This live presentation is neither logged nor replayed.
  153. *
  154. * @param request - component factory, layout constraints, and cancellation.
  155. * @returns the effect-owned overlay session.
  156. * @throws when the TUI has begun shutting down.
  157. */
  158. abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
  159. }
  160. import {
  161. activeAtToken,
  162. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  163. DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  164. DEFAULT_FILE_SEARCH_MAX_RESULTS,
  165. formatFileMention,
  166. WorkspaceFileSearch,
  167. } from './file-autocomplete.ts'
  168. export {
  169. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  170. DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  171. DEFAULT_FILE_SEARCH_MAX_RESULTS,
  172. } from './file-autocomplete.ts'
  173. export const name = 'ui-tui'
  174. export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
  175. /** Model guidance for path-only file references selected through the TUI. */
  176. 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.'
  177. /** Interaction and presentation settings for the pi-tui terminal mode. */
  178. export interface TuiConfig {
  179. /** Render model reasoning blocks. */
  180. showReasoning?: boolean
  181. /** Maximum tool-card body lines retained in its collapsed head/tail preview. */
  182. maxToolOutputLines?: number
  183. /** Maximum options visible at once in a user-question panel. */
  184. maxQuestionOptions?: number
  185. /** Maximum models visible at once in the model selector. */
  186. maxModelOptions?: number
  187. /** Maximum sessions visible at once in the resume selector. */
  188. maxResumeOptions?: number
  189. /** User-question panel width in terminal columns, clamped to the terminal. */
  190. questionDialogWidth?: number
  191. /** User-question panel maximum height in terminal rows. */
  192. questionDialogMaxHeight?: number
  193. /** Model-selector width in terminal columns. */
  194. modelDialogWidth?: number
  195. /** Model-selector maximum height in terminal rows. */
  196. modelDialogMaxHeight?: number
  197. /** Maximum fuzzy file candidates displayed for one `@` query. */
  198. fileSearchMaxResults?: number
  199. /** Maximum paths retained in one `@` workspace index. */
  200. fileSearchMaxEntries?: number
  201. /** Directory basenames excluded from `@` traversal and completion. */
  202. fileSearchExcludedDirectories?: string[]
  203. /** Show the terminal's hardware cursor at the pi editor's IME marker. */
  204. showHardwareCursor?: boolean
  205. /** Apply the built-in ANSI color palette. */
  206. color?: boolean
  207. /**
  208. * Paint the startup banner's product name in the DeepSeek brand gradient
  209. * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the
  210. * flat accent color when either is off. Unset auto-detects `COLORTERM` at the
  211. * process boundary, so most deployments leave it unset.
  212. */
  213. truecolor?: boolean
  214. /** Terminal window title while the UI is mounted; a logged session title prefixes it. */
  215. title?: string
  216. }
  217. const showReasoningSchema = z.boolean().default(true)
  218. const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
  219. const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
  220. const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
  221. const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
  222. const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
  223. const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
  224. const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
  225. const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
  226. const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
  227. const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
  228. const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
  229. const showHardwareCursorSchema = z.boolean().default(false)
  230. const colorSchema = z.boolean().default(true)
  231. // No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
  232. const truecolorSchema = z.boolean()
  233. const titleSchema = z.string().default('DeepSeek Harness')
  234. const tuiConfigSchemaFields = {
  235. showReasoning: showReasoningSchema,
  236. maxToolOutputLines: maxToolOutputLinesSchema,
  237. maxQuestionOptions: maxQuestionOptionsSchema,
  238. maxModelOptions: maxModelOptionsSchema,
  239. maxResumeOptions: maxResumeOptionsSchema,
  240. questionDialogWidth: questionDialogWidthSchema,
  241. questionDialogMaxHeight: questionDialogMaxHeightSchema,
  242. modelDialogWidth: modelDialogWidthSchema,
  243. modelDialogMaxHeight: modelDialogMaxHeightSchema,
  244. fileSearchMaxResults: fileSearchMaxResultsSchema,
  245. fileSearchMaxEntries: fileSearchMaxEntriesSchema,
  246. fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
  247. showHardwareCursor: showHardwareCursorSchema,
  248. color: colorSchema,
  249. truecolor: truecolorSchema,
  250. title: titleSchema,
  251. }
  252. /** Schemastery schema for presentation settings embedded by app bundles. */
  253. export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
  254. /** Serializable plugin configuration. */
  255. export interface Config extends TuiConfig {
  256. /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
  257. welcome?: string
  258. /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
  259. sessionId?: string
  260. /**
  261. * Shell command fallback printed on exit or after selecting a session when
  262. * the host cannot hand off in place. Every `{session}` becomes the selected
  263. * id; the TUI never executes this text. Absent disables only the fallback,
  264. * not the interactive selector.
  265. */
  266. resumeCommand?: string
  267. }
  268. export const Config: z<Config> = z.object({
  269. welcome: z.string(),
  270. sessionId: z.string().default('main'),
  271. resumeCommand: z.string(),
  272. showReasoning: tuiConfigSchemaFields.showReasoning,
  273. maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
  274. maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
  275. maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
  276. maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
  277. questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
  278. questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
  279. modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
  280. modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
  281. fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
  282. fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
  283. fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
  284. showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
  285. color: tuiConfigSchemaFields.color,
  286. truecolor: tuiConfigSchemaFields.truecolor,
  287. title: tuiConfigSchemaFields.title,
  288. })
  289. /** Fully defaulted TUI presentation settings. */
  290. export interface ResolvedTuiConfig {
  291. showReasoning: boolean
  292. maxToolOutputLines: number
  293. maxQuestionOptions: number
  294. maxModelOptions: number
  295. maxResumeOptions: number
  296. questionDialogWidth: number
  297. questionDialogMaxHeight: number
  298. modelDialogWidth: number
  299. modelDialogMaxHeight: number
  300. fileSearchMaxResults: number
  301. fileSearchMaxEntries: number
  302. fileSearchExcludedDirectories: string[]
  303. showHardwareCursor: boolean
  304. color: boolean
  305. truecolor: boolean
  306. title: string
  307. }
  308. /** Runtime boundary used by the interactive TUI. */
  309. export interface TuiRuntime {
  310. /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
  311. terminal: Terminal
  312. /** Exit hook used by terminal shutdown or a target-agent startup failure. */
  313. exit(code: number): void
  314. /**
  315. * Override the footer's logical working-directory label without changing the session directory used by tools.
  316. * @param cwd - Operational working directory from the session header.
  317. * @returns Unescaped label; the TUI makes terminal controls visible.
  318. */
  319. formatCwd?: (cwd: string | undefined) => string
  320. /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
  321. now?(): number
  322. /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
  323. handoffResume?: TuiResumeHost['handoff']
  324. }
  325. /**
  326. * Apply direct-call defaults after Loader schema validation has normally run.
  327. *
  328. * @param config - Deployment-provided terminal presentation settings.
  329. * @returns Complete settings consumed by the TUI renderer.
  330. */
  331. export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
  332. return {
  333. showReasoning: config?.showReasoning ?? true,
  334. maxToolOutputLines: config?.maxToolOutputLines ?? 6,
  335. maxQuestionOptions: config?.maxQuestionOptions ?? 8,
  336. maxModelOptions: config?.maxModelOptions ?? 8,
  337. maxResumeOptions: config?.maxResumeOptions ?? 8,
  338. questionDialogWidth: config?.questionDialogWidth ?? 200,
  339. questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
  340. modelDialogWidth: config?.modelDialogWidth ?? 72,
  341. modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
  342. fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
  343. fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  344. fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
  345. showHardwareCursor: config?.showHardwareCursor ?? false,
  346. color: config?.color ?? true,
  347. truecolor: config?.truecolor ?? false,
  348. title: config?.title ?? 'DeepSeek Harness',
  349. }
  350. }
  351. interface Palette {
  352. accent: (text: string) => string
  353. accent2: (text: string) => string
  354. text: (text: string) => string
  355. muted: (text: string) => string
  356. dim: (text: string) => string
  357. success: (text: string) => string
  358. warning: (text: string) => string
  359. error: (text: string) => string
  360. code: (text: string) => string
  361. added: (text: string) => string
  362. removed: (text: string) => string
  363. bold: (text: string) => string
  364. italic: (text: string) => string
  365. underline: (text: string) => string
  366. strike: (text: string) => string
  367. /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
  368. selected: (text: string) => string
  369. }
  370. function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
  371. return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
  372. }
  373. const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
  374. const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
  375. const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
  376. const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
  377. const BRACKETED_PASTE_START = '\u001B[200~'
  378. const BRACKETED_PASTE_END = '\u001B[201~'
  379. /**
  380. * Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
  381. * Line feeds remain structural so transcript and tool output retain their layout.
  382. */
  383. function displayText(text: string): string {
  384. return text.replace(TERMINAL_CONTROL_PATTERN, control =>
  385. `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
  386. }
  387. /** Escape external controls for terminal fields that must remain on one line. */
  388. function displayInlineText(text: string): string {
  389. return displayText(text).replaceAll('\n', '\\x0a')
  390. }
  391. /** Remove terminal controls from clipboard text before an editable field stores it. */
  392. function sanitizePastedText(text: string): string {
  393. return text
  394. .replace(TERMINAL_OSC_PATTERN, '')
  395. .replace(TERMINAL_CSI_PATTERN, '')
  396. .replace(TERMINAL_ESCAPE_PATTERN, '')
  397. .replace(TERMINAL_CONTROL_PATTERN, '')
  398. }
  399. /**
  400. * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
  401. * attributes, which every terminal remaps to its active color scheme. Body
  402. * `text` stays the terminal's default foreground so it reads on light and dark
  403. * backgrounds alike; grouping uses foreground-only gutter bars and reverse
  404. * video rather than fixed background fills.
  405. */
  406. function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
  407. return {
  408. accent: ansi('94', '39', enabled),
  409. accent2: ansi('95', '39', enabled),
  410. text: text => text,
  411. muted: ansi('90', '39', enabled),
  412. // SGR 2 (dim) lightens text on a light background — substitute ANSI 90
  413. // (bright black / gray) which renders as a readable muted tone on any scheme.
  414. dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
  415. success: ansi('32', '39', enabled),
  416. warning: ansi('33', '39', enabled),
  417. error: ansi('31', '39', enabled),
  418. // ANSI 36 (cyan) is difficult to read on a light background — use
  419. // ANSI 34 (blue) which is legible on both light and dark schemes.
  420. code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
  421. added: ansi('32', '39', enabled),
  422. removed: ansi('31', '39', enabled),
  423. bold: ansi('1', '22', enabled),
  424. italic: ansi('3', '23', enabled),
  425. underline: ansi('4', '24', enabled),
  426. strike: ansi('9', '29', enabled),
  427. selected: ansi('7', '27', enabled),
  428. }
  429. }
  430. /**
  431. * DeepSeek brand gradient stops (indigo → light blue) taken from the
  432. * deepseek.com logo, painted across the startup banner's product name on
  433. * truecolor terminals. Fixed brand identity, deliberately outside the
  434. * theme-adaptive {@link Palette}.
  435. */
  436. const BRAND_GRADIENT = [
  437. [77, 107, 254], // #4D6BFE
  438. [57, 130, 255], // #3982FF
  439. [36, 152, 255], // #2498FF
  440. ] as const
  441. /**
  442. * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
  443. * interpolation across its stops.
  444. *
  445. * @param t - Position along the gradient; clamped to [0, 1].
  446. * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255.
  447. */
  448. function brandColorAt(t: number): readonly [number, number, number] {
  449. const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1)
  450. const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2)
  451. const local = span - index
  452. // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds.
  453. const from = BRAND_GRADIENT[index] as readonly [number, number, number]
  454. const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number]
  455. return [
  456. Math.round(from[0] + (to[0] - from[0]) * local),
  457. Math.round(from[1] + (to[1] - from[1]) * local),
  458. Math.round(from[2] + (to[2] - from[2]) * local),
  459. ]
  460. }
  461. /**
  462. * Paint `text` left-to-right in the DeepSeek brand gradient with per-character
  463. * 24-bit foreground codes, resetting to the default foreground at the end.
  464. * Foreground-only, so it stays legible on any terminal background; the caller
  465. * gates it on truecolor support and wraps it in bold.
  466. *
  467. * @param text - Text to colorize; sampled once per character.
  468. * @returns `text` wrapped in truecolor SGR foreground codes.
  469. */
  470. function gradientText(text: string): string {
  471. // The sole caller passes the ASCII product name, so UTF-16 unit iteration
  472. // samples exactly one color per visible letter.
  473. const last = Math.max(1, text.length - 1)
  474. let painted = ''
  475. for (let index = 0; index < text.length; index += 1) {
  476. const [r, g, b] = brandColorAt(index / last)
  477. painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
  478. }
  479. return `${painted}\x1b[39m`
  480. }
  481. function markdownTheme(palette: Palette): MarkdownTheme {
  482. return {
  483. heading: text => palette.accent(text),
  484. link: text => palette.accent(text),
  485. // pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
  486. /* v8 ignore next */
  487. linkUrl: text => palette.dim(text),
  488. code: text => palette.code(text),
  489. codeBlock: text => palette.text(text),
  490. codeBlockBorder: text => palette.dim(text),
  491. quote: text => palette.muted(text),
  492. quoteBorder: text => palette.accent2(text),
  493. hr: text => palette.dim(text),
  494. listBullet: text => palette.accent(text),
  495. bold: text => palette.bold(text),
  496. italic: text => palette.italic(text),
  497. strikethrough: text => palette.strike(text),
  498. underline: text => palette.underline(text),
  499. }
  500. }
  501. function selectTheme(palette: Palette): SelectListTheme {
  502. return {
  503. selectedPrefix: palette.accent,
  504. selectedText: palette.accent,
  505. description: palette.muted,
  506. scrollInfo: palette.dim,
  507. noMatch: palette.warning,
  508. }
  509. }
  510. function dialogSelectTheme(palette: Palette): SelectListTheme {
  511. return {
  512. ...selectTheme(palette),
  513. selectedText: text => palette.selected(palette.accent(text)),
  514. }
  515. }
  516. function contentText(content: readonly ContentBlock[]): string {
  517. const parts: string[] = []
  518. for (const block of content) {
  519. switch (block.type) {
  520. case 'text':
  521. case 'reasoning':
  522. parts.push(block.text)
  523. break
  524. case 'tool-call':
  525. parts.push(`${block.name}(${block.arguments})`)
  526. break
  527. case 'tool-result':
  528. parts.push(contentText(block.content))
  529. break
  530. default: {
  531. const rawType = (block as { type?: unknown }).type
  532. parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
  533. break
  534. }
  535. }
  536. }
  537. return parts.join('')
  538. }
  539. function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
  540. return content
  541. .filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
  542. .map(block => block.text)
  543. .join('\n\n')
  544. }
  545. interface ModelChoice extends AgentLlmTarget {
  546. modelName: string
  547. description?: string
  548. }
  549. function targetLabel(target: AgentLlmTarget): string {
  550. return `${target.provider}/${target.model}`
  551. }
  552. function initialTarget(agent: Agent): AgentLlmTarget | undefined {
  553. const logged = agent.session.requestHeader()?.config
  554. if (logged !== undefined) return { provider: logged.provider, model: logged.model }
  555. if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
  556. return { provider: agent.options.provider, model: agent.options.model }
  557. }
  558. async function readModelChoices(
  559. ctx: Context,
  560. current: AgentLlmTarget | undefined,
  561. ): Promise<ModelChoice[]> {
  562. const providers = ctx.llm.listProviders()
  563. const groups = await Promise.all(providers.map(async (provider) => {
  564. const advertised = await ctx.llm.listModels(provider.id)
  565. const models: LlmModelInfo[] = [...advertised]
  566. if (
  567. current?.provider === provider.id
  568. && !models.some(model => model.id === current.model)
  569. ) {
  570. models.push({ provider: provider.id, id: current.model, name: current.model })
  571. }
  572. return models.map((model): ModelChoice => ({
  573. provider: provider.id,
  574. model: model.id,
  575. modelName: model.name,
  576. ...model.description === undefined ? {} : { description: model.description },
  577. }))
  578. }))
  579. return groups.flat()
  580. }
  581. /** Milliseconds between banner sweep-reveal frames (~60 fps). */
  582. const BANNER_REVEAL_INTERVAL_MS = 15
  583. /** Number of sweep frames the banner reveal spreads the terminal width over. */
  584. const BANNER_REVEAL_STEPS = 24
  585. /**
  586. * Borderless startup banner: product title, an optional configured subtitle,
  587. * and the model/session detail line. No box frame — each line renders as plain
  588. * left-padded text (matching transcript notices) so it reads on any theme.
  589. */
  590. class HeaderComponent implements Component {
  591. /** Columns of the banner currently revealed; `undefined` renders it whole. */
  592. private revealWidth: number | undefined
  593. constructor(
  594. private readonly agent: Agent,
  595. private readonly subtitle: () => string | undefined,
  596. private readonly palette: Palette,
  597. private readonly gradient: boolean,
  598. private readonly currentModel: () => string | undefined,
  599. ) {}
  600. /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */
  601. setRevealWidth(width: number | undefined): void {
  602. this.revealWidth = width
  603. }
  604. invalidate(): void {}
  605. render(width: number): string[] {
  606. const usable = Math.max(1, width - 2)
  607. const name = this.gradient
  608. ? this.palette.bold(gradientText('DEEPSEEK'))
  609. : this.palette.bold(this.palette.accent('DEEPSEEK'))
  610. const title = `${name} ${this.palette.bold('HARNESS')}`
  611. const model = displayText(this.currentModel() ?? 'model unset')
  612. const detail = `${model} • ${displayText(this.agent.session.id)}`
  613. const subtitle = this.subtitle()
  614. const lines = [
  615. title,
  616. ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
  617. this.palette.dim(detail),
  618. ]
  619. .flatMap(line => wrapTextWithAnsi(line, usable))
  620. .map(line => ` ${truncateToWidth(line, usable, '')}`)
  621. if (this.revealWidth === undefined) return lines
  622. const revealed = this.revealWidth
  623. return lines.map(line => truncateToWidth(line, revealed, ''))
  624. }
  625. }
  626. /** Milliseconds between elapsed-time refreshes of the running status line. */
  627. const STATUS_ELAPSED_INTERVAL_MS = 1000
  628. /** Steering/cancel affordance shown on every running status line. */
  629. const STATUS_HINT = 'Enter sends steering, Esc cancels'
  630. /**
  631. * Fine-grained activity of a running turn, derived in the TUI from session
  632. * lifecycle events for the status line. It is presentation-only, not a durable
  633. * agent state: `waiting` spans a step from its `step/start` until the first
  634. * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas,
  635. * and `executing` covers tool calls until the next step begins.
  636. */
  637. type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing'
  638. /**
  639. * Live controller for the running status line: its {@link Loader}, the derived
  640. * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer
  641. * that refreshes it. Present only while the turn runs; `undefined` when idle.
  642. */
  643. interface RunningStatus {
  644. loader: Loader
  645. phase: TurnPhase
  646. phaseStartedAt: number
  647. stepStartedAt: number
  648. timer: ReturnType<typeof setInterval>
  649. }
  650. /** Status-line label for each {@link TurnPhase}. */
  651. const TURN_PHASE_LABELS: Record<TurnPhase, string> = {
  652. waiting: 'Waiting for the first token',
  653. thinking: 'Thinking',
  654. responding: 'Responding',
  655. executing: 'Executing tools',
  656. }
  657. /**
  658. * Format a non-negative elapsed span as a compact status duration: whole
  659. * seconds under a minute (`8s`), else minutes and zero-padded seconds
  660. * (`1m05s`).
  661. * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero.
  662. * @returns The compact duration string.
  663. */
  664. function formatStatusDuration(elapsedMs: number): string {
  665. const total = Math.floor(Math.max(0, elapsedMs) / 1000)
  666. if (total < 60) return `${total}s`
  667. return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s`
  668. }
  669. /**
  670. * Compose the running status-line text from the current phase, its timers, and
  671. * the queued-steering badge. The waiting phase spans the whole step so it shows
  672. * one duration; later phases show time in the phase plus the running step
  673. * total, and a non-zero `queued` count surfaces as a badge before the hint.
  674. * @param phase - The current turn phase.
  675. * @param phaseMs - Elapsed time in the current phase, in milliseconds.
  676. * @param stepMs - Elapsed time in the current step, in milliseconds.
  677. * @param queued - Count of pending steering messages; zero hides the badge.
  678. * @returns The status-line text, including the steering/cancel hint.
  679. */
  680. function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string {
  681. const timing = phase === 'waiting'
  682. ? formatStatusDuration(stepMs)
  683. : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}`
  684. const badge = queued > 0 ? `${queued} queued · ` : ''
  685. return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}`
  686. }
  687. /**
  688. * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so
  689. * it renders legibly on any terminal background — unlike a filled block whose
  690. * body text would collide with the theme's default foreground.
  691. */
  692. class GutterBox implements Component {
  693. protected readonly children: Component[] = []
  694. constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {}
  695. addChild(child: Component): void {
  696. this.children.push(child)
  697. }
  698. invalidate(): void {
  699. for (const child of this.children) child.invalidate()
  700. }
  701. render(width: number): string[] {
  702. const inner = Math.max(1, width - 2)
  703. const body: string[] = []
  704. for (const child of this.children) for (const line of child.render(inner)) body.push(line)
  705. // Every caller adds a non-empty title/label child, so an all-empty box is unreachable;
  706. // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars.
  707. /* v8 ignore next */
  708. if (body.length === 0) return []
  709. const bar = this.barFn('▌')
  710. const pad = Array.from({ length: this.paddingY }, () => '')
  711. return [...pad, ...body, ...pad].map(line => `${bar} ${line}`)
  712. }
  713. }
  714. class UserMessageComponent extends GutterBox {
  715. constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
  716. super(value => palette.accent(value))
  717. this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0))
  718. this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
  719. preserveOrderedListMarkers: true,
  720. preserveBackslashEscapes: true,
  721. }))
  722. }
  723. }
  724. class AssistantMessageComponent extends Container {
  725. constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
  726. super()
  727. const reasoning = displayText(textBlocks(content, 'reasoning').trim())
  728. const text = displayText(textBlocks(content, 'text').trim())
  729. if (reasoning && showReasoning) {
  730. this.addChild(new Spacer(1))
  731. this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
  732. this.addChild(new Markdown(reasoning, 1, 0, mdTheme, {
  733. color: value => palette.muted(value),
  734. italic: true,
  735. }))
  736. }
  737. if (text) {
  738. this.addChild(new Spacer(1))
  739. this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0))
  740. this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) }))
  741. }
  742. }
  743. }
  744. interface StreamingBlock {
  745. type: string
  746. text: string
  747. }
  748. class StreamingAssistantComponent extends Container {
  749. private readonly blocks = new Map<number, StreamingBlock>()
  750. constructor(
  751. private showReasoning: boolean,
  752. private readonly palette: Palette,
  753. private readonly mdTheme: MarkdownTheme,
  754. ) {
  755. super()
  756. }
  757. update(chunk: StreamChunk): void {
  758. if (chunk.type === 'block-start') {
  759. this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
  760. } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
  761. const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
  762. const block = this.blocks.get(chunk.index) ?? { type, text: '' }
  763. block.text += chunk.text
  764. this.blocks.set(chunk.index, block)
  765. } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
  766. this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
  767. }
  768. this.rebuild()
  769. }
  770. setShowReasoning(show: boolean): void {
  771. this.showReasoning = show
  772. this.rebuild()
  773. }
  774. private rebuild(): void {
  775. this.clear()
  776. const content: ContentBlock[] = [...this.blocks.entries()]
  777. .sort(([left], [right]) => left - right)
  778. .flatMap<ContentBlock>(([, block]) => {
  779. if (block.type === 'text') return [{ type: 'text', text: block.text }]
  780. if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
  781. return []
  782. })
  783. const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme)
  784. for (const child of component.children) this.addChild(child)
  785. }
  786. }
  787. interface ParsedArguments {
  788. value: unknown
  789. valid: boolean
  790. }
  791. function parseArguments(raw: string): ParsedArguments {
  792. try {
  793. return { value: JSON.parse(raw), valid: true }
  794. } catch {
  795. return { value: raw, valid: false }
  796. }
  797. }
  798. function pretty(value: unknown): string {
  799. if (typeof value === 'string') return displayText(value)
  800. // The lib declaration narrows `unknown` to a string-returning overload, but
  801. // JSON.stringify returns undefined for runtime values such as symbols.
  802. const serialized = JSON.stringify(value, null, 2) as string | undefined
  803. return displayText(serialized ?? String(value))
  804. }
  805. function diffLines(diff: FileDiff, palette: Palette): string[] {
  806. const lines = [palette.bold(displayText(diff.path))]
  807. if (diff.oldText !== null) {
  808. for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
  809. }
  810. for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
  811. return lines
  812. }
  813. class ToolCardComponent implements Component {
  814. private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
  815. private expanded = false
  816. private callView: ToolCallView
  817. private resultView: ToolResultView | undefined
  818. constructor(
  819. private readonly name: string,
  820. private readonly parsed: ParsedArguments,
  821. private readonly definition: ToolDefinition | undefined,
  822. private readonly maxOutputLines: number,
  823. private readonly palette: Palette,
  824. ) {
  825. this.callView = this.presentCall()
  826. }
  827. private presentCall(): ToolCallView {
  828. if (this.parsed.valid && this.definition?.presentCall) {
  829. try {
  830. const view = this.definition.presentCall(this.parsed.value)
  831. if (view !== undefined) return view
  832. } catch (error: unknown) {
  833. return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
  834. }
  835. }
  836. return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
  837. }
  838. updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
  839. this.result = {
  840. content: [...event.content],
  841. isError: event.isError,
  842. ...event.meta !== undefined ? { meta: event.meta } : {},
  843. }
  844. if (this.parsed.valid && this.definition?.presentResult) {
  845. try {
  846. const view = this.definition.presentResult(this.parsed.value, this.result)
  847. if (view !== undefined) this.resultView = view
  848. } catch (error: unknown) {
  849. this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
  850. }
  851. }
  852. }
  853. setExpanded(expanded: boolean): void {
  854. this.expanded = expanded
  855. }
  856. invalidate(): void {}
  857. render(width: number): string[] {
  858. const isError = this.result?.isError ?? false
  859. const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
  860. const body = this.renderBody()
  861. const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
  862. const headLines = Math.ceil(this.maxOutputLines / 2)
  863. const tailLines = this.maxOutputLines - headLines
  864. const visibleBody = this.expanded || body.length <= this.maxOutputLines
  865. ? body
  866. : [
  867. ...body.slice(0, headLines),
  868. this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
  869. ...body.slice(body.length - tailLines),
  870. ]
  871. const barFn = this.result === undefined
  872. ? this.palette.warning
  873. : isError ? this.palette.error : this.palette.success
  874. const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0)
  875. box.addChild(new Text(this.palette.bold(title), 0, 0))
  876. if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0))
  877. return box.render(width)
  878. }
  879. private title(): string {
  880. return this.resultView?.title ?? this.callView.title
  881. }
  882. private renderBody(): string[] {
  883. const view = this.resultView ?? this.callView
  884. if (view.card === 'terminal') {
  885. const pending = this.callView.card === 'terminal' ? this.callView : undefined
  886. const lines: string[] = []
  887. if (pending?.description) lines.push(this.palette.muted(displayText(pending.description)))
  888. if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd)))
  889. if (this.resultView?.card === 'terminal') {
  890. if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
  891. if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
  892. if (this.resultView.signal !== undefined) {
  893. lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
  894. }
  895. } else if (this.result === undefined) {
  896. // A pending terminal view is the call view itself; TerminalCallView requires a title.
  897. lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`))
  898. } else {
  899. lines.push(...displayText(contentText(this.result.content)).split('\n'))
  900. }
  901. return lines.filter(Boolean)
  902. }
  903. if (view.card === 'diff') {
  904. return view.diffs.flatMap((diff, index) => [
  905. ...index > 0 ? [''] : [],
  906. ...diffLines(diff, this.palette),
  907. ])
  908. }
  909. const content = view.content ?? this.result?.content
  910. const lines: string[] = []
  911. if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
  912. const rawInput = this.result === undefined && this.callView.card === 'generic'
  913. ? this.callView.rawInput
  914. : undefined
  915. if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
  916. return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
  917. }
  918. }
  919. class TodoComponent implements Component {
  920. private todos: readonly TodoItem[] = []
  921. constructor(private readonly palette: Palette) {}
  922. update(todos: readonly TodoItem[]): void {
  923. this.todos = todos
  924. }
  925. invalidate(): void {}
  926. render(width: number): string[] {
  927. if (this.todos.length === 0) return []
  928. const lines = [this.palette.bold(this.palette.accent('Plan'))]
  929. for (const todo of this.todos) {
  930. const prefix = todo.status === 'completed'
  931. ? this.palette.success('✓')
  932. : todo.status === 'in_progress'
  933. ? this.palette.warning('●')
  934. : this.palette.dim('○')
  935. const content = displayText(todo.content)
  936. const text = todo.status === 'completed' ? this.palette.muted(content) : content
  937. lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
  938. }
  939. return ['', ...lines]
  940. }
  941. }
  942. function formatTokens(value: number): string {
  943. if (value < 1_000) return String(value)
  944. if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
  945. if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
  946. return `${(value / 1_000_000).toFixed(1)}m`
  947. }
  948. function formatCwd(cwd: string | undefined): string {
  949. if (cwd === undefined) return 'cwd unset'
  950. const home = homedir()
  951. const rel = relative(resolve(home), resolve(cwd))
  952. if (rel === '') return '~'
  953. /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
  954. if (isAbsolute(rel)) return cwd
  955. if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
  956. return cwd
  957. }
  958. /**
  959. * Running token totals for the footer, keyed per turn/step so replayed or
  960. * re-emitted usage replaces rather than double-counts; `input` is uncached
  961. * input, cache buckets are disjoint.
  962. */
  963. interface SessionTokenTotals {
  964. input: number
  965. output: number
  966. cacheRead: number
  967. cacheWrite: number
  968. readonly byStep: Map<string, TokenUsage>
  969. }
  970. function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
  971. const key = `${turn}:${step}`
  972. const previous = totals.byStep.get(key)
  973. if (previous !== undefined) {
  974. totals.input -= previous.inputTokens
  975. totals.output -= previous.outputTokens
  976. totals.cacheRead -= previous.cacheReadTokens ?? 0
  977. totals.cacheWrite -= previous.cacheWriteTokens ?? 0
  978. }
  979. totals.byStep.set(key, usage)
  980. totals.input += usage.inputTokens
  981. totals.output += usage.outputTokens
  982. totals.cacheRead += usage.cacheReadTokens ?? 0
  983. totals.cacheWrite += usage.cacheWriteTokens ?? 0
  984. }
  985. function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
  986. if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
  987. recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
  988. } else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
  989. recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
  990. }
  991. }
  992. /**
  993. * Share of billed input (prompt) tokens served from the provider cache, as an
  994. * integer percent, or `undefined` before any input is billed (avoids 0/0 and a
  995. * meaningless rate on an empty session).
  996. */
  997. function cacheHitRate(totals: SessionTokenTotals): number | undefined {
  998. const billedInput = totals.input + totals.cacheRead + totals.cacheWrite
  999. if (billedInput === 0) return undefined
  1000. return Math.round((totals.cacheRead / billedInput) * 100)
  1001. }
  1002. function sessionTokens(session: Session): SessionTokenTotals {
  1003. const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() }
  1004. for (const event of session.events) {
  1005. recordEventUsage(totals, event)
  1006. }
  1007. return totals
  1008. }
  1009. function formatDiagnosticNumber(value: number): string {
  1010. return value.toLocaleString('en-US')
  1011. }
  1012. function formatDiagnosticTime(value: number): string {
  1013. return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC')
  1014. }
  1015. function formatDiagnosticCount(value: number, singular: string): string {
  1016. return `${String(value)} ${singular}${value === 1 ? '' : 's'}`
  1017. }
  1018. function diagnosticMeter(percent: number, palette: Palette): string {
  1019. const width = 16
  1020. const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width)
  1021. return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}`
  1022. }
  1023. type StatusCardRow = readonly [label: string, value: string]
  1024. /** Bordered, grouped field card for one point-in-time status snapshot. */
  1025. class StatusCardComponent implements Component {
  1026. constructor(
  1027. private readonly groups: readonly (readonly StatusCardRow[])[],
  1028. private readonly palette: Palette,
  1029. ) {}
  1030. invalidate(): void {}
  1031. render(width: number): string[] {
  1032. const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`))
  1033. const naturalLabelWidth = Math.max(...labels.map(label => label.length))
  1034. const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) =>
  1035. 1 + naturalLabelWidth + 2 + visibleWidth(value))))
  1036. const cardWidth = Math.min(
  1037. Math.max(8, width),
  1038. Math.max('Session status'.length + 5, naturalBodyWidth + 4),
  1039. )
  1040. const innerWidth = Math.max(1, cardWidth - 4)
  1041. const labelWidth = Math.min(
  1042. naturalLabelWidth,
  1043. Math.max(1, Math.floor(innerWidth / 3)),
  1044. )
  1045. const body: string[] = []
  1046. for (const [groupIndex, group] of this.groups.entries()) {
  1047. if (groupIndex > 0) body.push('')
  1048. for (const [label, value] of group) {
  1049. const plainLabel = truncateToWidth(`${label}:`, labelWidth, '')
  1050. const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} `
  1051. const continuation = ' '.repeat(1 + labelWidth + 2)
  1052. const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
  1053. const wrapped = wrapTextWithAnsi(value, valueWidth)
  1054. for (const [lineIndex, line] of wrapped.entries()) {
  1055. body.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
  1056. }
  1057. }
  1058. }
  1059. const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '')
  1060. const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))
  1061. const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}`
  1062. const lines = [top]
  1063. for (const line of body) {
  1064. const clipped = truncateToWidth(line, innerWidth, '')
  1065. lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`)
  1066. }
  1067. lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`))
  1068. return lines
  1069. }
  1070. }
  1071. class FooterComponent implements Component {
  1072. constructor(
  1073. private readonly agent: Agent,
  1074. private readonly palette: Palette,
  1075. private readonly toolsExpanded: () => boolean,
  1076. private readonly tokens: () => SessionTokenTotals,
  1077. private readonly cwdFormatter: TuiRuntime['formatCwd'],
  1078. private readonly currentModel: () => string | undefined,
  1079. private readonly contextPercent: () => number | undefined,
  1080. ) {}
  1081. invalidate(): void {}
  1082. render(width: number): string[] {
  1083. const totals = this.tokens()
  1084. const model = displayText(this.currentModel() ?? 'model unset')
  1085. const rate = cacheHitRate(totals)
  1086. const cache = rate === undefined ? '' : ` cache ${rate}%`
  1087. const formattedCwd = displayText(
  1088. this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
  1089. )
  1090. const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}`
  1091. const contextPercent = this.contextPercent()
  1092. const context = contextPercent === undefined ? '' : `${contextPercent}% context `
  1093. const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}`
  1094. const leftStyled = this.palette.dim(left)
  1095. const available = Math.max(0, width - visibleWidth(left) - 2)
  1096. const rightClipped = truncateToWidth(right, available, '')
  1097. const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped)))
  1098. return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')]
  1099. }
  1100. }
  1101. interface QuestionSelection {
  1102. selected: string[]
  1103. custom?: string
  1104. }
  1105. function renderDialog(
  1106. title: string,
  1107. body: readonly string[],
  1108. width: number,
  1109. palette: Palette,
  1110. ): string[] {
  1111. const innerWidth = Math.max(1, width - 4)
  1112. const topLabel = ` ${displayText(title)} `
  1113. const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
  1114. const lines: string[] = [palette.accent(top)]
  1115. for (const line of body) {
  1116. const clipped = truncateToWidth(line, innerWidth, '')
  1117. lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
  1118. }
  1119. lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
  1120. return lines
  1121. }
  1122. class ModelDialog implements Component {
  1123. private readonly list: SelectList
  1124. constructor(
  1125. choices: readonly ModelChoice[],
  1126. current: AgentLlmTarget | undefined,
  1127. maxVisible: number,
  1128. private readonly palette: Palette,
  1129. done: (choice: ModelChoice) => void,
  1130. cancel: () => void,
  1131. ) {
  1132. this.list = new SelectList(choices.map(choice => ({
  1133. value: targetLabel(choice),
  1134. label: displayText(targetLabel(choice)),
  1135. description: [
  1136. displayText(choice.modelName),
  1137. ...choice.description === undefined ? [] : [displayText(choice.description)],
  1138. ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
  1139. ].join(' — '),
  1140. })), maxVisible, dialogSelectTheme(palette))
  1141. const currentIndex = current === undefined
  1142. ? 0
  1143. : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
  1144. this.list.setSelectedIndex(currentIndex)
  1145. this.list.onSelect = (item) => {
  1146. const selected = choices.find(choice => targetLabel(choice) === item.value)
  1147. /* v8 ignore next -- SelectList only returns values built from `choices`. */
  1148. if (selected === undefined) return
  1149. done(selected)
  1150. }
  1151. this.list.onCancel = cancel
  1152. }
  1153. invalidate(): void {
  1154. this.list.invalidate()
  1155. }
  1156. handleInput(data: string): void {
  1157. this.list.handleInput(data)
  1158. this.invalidate()
  1159. }
  1160. render(width: number): string[] {
  1161. const innerWidth = Math.max(1, width - 4)
  1162. return renderDialog('Select model', [
  1163. ...this.list.render(innerWidth),
  1164. '',
  1165. this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
  1166. ], width, this.palette)
  1167. }
  1168. }
  1169. interface ResumeRoute {
  1170. provider: string
  1171. model: string
  1172. }
  1173. interface ResumeCandidate {
  1174. record: SessionRecord
  1175. title: string
  1176. lastActivityAt: number
  1177. lastTurn: string
  1178. route?: ResumeRoute
  1179. goalPhase?: GoalPhase
  1180. disabledReason?: string
  1181. }
  1182. function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
  1183. const event = snapshot.events.findLast(item => item.type === 'turn/end')
  1184. if (event === undefined) return 'no completed turn'
  1185. const reason = event.data.reason
  1186. switch (reason.kind) {
  1187. case 'completed': return `turn ${event.data.turn}: completed`
  1188. case 'aborted': return `turn ${event.data.turn}: cancelled`
  1189. case 'error': return `turn ${event.data.turn}: error`
  1190. case 'disposed': return `turn ${event.data.turn}: disposed`
  1191. case 'max-tokens': return `turn ${event.data.turn}: max tokens`
  1192. case 'interrupted': return `turn ${event.data.turn}: interrupted`
  1193. default: return `turn ${event.data.turn}: unknown result`
  1194. }
  1195. }
  1196. function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
  1197. const header = snapshot.events.findLast(item => item.type === 'request/header')
  1198. if (header?.type === 'request/header') {
  1199. return { provider: header.data.header.config.provider, model: header.data.header.config.model }
  1200. }
  1201. const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
  1202. return assistant?.type === 'assistant/message'
  1203. ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
  1204. : undefined
  1205. }
  1206. function summarizeResumeCandidate(
  1207. record: SessionRecord,
  1208. snapshot: SessionLogSnapshot,
  1209. currentId: SessionId,
  1210. cwd: string | undefined,
  1211. availableProviders: ReadonlySet<string>,
  1212. ): ResumeCandidate {
  1213. const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
  1214. const route = resumeRoute(snapshot)
  1215. const foldedGoal = foldGoal(snapshot.events).goal
  1216. let disabledReason: string | undefined
  1217. if (record.header.id === currentId) disabledReason = 'current session'
  1218. else if (record.live) disabledReason = 'session is already live in this runtime'
  1219. else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
  1220. else if (route !== undefined && !availableProviders.has(route.provider)) {
  1221. disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
  1222. }
  1223. return {
  1224. record,
  1225. title,
  1226. lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
  1227. lastTurn: resumeTurnLabel(snapshot),
  1228. ...route === undefined ? {} : { route },
  1229. ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
  1230. ...disabledReason === undefined ? {} : { disabledReason },
  1231. }
  1232. }
  1233. /** Full-viewport keyboard selector over detached, preflighted resume summaries. */
  1234. class ResumePicker implements Component, Focusable {
  1235. private readonly search = new Input()
  1236. private pasteBuffer: string | undefined
  1237. private selectedIndex = 0
  1238. private error = ''
  1239. focused = false
  1240. constructor(
  1241. private readonly candidates: readonly ResumeCandidate[],
  1242. private readonly maxVisible: number,
  1243. private readonly workspaceLabel: string,
  1244. private readonly viewportRows: () => number,
  1245. private readonly palette: Palette,
  1246. private readonly done: (candidate: ResumeCandidate) => void,
  1247. private readonly cancel: () => void,
  1248. ) {}
  1249. invalidate(): void {
  1250. this.search.invalidate()
  1251. }
  1252. private filtered(): ResumeCandidate[] {
  1253. const query = this.search.getValue().trim().toLocaleLowerCase()
  1254. if (query === '') return [...this.candidates]
  1255. return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
  1256. || candidate.record.header.id.toLocaleLowerCase().includes(query))
  1257. }
  1258. private visibleCandidateCount(): number {
  1259. const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
  1260. return Math.min(this.maxVisible, candidateBudget)
  1261. }
  1262. private handleBracketedPaste(data: string): boolean {
  1263. const start = data.indexOf(BRACKETED_PASTE_START)
  1264. if (this.pasteBuffer === undefined && start < 0) return false
  1265. if (this.pasteBuffer === undefined) {
  1266. const prefix = data.slice(0, start)
  1267. if (prefix !== '') this.handleInput(prefix)
  1268. this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
  1269. } else {
  1270. this.pasteBuffer += data
  1271. }
  1272. const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
  1273. if (end < 0) return true
  1274. const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
  1275. const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
  1276. this.pasteBuffer = undefined
  1277. const previous = this.search.getValue()
  1278. this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
  1279. if (this.search.getValue() !== previous) {
  1280. this.selectedIndex = 0
  1281. this.error = ''
  1282. }
  1283. if (remaining !== '') this.handleInput(remaining)
  1284. this.invalidate()
  1285. return true
  1286. }
  1287. handleInput(data: string): void {
  1288. if (this.handleBracketedPaste(data)) return
  1289. const filtered = this.filtered()
  1290. if (matchesKey(data, Key.ctrl('c'))) {
  1291. this.cancel()
  1292. return
  1293. }
  1294. if (matchesKey(data, Key.escape)) {
  1295. if (this.search.getValue() === '') this.cancel()
  1296. else {
  1297. this.search.setValue('')
  1298. this.selectedIndex = 0
  1299. this.error = ''
  1300. }
  1301. } else if (matchesKey(data, Key.up)) {
  1302. this.selectedIndex = filtered.length === 0
  1303. ? 0
  1304. : (this.selectedIndex + filtered.length - 1) % filtered.length
  1305. } else if (matchesKey(data, Key.down)) {
  1306. this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
  1307. } else if (matchesKey(data, Key.pageUp)) {
  1308. this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
  1309. } else if (matchesKey(data, Key.pageDown)) {
  1310. this.selectedIndex = Math.min(
  1311. Math.max(0, filtered.length - 1),
  1312. this.selectedIndex + this.visibleCandidateCount(),
  1313. )
  1314. } else if (matchesKey(data, Key.enter)) {
  1315. const selected = filtered[this.selectedIndex]
  1316. if (selected === undefined) this.error = 'No session matches this search.'
  1317. else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
  1318. else this.done(selected)
  1319. } else {
  1320. const previous = this.search.getValue()
  1321. this.search.focused = this.focused
  1322. this.search.handleInput(data)
  1323. if (this.search.getValue() !== previous) {
  1324. this.selectedIndex = 0
  1325. this.error = ''
  1326. }
  1327. }
  1328. this.invalidate()
  1329. }
  1330. render(width: number): string[] {
  1331. this.search.focused = this.focused
  1332. const height = Math.max(1, this.viewportRows())
  1333. const horizontalPadding = width >= 12 ? 2 : 0
  1334. const contentWidth = Math.max(1, width - horizontalPadding * 2)
  1335. const indent = ' '.repeat(horizontalPadding)
  1336. const filtered = this.filtered()
  1337. if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
  1338. const selected = filtered[this.selectedIndex]
  1339. const position = selected === undefined ? 0 : this.selectedIndex + 1
  1340. const lines: string[] = [
  1341. '',
  1342. `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
  1343. '',
  1344. ]
  1345. const searchInnerWidth = Math.max(1, contentWidth - 4)
  1346. lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`)
  1347. const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ')
  1348. const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
  1349. lines.push(
  1350. `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
  1351. `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`,
  1352. '',
  1353. `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
  1354. '',
  1355. )
  1356. const visibleCount = this.visibleCandidateCount()
  1357. const start = Math.max(0, Math.min(
  1358. this.selectedIndex - Math.floor(visibleCount / 2),
  1359. filtered.length - visibleCount,
  1360. ))
  1361. const end = Math.min(filtered.length, start + visibleCount)
  1362. const push = (line: string): void => {
  1363. lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
  1364. }
  1365. for (let index = start; index < end; index += 1) {
  1366. const candidate = filtered[index] as ResumeCandidate
  1367. const active = index === this.selectedIndex
  1368. const status = [
  1369. candidate.disabledReason === 'current session' ? 'current' : undefined,
  1370. candidate.record.live ? 'live' : undefined,
  1371. candidate.record.persisted ? 'persisted' : undefined,
  1372. ].filter((value): value is string => value !== undefined).join(' · ')
  1373. const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
  1374. push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
  1375. const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
  1376. const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
  1377. push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
  1378. push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
  1379. if (candidate.disabledReason !== undefined) {
  1380. push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
  1381. }
  1382. }
  1383. if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
  1384. if (this.error !== '') {
  1385. lines.push('')
  1386. push(this.palette.error(displayText(this.error)))
  1387. }
  1388. const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
  1389. while (lines.length < height - 2) lines.push('')
  1390. lines.push(footer, '')
  1391. return lines.slice(0, height)
  1392. }
  1393. }
  1394. class QuestionDialog implements Component, Focusable {
  1395. private selectedIndex = 0
  1396. private selected = new Set<number>()
  1397. private mode: 'options' | 'custom'
  1398. private error = ''
  1399. private readonly input = new Input()
  1400. private readonly options: NonNullable<AskUserQuestionItem['options']>
  1401. focused = false
  1402. constructor(
  1403. private readonly question: AskUserQuestionItem,
  1404. private readonly position: number,
  1405. private readonly total: number,
  1406. private readonly unanswered: number,
  1407. private readonly maxVisible: number,
  1408. private readonly palette: Palette,
  1409. private readonly done: (selection: QuestionSelection) => void,
  1410. private readonly cancel: () => void,
  1411. ) {
  1412. this.options = question.options ?? []
  1413. this.mode = this.options.length > 0 ? 'options' : 'custom'
  1414. this.input.onSubmit = (value) => { this.submitCustom(value) }
  1415. this.input.onEscape = () => {
  1416. if (this.options.length > 0) {
  1417. this.mode = 'options'
  1418. this.error = ''
  1419. } else {
  1420. this.cancel()
  1421. }
  1422. }
  1423. }
  1424. invalidate(): void {
  1425. this.input.invalidate()
  1426. }
  1427. handleInput(data: string): void {
  1428. this.invalidate()
  1429. if (this.mode === 'custom') {
  1430. this.input.focused = this.focused
  1431. this.input.handleInput(data)
  1432. return
  1433. }
  1434. const options = this.options
  1435. if (matchesKey(data, Key.up)) {
  1436. this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
  1437. } else if (matchesKey(data, Key.down)) {
  1438. this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
  1439. } else if (matchesKey(data, Key.space) && this.question.multiSelect) {
  1440. if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
  1441. else this.selected.add(this.selectedIndex)
  1442. } else if (matchesKey(data, Key.enter)) {
  1443. const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
  1444. if (indices.length === 0) {
  1445. this.error = 'Select at least one option, or press Tab for a custom answer.'
  1446. return
  1447. }
  1448. this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
  1449. } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
  1450. this.mode = 'custom'
  1451. this.error = ''
  1452. } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
  1453. this.cancel()
  1454. }
  1455. }
  1456. private submitCustom(value: string): void {
  1457. const custom = value.trim()
  1458. if (custom === '') {
  1459. this.error = 'Enter an answer before submitting.'
  1460. return
  1461. }
  1462. this.done({ selected: [], custom })
  1463. }
  1464. render(width: number): string[] {
  1465. this.input.focused = this.focused
  1466. const innerWidth = Math.max(1, width - 4)
  1467. const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
  1468. const lines = [
  1469. this.palette.muted(header),
  1470. ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
  1471. ]
  1472. const push = (line: string): void => { lines.push(line) }
  1473. // Supporting detail (e.g. the full plan under review) renders between the
  1474. // question and the answer surface, kept out of option labels.
  1475. if (this.question.detail !== undefined) {
  1476. push('')
  1477. for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
  1478. }
  1479. push('')
  1480. if (this.mode === 'custom') {
  1481. for (const line of this.input.render(innerWidth)) push(line)
  1482. push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
  1483. } else {
  1484. const options = this.options
  1485. const start = Math.max(0, Math.min(
  1486. this.selectedIndex - Math.floor(this.maxVisible / 2),
  1487. options.length - this.maxVisible,
  1488. ))
  1489. const end = Math.min(options.length, start + this.maxVisible)
  1490. const optionRows = options.slice(start, end).map((option, offset) => {
  1491. const index = start + offset
  1492. const mark = this.question.multiSelect
  1493. ? this.selected.has(index) ? '[x] ' : '[ ] '
  1494. : ''
  1495. return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
  1496. })
  1497. const descriptionColumn = Math.min(
  1498. Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
  1499. Math.max(1, Math.floor(innerWidth * 0.55)),
  1500. )
  1501. for (let index = start; index < end; index += 1) {
  1502. // `index < end <= options.length`; the options array is borrowed immutably for this dialog.
  1503. const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
  1504. const mark = this.question.multiSelect
  1505. ? this.selected.has(index) ? '[x] ' : '[ ] '
  1506. : ''
  1507. const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
  1508. const leftStyled = index === this.selectedIndex
  1509. ? this.palette.bold(this.palette.accent(left))
  1510. : left
  1511. const description = option.description === undefined
  1512. ? ''
  1513. : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
  1514. push(`${leftStyled}${description}`)
  1515. }
  1516. if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
  1517. const hint = this.palette.dim(this.question.multiSelect
  1518. ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt'
  1519. : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt')
  1520. for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
  1521. }
  1522. if (this.error) {
  1523. for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
  1524. }
  1525. return ['', ...lines, ''].map((line) => {
  1526. const clipped = truncateToWidth(line, innerWidth, '')
  1527. return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
  1528. })
  1529. }
  1530. }
  1531. interface PendingQuestion {
  1532. request: AskUserQuestionRequest
  1533. index: number
  1534. answers: AskUserQuestionAnswerItem[]
  1535. resolve(answer: AskUserQuestionAnswer): void
  1536. reject(error: unknown): void
  1537. onAbort: () => void
  1538. overlay: TuiOverlaySession | undefined
  1539. }
  1540. /** Merge path-only file candidates and optional session snapshots with commands. */
  1541. class ReferenceAutocompleteProvider implements AutocompleteProvider {
  1542. constructor(
  1543. private readonly base: CombinedAutocompleteProvider,
  1544. private readonly files: WorkspaceFileSearch,
  1545. private readonly sessions: SessionReferenceService | undefined,
  1546. private readonly agent: Agent,
  1547. ) {}
  1548. async getSuggestions(
  1549. lines: string[],
  1550. cursorLine: number,
  1551. cursorCol: number,
  1552. options: { signal: AbortSignal; force?: boolean },
  1553. ): Promise<AutocompleteSuggestions | null> {
  1554. const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
  1555. const currentLine = lines[cursorLine]
  1556. /* v8 ignore next -- Editor always supplies its current state line. */
  1557. if (currentLine === undefined) return basePromise
  1558. const token = activeAtToken(currentLine, cursorCol)
  1559. if (token === undefined) {
  1560. this.files.invalidate()
  1561. return basePromise
  1562. }
  1563. const filePromise = this.files.list(token.query, options.signal).catch(() => [])
  1564. const sessionPromise = this.sessions === undefined || token.quoted
  1565. ? Promise.resolve([])
  1566. : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
  1567. const [base, fileCandidates, sessionCandidates] = await Promise.all([
  1568. basePromise,
  1569. filePromise,
  1570. sessionPromise,
  1571. ])
  1572. if (options.signal.aborted) return base
  1573. const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
  1574. const value = formatFileMention(candidate, token.quoted)
  1575. if (value === undefined) return []
  1576. const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
  1577. const directory = candidate.kind === 'directory'
  1578. return [{
  1579. value,
  1580. label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
  1581. description: displayInlineText(candidate.path),
  1582. }]
  1583. })
  1584. const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
  1585. const mentionLabel = displayInlineText(candidate.label)
  1586. const sessionId = displayInlineText(candidate.sessionId)
  1587. const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
  1588. const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
  1589. return {
  1590. value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
  1591. label: `Session · ${mentionLabel}`,
  1592. description,
  1593. }
  1594. })
  1595. const items = [...fileItems, ...sessionItems]
  1596. if (items.length === 0) return base
  1597. return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
  1598. }
  1599. applyCompletion(
  1600. lines: string[],
  1601. cursorLine: number,
  1602. cursorCol: number,
  1603. item: AutocompleteItem,
  1604. prefix: string,
  1605. ): { lines: string[]; cursorLine: number; cursorCol: number } {
  1606. return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
  1607. }
  1608. shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
  1609. return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
  1610. }
  1611. }
  1612. /** Lifecycle handle for a mounted interactive terminal channel. */
  1613. export interface TuiController {
  1614. /** Stop rendering, restore the terminal, and reject pending questions. */
  1615. dispose(): Promise<void>
  1616. }
  1617. /** Prefix that marks an editor submission as a manual skill invocation. */
  1618. const SKILL_COMMAND_PREFIX = '/skill:'
  1619. /** Parsed `/skill:<name> [instructions]` submission; `name` is empty when the prefix carries no name. */
  1620. interface ParsedSkillCommand {
  1621. /** Skill name typed after `/skill:`, up to the first space. */
  1622. name: string
  1623. /** Trimmed text after the name; empty when none was typed. */
  1624. instructions: string
  1625. }
  1626. /**
  1627. * Split a `/skill:<name> [instructions]` submission into its name and trailing instructions.
  1628. * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}.
  1629. * @returns the skill name and any trailing instructions.
  1630. */
  1631. function parseSkillCommand(text: string): ParsedSkillCommand {
  1632. const rest = text.slice(SKILL_COMMAND_PREFIX.length)
  1633. const spaceIndex = rest.indexOf(' ')
  1634. if (spaceIndex === -1) return { name: rest, instructions: '' }
  1635. return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() }
  1636. }
  1637. /** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */
  1638. function skillResourceReference(base: SkillResourceBase | undefined): string | undefined {
  1639. if (base === undefined) return undefined
  1640. switch (base.kind) {
  1641. case 'directory':
  1642. return `References in this skill are relative to ${base.path}.`
  1643. case 'url':
  1644. return `References in this skill are relative to ${base.url}.`
  1645. case 'opaque':
  1646. return base.description
  1647. default:
  1648. return assertNever(base, 'SkillResourceBase.kind')
  1649. }
  1650. }
  1651. /**
  1652. * Render a manually invoked skill into the model-visible user-message text. The
  1653. * `<skill>` block carries the body and, when the provider supplies one, its
  1654. * resource base; the trimmed `instructions` follow the block as the user's
  1655. * request for this turn. The name is registry-validated kebab-case
  1656. * ({@link SkillService} rejects any other) and the resource base is trusted
  1657. * same-process provider prose, so — unlike the model-facing `dsh-tool-skill`
  1658. * result, which escapes for a tool channel — this user turn is assembled raw.
  1659. * @param skill - the loaded skill definition.
  1660. * @param instructions - trimmed text typed after `/skill:<name>`; empty when absent.
  1661. * @returns the user-message text delivered to the agent.
  1662. */
  1663. export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string {
  1664. const lines = [`<skill name="${skill.name}">`]
  1665. const reference = skillResourceReference(skill.resourceBase)
  1666. if (reference !== undefined) lines.push(reference, '')
  1667. lines.push(skill.content, '</skill>')
  1668. const block = lines.join('\n')
  1669. return instructions === '' ? block : `${block}\n\n${instructions}`
  1670. }
  1671. function activeSurfaceSeqs(session: Session): Set<number> {
  1672. return new Set(session.surface.nodes)
  1673. }
  1674. function sessionReferenceCard(source: unknown): string[] | undefined {
  1675. if (typeof source !== 'object' || source === null) return undefined
  1676. const record = source as Record<string, unknown>
  1677. if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
  1678. const references = record['references'] as unknown[]
  1679. const labels: string[] = []
  1680. for (const reference of references) {
  1681. if (typeof reference !== 'object' || reference === null) return undefined
  1682. const entry = reference as Record<string, unknown>
  1683. const sessionId = entry['sessionId']
  1684. const label = entry['label']
  1685. if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
  1686. labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
  1687. }
  1688. return labels
  1689. }
  1690. function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
  1691. const ids = new Set<string>()
  1692. for (const event of session.events) {
  1693. if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
  1694. for (const block of event.data.content) {
  1695. if (block.type === 'tool-call') ids.add(block.id)
  1696. }
  1697. }
  1698. return ids
  1699. }
  1700. /**
  1701. * Start the interactive pi-tui channel for an already-created target agent.
  1702. * @param ctx - agent, tools, session-event, and user-interaction context.
  1703. * @param config - target agent, banner, and TUI presentation config.
  1704. * @param runtime - terminal and process-exit boundary.
  1705. * @returns lifecycle controller used by the Cordis effect disposer.
  1706. */
  1707. export function createTuiChat(
  1708. ctx: Context,
  1709. config: Config,
  1710. runtime: TuiRuntime,
  1711. ): TuiController {
  1712. const sessionId = SessionId(config.sessionId ?? 'main')
  1713. const agent = ctx.agents.get(sessionId)
  1714. if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
  1715. const persistence = ctx.get('sessionPersistence')
  1716. const sessionQuery = ctx.get('sessionQuery')
  1717. const resolved = resolveTuiConfig(config)
  1718. const palette = createPalette(resolved.color)
  1719. const mdTheme = markdownTheme(palette)
  1720. const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
  1721. const chat = new Container()
  1722. const todoContainer = new Container()
  1723. const statusContainer = new Container()
  1724. const editor = new Editor(ui, {
  1725. borderColor: palette.dim,
  1726. selectList: selectTheme(palette),
  1727. } satisfies EditorTheme, { paddingX: 1 })
  1728. const todo = new TodoComponent(palette)
  1729. let showReasoning = resolved.showReasoning
  1730. let toolsExpanded = false
  1731. let streaming: StreamingAssistantComponent | undefined
  1732. let runningStatus: RunningStatus | undefined
  1733. // TUI steering submissions that the inbox has not yet claimed or discarded.
  1734. // Correlation ids avoid guessing whether a running-state submission actually
  1735. // joined steering or fell back to the queued-turn FIFO during turn close.
  1736. const pendingSteering = new Set<AgentMessageId>()
  1737. let disposed = false
  1738. let shuttingDown: Promise<void> | undefined
  1739. // Optional: skills mount conditionally, so read the global service store
  1740. // rather than declaring an injection that would make the TUI require them.
  1741. const skills = ctx.get('skills')
  1742. const cwd = agent.session.header.cwd ?? process.cwd()
  1743. const fileSearch = new WorkspaceFileSearch(cwd, {
  1744. maxResults: resolved.fileSearchMaxResults,
  1745. maxEntries: resolved.fileSearchMaxEntries,
  1746. excludedDirectories: resolved.fileSearchExcludedDirectories,
  1747. })
  1748. const skillAbort = new AbortController()
  1749. const tokens = sessionTokens(agent.session)
  1750. const toolCards = new Map<string, ToolCardComponent>()
  1751. const allToolCards = new Set<ToolCardComponent>()
  1752. const liveErrors = new Set<string>()
  1753. const questionQueue: PendingQuestion[] = []
  1754. const commandControllers = new Set<AbortController>()
  1755. const referenceControllers = new Set<AbortController>()
  1756. let activeQuestion: PendingQuestion | undefined
  1757. let modelOverlay: TuiOverlaySession | undefined
  1758. let resumeOverlay: TuiOverlaySession | undefined
  1759. let resumeInFlight = false
  1760. let resumeScan = 0
  1761. let tuiServiceFiber: Fiber | undefined
  1762. const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
  1763. let contextWindow: number | undefined
  1764. let contextResolution: Promise<
  1765. | { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
  1766. | { readonly kind: 'error'; readonly error: unknown }
  1767. > | undefined
  1768. let modelCommands = Promise.resolve()
  1769. const now = (): number => runtime.now?.() ?? Date.now()
  1770. const agentStatus = (): AgentStatus => agent.status
  1771. const isDisposed = (): boolean => disposed
  1772. // A configured subtitle renders as a banner line; when absent, the banner has
  1773. // no subtitle. The banner itself sweeps in on start (see startBannerReveal).
  1774. let sessionTitle = foldSessionTitle(agent.session.events)?.title
  1775. const header = new HeaderComponent(
  1776. agent,
  1777. () => sessionTitle ?? config.welcome,
  1778. palette,
  1779. resolved.color && resolved.truecolor,
  1780. () => target.current?.model,
  1781. )
  1782. const footer = new FooterComponent(
  1783. agent,
  1784. palette,
  1785. () => toolsExpanded,
  1786. () => tokens,
  1787. runtime.formatCwd,
  1788. () => target.current?.model,
  1789. () => contextWindow === undefined
  1790. ? undefined
  1791. : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
  1792. )
  1793. ui.addChild(header)
  1794. ui.addChild(chat)
  1795. ui.addChild(statusContainer)
  1796. todoContainer.addChild(todo)
  1797. ui.addChild(todoContainer)
  1798. ui.addChild(editor)
  1799. ui.addChild(footer)
  1800. ui.setFocus(editor)
  1801. const updateTerminalTitle = (): void => {
  1802. runtime.terminal.setTitle(displayText(
  1803. sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`,
  1804. ))
  1805. }
  1806. updateTerminalTitle()
  1807. const requestRender = (): void => {
  1808. footer.invalidate()
  1809. ui.requestRender()
  1810. }
  1811. const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
  1812. const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
  1813. chat.addChild(new Spacer(1))
  1814. chat.addChild(new Text(color(displayText(message)), 1, 0))
  1815. requestRender()
  1816. }
  1817. const extensionTheme: TuiTheme = Object.freeze({
  1818. text: (value: string) => palette.text(value),
  1819. muted: (value: string) => palette.muted(value),
  1820. dim: (value: string) => palette.dim(value),
  1821. accent: (value: string) => palette.accent(value),
  1822. success: (value: string) => palette.success(value),
  1823. warning: (value: string) => palette.warning(value),
  1824. error: (value: string) => palette.error(value),
  1825. bold: (value: string) => palette.bold(value),
  1826. })
  1827. const overlayManager = new TuiOverlayManager({
  1828. viewport: () => Object.freeze({
  1829. columns: runtime.terminal.columns,
  1830. rows: runtime.terminal.rows,
  1831. }),
  1832. theme: () => extensionTheme,
  1833. display: displayText,
  1834. show: (component, options) => ui.showOverlay(component, options === undefined
  1835. ? undefined
  1836. : {
  1837. ...options,
  1838. ...typeof options.margin === 'object'
  1839. ? { margin: { ...options.margin } }
  1840. : {},
  1841. }),
  1842. invalidate: requestRender,
  1843. reportError: (error) => {
  1844. const message = errorChain(error)
  1845. ctx.logger.warn(`ui-tui: overlay failed: ${message}`)
  1846. /* v8 ignore next -- shutdown removes overlays before the terminal stops */
  1847. if (disposed) return
  1848. appendNotice(`TUI overlay failed: ${message}`, 'error')
  1849. },
  1850. })
  1851. const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
  1852. const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
  1853. contextWindow = undefined
  1854. const resolution = selected === undefined
  1855. ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
  1856. : ctx.llm.resolveModelContext(selected.provider, selected.model).then(
  1857. context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
  1858. (error: unknown) => ({ kind: 'error', error } as const),
  1859. )
  1860. contextResolution = resolution
  1861. void resolution.then((result) => {
  1862. if (contextResolution !== resolution) return
  1863. if (result.kind === 'error') {
  1864. appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
  1865. return
  1866. }
  1867. contextWindow = result.contextWindow
  1868. requestRender()
  1869. })
  1870. }
  1871. resolveContextWindow(target.current)
  1872. const selectModel = (selected: ModelChoice): void => {
  1873. if (target.current?.provider === selected.provider && target.current.model === selected.model) {
  1874. appendNotice(`Model is already ${targetLabel(selected)}.`)
  1875. return
  1876. }
  1877. target.current = { provider: selected.provider, model: selected.model }
  1878. resolveContextWindow(target.current)
  1879. appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
  1880. }
  1881. const showModelSelector = (choices: readonly ModelChoice[]): void => {
  1882. const current = target.current === undefined ? 'unset' : targetLabel(target.current)
  1883. if (choices.length === 0) {
  1884. appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
  1885. return
  1886. }
  1887. void modelOverlay?.close()
  1888. const session = overlayManager.open({
  1889. create: () => new ModelDialog(
  1890. choices,
  1891. target.current,
  1892. resolved.maxModelOptions,
  1893. palette,
  1894. (selected) => {
  1895. void session.close()
  1896. selectModel(selected)
  1897. },
  1898. () => { void session.close() },
  1899. ),
  1900. options: {
  1901. width: resolved.modelDialogWidth,
  1902. maxHeight: resolved.modelDialogMaxHeight,
  1903. anchor: 'center',
  1904. margin: 1,
  1905. },
  1906. })
  1907. modelOverlay = session
  1908. void session.closed.then(() => {
  1909. if (modelOverlay === session) modelOverlay = undefined
  1910. })
  1911. requestRender()
  1912. }
  1913. const handleModelCommand = async (raw: string): Promise<void> => {
  1914. const choices = await readModelChoices(ctx, target.current)
  1915. if (disposed) return
  1916. const argument = raw.trim()
  1917. if (argument === '') {
  1918. showModelSelector(choices)
  1919. return
  1920. }
  1921. const parts = argument.split(/\s+/u)
  1922. if (parts.length > 2) {
  1923. appendNotice('Usage: /model [provider/]model', 'warning')
  1924. return
  1925. }
  1926. let matches: ModelChoice[]
  1927. if (parts.length === 2) {
  1928. matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
  1929. } else {
  1930. const value = argument
  1931. const qualified = choices.filter(choice => targetLabel(choice) === value)
  1932. matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
  1933. }
  1934. if (matches.length === 0) {
  1935. appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
  1936. return
  1937. }
  1938. if (matches.length > 1) {
  1939. appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
  1940. return
  1941. }
  1942. const selected = matches[0]
  1943. /* v8 ignore next -- a non-empty matches array always has index zero. */
  1944. if (selected === undefined) return
  1945. selectModel(selected)
  1946. }
  1947. const queueModelCommand = (raw: string): void => {
  1948. modelCommands = modelCommands.then(async () => {
  1949. await handleModelCommand(raw)
  1950. }).catch((error: unknown) => {
  1951. if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
  1952. })
  1953. }
  1954. const clearStatus = (): void => {
  1955. if (runningStatus !== undefined) {
  1956. clearInterval(runningStatus.timer)
  1957. runningStatus.loader.stop()
  1958. runningStatus = undefined
  1959. }
  1960. statusContainer.clear()
  1961. runtime.terminal.setProgress(false)
  1962. }
  1963. // Refresh the status line's elapsed timers and queued badge from the
  1964. // controller's phase and the current steering count.
  1965. const renderStatus = (running: RunningStatus): void => {
  1966. const at = now()
  1967. running.loader.setMessage(
  1968. formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.size),
  1969. )
  1970. }
  1971. // Move to a derived phase, resetting the phase timer on a genuine change and
  1972. // the step timer when a new step begins; ignored unless a turn is running.
  1973. const enterPhase = (phase: TurnPhase, resetStep: boolean): void => {
  1974. const running = runningStatus
  1975. if (running === undefined) return
  1976. const at = now()
  1977. if (resetStep) running.stepStartedAt = at
  1978. if (phase !== running.phase || resetStep) running.phaseStartedAt = at
  1979. running.phase = phase
  1980. renderStatus(running)
  1981. }
  1982. const setStatus = (status: AgentStatus): void => {
  1983. // A running→running rebuild (a mid-turn palette swap re-derives the border)
  1984. // carries the derived phase and both elapsed baselines across; only a fresh
  1985. // idle→running turn starts at `waiting`.
  1986. const prior = runningStatus
  1987. clearStatus()
  1988. editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
  1989. if (status === 'running') {
  1990. const at = now()
  1991. const phase = prior?.phase ?? 'waiting'
  1992. const phaseStartedAt = prior?.phaseStartedAt ?? at
  1993. const stepStartedAt = prior?.stepStartedAt ?? at
  1994. const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.size)
  1995. const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message)
  1996. statusContainer.addChild(loader)
  1997. const running: RunningStatus = {
  1998. loader,
  1999. phase,
  2000. phaseStartedAt,
  2001. stepStartedAt,
  2002. timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS),
  2003. }
  2004. runningStatus = running
  2005. runtime.terminal.setProgress(true)
  2006. }
  2007. requestRender()
  2008. }
  2009. // Refresh the running status line's queued-steering badge from the current
  2010. // count; a no-op when idle because the controller only exists while running.
  2011. const refreshStatus = (): void => {
  2012. if (runningStatus !== undefined) renderStatus(runningStatus)
  2013. requestRender()
  2014. }
  2015. // Derive the status-line phase from live session lifecycle events. The event
  2016. // map is merge-extensible, so unhandled types fall through the default.
  2017. const advanceTurnPhase = (event: SessionEvent): void => {
  2018. switch (event.type) {
  2019. case 'step/start':
  2020. enterPhase('waiting', true)
  2021. break
  2022. case 'assistant/chunk': {
  2023. const chunk = event.data.chunk
  2024. if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
  2025. enterPhase('thinking', false)
  2026. } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
  2027. enterPhase('responding', false)
  2028. }
  2029. break
  2030. }
  2031. case 'tool/call':
  2032. enterPhase('executing', false)
  2033. break
  2034. default:
  2035. break
  2036. }
  2037. }
  2038. const parsedTool = (event: Extract<SessionEvent, { type: 'tool/call' }>): ToolCardComponent => {
  2039. const parsed = parseArguments(event.data.arguments)
  2040. const card = new ToolCardComponent(
  2041. event.data.name,
  2042. parsed,
  2043. ctx.tools.get(event.data.name, agent),
  2044. resolved.maxToolOutputLines,
  2045. palette,
  2046. )
  2047. card.setExpanded(toolsExpanded)
  2048. toolCards.set(event.data.callId, card)
  2049. allToolCards.add(card)
  2050. return card
  2051. }
  2052. const clearStreaming = (): void => {
  2053. if (streaming === undefined) return
  2054. const index = chat.children.indexOf(streaming)
  2055. /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */
  2056. if (index >= 0) chat.children.splice(index, 1)
  2057. streaming = undefined
  2058. }
  2059. const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
  2060. switch (event.type) {
  2061. case 'user/message': {
  2062. // Injected context (plugin/goal source) renders as a dim context card,
  2063. // not a human bubble; only a direct human prompt is a user message. The
  2064. // boolean avoids narrowing `source`, so the label keeps its full union.
  2065. const source = event.data.source
  2066. if (source.kind !== 'user') {
  2067. const references = sessionReferenceCard(event.data.source)
  2068. if (references !== undefined) {
  2069. chat.addChild(new Spacer(1))
  2070. chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
  2071. break
  2072. }
  2073. const text = displayText(contentText(event.data.content).trim())
  2074. if (text) {
  2075. // The tui type view lacks plugin-augmented source kinds (e.g. goal),
  2076. // so read the display label without narrowing on `kind`.
  2077. const labelled = source as { kind: string; plugin?: string }
  2078. const label = labelled.plugin ?? labelled.kind
  2079. chat.addChild(new Spacer(1))
  2080. chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0))
  2081. chat.addChild(new Text(palette.muted(text), 1, 0))
  2082. }
  2083. break
  2084. }
  2085. const text = displayText(contentText(event.data.content).trim())
  2086. if (text) {
  2087. chat.addChild(new Spacer(1))
  2088. chat.addChild(new UserMessageComponent(text, palette, mdTheme))
  2089. if (options.addHistory) editor.addToHistory(text)
  2090. }
  2091. break
  2092. }
  2093. case 'steering/message': {
  2094. const text = displayText(contentText(event.data.content).trim())
  2095. if (text) {
  2096. chat.addChild(new Spacer(1))
  2097. chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
  2098. }
  2099. break
  2100. }
  2101. case 'assistant/chunk':
  2102. if (options.renderChunks) {
  2103. if (streaming === undefined) {
  2104. streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme)
  2105. chat.addChild(streaming)
  2106. }
  2107. streaming.update(event.data.chunk)
  2108. }
  2109. break
  2110. case 'assistant/message': {
  2111. clearStreaming()
  2112. const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme)
  2113. if (component.children.length > 0) chat.addChild(component)
  2114. break
  2115. }
  2116. case 'llm/retry': {
  2117. clearStreaming()
  2118. appendNotice(
  2119. `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`,
  2120. 'warning',
  2121. )
  2122. break
  2123. }
  2124. case 'tool/call':
  2125. chat.addChild(new Spacer(1))
  2126. chat.addChild(parsedTool(event))
  2127. break
  2128. case 'tool/result': {
  2129. let card = toolCards.get(event.data.callId)
  2130. if (card === undefined) {
  2131. card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette)
  2132. chat.addChild(new Spacer(1))
  2133. chat.addChild(card)
  2134. allToolCards.add(card)
  2135. }
  2136. card.updateResult(event.data)
  2137. toolCards.delete(event.data.callId)
  2138. break
  2139. }
  2140. case 'todo/write':
  2141. todo.update(event.data.todos)
  2142. break
  2143. case 'session/title':
  2144. sessionTitle = event.data.title
  2145. header.invalidate()
  2146. updateTerminalTitle()
  2147. break
  2148. case 'turn/end':
  2149. clearStreaming()
  2150. if (event.data.reason.kind === 'error') {
  2151. const key = `${event.data.turn}:${event.data.reason.step}`
  2152. const message = 'failure' in event.data.reason
  2153. ? event.data.reason.failure.message
  2154. : event.data.reason.message
  2155. if (!liveErrors.delete(key)) appendNotice(message, 'error')
  2156. } else if (event.data.reason.kind === 'aborted') {
  2157. appendNotice('Turn cancelled.', 'warning')
  2158. } else if (event.data.reason.kind === 'max-tokens') {
  2159. appendNotice('The model reached its output-token limit.', 'warning')
  2160. } else if (event.data.reason.kind === 'interrupted') {
  2161. appendNotice('The previous process ended during this turn.', 'warning')
  2162. }
  2163. break
  2164. default:
  2165. break
  2166. }
  2167. }
  2168. const rebuildTranscript = (populateHistory: boolean): void => {
  2169. chat.clear()
  2170. toolCards.clear()
  2171. allToolCards.clear()
  2172. streaming = undefined
  2173. const active = activeSurfaceSeqs(agent.session)
  2174. const activeCalls = activeToolCallIds(agent.session, active)
  2175. for (const event of agent.session.events) {
  2176. const isSurface = event.type === 'user/message'
  2177. || event.type === 'assistant/message'
  2178. || event.type === 'tool/result'
  2179. || event.type === 'steering/message'
  2180. if (isSurface && !active.has(event.seq)) continue
  2181. if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
  2182. renderEvent(event, { addHistory: populateHistory, renderChunks: false })
  2183. }
  2184. requestRender()
  2185. }
  2186. const removeAbortListener = (pending: PendingQuestion): void => {
  2187. pending.request.signal?.removeEventListener('abort', pending.onAbort)
  2188. }
  2189. const rejectQuestion = (pending: PendingQuestion): void => {
  2190. void pending.overlay?.close()
  2191. pending.overlay = undefined
  2192. removeAbortListener(pending)
  2193. pending.reject(new UserInteractionError(
  2194. 'ask_user_question was interrupted before the user answered',
  2195. 'ASK_ABORTED',
  2196. ))
  2197. }
  2198. const startNextQuestion = (): void => {
  2199. if (activeQuestion !== undefined || disposed) return
  2200. const pending = questionQueue.shift()
  2201. if (pending === undefined) return
  2202. activeQuestion = pending
  2203. const show = (): void => {
  2204. const question = pending.request.questions[pending.index]
  2205. if (question === undefined) {
  2206. activeQuestion = undefined
  2207. removeAbortListener(pending)
  2208. pending.resolve({ answers: pending.answers })
  2209. startNextQuestion()
  2210. return
  2211. }
  2212. const session = overlayManager.open({
  2213. ...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
  2214. create: () => new QuestionDialog(
  2215. question,
  2216. pending.index + 1,
  2217. pending.request.questions.length,
  2218. pending.request.questions.length - pending.answers.length,
  2219. resolved.maxQuestionOptions,
  2220. palette,
  2221. (selection) => {
  2222. pending.overlay = undefined
  2223. void session.close()
  2224. pending.answers.push({ id: question.id, ...selection })
  2225. pending.index += 1
  2226. show()
  2227. },
  2228. () => {
  2229. activeQuestion = undefined
  2230. rejectQuestion(pending)
  2231. startNextQuestion()
  2232. },
  2233. ),
  2234. options: {
  2235. width: resolved.questionDialogWidth,
  2236. maxHeight: resolved.questionDialogMaxHeight,
  2237. anchor: 'bottom-left',
  2238. margin: { bottom: 1 },
  2239. },
  2240. })
  2241. pending.overlay = session
  2242. void session.closed.then((result) => {
  2243. if (pending.overlay !== session) return
  2244. pending.overlay = undefined
  2245. /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
  2246. if (result.reason !== 'error') return
  2247. activeQuestion = undefined
  2248. removeAbortListener(pending)
  2249. pending.reject(new UserInteractionError(
  2250. `ask_user_question TUI failed: ${errorChain(result.error)}`,
  2251. 'ASK_ABORTED',
  2252. ))
  2253. startNextQuestion()
  2254. })
  2255. requestRender()
  2256. }
  2257. show()
  2258. }
  2259. const disposeUserInteraction = ctx.userInteraction.registerProvider({
  2260. ask(request) {
  2261. return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
  2262. const pending: PendingQuestion = {
  2263. request,
  2264. index: 0,
  2265. answers: [],
  2266. resolve: resolveAnswer,
  2267. reject,
  2268. overlay: undefined,
  2269. onAbort: () => {
  2270. if (activeQuestion === pending) {
  2271. activeQuestion = undefined
  2272. rejectQuestion(pending)
  2273. startNextQuestion()
  2274. return
  2275. }
  2276. // A non-active pending ask remains in the queue until this listener settles it.
  2277. questionQueue.splice(questionQueue.indexOf(pending), 1)
  2278. rejectQuestion(pending)
  2279. },
  2280. }
  2281. request.signal?.addEventListener('abort', pending.onAbort, { once: true })
  2282. questionQueue.push(pending)
  2283. startNextQuestion()
  2284. })
  2285. },
  2286. })
  2287. /**
  2288. * Persisted sessions for this workspace, newest first. Empty when no
  2289. * persistence backend is mounted or a listing failure would otherwise block
  2290. * exit or crash `/resume`; the resume hint is best-effort convenience.
  2291. */
  2292. const listWorkspaceSessions = async (): Promise<SessionHeader[]> => {
  2293. if (persistence === undefined) return []
  2294. let all: readonly SessionHeader[]
  2295. try {
  2296. all = await persistence.list()
  2297. } catch {
  2298. // A listing failure must never block terminal exit or crash `/resume`.
  2299. return []
  2300. }
  2301. return all
  2302. .filter(header => header.cwd === agent.session.header.cwd)
  2303. }
  2304. /**
  2305. * The resume command for the current session — the configured template with
  2306. * every `{session}` filled — but only once the session is durably persisted,
  2307. * so a session abandoned before its first flush yields no hint (resuming that
  2308. * id would fail to load).
  2309. */
  2310. const currentResumeCommand = async (): Promise<string | undefined> => {
  2311. if (config.resumeCommand === undefined) return undefined
  2312. const sessions = await listWorkspaceSessions()
  2313. if (!sessions.some(header => header.id === agent.session.id)) return undefined
  2314. return config.resumeCommand.replaceAll('{session}', agent.session.id)
  2315. }
  2316. const shutdown = (exitProcess: boolean): Promise<void> => {
  2317. shuttingDown ??= (async () => {
  2318. disposed = true
  2319. overlayManager.beginShutdown()
  2320. contextResolution = undefined
  2321. clearStatus()
  2322. for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
  2323. commandControllers.clear()
  2324. for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
  2325. referenceControllers.clear()
  2326. await tuiServiceFiber?.dispose()
  2327. tuiServiceFiber = undefined
  2328. if (activeQuestion !== undefined) {
  2329. const pending = activeQuestion
  2330. activeQuestion = undefined
  2331. rejectQuestion(pending)
  2332. }
  2333. for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
  2334. await overlayManager.dispose()
  2335. modelOverlay = undefined
  2336. disposeUserInteraction()
  2337. await runtime.terminal.drainInput(100, 20)
  2338. ui.stop()
  2339. if (exitProcess) {
  2340. const command = await currentResumeCommand()
  2341. if (command !== undefined) {
  2342. runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`)
  2343. }
  2344. runtime.exit(0)
  2345. }
  2346. })()
  2347. return shuttingDown
  2348. }
  2349. const requestExit = (): void => {
  2350. if (agent.status === 'running') {
  2351. agent.cancel({ kind: 'user' })
  2352. appendNotice('Cancelling the active turn before exit…', 'warning')
  2353. void agent.whenIdle().then(() => shutdown(true))
  2354. return
  2355. }
  2356. void shutdown(true)
  2357. }
  2358. /** Swap the palette and all derived themes for the given terminal color scheme. */
  2359. const applyColorScheme = (scheme: TerminalColorScheme): void => {
  2360. if (scheme === currentScheme) return
  2361. currentScheme = scheme
  2362. Object.assign(palette, createPalette(resolved.color, scheme))
  2363. Object.assign(mdTheme, markdownTheme(palette))
  2364. // `setStatus` below re-derives `editor.borderColor` from the new palette.
  2365. rebuildTranscript(false)
  2366. setStatus(agent.status)
  2367. requestRender()
  2368. }
  2369. let currentScheme: TerminalColorScheme = 'dark'
  2370. // Apply any color scheme the terminal reports. Registering before the query
  2371. // below means even a synchronous reply reaches `applyColorScheme`; in practice
  2372. // the startup query's reply is the only report, since dsh-tui leaves
  2373. // unsolicited color-scheme notifications disabled.
  2374. const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)
  2375. // Ask the terminal for its color scheme via device-status report; the reply,
  2376. // if any, arrives through the listener above. Most terminals do not respond,
  2377. // so we keep the dark-optimised palette. Swallow a query-write failure for the
  2378. // same reason.
  2379. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
  2380. const toggleTools = (): void => {
  2381. toolsExpanded = !toolsExpanded
  2382. for (const card of allToolCards) card.setExpanded(toolsExpanded)
  2383. appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
  2384. }
  2385. const toggleReasoning = (): void => {
  2386. showReasoning = !showReasoning
  2387. const activeStreaming = streaming
  2388. rebuildTranscript(false)
  2389. if (activeStreaming !== undefined) {
  2390. streaming = activeStreaming
  2391. streaming.setShowReasoning(showReasoning)
  2392. chat.addChild(activeStreaming)
  2393. }
  2394. appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
  2395. }
  2396. const showHelp = (): void => {
  2397. const commandLines = ctx.commands.list(agent).map((command) => {
  2398. const input = command.input === undefined ? '' : ` ${command.input.hint}`
  2399. return `/${command.name}${input} — ${command.description}`
  2400. })
  2401. chat.addChild(new Spacer(1))
  2402. chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
  2403. chat.addChild(new Text([
  2404. 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
  2405. 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
  2406. 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
  2407. '',
  2408. ...commandLines,
  2409. '/skill:<name> [instructions] — load a skill into the conversation',
  2410. ].map(line => palette.muted(line)).join('\n'), 1, 0))
  2411. requestRender()
  2412. }
  2413. const showStatus = (): void => {
  2414. const events = agent.session.events
  2415. const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt
  2416. const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens))
  2417. let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown`
  2418. if (contextWindow !== undefined) {
  2419. const contextPercent = Math.round(usedContext / contextWindow * 100)
  2420. context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})`
  2421. }
  2422. const rate = cacheHitRate(tokens)
  2423. const turns = events.filter(event => event.type === 'turn/start').length
  2424. const steps = events.filter(event => event.type === 'step/start').length
  2425. const toolCalls = events.filter(event => event.type === 'tool/call').length
  2426. const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current))
  2427. const groups: readonly (readonly StatusCardRow[])[] = [
  2428. [
  2429. ['Session', displayText(agent.session.id)],
  2430. ['Title', displayText(sessionTitle ?? 'untitled')],
  2431. ['Directory', displayText(cwd)],
  2432. ['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`],
  2433. ],
  2434. [
  2435. ['Agent', [
  2436. agent.status,
  2437. formatDiagnosticCount(events.length, 'event'),
  2438. formatDiagnosticCount(turns, 'turn'),
  2439. formatDiagnosticCount(steps, 'step'),
  2440. formatDiagnosticCount(toolCalls, 'tool call'),
  2441. ].join(' · ')],
  2442. ],
  2443. [
  2444. ['Tokens', `${formatDiagnosticNumber(tokens.input)} input + ${formatDiagnosticNumber(tokens.output)} output`],
  2445. ['KV cache', rate === undefined
  2446. ? `n/a (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`
  2447. : `${diagnosticMeter(rate, palette)} ${String(rate)}% hit (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`],
  2448. ['Context', context],
  2449. ],
  2450. [
  2451. ['Created', formatDiagnosticTime(agent.session.header.createdAt)],
  2452. ['Active', formatDiagnosticTime(latestActivity)],
  2453. ],
  2454. ]
  2455. const card = new StatusCardComponent(groups, palette)
  2456. chat.addChild(new Spacer(1))
  2457. chat.addChild(card)
  2458. requestRender()
  2459. }
  2460. // Skill listing is async while `createTuiChat` is synchronous, so the
  2461. // completions rebuild once the catalog resolves. Disabled-for-model skills
  2462. // are absent from `list()`, so they never appear as completions; a user can
  2463. // still invoke one by typing its exact name.
  2464. let skillCommands: SlashCommand[] = []
  2465. const refreshCommandAutocomplete = (): void => {
  2466. const base = new CombinedAutocompleteProvider(
  2467. [
  2468. ...ctx.commands.list(agent).map(command => ({
  2469. name: command.name,
  2470. description: command.description,
  2471. ...(command.input === undefined ? {} : { argumentHint: command.input.hint }),
  2472. })),
  2473. ...skillCommands,
  2474. ],
  2475. agent.session.header.cwd ?? process.cwd(),
  2476. )
  2477. const sessionReferences = ctx.get('sessionReferences')
  2478. editor.setAutocompleteProvider(new ReferenceAutocompleteProvider(
  2479. base,
  2480. fileSearch,
  2481. sessionReferences,
  2482. agent,
  2483. ))
  2484. }
  2485. const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
  2486. refreshCommandAutocomplete()
  2487. const loadSkillCommands = (service: SkillService): void => {
  2488. service.list({ cwd, signal: skillAbort.signal }).then(
  2489. (summaries) => {
  2490. if (disposed || summaries.length === 0) return
  2491. skillCommands = summaries.map(skill => ({
  2492. name: `skill:${skill.name}`,
  2493. description: skill.description,
  2494. argumentHint: '[instructions]',
  2495. }))
  2496. refreshCommandAutocomplete()
  2497. requestRender()
  2498. },
  2499. () => {
  2500. // Discovery failed or was aborted on dispose; keep the base slash
  2501. // commands so autocomplete still works without skill entries.
  2502. },
  2503. )
  2504. }
  2505. if (skills !== undefined) loadSkillCommands(skills)
  2506. // The agent scope is minted by agent-loop and intentionally inherits only
  2507. // that core plugin's dependencies. A child command producer declares its own
  2508. // UI-service dependency while retaining the parent agent scope and lifetime.
  2509. const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
  2510. commandCtx.commands.register({
  2511. name: 'help',
  2512. description: 'Show keyboard shortcuts and commands',
  2513. handler: () => { showHelp(); return { kind: 'success' } },
  2514. })
  2515. commandCtx.commands.register({
  2516. name: 'model',
  2517. description: 'Show or switch this session\'s model',
  2518. input: { hint: '[[provider/]model]' },
  2519. handler: ({ rawInput }) => {
  2520. queueModelCommand(rawInput)
  2521. return { kind: 'success' }
  2522. },
  2523. })
  2524. commandCtx.commands.register({
  2525. name: 'clear',
  2526. description: 'Clear the transcript view (session history is unchanged)',
  2527. handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
  2528. })
  2529. commandCtx.commands.register({
  2530. name: 'reasoning',
  2531. description: 'Toggle reasoning blocks',
  2532. handler: () => { toggleReasoning(); return { kind: 'success' } },
  2533. })
  2534. commandCtx.commands.register({
  2535. name: 'tools',
  2536. description: 'Expand or collapse all tool cards',
  2537. handler: () => { toggleTools(); return { kind: 'success' } },
  2538. })
  2539. commandCtx.commands.register({
  2540. name: 'redraw',
  2541. description: 'Invalidate components and redraw the terminal',
  2542. handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
  2543. })
  2544. commandCtx.commands.register({
  2545. name: 'reload',
  2546. description: 'EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)',
  2547. handler: () => { runReload(); return { kind: 'success' } },
  2548. })
  2549. commandCtx.commands.register({
  2550. name: 'resume',
  2551. description: 'List this workspace\'s resumable sessions',
  2552. handler: () => { showResume(); return { kind: 'success' } },
  2553. })
  2554. commandCtx.commands.register({
  2555. name: 'status',
  2556. description: 'Show detailed session diagnostics',
  2557. handler: () => { showStatus(); return { kind: 'success' } },
  2558. })
  2559. commandCtx.commands.register({
  2560. name: 'exit',
  2561. description: 'Exit after the active turn reaches idle',
  2562. handler: () => { requestExit(); return { kind: 'success' } },
  2563. })
  2564. })
  2565. const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => {
  2566. promptCtx.systemPrompt.section({
  2567. name: 'ui:tui-file-reference',
  2568. order: 99,
  2569. // Tool visibility can change dynamically or by agent scope. Empty
  2570. // sections are omitted by renderPrompt, so guidance never names a tool
  2571. // that this agent cannot call.
  2572. text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
  2573. })
  2574. })
  2575. const runCommand = (text: string): void => {
  2576. const controller = new AbortController()
  2577. commandControllers.add(controller)
  2578. void ctx.commands.execute(agent, text, controller.signal).then(
  2579. (result) => {
  2580. if (disposed) return
  2581. if (result === undefined) {
  2582. appendNotice(`Unknown command: ${text}`, 'warning')
  2583. } else if (result.text !== undefined && result.text !== '') {
  2584. appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
  2585. }
  2586. },
  2587. (error: unknown) => {
  2588. if (!disposed) {
  2589. appendNotice(`Command failed: ${errorChain(error)}`, 'error')
  2590. }
  2591. },
  2592. ).finally(() => { commandControllers.delete(controller) })
  2593. }
  2594. const dispatchMessage = (content: ContentBlock[]): void => {
  2595. if (disposed) {
  2596. appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
  2597. } else if (agent.status === 'running') {
  2598. pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
  2599. refreshStatus()
  2600. } else {
  2601. agent.followup({ content, source: { kind: 'user' } })
  2602. }
  2603. }
  2604. /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
  2605. const deliver = (payload: string): void => {
  2606. dispatchMessage([{ type: 'text', text: payload }])
  2607. }
  2608. /** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */
  2609. const invokeSkill = (name: string, instructions: string): void => {
  2610. if (skills === undefined) {
  2611. appendNotice('Skills are not available in this session.', 'warning')
  2612. return
  2613. }
  2614. skills.get(name, { cwd, signal: skillAbort.signal }).then(
  2615. (skill) => {
  2616. if (disposed) return
  2617. if (skill === undefined) {
  2618. appendNotice(`Unknown skill: ${name}`, 'warning')
  2619. return
  2620. }
  2621. deliver(renderSkillInvocation(skill, instructions))
  2622. },
  2623. (error: unknown) => {
  2624. if (disposed) return
  2625. appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
  2626. },
  2627. )
  2628. }
  2629. // EXPERIMENTAL, dev-only: manually re-read every file-backed loader config
  2630. // tree and apply the diff to the running app — the same path the HMR
  2631. // watcher's config-change branch drives, minus the watcher. Useful when the
  2632. // watcher misses an edit (replace-by-rename saves) or HMR is not mounted.
  2633. // Module-source hot reload stays watcher-owned; this refreshes configs only.
  2634. let reloadInFlight = false
  2635. const runReload = (): void => {
  2636. // Idle-only: a reload can dispose and re-mount entries mid-flight; doing
  2637. // that under an active turn could tear tools or the adapter out from
  2638. // under in-flight calls. Idleness is advisory (a send can race in after
  2639. // the check), but it removes the common footgun.
  2640. if (agent.status !== 'idle') {
  2641. appendNotice(`/reload requires an idle agent (status: ${agent.status}).`, 'warning')
  2642. return
  2643. }
  2644. // Re-entrancy guard: concurrent refreshes over a genuinely changed file
  2645. // would race unmutexed tree updates (create/remove interleaving); one
  2646. // reload at a time keeps the update pass single-writer.
  2647. if (reloadInFlight) {
  2648. appendNotice('A config reload is already running.', 'warning')
  2649. return
  2650. }
  2651. // Optional-service lookup: the TUI must not depend on the Loader (tests
  2652. // and embedders run without one), so `loader` stays out of `inject` and
  2653. // is read through the non-throwing `ctx.get` accessor — a bare `ctx.loader`
  2654. // proxy read would throw `cannot get property without inject` in a fiber.
  2655. const loader = ctx.get('loader') as { entries(): Iterable<{ subtree?: { refresh?(): Promise<void> } }> } | undefined
  2656. if (loader === undefined) {
  2657. appendNotice('/reload needs the cordis Loader; this runtime has none.', 'warning')
  2658. return
  2659. }
  2660. const refreshes: Promise<void>[] = []
  2661. for (const entry of loader.entries()) {
  2662. if (entry.subtree?.refresh !== undefined) refreshes.push(entry.subtree.refresh())
  2663. }
  2664. reloadInFlight = true
  2665. appendNotice(`Reloading ${refreshes.length} config tree(s)… (experimental)`)
  2666. // refresh() never rejects (it warns and keeps the running tree), so the
  2667. // join can only fulfill; the catch arm guards a future contract change.
  2668. void Promise.all(refreshes).then(() => {
  2669. appendNotice('Config reload complete. Unchanged files were skipped; invalid files keep the running tree (see logs).')
  2670. }).catch((error: unknown) => {
  2671. appendNotice(`Config reload failed: ${errorChain(error)}`, 'error')
  2672. }).finally(() => {
  2673. reloadInFlight = false
  2674. })
  2675. }
  2676. /** Build one display candidate without letting a corrupt neighbor abort the selector. */
  2677. const readResumeCandidate = async (
  2678. record: SessionRecord,
  2679. providers: ReadonlySet<string>,
  2680. ): Promise<ResumeCandidate> => {
  2681. try {
  2682. let snapshot: SessionLogSnapshot
  2683. const live = ctx.sessions.get(record.header.id)
  2684. if (live !== undefined) {
  2685. snapshot = {
  2686. session: structuredClone(live.header),
  2687. events: live.events.map(event => structuredClone(event)),
  2688. }
  2689. } else {
  2690. /* v8 ignore next -- caller checks the optional service before mapping records */
  2691. if (sessionQuery === undefined) throw new Error('session query is unavailable')
  2692. snapshot = await sessionQuery.readSession(record.header.id)
  2693. }
  2694. return summarizeResumeCandidate(
  2695. record,
  2696. snapshot,
  2697. agent.session.id,
  2698. agent.session.header.cwd,
  2699. providers,
  2700. )
  2701. } catch (error: unknown) {
  2702. return {
  2703. record,
  2704. title: 'Unreadable session',
  2705. lastActivityAt: record.header.createdAt,
  2706. lastTurn: 'log unavailable',
  2707. disabledReason: `session cannot be loaded: ${errorChain(error)}`,
  2708. }
  2709. }
  2710. }
  2711. /** Re-read every mutable precondition immediately before terminal handoff. */
  2712. const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
  2713. /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
  2714. if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
  2715. const initialStatus = agentStatus()
  2716. if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
  2717. const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
  2718. if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
  2719. const candidate = await readResumeCandidate(
  2720. record,
  2721. new Set(ctx.llm.listProviders().map(provider => provider.id)),
  2722. )
  2723. if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
  2724. const finalStatus = agentStatus()
  2725. if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
  2726. return candidate
  2727. }
  2728. const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
  2729. if (resumeInFlight) return
  2730. resumeInFlight = true
  2731. let terminalReleased = false
  2732. try {
  2733. const checked = await preflightResume(candidate.record.header.id)
  2734. const hostHandoff = runtime.handoffResume
  2735. if (hostHandoff === undefined) {
  2736. const template = config.resumeCommand
  2737. const fallback = template?.replaceAll('{session}', checked.record.header.id)
  2738. await overlay.close()
  2739. resumeOverlay = undefined
  2740. appendNotice(fallback === undefined
  2741. ? 'Session is resumable, but this host cannot hand it off in place.'
  2742. : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
  2743. return
  2744. }
  2745. /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
  2746. if (disposed) return
  2747. await ctx.sessions.flush(agent.session)
  2748. // Disposal can run while the flush promise is pending; TypeScript does not model that reentry.
  2749. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  2750. if (disposed) return
  2751. if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
  2752. await overlay.close()
  2753. resumeOverlay = undefined
  2754. await runtime.terminal.drainInput(100, 20)
  2755. // Disposal can run while terminal draining is pending; TypeScript does not model that reentry.
  2756. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  2757. if (disposed) return
  2758. ui.stop()
  2759. terminalReleased = true
  2760. await hostHandoff(checked.record.header.id)
  2761. throw new Error('resume host returned without replacing the process')
  2762. } catch (error: unknown) {
  2763. if (!disposed) {
  2764. if (terminalReleased) {
  2765. ui.start()
  2766. ui.setFocus(editor)
  2767. appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
  2768. } else {
  2769. await overlay.close()
  2770. resumeOverlay = undefined
  2771. appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
  2772. }
  2773. }
  2774. } finally {
  2775. resumeInFlight = false
  2776. }
  2777. }
  2778. /** Open the current-workspace searchable session selector. */
  2779. const showResume = (): void => {
  2780. if (agent.status !== 'idle') {
  2781. appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
  2782. return
  2783. }
  2784. if (sessionQuery === undefined) {
  2785. appendNotice('Resume is not available: session query is not mounted.', 'warning')
  2786. return
  2787. }
  2788. const scan = ++resumeScan
  2789. void resumeOverlay?.close()
  2790. void sessionQuery.listSessions().then(async (records) => {
  2791. if (isDisposed() || scan !== resumeScan) return
  2792. const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
  2793. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
  2794. const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
  2795. candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
  2796. || a.record.header.id.localeCompare(b.record.header.id))
  2797. if (isDisposed() || scan !== resumeScan) return
  2798. const session = overlayManager.open({
  2799. create: host => new ResumePicker(
  2800. candidates,
  2801. resolved.maxResumeOptions,
  2802. runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
  2803. () => host.viewport.rows,
  2804. palette,
  2805. (candidate) => { void handoffResume(candidate, session) },
  2806. () => { void session.close() },
  2807. ),
  2808. options: {
  2809. width: '100%',
  2810. maxHeight: '100%',
  2811. anchor: 'top-left',
  2812. margin: 0,
  2813. },
  2814. })
  2815. resumeOverlay = session
  2816. void session.closed.then(() => {
  2817. /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
  2818. if (resumeOverlay === session) resumeOverlay = undefined
  2819. })
  2820. requestRender()
  2821. }, (error: unknown) => {
  2822. if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
  2823. })
  2824. }
  2825. editor.onSubmit = (value: string) => {
  2826. const text = value.trim()
  2827. if (text === '') return
  2828. const restoreSubmittedInput = (): void => {
  2829. if (editor.getText() === '') editor.setText(value)
  2830. }
  2831. // `/skill:<name>` carries a colon, which the command registry's name
  2832. // grammar rejects, so it is intercepted before generic command routing.
  2833. if (text.startsWith(SKILL_COMMAND_PREFIX)) {
  2834. editor.addToHistory(text)
  2835. editor.setText('')
  2836. const { name, instructions } = parseSkillCommand(text)
  2837. if (name === '') appendNotice('Usage: /skill:<name> [instructions]', 'warning')
  2838. else invokeSkill(name, instructions)
  2839. return
  2840. }
  2841. if (value.startsWith('/')) {
  2842. editor.addToHistory(text)
  2843. editor.setText('')
  2844. runCommand(value)
  2845. return
  2846. }
  2847. let parsed: ReturnType<typeof parseSessionReferenceText>
  2848. try {
  2849. parsed = parseSessionReferenceText(text)
  2850. } catch (error: unknown) {
  2851. restoreSubmittedInput()
  2852. appendNotice(`Invalid session reference: ${errorChain(error)}`, 'error')
  2853. return
  2854. }
  2855. if (parsed.references.length === 0) {
  2856. editor.addToHistory(text)
  2857. editor.setText('')
  2858. dispatchMessage([{ type: 'text', text: parsed.text }])
  2859. return
  2860. }
  2861. const sessionReferences = ctx.get('sessionReferences')
  2862. if (sessionReferences === undefined) {
  2863. restoreSubmittedInput()
  2864. appendNotice('Session reference capability unavailable.', 'error')
  2865. return
  2866. }
  2867. const controller = new AbortController()
  2868. referenceControllers.add(controller)
  2869. editor.disableSubmit = true
  2870. void sessionReferences.prepare(
  2871. agent,
  2872. [{ type: 'text', text: parsed.text }],
  2873. parsed.references,
  2874. controller.signal,
  2875. ).then((prepared) => {
  2876. if (disposed) return
  2877. editor.addToHistory(text)
  2878. if (editor.getText() === value) editor.setText('')
  2879. if (prepared.additionalContext !== undefined) {
  2880. agent.inject({ content: prepared.additionalContext.content, source: prepared.additionalContext.source })
  2881. }
  2882. dispatchMessage(prepared.content)
  2883. }, (error: unknown) => {
  2884. if (!disposed && !controller.signal.aborted) {
  2885. restoreSubmittedInput()
  2886. appendNotice(`Session reference failed: ${errorChain(error)}`, 'error')
  2887. }
  2888. }).finally(() => {
  2889. referenceControllers.delete(controller)
  2890. editor.disableSubmit = false
  2891. requestRender()
  2892. })
  2893. }
  2894. const removeInputListener = ui.addInputListener((data) => {
  2895. if (overlayManager.hasActiveOverlay()) return undefined
  2896. if (matchesKey(data, Key.ctrl('o'))) {
  2897. toggleTools()
  2898. return { consume: true }
  2899. }
  2900. if (matchesKey(data, Key.ctrl('r'))) {
  2901. toggleReasoning()
  2902. return { consume: true }
  2903. }
  2904. if (matchesKey(data, Key.ctrl('l'))) {
  2905. ui.invalidate()
  2906. ui.requestRender(true)
  2907. return { consume: true }
  2908. }
  2909. if (matchesKey(data, Key.escape) && agent.status === 'running') {
  2910. agent.cancel({ kind: 'user' })
  2911. return { consume: true }
  2912. }
  2913. if (matchesKey(data, Key.ctrl('c'))) {
  2914. if (agent.status === 'running') {
  2915. agent.cancel({ kind: 'user' })
  2916. } else if (editor.getText() !== '') {
  2917. editor.setText('')
  2918. } else {
  2919. requestExit()
  2920. }
  2921. return { consume: true }
  2922. }
  2923. if (matchesKey(data, Key.ctrl('d'))) {
  2924. if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning')
  2925. else requestExit()
  2926. return { consume: true }
  2927. }
  2928. return undefined
  2929. })
  2930. const disposeSessionEvents = ctx.on('session/event', (session, event) => {
  2931. if (session !== agent.session) return
  2932. if (event.type === 'tool/result') fileSearch.invalidate()
  2933. recordEventUsage(tokens, event)
  2934. advanceTurnPhase(event)
  2935. if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
  2936. rebuildTranscript(false)
  2937. return
  2938. }
  2939. renderEvent(event, { addHistory: false, renderChunks: true })
  2940. requestRender()
  2941. })
  2942. const settlePendingSteering = (id: AgentMessageId): void => {
  2943. if (pendingSteering.delete(id)) refreshStatus()
  2944. }
  2945. const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {
  2946. if (subject === agent) settlePendingSteering(message.id)
  2947. })
  2948. const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => {
  2949. if (subject !== agent) return
  2950. let changed = false
  2951. for (const message of messages) changed = pendingSteering.delete(message.id) || changed
  2952. if (changed) refreshStatus()
  2953. })
  2954. const disposeStatus = ctx.on('agent/status', (subject, status) => {
  2955. if (subject !== agent) return
  2956. // Leaving 'running' ends the turn's status line; clear any badge so the
  2957. // next running turn starts from zero (and a cancellation, which discards
  2958. // the queue without logging drains, cannot strand a stale count).
  2959. if (status !== 'running') pendingSteering.clear()
  2960. setStatus(status)
  2961. })
  2962. const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
  2963. if (subject !== agent) return
  2964. liveErrors.add(`${turn}:${step}`)
  2965. // Full cause chain: wrapper messages like `fetch failed` carry the
  2966. // actionable transport detail on `cause`.
  2967. appendNotice(errorChain(error), 'error')
  2968. })
  2969. const disposeAgent = ctx.on('agent/disposed', (subject) => {
  2970. if (subject !== agent) return
  2971. clearStatus()
  2972. appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
  2973. })
  2974. const detachListeners = (): void => {
  2975. skillAbort.abort()
  2976. fileSearch.dispose()
  2977. removeInputListener()
  2978. disposeCommandChanges()
  2979. stopBannerReveal()
  2980. disposeSessionEvents()
  2981. disposeDequeued()
  2982. disposeDiscarded()
  2983. disposeStatus()
  2984. disposeError()
  2985. disposeAgent()
  2986. disposeSchemeListener()
  2987. disposeTargetListeners()
  2988. }
  2989. // Sweep reveal of the whole banner: the header wipes in left-to-right over
  2990. // ~BANNER_REVEAL_STEPS frames (started after `ui.start()` succeeds).
  2991. // Configured subtitles skip it so deployments (and snapshot fixtures) stay
  2992. // frame-deterministic.
  2993. let revealTimer: ReturnType<typeof setInterval> | undefined
  2994. const stopBannerReveal = (): void => {
  2995. if (revealTimer === undefined) return
  2996. clearInterval(revealTimer)
  2997. revealTimer = undefined
  2998. header.setRevealWidth(undefined)
  2999. }
  3000. const startBannerReveal = (): void => {
  3001. if (config.welcome !== undefined) return
  3002. const total = Math.max(1, runtime.terminal.columns)
  3003. const step = Math.max(1, Math.ceil(total / BANNER_REVEAL_STEPS))
  3004. let shown = 0
  3005. header.setRevealWidth(0)
  3006. revealTimer = setInterval(() => {
  3007. shown += step
  3008. if (shown >= total) {
  3009. stopBannerReveal()
  3010. } else {
  3011. header.setRevealWidth(shown)
  3012. }
  3013. requestRender()
  3014. }, BANNER_REVEAL_INTERVAL_MS)
  3015. }
  3016. rebuildTranscript(true)
  3017. const restoredGoal = foldGoal(agent.session.events).goal
  3018. if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') {
  3019. appendNotice(
  3020. `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. `
  3021. + 'Human confirmation is required; send “继续” or run /goal resume.',
  3022. 'warning',
  3023. )
  3024. }
  3025. setStatus(agent.status)
  3026. try {
  3027. ui.start()
  3028. } catch (error: unknown) {
  3029. disposed = true
  3030. detachListeners()
  3031. void Promise.all([
  3032. commandFiber.dispose(),
  3033. fileReferencePromptFiber.dispose(),
  3034. ]).catch(
  3035. /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
  3036. (cleanupError: unknown) => {
  3037. ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`)
  3038. },
  3039. )
  3040. clearStatus()
  3041. disposeUserInteraction()
  3042. ui.stop()
  3043. throw error
  3044. }
  3045. tuiServiceFiber = ctx.inject([], (serviceCtx) => {
  3046. new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
  3047. })
  3048. startBannerReveal()
  3049. return {
  3050. async dispose(): Promise<void> {
  3051. detachListeners()
  3052. await shutdown(false)
  3053. await Promise.all([
  3054. commandFiber.dispose(),
  3055. fileReferencePromptFiber.dispose(),
  3056. ])
  3057. },
  3058. }
  3059. }
  3060. /**
  3061. * Open the pi-tui channel once its configured agent exists.
  3062. *
  3063. * @param ctx - Context supplying the agent registry, tools, and event stream.
  3064. * @param config - Target agent and presentation configuration.
  3065. * @param runtime - Terminal and process-exit boundary.
  3066. */
  3067. export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void {
  3068. const sessionId = SessionId(config.sessionId ?? 'main')
  3069. const matchesConfiguredIdentity = (agent: Agent): boolean =>
  3070. agent.id === sessionId && ctx.agents.roots().includes(agent)
  3071. let settled = false
  3072. const stopWaiting = (): void => {
  3073. disposeCreated()
  3074. disposeFailure()
  3075. }
  3076. const start = (agent: Agent): void => {
  3077. if (settled || !matchesConfiguredIdentity(agent)) return
  3078. settled = true
  3079. stopWaiting()
  3080. ctx.effect(() => {
  3081. const controller = createTuiChat(ctx, config, runtime)
  3082. return () => controller.dispose()
  3083. }, 'ui-tui')
  3084. }
  3085. const fail = (failedSessionId: SessionId, error: unknown): void => {
  3086. if (settled || failedSessionId !== sessionId) return
  3087. settled = true
  3088. stopWaiting()
  3089. runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`))
  3090. runtime.exit(1)
  3091. }
  3092. const disposeCreated = ctx.on('agent/created', start)
  3093. const disposeFailure = ctx.on('agent-loop/config-start-failed', fail)
  3094. const existing = ctx.agents.roots().find(agent => agent.id === sessionId)
  3095. if (existing !== undefined) start(existing)
  3096. }
  3097. /** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
  3098. /* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
  3099. and the tui-agent PTY smoke covers the real entry */
  3100. export function apply(ctx: Context, config: Config): void {
  3101. if (!process.stdin.isTTY || !process.stdout.isTTY) {
  3102. throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
  3103. }
  3104. // Truecolor is a terminal capability, so detect it here at the process
  3105. // boundary from COLORTERM; an explicit `truecolor` config value still wins.
  3106. const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
  3107. const resumeHost = ctx.get('tuiResumeHost')
  3108. mountTui(ctx, Object.assign({}, config, { truecolor }), {
  3109. terminal: new ProcessTerminal(),
  3110. exit: code => process.exit(code),
  3111. ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
  3112. })
  3113. }
  3114. /* v8 ignore stop */