Something went wrong. Try again.
more up to date at codeberg https://codeberg.org/cowie/tau.git
Something went wrong. Try again.
172 kB · 5135 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136/** * Interactive mode for the coding agent. * Handles TUI rendering and user interaction, delegating business logic to AgentSession. */
import * as crypto from "node:crypto";import * as fs from "node:fs";import * as os from "node:os";import * as path from "node:path";import type { AgentMessage } from "@earendil-works/pi-agent-core";import { type AssistantMessage, getProviders, type ImageContent, type Message, type Model, type OAuthProviderId, type OAuthSelectPrompt,} from "@earendil-works/pi-ai";import type { AutocompleteItem, AutocompleteProvider, EditorComponent, Keybinding, KeyId, MarkdownTheme, OverlayHandle, OverlayOptions, SlashCommand,} from "@earendil-works/pi-tui";import { CombinedAutocompleteProvider, type Component, Container, fuzzyFilter, Loader, type LoaderIndicatorOptions, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth,} from "@earendil-works/pi-tui";import { spawn, spawnSync } from "child_process";import { APP_NAME, APP_TITLE, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, getShareViewerUrl, VERSION,} from "../../config.ts";import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";import type { AgentSessionRuntime } from "../../core/agent-session-runtime.ts";import type { AutocompleteProviderFactory, EditorFactory, ExtensionCommandContext, ExtensionContext, ExtensionRunner, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions,} from "../../core/extensions/index.ts";import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";import { createCompactionSummaryMessage } from "../../core/messages.ts";import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";import { DefaultPackageManager } from "../../core/package-manager.ts";import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";import type { ResourceDiagnostic } from "../../core/resource-loader.ts";import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";import { type SessionContext, SessionManager } from "../../core/session-manager.ts";import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";import type { SourceInfo } from "../../core/source-info.ts";import type { TruncationResult } from "../../core/tools/truncate.ts";import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";import { copyToClipboard } from "../../utils/clipboard.ts";import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";import { parseGitUrl } from "../../utils/git.ts";import { killTrackedDetachedChildren } from "../../utils/shell.ts";import { ensureTool } from "../../utils/tools-manager.ts";import { ArminComponent } from "./components/armin.ts";import { AssistantMessageComponent } from "./components/assistant-message.ts";import { BashExecutionComponent } from "./components/bash-execution.ts";import { BorderedLoader } from "./components/bordered-loader.ts";import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";import { CustomEditor } from "./components/custom-editor.ts";import { CustomMessageComponent } from "./components/custom-message.ts";import { DaxnutsComponent } from "./components/daxnuts.ts";import { DynamicBorder } from "./components/dynamic-border.ts";import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts";import { ExtensionEditorComponent } from "./components/extension-editor.ts";import { ExtensionInputComponent } from "./components/extension-input.ts";import { ExtensionSelectorComponent } from "./components/extension-selector.ts";import { FooterComponent } from "./components/footer.ts";import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";import { LoginDialogComponent } from "./components/login-dialog.ts";import { ModelSelectorComponent } from "./components/model-selector.ts";import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.ts";import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";import { SessionSelectorComponent } from "./components/session-selector.ts";import { SettingsSelectorComponent } from "./components/settings-selector.ts";import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";import { ToolExecutionComponent } from "./components/tool-execution.ts";import { TreeSelectorComponent } from "./components/tree-selector.ts";import { UserMessageComponent } from "./components/user-message.ts";import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, type ThemeColor, theme,} from "./theme/theme.ts";
/** Interface for components that can be expanded/collapsed */interface Expandable { setExpanded(expanded: boolean): void;}
function isExpandable(obj: unknown): obj is Expandable { return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";}
type CompactionQueuedMessage = { text: string; mode: "steer" | "followUp";};
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
function _isDeadTerminalError(error: unknown): boolean { if (!error || typeof error !== "object" || !("code" in error)) { return false; } const code = (error as NodeJS.ErrnoException).code; return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);}
const _ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
function _isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");}
function isUnknownModel(model: Model<any> | undefined): boolean { return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";}
function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider { return providerId in defaultModelPerProvider;}
const BEDROCK_PROVIDER_ID = "amazon-bedrock";
const BUILT_IN_MODEL_PROVIDERS = new Set<string>(getProviders());
export function isApiKeyLoginProvider( providerId: string, oauthProviderIds: ReadonlySet<string>, builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS,): boolean { if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) { return true; } if (builtInProviderIds.has(providerId)) { return false; } return !oauthProviderIds.has(providerId);}
export function getApiKeyProviderDisplayName(providerId: string): string { return BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId] ?? providerId;}
/** * Options for InteractiveMode initialization. */export interface InteractiveModeOptions { /** Providers that were migrated to auth.json (shows warning) */ migratedProviders?: string[]; /** Warning message if session model couldn't be restored */ modelFallbackMessage?: string; /** Initial message to send on startup (can include @file content) */ initialMessage?: string; /** Images to attach to the initial message */ initialImages?: ImageContent[]; /** Additional messages to send after the initial message */ initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean;}
export class InteractiveMode { private runtimeHost: AgentSessionRuntime; private ui: TUI; private chatContainer: Container; private pendingMessagesContainer: Container; private statusContainer: Container; private defaultEditor: CustomEditor; private editor: EditorComponent; private editorComponentFactory: EditorFactory | undefined; private autocompleteProvider: AutocompleteProvider | undefined; private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; private fdPath: string | undefined; private editorContainer: Container; private footer: FooterComponent; private footerDataProvider: FooterDataProvider; // Stored so the same manager can be injected into custom editors, selectors, and extension UI. private keybindings: KeybindingsManager; private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | undefined = undefined; private workingMessage: string | undefined = undefined; private workingVisible = true; private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; private readonly defaultWorkingMessage = "Working..."; private readonly defaultHiddenThinkingLabel = "Thinking..."; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
private lastSigintTime = 0; private lastEscapeTime = 0; private changelogMarkdown: string | undefined = undefined; private startupNoticesShown = false;
// Status line tracking (for mutating immediately-sequential status updates) private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined;
// Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; private streamingMessage: AssistantMessage | undefined = undefined;
// Tool execution tracking: toolCallId -> component private pendingTools = new Map<string, ToolExecutionComponent>();
// Tool output expansion state private toolOutputExpanded = false;
// Thinking block visibility state private hideThinkingBlock = false;
// Skill commands: command name -> skill file path private skillCommands = new Map<string, string>();
// Agent subscription unsubscribe function private unsubscribe?: () => void; private signalCleanupHandlers: Array<() => void> = [];
// Track if editor is in bash mode (text starts with !) private isBashMode = false;
// Track current bash execution component private bashComponent: BashExecutionComponent | undefined = undefined;
// Track pending bash components (shown in pending area, moved to chat on submit) private pendingBashComponents: BashExecutionComponent[] = [];
// Auto-compaction state private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void;
// Auto-retry state private retryLoader: Loader | undefined = undefined; private retryEscapeHandler?: () => void;
// Messages queued while compaction is running private compactionQueuedMessages: CompactionQueuedMessage[] = [];
// Shutdown state private shutdownRequested = false;
// Extension UI state private extensionSelector: ExtensionSelectorComponent | undefined = undefined; private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputUnsubscribers = new Set<() => void>();
// Extension widgets (components rendered above/below the editor) private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>(); private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>(); private widgetContainerAbove!: Container; private widgetContainerBelow!: Container;
// Custom footer from extension (undefined = use built-in footer) private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
// Header container that holds the built-in or custom header private headerContainer: Container;
// Built-in header (logo + keybinding hints + changelog) private builtInHeader: Component | undefined = undefined;
// Custom header from extension (undefined = use built-in header) private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
private options: InteractiveModeOptions;
// Convenience accessors private get session(): AgentSession { return this.runtimeHost.session; } private get agent() { return this.session.agent; } private get sessionManager() { return this.session.sessionManager; } private get settingsManager() { return this.session.settingsManager; }
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; this.options = options; this.runtimeHost.setBeforeSessionInvalidate(() => { this.resetExtensionUI(); }); this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession(); }); this.version = VERSION; this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.headerContainer = new Container(); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); this.widgetContainerAbove = new Container(); this.widgetContainerBelow = new Container(); this.keybindings = KeybindingsManager.create(); setKeybindings(this.keybindings); const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { paddingX: editorPaddingX, autocompleteMaxVisible, }); this.editor = this.defaultEditor; this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); this.footer = new FooterComponent(this.session, this.footerDataProvider); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
// Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); initTheme(this.settingsManager.getTheme(), true); }
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { if (!sourceInfo) { return undefined; }
const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t"; const source = sourceInfo.source.trim();
if (source === "auto" || source === "local" || source === "cli") { return scopePrefix; }
if (source.startsWith("npm:")) { return `${scopePrefix}:${source}`; }
const gitSource = parseGitUrl(source); if (gitSource) { const ref = gitSource.ref ? `@${gitSource.ref}` : ""; return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`; }
return scopePrefix; }
private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined { const sourceTag = this.getAutocompleteSourceTag(sourceInfo); if (!sourceTag) { return description; } return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`; }
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] { const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); return extensionRunner .getRegisteredCommands() .filter((command) => builtinNames.has(command.name)) .map((command) => ({ type: "warning" as const, message: command.invocationName === command.name ? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.` : `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`, path: command.sourceInfo.path, })); }
private createBaseAutocompleteProvider(): AutocompleteProvider { // Define commands for autocomplete const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ name: command.name, description: command.description, }));
const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { // Get available models (scoped or from registry) const models = this.session.scopedModels.length > 0 ? this.session.scopedModels.map((s) => s.model) : this.session.modelRegistry.getAvailable();
if (models.length === 0) return null;
// Create items with provider/id format const items = models.map((m) => ({ id: m.id, provider: m.provider, label: `${m.provider}/${m.id}`, }));
// Fuzzy filter by model ID + provider (allows "opus anthropic" to match) const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);
if (filtered.length === 0) return null;
return filtered.map((item) => ({ value: item.label, label: item.id, description: item.provider, })); }; }
// Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), }));
// Convert extension commands to SlashCommand format const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); const extensionCommands: SlashCommand[] = this.session.extensionRunner .getRegisteredCommands() .filter((cmd) => !builtinCommandNames.has(cmd.name)) .map((cmd) => ({ name: cmd.invocationName, description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), getArgumentCompletions: cmd.getArgumentCompletions, }));
// Build skill commands from session.skills (if enabled) this.skillCommands.clear(); const skillCommandList: SlashCommand[] = []; if (this.settingsManager.getEnableSkillCommands()) { for (const skill of this.session.resourceLoader.getSkills().skills) { const commandName = `skill:${skill.name}`; this.skillCommands.set(commandName, skill.filePath); skillCommandList.push({ name: commandName, description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo), }); } }
return new CombinedAutocompleteProvider( [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], this.sessionManager.getCwd(), this.fdPath, ); }
private setupAutocompleteProvider(): void { let provider = this.createBaseAutocompleteProvider(); for (const wrapProvider of this.autocompleteProviderWrappers) { provider = wrapProvider(provider); }
this.autocompleteProvider = provider; this.defaultEditor.setAutocompleteProvider(provider); if (this.editor !== this.defaultEditor) { this.editor.setAutocompleteProvider?.(provider); } }
private showStartupNoticesIfNeeded(): void { if (this.startupNoticesShown) { return; } this.startupNoticesShown = true;
if (!this.changelogMarkdown) { return; }
if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } this.chatContainer.addChild(new DynamicBorder()); if (this.settingsManager.getCollapseChangelog()) { const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); const latestVersion = versionMatch ? versionMatch[1] : this.version; const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; this.chatContainer.addChild(new Text(condensedText, 1, 0)); } else { this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()), ); this.chatContainer.addChild(new Spacer(1)); } this.chatContainer.addChild(new DynamicBorder()); }
async init(): Promise<void> { if (this.isInitialized) return;
this.registerSignalHandlers();
// Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) // Both are needed: fd for autocomplete, rg for grep tool and bash commands const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { const modelList = this.session.scopedModels .map((sm) => { const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : ""; return `${sm.model.id}${thinkingStr}`; }) .join(", "); const cycleKeys = this.keybindings.getKeys("app.model.cycleForward"); const cycleHint = cycleKeys.length > 0 ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`) : ""; console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); }
// Add header container as first child this.ui.addChild(this.headerContainer);
// Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
// Build startup instructions using keybinding hint helpers const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
const instructions = [ hint("app.interrupt", "to interrupt"), hint("app.clear", "to clear"), rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), hint("app.exit", "to exit (empty)"), hint("app.suspend", "to suspend"), keyHint("tui.editor.deleteToLineEnd", "to delete to end"), hint("app.thinking.cycle", "to cycle thinking level"), rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"), hint("app.model.select", "to select model"), hint("app.tools.expand", "to expand tools"), hint("app.thinking.toggle", "to expand thinking"), hint("app.editor.external", "for external editor"), rawKeyHint("/", "for commands"), rawKeyHint("!", "to run bash"), rawKeyHint("!!", "to run bash (no context)"), hint("app.message.followUp", "to queue follow-up"), hint("app.message.dequeue", "to edit all queued messages"), hint("app.clipboard.pasteImage", "to paste image"), rawKeyHint("drop files", "to attach"), ].join("\n"); const onboarding = theme.fg( "dim", `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`, ); this.builtInHeader = new Text(`${logo}\n${instructions}\n\n${onboarding}`, 1, 0);
// Setup UI layout this.headerContainer.addChild(new Spacer(1)); this.headerContainer.addChild(this.builtInHeader); this.headerContainer.addChild(new Spacer(1)); } else { // Minimal header when silenced this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); }
this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); this.renderWidgets(); // Initialize with default spacer this.ui.addChild(this.widgetContainerAbove); this.ui.addChild(this.editorContainer); this.ui.addChild(this.widgetContainerBelow); this.ui.addChild(this.footer); this.ui.setFocus(this.editor);
this.setupKeyHandlers(); this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs this.ui.start(); this.isInitialized = true;
// Initialize extensions first so resources are shown before messages await this.rebindCurrentSession();
// Render initial messages AFTER showing loaded resources this.renderInitialMessages();
// Set up theme file watcher onThemeChange(() => { this.ui.invalidate(); this.updateEditorBorderColor(); this.ui.requestRender(); });
// Set up git branch watcher (uses provider instead of footer) this.footerDataProvider.onBranchChange(() => { this.ui.requestRender(); });
// Initialize available provider count for footer display await this.updateAvailableProviderCount(); }
/** * Update terminal title with session name and cwd. */ private updateTerminalTitle(): void { const cwdBasename = path.basename(this.sessionManager.getCwd()); const sessionName = this.sessionManager.getSessionName(); if (sessionName) { this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`); } else { this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`); } }
/** * Run the interactive mode. This is the main entry point. * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop. */ async run(): Promise<void> { await this.init();
// Start package update check asynchronously this.checkForPackageUpdates().then((updates) => { if (updates.length > 0) { this.showPackageUpdateNotification(updates); } });
// Check tmux keyboard setup asynchronously this.checkTmuxKeyboardSetup().then((warning) => { if (warning) { this.showWarning(warning); } });
// Show startup warnings const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); }
const modelsJsonError = this.session.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); }
if (modelFallbackMessage) { this.showWarning(modelFallbackMessage); }
// Process initial messages if (initialMessage) { try { await this.session.prompt(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } }
if (initialMessages) { for (const message of initialMessages) { try { await this.session.prompt(message); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } } }
// Main interactive loop while (true) { const userInput = await this.getUserInput(); try { await this.session.prompt(userInput); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; this.showError(errorMessage); } } }
private async checkForPackageUpdates(): Promise<string[]> { if (process.env.PI_OFFLINE) { return []; }
try { const packageManager = new DefaultPackageManager({ cwd: this.sessionManager.getCwd(), agentDir: getAgentDir(), settingsManager: this.settingsManager, }); const updates = await packageManager.checkForAvailableUpdates(); return updates.map((update) => update.displayName); } catch { return []; } }
private async checkTmuxKeyboardSetup(): Promise<string | undefined> { if (!process.env.TMUX) return undefined;
const runTmuxShow = (option: string): Promise<string | undefined> => { return new Promise((resolve) => { const proc = spawn("tmux", ["show", "-gv", option], { stdio: ["ignore", "pipe", "ignore"], }); let stdout = ""; const timer = setTimeout(() => { proc.kill(); resolve(undefined); }, 2000);
proc.stdout?.on("data", (data) => { stdout += data.toString(); }); proc.on("error", () => { clearTimeout(timer); resolve(undefined); }); proc.on("close", (code) => { clearTimeout(timer); resolve(code === 0 ? stdout.trim() : undefined); }); }); };
const [extendedKeys, extendedKeysFormat] = await Promise.all([ runTmuxShow("extended-keys"), runTmuxShow("extended-keys-format"), ]);
// If we couldn't query tmux (timeout, sandbox, etc.), don't warn if (extendedKeys === undefined) return undefined;
if (extendedKeys !== "on" && extendedKeys !== "always") { return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; }
if (extendedKeysFormat === "xterm") { return "tmux extended-keys-format is xterm. Pi works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux."; }
return undefined; }
/** * Get changelog entries to display on startup. * Only shows new entries since last seen version, skips for resumed sessions. */ private getChangelogForDisplay(): string | undefined { // Skip changelog for resumed/continued sessions (already have messages) if (this.session.state.messages.length > 0) { return undefined; }
const lastVersion = this.settingsManager.getLastChangelogVersion(); const changelogPath = getChangelogPath(); const entries = parseChangelog(changelogPath);
if (!lastVersion) { // Fresh install - just record the version, don't show changelog this.settingsManager.setLastChangelogVersion(VERSION); return undefined; } else { const newEntries = getNewEntries(entries, lastVersion); if (newEntries.length > 0) { this.settingsManager.setLastChangelogVersion(VERSION); return newEntries.map((e) => e.content).join("\n\n"); } }
return undefined; }
private getMarkdownThemeWithSettings(): MarkdownTheme { return { ...getMarkdownTheme(), codeBlockIndent: this.settingsManager.getCodeBlockIndent(), }; }
// ========================================================================= // Extension System // =========================================================================
private formatDisplayPath(p: string): string { const home = os.homedir(); let result = p;
// Replace home directory with ~ if (result.startsWith(home)) { result = `~${result.slice(home.length)}`; }
return result; }
private formatExtensionDisplayPath(path: string): string { let result = this.formatDisplayPath(path); result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, ""); return result; }
/** * Get a short path relative to the package root for display. */ private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string { const source = sourceInfo?.source ?? ""; const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); if (npmMatch && source.startsWith("npm:")) { return npmMatch[2]; }
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); if (gitMatch && source.startsWith("git:")) { return gitMatch[1]; }
return this.formatDisplayPath(fullPath); }
private getDisplaySourceInfo(sourceInfo?: SourceInfo): { label: string; scopeLabel?: string; color: "accent" | "muted"; } { const source = sourceInfo?.source ?? "local"; const scope = sourceInfo?.scope ?? "project"; if (source === "local") { if (scope === "user") { return { label: "user", color: "muted" }; } if (scope === "project") { return { label: "project", color: "muted" }; } if (scope === "temporary") { return { label: "path", scopeLabel: "temp", color: "muted" }; } return { label: "path", color: "muted" }; }
if (source === "cli") { return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" }; }
const scopeLabel = scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined; return { label: source, scopeLabel, color: "accent" }; }
private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" { const source = sourceInfo?.source ?? "local"; const scope = sourceInfo?.scope ?? "project"; if (source === "cli" || scope === "temporary") return "path"; if (scope === "user") return "user"; if (scope === "project") return "project"; return "path"; }
private isPackageSource(sourceInfo?: SourceInfo): boolean { const source = sourceInfo?.source ?? ""; return source.startsWith("npm:") || source.startsWith("git:"); }
private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{ scope: "user" | "project" | "path"; paths: Array<{ path: string; sourceInfo?: SourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; }> { const groups: Record< "user" | "project" | "path", { scope: "user" | "project" | "path"; paths: Array<{ path: string; sourceInfo?: SourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; } > = { user: { scope: "user", paths: [], packages: new Map() }, project: { scope: "project", paths: [], packages: new Map() }, path: { scope: "path", paths: [], packages: new Map() }, };
for (const item of items) { const groupKey = this.getScopeGroup(item.sourceInfo); const group = groups[groupKey]; const source = item.sourceInfo?.source ?? "local";
if (this.isPackageSource(item.sourceInfo)) { const list = group.packages.get(source) ?? []; list.push(item); group.packages.set(source, list); } else { group.paths.push(item); } }
return [groups.project, groups.user, groups.path].filter( (group) => group.paths.length > 0 || group.packages.size > 0, ); }
private formatScopeGroups( groups: Array<{ scope: "user" | "project" | "path"; paths: Array<{ path: string; sourceInfo?: SourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>; }>, options: { formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string; formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string; }, ): string { const lines: string[] = [];
for (const group of groups) { lines.push(` ${theme.fg("accent", group.scope)}`);
const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path)); for (const item of sortedPaths) { lines.push(theme.fg("dim", ` ${options.formatPath(item)}`)); }
const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b)); for (const [source, items] of sortedPackages) { lines.push(` ${theme.fg("mdLink", source)}`); const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path)); for (const item of sortedPackagePaths) { lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`)); } } }
return lines.join("\n"); }
private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined { const exact = sourceInfos.get(p); if (exact) return exact;
let current = p; while (current.includes("/")) { current = current.substring(0, current.lastIndexOf("/")); const parent = sourceInfos.get(current); if (parent) return parent; }
return undefined; }
private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string { if (sourceInfo) { const shortPath = this.getShortPath(p, sourceInfo); const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo); const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; return `${labelText} ${shortPath}`; } return this.formatDisplayPath(p); }
private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string { const lines: string[] = [];
// Group collision diagnostics by name const collisions = new Map<string, ResourceDiagnostic[]>(); const otherDiagnostics: ResourceDiagnostic[] = [];
for (const d of diagnostics) { if (d.type === "collision" && d.collision) { const list = collisions.get(d.collision.name) ?? []; list.push(d); collisions.set(d.collision.name, list); } else { otherDiagnostics.push(d); } }
// Format collision diagnostics grouped by name for (const [name, collisionList] of collisions) { const first = collisionList[0]?.collision; if (!first) continue; lines.push(theme.fg("warning", ` "${name}" collision:`)); lines.push( theme.fg( "dim", ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`, ), ); for (const d of collisionList) { if (d.collision) { lines.push( theme.fg( "dim", ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`, ), ); } } }
for (const d of otherDiagnostics) { if (d.path) { const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos)); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`)); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); } else { lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); } }
return lines.join("\n"); }
private showLoadedResources(options?: { extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>; force?: boolean; showDiagnosticsWhenQuiet?: boolean; }): void { const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup(); const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true; if (!showListing && !showDiagnostics) { return; }
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
const skillsResult = this.session.resourceLoader.getSkills(); const promptsResult = this.session.resourceLoader.getPrompts(); const themesResult = this.session.resourceLoader.getThemes(); const extensions = options?.extensions ?? this.session.resourceLoader.getExtensions().extensions.map((extension) => ({ path: extension.path, sourceInfo: extension.sourceInfo, })); const sourceInfos = new Map<string, SourceInfo>(); for (const extension of extensions) { if (extension.sourceInfo) { sourceInfos.set(extension.path, extension.sourceInfo); } } for (const skill of skillsResult.skills) { if (skill.sourceInfo) { sourceInfos.set(skill.filePath, skill.sourceInfo); } } for (const prompt of promptsResult.prompts) { if (prompt.sourceInfo) { sourceInfos.set(prompt.filePath, prompt.sourceInfo); } } for (const loadedTheme of themesResult.themes) { if (loadedTheme.sourcePath && loadedTheme.sourceInfo) { sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo); } }
if (showListing) { const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; if (contextFiles.length > 0) { this.chatContainer.addChild(new Spacer(1)); const contextList = contextFiles .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) .join("\n"); this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); }
const skills = skillsResult.skills; if (skills.length > 0) { const groups = this.buildScopeGroups( skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })), ); const skillList = this.formatScopeGroups(groups, { formatPath: (item) => this.formatDisplayPath(item.path), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); }
const templates = this.session.promptTemplates; if (templates.length > 0) { const groups = this.buildScopeGroups( templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), ); const templateByPath = new Map(templates.map((t) => [t.filePath, t])); const templateList = this.formatScopeGroups(groups, { formatPath: (item) => { const template = templateByPath.get(item.path); return template ? `/${template.name}` : this.formatDisplayPath(item.path); }, formatPackagePath: (item) => { const template = templateByPath.get(item.path); return template ? `/${template.name}` : this.formatDisplayPath(item.path); }, }); this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); }
if (extensions.length > 0) { const groups = this.buildScopeGroups(extensions); const extList = this.formatScopeGroups(groups, { formatPath: (item) => this.formatExtensionDisplayPath(item.path), formatPackagePath: (item) => this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)), }); this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); }
// Show loaded themes (excluding built-in) const loadedThemes = themesResult.themes; const customThemes = loadedThemes.filter((t) => t.sourcePath); if (customThemes.length > 0) { const groups = this.buildScopeGroups( customThemes.map((loadedTheme) => ({ path: loadedTheme.sourcePath!, sourceInfo: loadedTheme.sourceInfo, })), ); const themeList = this.formatScopeGroups(groups, { formatPath: (item) => this.formatDisplayPath(item.path), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), }); this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } }
if (showDiagnostics) { const skillDiagnostics = skillsResult.diagnostics; if (skillDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); }
const promptDiagnostics = promptsResult.diagnostics; if (promptDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos); this.chatContainer.addChild( new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0), ); this.chatContainer.addChild(new Spacer(1)); }
const extensionDiagnostics: ResourceDiagnostic[] = []; const extensionErrors = this.session.resourceLoader.getExtensions().errors; if (extensionErrors.length > 0) { for (const error of extensionErrors) { extensionDiagnostics.push({ type: "error", message: error.error, path: error.path }); } }
const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics(); extensionDiagnostics.push(...commandDiagnostics); extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics(); extensionDiagnostics.push(...shortcutDiagnostics);
if (extensionDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos); this.chatContainer.addChild( new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0), ); this.chatContainer.addChild(new Spacer(1)); }
const themeDiagnostics = themesResult.diagnostics; if (themeDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Spacer(1)); } } }
/** * Initialize the extension system with TUI-based UI context. */ private async bindCurrentSessionExtensions(): Promise<void> { const uiContext = this.createExtensionUIContext(); await this.session.bindExtensions({ uiContext, abortHandler: () => { this.restoreQueuedMessagesToEditor({ abort: true }); }, commandContextActions: { waitForIdle: () => this.session.agent.waitForIdle(), newSession: async (options) => { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); try { const result = await this.runtimeHost.newSession(options); if (!result.cancelled) { this.renderCurrentSessionState(); this.ui.requestRender(); } return result; } catch (error: unknown) { return this.handleFatalRuntimeError("Failed to create session", error); } }, fork: async (entryId, options) => { try { const result = await this.runtimeHost.fork(entryId, options); if (!result.cancelled) { this.renderCurrentSessionState(); this.editor.setText(result.selectedText ?? ""); this.showStatus("Forked to new session"); } return { cancelled: result.cancelled }; } catch (error: unknown) { return this.handleFatalRuntimeError("Failed to fork session", error); } }, navigateTree: async (targetId, options) => { const result = await this.session.navigateTree(targetId, { summarize: options?.summarize, customInstructions: options?.customInstructions, replaceInstructions: options?.replaceInstructions, label: options?.label, }); if (result.cancelled) { return { cancelled: true }; }
this.chatContainer.clear(); this.renderInitialMessages(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); return { cancelled: false }; }, switchSession: async (sessionPath, options) => { return this.handleResumeSession(sessionPath, options); }, reload: async () => { await this.handleReloadCommand(); }, }, shutdownHandler: () => { this.shutdownRequested = true; if (!this.session.isStreaming) { void this.shutdown(); } }, onError: (error) => { this.showExtensionError(error.extensionPath, error.error, error.stack); }, });
setRegisteredThemes(this.session.resourceLoader.getThemes().themes); this.setupAutocompleteProvider();
const extensionRunner = this.session.extensionRunner; this.setupExtensionShortcuts(extensionRunner); this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); this.showStartupNoticesIfNeeded(); }
private applyRuntimeSettings(): void { configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.footer.setSession(this.session); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor.setPaddingX(editorPaddingX); this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible); if (this.editor !== this.defaultEditor) { this.editor.setPaddingX?.(editorPaddingX); this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); } }
private async rebindCurrentSession(): Promise<void> { this.unsubscribe?.(); this.unsubscribe = undefined; this.applyRuntimeSettings(); await this.bindCurrentSessionExtensions(); this.subscribeToAgent(); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); }
private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> { const message = error instanceof Error ? error.message : String(error); this.showError(`${prefix}: ${message}`); stopThemeWatcher(); this.stop(); process.exit(1); }
private renderCurrentSessionState(): void { this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); this.renderInitialMessages(); }
/** * Get a registered tool definition by name (for custom rendering). */ private getRegisteredToolDefinition(toolName: string) { return this.session.getToolDefinition(toolName); }
/** * Set up keyboard shortcuts registered by extensions. */ private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void { const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); if (shortcuts.size === 0) return;
// Create a context for shortcut handlers const createContext = (): ExtensionContext => ({ ui: this.createExtensionUIContext(), hasUI: true, cwd: this.sessionManager.getCwd(), sessionManager: this.sessionManager, modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, signal: this.session.agent.signal, abort: () => { this.restoreQueuedMessagesToEditor({ abort: true }); }, hasPendingMessages: () => this.session.pendingMessageCount > 0, shutdown: () => { this.shutdownRequested = true; }, getContextUsage: () => this.session.getContextUsage(), compact: (options) => { void (async () => { try { const result = await this.session.compact(options?.customInstructions); options?.onComplete?.(result); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); options?.onError?.(err); } })(); }, getSystemPrompt: () => this.session.systemPrompt, });
// Set up the extension shortcut handler on the default editor this.defaultEditor.onExtensionShortcut = (data: string) => { for (const [shortcutStr, shortcut] of shortcuts) { // Cast to KeyId - extension shortcuts use the same format if (matchesKey(data, shortcutStr as KeyId)) { // Run handler async, don't block input Promise.resolve(shortcut.handler(createContext())).catch((err) => { this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`); }); return true; } } return false; }; }
/** * Set extension status text in the footer. */ private setExtensionStatus(key: string, text: string | undefined): void { this.footerDataProvider.setExtensionStatus(key, text); this.ui.requestRender(); }
private getWorkingLoaderMessage(): string { return this.workingMessage ?? this.defaultWorkingMessage; }
private createWorkingLoader(): Loader { return new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), this.getWorkingLoaderMessage(), this.workingIndicatorOptions, ); }
private stopWorkingLoader(): void { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); }
private setWorkingVisible(visible: boolean): void { this.workingVisible = visible; if (!visible) { this.stopWorkingLoader(); this.ui.requestRender(); return; } if (this.session.isStreaming && !this.loadingAnimation) { this.statusContainer.clear(); this.loadingAnimation = this.createWorkingLoader(); this.statusContainer.addChild(this.loadingAnimation); } this.ui.requestRender(); }
private setWorkingIndicator(options?: LoaderIndicatorOptions): void { this.workingIndicatorOptions = options; this.loadingAnimation?.setIndicator(options); this.ui.requestRender(); }
private setHiddenThinkingLabel(label?: string): void { this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel; for (const child of this.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHiddenThinkingLabel(this.hiddenThinkingLabel); } } if (this.streamingComponent) { this.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel); } this.ui.requestRender(); }
/** * Set an extension widget (string array or custom component). */ private setExtensionWidget( key: string, content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, options?: ExtensionWidgetOptions, ): void { const placement = options?.placement ?? "aboveEditor"; const removeExisting = (map: Map<string, Component & { dispose?(): void }>) => { const existing = map.get(key); if (existing?.dispose) existing.dispose(); map.delete(key); };
removeExisting(this.extensionWidgetsAbove); removeExisting(this.extensionWidgetsBelow);
if (content === undefined) { this.renderWidgets(); return; }
let component: Component & { dispose?(): void };
if (Array.isArray(content)) { // Wrap string array in a Container with Text components const container = new Container(); for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) { container.addChild(new Text(line, 1, 0)); } if (content.length > InteractiveMode.MAX_WIDGET_LINES) { container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); } component = container; } else { // Factory function - create component component = content(this.ui, theme); }
const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove; targetMap.set(key, component); this.renderWidgets(); }
private clearExtensionWidgets(): void { for (const widget of this.extensionWidgetsAbove.values()) { widget.dispose?.(); } for (const widget of this.extensionWidgetsBelow.values()) { widget.dispose?.(); } this.extensionWidgetsAbove.clear(); this.extensionWidgetsBelow.clear(); this.renderWidgets(); }
private resetExtensionUI(): void { if (this.extensionSelector) { this.hideExtensionSelector(); } if (this.extensionInput) { this.hideExtensionInput(); } if (this.extensionEditor) { this.hideExtensionEditor(); } this.ui.hideOverlay(); this.clearExtensionTerminalInputListeners(); this.setExtensionFooter(undefined); this.setExtensionHeader(undefined); this.clearExtensionWidgets(); this.footerDataProvider.clearExtensionStatuses(); this.footer.invalidate(); this.autocompleteProviderWrappers = []; this.setCustomEditorComponent(undefined); this.setupAutocompleteProvider(); this.defaultEditor.onExtensionShortcut = undefined; this.updateTerminalTitle(); this.workingMessage = undefined; this.workingVisible = true; this.setWorkingIndicator(); if (this.loadingAnimation) { this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); } this.setHiddenThinkingLabel(); }
// Maximum total widget lines to prevent viewport overflow private static readonly MAX_WIDGET_LINES = 10;
/** * Render all extension widgets to the widget container. */ private renderWidgets(): void { if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true); this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false); this.ui.requestRender(); }
private renderWidgetContainer( container: Container, widgets: Map<string, Component & { dispose?(): void }>, spacerWhenEmpty: boolean, leadingSpacer: boolean, ): void { container.clear();
if (widgets.size === 0) { if (spacerWhenEmpty) { container.addChild(new Spacer(1)); } return; }
if (leadingSpacer) { container.addChild(new Spacer(1)); } for (const component of widgets.values()) { container.addChild(component); } }
/** * Set a custom footer component, or restore the built-in footer. */ private setExtensionFooter( factory: | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) | undefined, ): void { // Dispose existing custom footer if (this.customFooter?.dispose) { this.customFooter.dispose(); }
// Remove current footer from UI if (this.customFooter) { this.ui.removeChild(this.customFooter); } else { this.ui.removeChild(this.footer); }
if (factory) { // Create and add custom footer, passing the data provider this.customFooter = factory(this.ui, theme, this.footerDataProvider); this.ui.addChild(this.customFooter); } else { // Restore built-in footer this.customFooter = undefined; this.ui.addChild(this.footer); }
this.ui.requestRender(); }
/** * Set a custom header component, or restore the built-in header. */ private setExtensionHeader(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void { // Header may not be initialized yet if called during early initialization if (!this.builtInHeader) { return; }
// Dispose existing custom header if (this.customHeader?.dispose) { this.customHeader.dispose(); }
// Find the index of the current header in the header container const currentHeader = this.customHeader || this.builtInHeader; const index = this.headerContainer.children.indexOf(currentHeader);
if (factory) { // Create and add custom header this.customHeader = factory(this.ui, theme); if (index !== -1) { this.headerContainer.children[index] = this.customHeader; } else { // If not found (e.g. builtInHeader was never added), add at the top this.headerContainer.children.unshift(this.customHeader); } } else { // Restore built-in header this.customHeader = undefined; if (index !== -1) { this.headerContainer.children[index] = this.builtInHeader; } }
this.ui.requestRender(); }
private addExtensionTerminalInputListener( handler: (data: string) => { consume?: boolean; data?: string } | undefined, ): () => void { const unsubscribe = this.ui.addInputListener(handler); this.extensionTerminalInputUnsubscribers.add(unsubscribe); return () => { unsubscribe(); this.extensionTerminalInputUnsubscribers.delete(unsubscribe); }; }
private clearExtensionTerminalInputListeners(): void { for (const unsubscribe of this.extensionTerminalInputUnsubscribers) { unsubscribe(); } this.extensionTerminalInputUnsubscribers.clear(); }
/** * Create the ExtensionUIContext for extensions. */ private createExtensionUIContext(): ExtensionUIContext { return { select: (title, options, opts) => this.showExtensionSelector(title, options, opts), confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), notify: (message, type) => this.showExtensionNotify(message, type), onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setExtensionStatus(key, text), setWorkingMessage: (message) => { this.workingMessage = message; if (this.loadingAnimation) { this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage); } }, setWorkingVisible: (visible) => this.setWorkingVisible(visible), setWorkingIndicator: (options) => this.setWorkingIndicator(options), setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label), setWidget: (key, content, options) => this.setExtensionWidget(key, content, options), setFooter: (factory) => this.setExtensionFooter(factory), setHeader: (factory) => this.setExtensionHeader(factory), setTitle: (title) => this.ui.terminal.setTitle(title), custom: (factory, options) => this.showExtensionCustom(factory, options), pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(), editor: (title, prefill) => this.showExtensionEditor(title, prefill), addAutocompleteProvider: (factory) => { this.autocompleteProviderWrappers.push(factory); this.setupAutocompleteProvider(); }, setEditorComponent: (factory) => this.setCustomEditorComponent(factory), getEditorComponent: () => this.editorComponentFactory, get theme() { return theme; }, getAllThemes: () => getAvailableThemesWithPaths(), getTheme: (name) => getThemeByName(name), setTheme: (themeOrName) => { if (themeOrName instanceof Theme) { setThemeInstance(themeOrName); this.ui.requestRender(); return { success: true }; } const result = setTheme(themeOrName, true); if (result.success) { if (this.settingsManager.getTheme() !== themeOrName) { this.settingsManager.setTheme(themeOrName); } this.ui.requestRender(); } return result; }, getToolsExpanded: () => this.toolOutputExpanded, setToolsExpanded: (expanded) => this.setToolsExpanded(expanded), }; }
/** * Show a selector for extensions. */ private showExtensionSelector( title: string, options: string[], opts?: ExtensionUIDialogOptions, ): Promise<string | undefined> { return new Promise((resolve) => { if (opts?.signal?.aborted) { resolve(undefined); return; }
const onAbort = () => { this.hideExtensionSelector(); resolve(undefined); }; opts?.signal?.addEventListener("abort", onAbort, { once: true });
this.extensionSelector = new ExtensionSelectorComponent( title, options, (option) => { opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(option); }, () => { opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionSelector(); resolve(undefined); }, { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, );
this.editorContainer.clear(); this.editorContainer.addChild(this.extensionSelector); this.ui.setFocus(this.extensionSelector); this.ui.requestRender(); }); }
/** * Hide the extension selector. */ private hideExtensionSelector(): void { this.extensionSelector?.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionSelector = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); }
/** * Show a confirmation dialog for extensions. */ private async showExtensionConfirm( title: string, message: string, opts?: ExtensionUIDialogOptions, ): Promise<boolean> { const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); return result === "Yes"; }
private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise<string | undefined> { const confirmed = await this.showExtensionConfirm( "Session cwd not found", formatMissingSessionCwdPrompt(error.issue), ); return confirmed ? error.issue.fallbackCwd : undefined; }
/** * Show a text input for extensions. */ private showExtensionInput( title: string, placeholder?: string, opts?: ExtensionUIDialogOptions, ): Promise<string | undefined> { return new Promise((resolve) => { if (opts?.signal?.aborted) { resolve(undefined); return; }
const onAbort = () => { this.hideExtensionInput(); resolve(undefined); }; opts?.signal?.addEventListener("abort", onAbort, { once: true });
this.extensionInput = new ExtensionInputComponent( title, placeholder, (value) => { opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(value); }, () => { opts?.signal?.removeEventListener("abort", onAbort); this.hideExtensionInput(); resolve(undefined); }, { tui: this.ui, timeout: opts?.timeout }, );
this.editorContainer.clear(); this.editorContainer.addChild(this.extensionInput); this.ui.setFocus(this.extensionInput); this.ui.requestRender(); }); }
/** * Hide the extension input. */ private hideExtensionInput(): void { this.extensionInput?.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionInput = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); }
/** * Show a multi-line editor for extensions (with Ctrl+G support). */ private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> { return new Promise((resolve) => { this.extensionEditor = new ExtensionEditorComponent( this.ui, this.keybindings, title, prefill, (value) => { this.hideExtensionEditor(); resolve(value); }, () => { this.hideExtensionEditor(); resolve(undefined); }, );
this.editorContainer.clear(); this.editorContainer.addChild(this.extensionEditor); this.ui.setFocus(this.extensionEditor); this.ui.requestRender(); }); }
/** * Hide the extension editor. */ private hideExtensionEditor(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionEditor = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); }
/** * Set a custom editor component from an extension. * Pass undefined to restore the default editor. */ private setCustomEditorComponent(factory: EditorFactory | undefined): void { this.editorComponentFactory = factory;
// Save text from current editor before switching const currentText = this.editor.getText();
this.editorContainer.clear();
if (factory) { // Create the custom editor with tui, theme, and keybindings const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
// Wire up callbacks from the default editor newEditor.onSubmit = this.defaultEditor.onSubmit; newEditor.onChange = this.defaultEditor.onChange;
// Copy text from previous editor newEditor.setText(currentText);
// Copy appearance settings if supported if (newEditor.borderColor !== undefined) { newEditor.borderColor = this.defaultEditor.borderColor; } if (newEditor.setPaddingX !== undefined) { newEditor.setPaddingX(this.defaultEditor.getPaddingX()); }
// Set autocomplete if supported if (newEditor.setAutocompleteProvider && this.autocompleteProvider) { newEditor.setAutocompleteProvider(this.autocompleteProvider); }
// If extending CustomEditor, copy app-level handlers // Use duck typing since instanceof fails across jiti module boundaries const customEditor = newEditor as unknown as Record<string, unknown>; if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) { if (!customEditor.onEscape) { customEditor.onEscape = () => this.defaultEditor.onEscape?.(); } if (!customEditor.onCtrlD) { customEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.(); } if (!customEditor.onPasteImage) { customEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.(); } if (!customEditor.onExtensionShortcut) { customEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data); } // Copy action handlers (clear, suspend, model switching, etc.) for (const [action, handler] of this.defaultEditor.actionHandlers) { (customEditor.actionHandlers as Map<string, () => void>).set(action, handler); } }
this.editor = newEditor; } else { // Restore default editor with text from custom editor this.defaultEditor.setText(currentText); this.editor = this.defaultEditor; }
this.editorContainer.addChild(this.editor as Component); this.ui.setFocus(this.editor as Component); this.ui.requestRender(); }
/** * Show a notification for extensions. */ private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void { if (type === "error") { this.showError(message); } else if (type === "warning") { this.showWarning(message); } else { this.showStatus(message); } }
/** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */ private async showExtensionCustom<T>( factory: ( tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>, options?: { overlay?: boolean; overlayOptions?: OverlayOptions | (() => OverlayOptions); onHandle?: (handle: OverlayHandle) => void; }, ): Promise<T> { const savedText = this.editor.getText(); const isOverlay = options?.overlay ?? false;
const restoreEditor = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); this.ui.setFocus(this.editor); this.ui.requestRender(); };
return new Promise((resolve, reject) => { let component: Component & { dispose?(): void }; let closed = false;
const close = (result: T) => { if (closed) return; closed = true; if (isOverlay) this.ui.hideOverlay(); else restoreEditor(); // Note: both branches above already call requestRender resolve(result); try { component?.dispose?.(); } catch { /* ignore dispose errors */ } };
Promise.resolve(factory(this.ui, theme, this.keybindings, close)) .then((c) => { if (closed) return; component = c; if (isOverlay) { // Resolve overlay options - can be static or dynamic function const resolveOptions = (): OverlayOptions | undefined => { if (options?.overlayOptions) { const opts = typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions; return opts; } // Fallback: use component's width property if available const w = (component as { width?: number }).width; return w ? { width: w } : undefined; }; const handle = this.ui.showOverlay(component, resolveOptions()); // Expose handle to caller for visibility control options?.onHandle?.(handle); } else { this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(component); this.ui.requestRender(); } }) .catch((err) => { if (closed) return; if (!isOverlay) restoreEditor(); reject(err); }); }); }
/** * Show an extension error in the UI. */ private showExtensionError(extensionPath: string, error: string, stack?: string): void { const errorMsg = `Extension "${extensionPath}" error: ${error}`; const errorText = new Text(theme.fg("error", errorMsg), 1, 0); this.chatContainer.addChild(errorText); if (stack) { // Show stack trace in dim color, indented const stackLines = stack .split("\n") .slice(1) // Skip first line (duplicates error message) .map((line) => theme.fg("dim", ` ${line.trim()}`)) .join("\n"); if (stackLines) { this.chatContainer.addChild(new Text(stackLines, 1, 0)); } } this.ui.requestRender(); }
// ========================================================================= // Key Handlers // =========================================================================
private setupKeyHandlers(): void { // Set up handlers on defaultEditor - they use this.editor for text access // so they work correctly regardless of which editor is active this.defaultEditor.onEscape = () => { if (this.session.isStreaming) { this.restoreQueuedMessagesToEditor({ abort: true }); } else if (this.session.isBashRunning) { this.session.abortBash(); } else if (this.isBashMode) { this.editor.setText(""); this.isBashMode = false; this.updateEditorBorderColor(); } else if (!this.editor.getText().trim()) { // Double-escape with empty editor triggers /tree, /fork, or nothing based on setting const action = this.settingsManager.getDoubleEscapeAction(); if (action !== "none") { const now = Date.now(); if (now - this.lastEscapeTime < 500) { if (action === "tree") { this.showTreeSelector(); } else { this.showUserMessageSelector(); } this.lastEscapeTime = 0; } else { this.lastEscapeTime = now; } } } };
// Register app action handlers this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); this.defaultEditor.onCtrlD = () => this.handleCtrlD(); this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ()); this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel()); this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward")); this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward"));
// Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector()); this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector()); this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector());
this.defaultEditor.onChange = (text: string) => { const wasBashMode = this.isBashMode; this.isBashMode = text.trimStart().startsWith("!"); if (wasBashMode !== this.isBashMode) { this.updateEditorBorderColor(); } };
// Handle clipboard image paste (triggered on Ctrl+V) this.defaultEditor.onPasteImage = () => { this.handleClipboardImagePaste(); }; }
private async handleClipboardImagePaste(): Promise<void> { try { const image = await readClipboardImage(); if (!image) { return; }
// Write to temp file const tmpDir = os.tmpdir(); const ext = extensionForImageMimeType(image.mimeType) ?? "png"; const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`; const filePath = path.join(tmpDir, fileName); fs.writeFileSync(filePath, Buffer.from(image.bytes));
// Insert file path directly this.editor.insertTextAtCursor?.(filePath); this.ui.requestRender(); } catch { // Silently ignore clipboard errors (may not have permission, etc.) } }
private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); if (!text) return;
// Handle commands if (text === "/settings") { this.showSettingsSelector(); this.editor.setText(""); return; } if (text === "/scoped-models") { this.editor.setText(""); await this.showModelsSelector(); return; } if (text === "/model" || text.startsWith("/model ")) { const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; this.editor.setText(""); await this.handleModelCommand(searchTerm); return; } if (text.startsWith("/export")) { await this.handleExportCommand(text); this.editor.setText(""); return; } if (text.startsWith("/import")) { await this.handleImportCommand(text); this.editor.setText(""); return; } if (text === "/share") { await this.handleShareCommand(); this.editor.setText(""); return; } if (text === "/copy") { await this.handleCopyCommand(); this.editor.setText(""); return; } if (text === "/name" || text.startsWith("/name ")) { this.handleNameCommand(text); this.editor.setText(""); return; } if (text === "/session") { this.handleSessionCommand(); this.editor.setText(""); return; } if (text === "/changelog") { this.handleChangelogCommand(); this.editor.setText(""); return; } if (text === "/hotkeys") { this.handleHotkeysCommand(); this.editor.setText(""); return; } if (text === "/fork") { this.showUserMessageSelector(); this.editor.setText(""); return; } if (text === "/clone") { this.editor.setText(""); await this.handleCloneCommand(); return; } if (text === "/tree") { this.showTreeSelector(); this.editor.setText(""); return; } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); return; } if (text === "/logout") { this.showOAuthSelector("logout"); this.editor.setText(""); return; } if (text === "/new") { this.editor.setText(""); await this.handleClearCommand(); return; } if (text === "/compact" || text.startsWith("/compact ")) { const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; this.editor.setText(""); await this.handleCompactCommand(customInstructions); return; } if (text === "/reload") { this.editor.setText(""); await this.handleReloadCommand(); return; } if (text === "/debug") { this.handleDebugCommand(); this.editor.setText(""); return; } if (text === "/arminsayshi") { this.handleArminSaysHi(); this.editor.setText(""); return; } if (text === "/dementedelves") { this.handleDementedDelves(); this.editor.setText(""); return; } if (text === "/resume") { this.showSessionSelector(); this.editor.setText(""); return; } if (text === "/quit") { this.editor.setText(""); await this.shutdown(); return; }
// Handle bash command (! for normal, !! for excluded from context) if (text.startsWith("!")) { const isExcluded = text.startsWith("!!"); const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (command) { if (this.session.isBashRunning) { this.showWarning("A bash command is already running. Press Esc to cancel it first."); this.editor.setText(text); return; } this.editor.addToHistory?.(text); await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); return; } }
// Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text); } else { this.queueCompactionMessage(text, "steer"); } return; }
// If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "steer" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; }
// Normal message submission // First, move any pending bash components to chat this.flushPendingBashComponents();
if (this.onInputCallback) { this.onInputCallback(text); } this.editor.addToHistory?.(text); }; }
private subscribeToAgent(): void { this.unsubscribe = this.session.subscribe(async (event) => { await this.handleEvent(event); }); }
private async handleEvent(event: AgentSessionEvent): Promise<void> { if (!this.isInitialized) { await this.init(); }
this.footer.invalidate();
switch (event.type) { case "agent_start": this.pendingTools.clear(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } // Restore main escape handler if retry handler is still active // (retry success event fires later, but we need main handler now) if (this.retryEscapeHandler) { this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; } this.stopWorkingLoader(); if (this.workingVisible) { this.loadingAnimation = this.createWorkingLoader(); this.statusContainer.addChild(this.loadingAnimation); } this.ui.requestRender(); break;
case "queue_update": this.updatePendingMessagesDisplay(); this.ui.requestRender(); break;
case "session_info_changed": this.updateTerminalTitle(); this.footer.invalidate(); this.ui.requestRender(); break;
case "thinking_level_changed": this.footer.invalidate(); this.updateEditorBorderColor(); break;
case "message_start": if (event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent( undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage); this.ui.requestRender(); } break;
case "message_update": if (this.streamingComponent && event.message.role === "assistant") { this.streamingMessage = event.message; this.streamingComponent.updateContent(this.streamingMessage);
for (const content of this.streamingMessage.content) { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { const component = new ToolExecutionComponent( content.name, content.id, content.arguments, { showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), }, this.getRegisteredToolDefinition(content.name), this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { const component = this.pendingTools.get(content.id); if (component) { component.updateArgs(content.arguments); } } } } this.ui.requestRender(); } break;
case "message_end": if (event.message.role === "user") break; if (this.streamingComponent && event.message.role === "assistant") { this.streamingMessage = event.message; let errorMessage: string | undefined; if (this.streamingMessage.stopReason === "aborted") { const retryAttempt = this.session.retryAttempt; errorMessage = retryAttempt > 0 ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` : "Operation aborted"; this.streamingMessage.errorMessage = errorMessage; } this.streamingComponent.updateContent(this.streamingMessage);
if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { if (!errorMessage) { errorMessage = this.streamingMessage.errorMessage || "Error"; } for (const [, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true, }); } this.pendingTools.clear(); } else { // Args are now complete - trigger diff computation for edit tools for (const [, component] of this.pendingTools.entries()) { component.setArgsComplete(); } } this.streamingComponent = undefined; this.streamingMessage = undefined; this.footer.invalidate(); } this.ui.requestRender(); break;
case "tool_execution_start": { let component = this.pendingTools.get(event.toolCallId); if (!component) { component = new ToolExecutionComponent( event.toolName, event.toolCallId, event.args, { showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), }, this.getRegisteredToolDefinition(event.toolName), this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); } component.markExecutionStarted(); this.ui.requestRender(); break; }
case "tool_execution_update": { const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.partialResult, isError: false }, true); this.ui.requestRender(); } break; }
case "tool_execution_end": { const component = this.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError }); this.pendingTools.delete(event.toolCallId); this.ui.requestRender(); } break; }
case "agent_end": if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; this.statusContainer.clear(); } if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = undefined; this.streamingMessage = undefined; } this.pendingTools.clear();
await this.checkShutdownRequested();
this.ui.requestRender(); break;
case "compaction_start": { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } // Keep editor active; submissions are queued during compaction. this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; this.statusContainer.clear(); const cancelHint = `(${keyText("app.interrupt")} to cancel)`; const label = event.reason === "manual" ? `Compacting context... ${cancelHint}` : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; this.autoCompactionLoader = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), label, ); this.statusContainer.addChild(this.autoCompactionLoader); this.ui.requestRender(); break; }
case "compaction_end": { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } if (this.autoCompactionEscapeHandler) { this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; } if (this.autoCompactionLoader) { this.autoCompactionLoader.stop(); this.autoCompactionLoader = undefined; this.statusContainer.clear(); } if (event.aborted) { if (event.reason === "manual") { this.showError("Compaction cancelled"); } else { this.showStatus("Auto-compaction cancelled"); } } else if (event.result) { this.chatContainer.clear(); this.rebuildChatFromMessages(); this.addMessageToChat( createCompactionSummaryMessage( event.result.summary, event.result.tokensBefore, new Date().toISOString(), ), ); this.footer.invalidate(); } else if (event.errorMessage) { if (event.reason === "manual") { this.showError(event.errorMessage); } else { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0)); } } void this.flushCompactionQueue({ willRetry: event.willRetry }); this.ui.requestRender(); break; }
case "auto_retry_start": { // Set up escape to abort retry this.retryEscapeHandler = this.defaultEditor.onEscape; this.defaultEditor.onEscape = () => { this.session.abortRetry(); }; // Show retry indicator this.statusContainer.clear(); const delaySeconds = Math.round(event.delayMs / 1000); this.retryLoader = new Loader( this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${keyText("app.interrupt")} to cancel)`, ); this.statusContainer.addChild(this.retryLoader); this.ui.requestRender(); break; }
case "auto_retry_end": { // Restore escape handler if (this.retryEscapeHandler) { this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } // Stop loader if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; this.statusContainer.clear(); } // Show error only on final failure (success shows normal response) if (!event.success) { this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); } this.ui.requestRender(); break; } } }
/** Extract text content from a user message */ private getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const textBlocks = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content.filter((c: { type: string }) => c.type === "text"); return textBlocks.map((c) => (c as { text: string }).text).join(""); }
/** * Show a status message in the chat. * * If multiple status messages are emitted back-to-back (without anything else being added to the chat), * we update the previous status line instead of appending new ones to avoid log spam. */ private showStatus(message: string): void { const children = this.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { this.lastStatusText.setText(theme.fg("dim", message)); this.ui.requestRender(); return; }
const spacer = new Spacer(1); const text = new Text(theme.fg("dim", message), 1, 0); this.chatContainer.addChild(spacer); this.chatContainer.addChild(text); this.lastStatusSpacer = spacer; this.lastStatusText = text; this.ui.requestRender(); }
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete( message.exitCode, message.cancelled, message.truncated ? ({ truncated: true } as TruncationResult) : undefined, message.fullOutputPath, ); this.chatContainer.addChild(component); break; } case "custom": { if (message.display) { const renderer = this.session.extensionRunner.getMessageRenderer(message.customType); const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings()); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); } break; } case "compactionSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "branchSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "user": { const textContent = this.getUserMessageText(message); if (textContent) { if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } const skillBlock = parseSkillBlock(textContent); if (skillBlock) { // Render skill block (collapsible) this.chatContainer.addChild(new Spacer(1)); const component = new SkillInvocationMessageComponent( skillBlock, this.getMarkdownThemeWithSettings(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); // Render user message separately if present if (skillBlock.userMessage) { const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), ); this.chatContainer.addChild(userComponent); } } else { const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); this.chatContainer.addChild(userComponent); } if (options?.populateHistory) { this.editor.addToHistory?.(textContent); } } break; } case "assistant": { const assistantComponent = new AssistantMessageComponent( message, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, ); this.chatContainer.addChild(assistantComponent); break; } case "toolResult": { // Tool results are rendered inline with tool calls, handled separately break; } default: { const _exhaustive: never = message; } } }
/** * Render session context to chat. Used for initial load and rebuild after compaction. * @param sessionContext Session context to render * @param options.updateFooter Update footer state * @param options.populateHistory Add user messages to editor history */ private renderSessionContext( sessionContext: SessionContext, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); const renderedPendingTools = new Map<string, ToolExecutionComponent>();
if (options.updateFooter) { this.footer.invalidate(); this.updateEditorBorderColor(); }
for (const message of sessionContext.messages) { // Assistant messages need special handling for tool calls if (message.role === "assistant") { this.addMessageToChat(message); // Render tool call components for (const content of message.content) { if (content.type === "toolCall") { const component = new ToolExecutionComponent( content.name, content.id, content.arguments, { showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), }, this.getRegisteredToolDefinition(content.name), this.ui, this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component);
if (message.stopReason === "aborted" || message.stopReason === "error") { let errorMessage: string; if (message.stopReason === "aborted") { const retryAttempt = this.session.retryAttempt; errorMessage = retryAttempt > 0 ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` : "Operation aborted"; } else { errorMessage = message.errorMessage || "Error"; } component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { renderedPendingTools.set(content.id, component); } } } } else if (message.role === "toolResult") { // Match tool results to pending tool components const component = renderedPendingTools.get(message.toolCallId); if (component) { component.updateResult(message); renderedPendingTools.delete(message.toolCallId); } } else { // All other messages use standard rendering this.addMessageToChat(message, options); } }
for (const [toolCallId, component] of renderedPendingTools) { this.pendingTools.set(toolCallId, component); } this.ui.requestRender(); }
renderInitialMessages(): void { // Get aligned messages and entries from session context const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context, { updateFooter: true, populateHistory: true, });
// Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); const compactionCount = allEntries.filter((e) => e.type === "compaction").length; if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.showStatus(`Session compacted ${times}`); } }
async getUserInput(): Promise<string> { return new Promise((resolve) => { this.onInputCallback = (text: string) => { this.onInputCallback = undefined; resolve(text); }; }); }
private rebuildChatFromMessages(): void { this.chatContainer.clear(); const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context); }
// ========================================================================= // Key handlers // =========================================================================
private handleCtrlC(): void { const now = Date.now(); if (now - this.lastSigintTime < 500) { void this.shutdown(); } else { this.clearEditor(); this.lastSigintTime = now; } }
private handleCtrlD(): void { // Only called when editor is empty (enforced by CustomEditor) void this.shutdown(); }
/** * Gracefully shutdown the agent. * Stops the TUI before emitting shutdown events so extension UI cleanup cannot * repaint the final frame while the process is exiting. */ private isShuttingDown = false;
private async shutdown(): Promise<void> { if (this.isShuttingDown) return; this.isShuttingDown = true; this.unregisterSignalHandlers();
// Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000);
this.stop(); await this.runtimeHost.dispose(); process.exit(0); }
/** * Check if shutdown was requested and perform shutdown if so. */ private async checkShutdownRequested(): Promise<void> { if (!this.shutdownRequested) return; await this.shutdown(); }
private registerSignalHandlers(): void { this.unregisterSignalHandlers();
const signals: NodeJS.Signals[] = ["SIGTERM"]; if (process.platform !== "win32") { signals.push("SIGHUP"); }
for (const signal of signals) { const handler = () => { killTrackedDetachedChildren(); void this.shutdown(); }; process.on(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); } }
private unregisterSignalHandlers(): void { for (const cleanup of this.signalCleanupHandlers) { cleanup(); } this.signalCleanupHandlers = []; }
private handleCtrlZ(): void { // Keep the event loop alive while suspended. Without this, stopping the TUI // can leave Node with no ref'ed handles, causing the process to exit on fg // before the SIGCONT handler gets a chance to restore the terminal. const suspendKeepAlive = setInterval(() => {}, 2 ** 30);
// Ignore SIGINT while suspended so Ctrl+C in the terminal does not // kill the backgrounded process. The handler is removed on resume. const ignoreSigint = () => {}; process.on("SIGINT", ignoreSigint);
// Set up handler to restore TUI when resumed process.once("SIGCONT", () => { clearInterval(suspendKeepAlive); process.removeListener("SIGINT", ignoreSigint); this.ui.start(); this.ui.requestRender(true); });
try { // Stop the TUI (restore terminal to normal mode) this.ui.stop();
// Send SIGTSTP to process group (pid=0 means all processes in group) process.kill(0, "SIGTSTP"); } catch (error) { clearInterval(suspendKeepAlive); process.removeListener("SIGINT", ignoreSigint); throw error; } }
private async handleFollowUp(): Promise<void> { const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim(); if (!text) return;
// Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text); } else { this.queueCompactionMessage(text, "followUp"); } return; }
// Alt+Enter queues a follow-up message (waits until agent finishes) // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "followUp" }); this.updatePendingMessagesDisplay(); this.ui.requestRender(); } // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) else if (this.editor.onSubmit) { this.editor.setText(""); this.editor.onSubmit(text); } }
private handleDequeue(): void { const restored = this.restoreQueuedMessagesToEditor(); if (restored === 0) { this.showStatus("No queued messages to restore"); } else { this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`); } }
private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); } else { const level = this.session.thinkingLevel || "off"; this.editor.borderColor = theme.getThinkingBorderColor(level); } this.ui.requestRender(); }
private cycleThinkingLevel(): void { const newLevel = this.session.cycleThinkingLevel(); if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Thinking level: ${newLevel}`); } }
private async cycleModel(direction: "forward" | "backward"): Promise<void> { try { const result = await this.session.cycleModel(direction); if (result === undefined) { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); } else { this.footer.invalidate(); this.updateEditorBorderColor(); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private toggleToolOutputExpansion(): void { this.setToolsExpanded(!this.toolOutputExpanded); }
private setToolsExpanded(expanded: boolean): void { this.toolOutputExpanded = expanded; for (const child of this.chatContainer.children) { if (isExpandable(child)) { child.setExpanded(expanded); } } this.ui.requestRender(); }
private toggleThinkingBlockVisibility(): void { this.hideThinkingBlock = !this.hideThinkingBlock; this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
// Rebuild chat from session messages this.chatContainer.clear(); this.rebuildChatFromMessages();
// If streaming, re-add the streaming component with updated visibility and re-render if (this.streamingComponent && this.streamingMessage) { this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); this.streamingComponent.updateContent(this.streamingMessage); this.chatContainer.addChild(this.streamingComponent); }
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); }
private async openExternalEditor(): Promise<void> { // Determine editor (respect $VISUAL, then $EDITOR) const editorCmd = process.env.VISUAL || process.env.EDITOR; if (!editorCmd) { this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); return; }
const currentText = this.editor.getExpandedText?.() ?? this.editor.getText(); const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
try { // Write current content to temp file fs.writeFileSync(tmpFile, currentText, "utf-8");
// Stop TUI to release terminal this.ui.stop();
// Split by space to support editor arguments (e.g., "code --wait") const [editor, ...editorArgs] = editorCmd.split(" ");
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep // Node/libuv's console input read active after ui.stop() pauses stdin, racing // vim/nvim for the console input buffer until Ctrl+C cancels the pending read. const status = await new Promise<number | null>((resolve) => { const child = spawn(editor, [...editorArgs, tmpFile], { stdio: "inherit", shell: process.platform === "win32", }); child.on("error", () => resolve(null)); child.on("close", (code) => resolve(code)); });
// On successful exit (status 0), replace editor content if (status === 0) { const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); this.editor.setText(newContent); } // On non-zero exit, keep original text (no action needed) } finally { // Clean up temp file try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors }
// Restart TUI this.ui.start(); // Force full re-render since external editor uses alternate screen this.ui.requestRender(true); } }
// ========================================================================= // UI helpers // =========================================================================
clearEditor(): void { this.editor.setText(""); this.ui.requestRender(); }
showError(errorMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.ui.requestRender(); }
showWarning(warningMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); this.ui.requestRender(); }
showPackageUpdateNotification(packages: string[]): void { const action = theme.fg("accent", `${APP_NAME} update`); const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action; const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.chatContainer.addChild( new Text( `${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction}\n${theme.fg("muted", "Packages:")}\n${packageLines}`, 1, 0, ), ); this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text))); this.ui.requestRender(); }
/** * Get all queued messages (read-only). * Combines session queue and compaction queue. */ private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { return { steering: [ ...this.session.getSteeringMessages(), ...this.compactionQueuedMessages.filter((msg) => msg.mode === "steer").map((msg) => msg.text), ], followUp: [ ...this.session.getFollowUpMessages(), ...this.compactionQueuedMessages.filter((msg) => msg.mode === "followUp").map((msg) => msg.text), ], }; }
/** * Clear all queued messages and return their contents. * Clears both session queue and compaction queue. */ private clearAllQueues(): { steering: string[]; followUp: string[] } { const { steering, followUp } = this.session.clearQueue(); const compactionSteering = this.compactionQueuedMessages .filter((msg) => msg.mode === "steer") .map((msg) => msg.text); const compactionFollowUp = this.compactionQueuedMessages .filter((msg) => msg.mode === "followUp") .map((msg) => msg.text); this.compactionQueuedMessages = []; return { steering: [...steering, ...compactionSteering], followUp: [...followUp, ...compactionFollowUp], }; }
private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); if (steeringMessages.length > 0 || followUpMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); for (const message of steeringMessages) { const text = theme.fg("dim", `Steering: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } for (const message of followUpMessages) { const text = theme.fg("dim", `Follow-up: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } }
private restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { const { steering, followUp } = this.clearAllQueues(); const allQueued = [...steering, ...followUp]; if (allQueued.length === 0) { this.updatePendingMessagesDisplay(); if (options?.abort) { this.agent.abort(); } return 0; } const queuedText = allQueued.join("\n\n"); const currentText = options?.currentText ?? this.editor.getText(); const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); this.editor.setText(combinedText); this.updatePendingMessagesDisplay(); if (options?.abort) { this.agent.abort(); } return allQueued.length; }
private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { this.compactionQueuedMessages.push({ text, mode }); this.editor.addToHistory?.(text); this.editor.setText(""); this.updatePendingMessagesDisplay(); this.showStatus("Queued message for after compaction"); }
private isExtensionCommand(text: string): boolean { if (!text.startsWith("/")) return false;
const extensionRunner = this.session.extensionRunner;
const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); return !!extensionRunner.getCommand(commandName); }
private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> { if (this.compactionQueuedMessages.length === 0) { return; }
const queuedMessages = [...this.compactionQueuedMessages]; this.compactionQueuedMessages = []; this.updatePendingMessagesDisplay();
const restoreQueue = (error: unknown) => { this.session.clearQueue(); this.compactionQueuedMessages = queuedMessages; this.updatePendingMessagesDisplay(); this.showError( `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ error instanceof Error ? error.message : String(error) }`, ); };
try { if (options?.willRetry) { // When retry is pending, queue messages for the retry turn for (const message of queuedMessages) { if (this.isExtensionCommand(message.text)) { await this.session.prompt(message.text); } else if (message.mode === "followUp") { await this.session.followUp(message.text); } else { await this.session.steer(message.text); } } this.updatePendingMessagesDisplay(); return; }
// Find first non-extension-command message to use as prompt const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text)); if (firstPromptIndex === -1) { // All extension commands - execute them all for (const message of queuedMessages) { await this.session.prompt(message.text); } return; }
// Execute any extension commands before the first prompt const preCommands = queuedMessages.slice(0, firstPromptIndex); const firstPrompt = queuedMessages[firstPromptIndex]; const rest = queuedMessages.slice(firstPromptIndex + 1);
for (const message of preCommands) { await this.session.prompt(message.text); }
// Send first prompt (starts streaming) const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => { restoreQueue(error); });
// Queue remaining messages for (const message of rest) { if (this.isExtensionCommand(message.text)) { await this.session.prompt(message.text); } else if (message.mode === "followUp") { await this.session.followUp(message.text); } else { await this.session.steer(message.text); } } this.updatePendingMessagesDisplay(); void promptPromise; } catch (error) { restoreQueue(error); } }
/** Move pending bash components from pending area to chat */ private flushPendingBashComponents(): void { for (const component of this.pendingBashComponents) { this.pendingMessagesContainer.removeChild(component); this.chatContainer.addChild(component); } this.pendingBashComponents = []; }
// ========================================================================= // Selectors // =========================================================================
/** * Shows a selector component in place of the editor. * @param create Factory that receives a `done` callback and returns the component and focus target */ private showSelector(create: (done: () => void) => { component: Component; focus: Component }): void { const done = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); }; const { component, focus } = create(done); this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(focus); this.ui.requestRender(); }
private showSettingsSelector(): void { this.showSelector((done) => { const selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, showImages: this.settingsManager.getShowImages(), imageWidthCells: this.settingsManager.getImageWidthCells(), autoResizeImages: this.settingsManager.getImageAutoResize(), blockImages: this.settingsManager.getBlockImages(), enableSkillCommands: this.settingsManager.getEnableSkillCommands(), steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), cacheRetention: this.settingsManager.getCacheRetention(), httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), editorPaddingX: this.settingsManager.getEditorPaddingX(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(), warnings: this.settingsManager.getWarnings(), }, { onAutoCompactChange: (enabled) => { this.session.setAutoCompactionEnabled(enabled); this.footer.setAutoCompactEnabled(enabled); }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setShowImages(enabled); } } }, onImageWidthCellsChange: (width) => { this.settingsManager.setImageWidthCells(width); for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setImageWidthCells(width); } } }, onAutoResizeImagesChange: (enabled) => { this.settingsManager.setImageAutoResize(enabled); }, onBlockImagesChange: (blocked) => { this.settingsManager.setBlockImages(blocked); }, onEnableSkillCommandsChange: (enabled) => { this.settingsManager.setEnableSkillCommands(enabled); this.setupAutocompleteProvider(); }, onSteeringModeChange: (mode) => { this.session.setSteeringMode(mode); }, onFollowUpModeChange: (mode) => { this.session.setFollowUpMode(mode); }, onTransportChange: (transport) => { this.settingsManager.setTransport(transport); this.session.agent.transport = transport; }, onCacheRetentionChange: (retention) => { this.settingsManager.setCacheRetention(retention); this.session.agent.cacheRetention = retention; }, onHttpIdleTimeoutMsChange: (timeoutMs) => { this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); configureHttpDispatcher(timeoutMs); this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); this.footer.invalidate(); this.updateEditorBorderColor(); }, onThemeChange: (themeName) => { const result = setTheme(themeName, true); this.settingsManager.setTheme(themeName); this.ui.invalidate(); if (!result.success) { this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); } }, onThemePreview: (themeName) => { const result = setTheme(themeName, true); if (result.success) { this.ui.invalidate(); this.ui.requestRender(); } }, onHideThinkingBlockChange: (hidden) => { this.hideThinkingBlock = hidden; this.settingsManager.setHideThinkingBlock(hidden); for (const child of this.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(hidden); } } this.chatContainer.clear(); this.rebuildChatFromMessages(); }, onCollapseChangelogChange: (collapsed) => { this.settingsManager.setCollapseChangelog(collapsed); }, onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, onDoubleEscapeActionChange: (action) => { this.settingsManager.setDoubleEscapeAction(action); }, onTreeFilterModeChange: (mode) => { this.settingsManager.setTreeFilterMode(mode); }, onShowHardwareCursorChange: (enabled) => { this.settingsManager.setShowHardwareCursor(enabled); this.ui.setShowHardwareCursor(enabled); }, onEditorPaddingXChange: (padding) => { this.settingsManager.setEditorPaddingX(padding); this.defaultEditor.setPaddingX(padding); if (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) { this.editor.setPaddingX(padding); } }, onAutocompleteMaxVisibleChange: (maxVisible) => { this.settingsManager.setAutocompleteMaxVisible(maxVisible); this.defaultEditor.setAutocompleteMaxVisible(maxVisible); if (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) { this.editor.setAutocompleteMaxVisible(maxVisible); } }, onClearOnShrinkChange: (enabled) => { this.settingsManager.setClearOnShrink(enabled); this.ui.setClearOnShrink(enabled); }, onShowTerminalProgressChange: (enabled) => { this.settingsManager.setShowTerminalProgress(enabled); }, onEnableInstallTelemetryChange: (enabled) => { this.settingsManager.setEnableInstallTelemetry(enabled); }, onWarningsChange: (warnings) => { this.settingsManager.setWarnings(warnings); }, onCancel: () => { done(); this.ui.requestRender(); }, }, ); return { component: selector, focus: selector.getSettingsList() }; }); }
private async handleModelCommand(searchTerm?: string): Promise<void> { if (!searchTerm) { this.showModelSelector(); return; }
const model = await this.findExactModelMatch(searchTerm); if (model) { try { await this.session.setModel(model); this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Model: ${model.id}`); this.checkDaxnutsEasterEgg(model); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } return; }
this.showModelSelector(searchTerm); }
private async findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> { const models = await this.getModelCandidates(); return findExactModelReferenceMatch(searchTerm, models); }
private async getModelCandidates(): Promise<Model<any>[]> { if (this.session.scopedModels.length > 0) { return this.session.scopedModels.map((scoped) => scoped.model); }
this.session.modelRegistry.refresh(); try { return await this.session.modelRegistry.getAvailable(); } catch { return []; } }
/** Update the footer's available provider count from current model candidates */ private async updateAvailableProviderCount(): Promise<void> { const models = await this.getModelCandidates(); const uniqueProviders = new Set(models.map((m) => m.provider)); this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); }
private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( this.ui, this.session.model, this.settingsManager, this.session.modelRegistry, this.session.scopedModels, async (model) => { try { await this.session.setModel(model); this.footer.invalidate(); this.updateEditorBorderColor(); done(); this.showStatus(`Model: ${model.id}`); this.checkDaxnutsEasterEgg(model); } catch (error) { done(); this.showError(error instanceof Error ? error.message : String(error)); } }, () => { done(); this.ui.requestRender(); }, initialSearchInput, ); return { component: selector, focus: selector }; }); }
private async showModelsSelector(): Promise<void> { // Get all available models this.session.modelRegistry.refresh(); const allModels = this.session.modelRegistry.getAvailable();
if (allModels.length === 0) { this.showStatus("No models available"); return; }
// Check if session has scoped models (from previous session-only changes or CLI --models) const sessionScopedModels = this.session.scopedModels; const hasSessionScope = sessionScopedModels.length > 0;
// Build enabled model IDs from session state or settings const enabledModelIds = new Set<string>(); let hasFilter = false;
if (hasSessionScope) { // Use current session's scoped models for (const sm of sessionScopedModels) { enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`); } hasFilter = true; } else { // Fall back to settings const patterns = this.settingsManager.getEnabledModels(); if (patterns !== undefined && patterns.length > 0) { hasFilter = true; const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry); for (const sm of scopedModels) { enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`); } } }
// Track current enabled state as array (null = all enabled) const currentEnabledIds: string[] | null = hasFilter ? [...enabledModelIds] : null;
// Helper to update session's scoped models (session-only, no persist) const updateSessionModels = async (enabledIds: string[] | null) => { if (enabledIds !== null && enabledIds.length > 0 && enabledIds.length < allModels.length) { const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry); this.session.setScopedModels( newScopedModels.map((sm) => ({ model: sm.model, thinkingLevel: sm.thinkingLevel, })), ); } else { // All enabled or none enabled = no filter this.session.setScopedModels([]); } await this.updateAvailableProviderCount(); this.ui.requestRender(); };
this.showSelector((done) => { const selector = new ScopedModelsSelectorComponent( { allModels, enabledModelIds: currentEnabledIds, }, { onChange: async (enabledIds) => { await updateSessionModels(enabledIds); }, onPersist: (enabledIds) => { // Persist to settings const newPatterns = enabledIds === null || enabledIds.length === allModels.length ? undefined // All enabled = clear filter : enabledIds; this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined); this.showStatus("Model selection saved to settings"); }, onCancel: () => { done(); this.ui.requestRender(); }, }, ); return { component: selector, focus: selector }; }); }
private showUserMessageSelector(): void { const userMessages = this.session.getUserMessagesForForking();
if (userMessages.length === 0) { this.showStatus("No messages to fork from"); return; }
const initialSelectedId = userMessages[userMessages.length - 1]?.entryId;
this.showSelector((done) => { const selector = new UserMessageSelectorComponent( userMessages.map((m) => ({ id: m.entryId, text: m.text })), async (entryId) => { try { const result = await this.runtimeHost.fork(entryId); if (result.cancelled) { done(); this.ui.requestRender(); return; }
this.renderCurrentSessionState(); this.editor.setText(result.selectedText ?? ""); done(); this.showStatus("Forked to new session"); } catch (error: unknown) { done(); this.showError(error instanceof Error ? error.message : String(error)); } }, () => { done(); this.ui.requestRender(); }, initialSelectedId, ); return { component: selector, focus: selector.getMessageList() }; }); }
private async handleCloneCommand(): Promise<void> { const leafId = this.sessionManager.getLeafId(); if (!leafId) { this.showStatus("Nothing to clone yet"); return; }
try { const result = await this.runtimeHost.fork(leafId, { position: "at" }); if (result.cancelled) { this.ui.requestRender(); return; }
this.renderCurrentSessionState(); this.editor.setText(""); this.showStatus("Cloned to new session"); } catch (error: unknown) { this.showError(error instanceof Error ? error.message : String(error)); } }
private showTreeSelector(initialSelectedId?: string): void { const tree = this.sessionManager.getTree(); const realLeafId = this.sessionManager.getLeafId(); const initialFilterMode = this.settingsManager.getTreeFilterMode();
if (tree.length === 0) { this.showStatus("No entries in session"); return; }
this.showSelector((done) => { const selector = new TreeSelectorComponent( tree, realLeafId, this.ui.terminal.rows, async (entryId) => { // Selecting the current leaf is a no-op (already there) if (entryId === realLeafId) { done(); this.showStatus("Already at this point"); return; }
// Ask about summarization done(); // Close selector first
// Loop until user makes a complete choice or cancels to tree let wantsSummary = false; let customInstructions: string | undefined;
// Check if we should skip the prompt (user preference to always default to no summary) if (!this.settingsManager.getBranchSummarySkipPrompt()) { while (true) { const summaryChoice = await this.showExtensionSelector("Summarize branch?", [ "No summary", "Summarize", "Summarize with custom prompt", ]);
if (summaryChoice === undefined) { // User pressed escape - re-show tree selector with same selection this.showTreeSelector(entryId); return; }
wantsSummary = summaryChoice !== "No summary";
if (summaryChoice === "Summarize with custom prompt") { customInstructions = await this.showExtensionEditor("Custom summarization instructions"); if (customInstructions === undefined) { // User cancelled - loop back to summary selector continue; } }
// User made a complete choice break; } }
// Set up escape handler and loader if summarizing let summaryLoader: Loader | undefined; const originalOnEscape = this.defaultEditor.onEscape;
if (wantsSummary) { this.defaultEditor.onEscape = () => { this.session.abortBranchSummary(); }; this.chatContainer.addChild(new Spacer(1)); summaryLoader = new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, ); this.statusContainer.addChild(summaryLoader); this.ui.requestRender(); }
try { const result = await this.session.navigateTree(entryId, { summarize: wantsSummary, customInstructions, });
if (result.aborted) { // Summarization aborted - re-show tree selector with same selection this.showStatus("Branch summarization cancelled"); this.showTreeSelector(entryId); return; } if (result.cancelled) { this.showStatus("Navigation cancelled"); return; }
// Update UI this.chatContainer.clear(); this.renderInitialMessages(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } finally { if (summaryLoader) { summaryLoader.stop(); this.statusContainer.clear(); } this.defaultEditor.onEscape = originalOnEscape; } }, () => { done(); this.ui.requestRender(); }, (entryId, label) => { this.sessionManager.appendLabelChange(entryId, label); this.ui.requestRender(); }, initialSelectedId, initialFilterMode, ); return { component: selector, focus: selector }; }); }
private showSessionSelector(): void { this.showSelector((done) => { const selector = new SessionSelectorComponent( (onProgress) => SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), SessionManager.listAll, async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); }, () => { done(); this.ui.requestRender(); }, () => { void this.shutdown(); }, () => this.ui.requestRender(), { renameSession: async (sessionFilePath: string, nextName: string | undefined) => { const next = (nextName ?? "").trim(); if (!next) return; const mgr = SessionManager.open(sessionFilePath); mgr.appendSessionInfo(next); }, showRenameHint: true, keybindings: this.keybindings, },
this.sessionManager.getSessionFile(), ); return { component: selector, focus: selector }; }); }
private async handleResumeSession( sessionPath: string, options?: Parameters<ExtensionCommandContext["switchSession"]>[1], ): Promise<{ cancelled: boolean }> { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); try { const result = await this.runtimeHost.switchSession(sessionPath, { withSession: options?.withSession, }); if (result.cancelled) { return result; } this.renderCurrentSessionState(); this.showStatus("Resumed session"); return result; } catch (error: unknown) { if (error instanceof MissingSessionCwdError) { const selectedCwd = await this.promptForMissingSessionCwd(error); if (!selectedCwd) { this.showStatus("Resume cancelled"); return { cancelled: true }; } const result = await this.runtimeHost.switchSession(sessionPath, { cwdOverride: selectedCwd, withSession: options?.withSession, }); if (result.cancelled) { return result; } this.renderCurrentSessionState(); this.showStatus("Resumed session in current cwd"); return result; } return this.handleFatalRuntimeError("Failed to resume session", error); } }
private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] { const authStorage = this.session.modelRegistry.authStorage; const oauthProviders = authStorage.getOAuthProviders(); const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id)); const options: AuthSelectorProvider[] = oauthProviders.map((provider) => ({ id: provider.id, name: provider.name, authType: "oauth", }));
const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider)); for (const providerId of modelProviders) { if (!isApiKeyLoginProvider(providerId, oauthProviderIds)) { continue; } options.push({ id: providerId, name: this.session.modelRegistry.getProviderDisplayName(providerId), authType: "api_key", }); }
const filteredOptions = authType ? options.filter((option) => option.authType === authType) : options; return filteredOptions.sort((a, b) => a.name.localeCompare(b.name)); }
private getLogoutProviderOptions(): AuthSelectorProvider[] { const authStorage = this.session.modelRegistry.authStorage; const options: AuthSelectorProvider[] = [];
for (const providerId of authStorage.list()) { const credential = authStorage.get(providerId); if (!credential) { continue; } options.push({ id: providerId, name: this.session.modelRegistry.getProviderDisplayName(providerId), authType: credential.type, }); }
return options.sort((a, b) => a.name.localeCompare(b.name)); }
private showLoginAuthTypeSelector(): void { const subscriptionLabel = "Use a subscription"; const apiKeyLabel = "Use an API key"; this.showSelector((done) => { const selector = new ExtensionSelectorComponent( "Select authentication method:", [subscriptionLabel, apiKeyLabel], (option) => { done(); const authType = option === subscriptionLabel ? "oauth" : "api_key"; this.showLoginProviderSelector(authType); }, () => { done(); this.ui.requestRender(); }, ); return { component: selector, focus: selector }; }); }
private showLoginProviderSelector(authType: "oauth" | "api_key"): void { const providerOptions = this.getLoginProviderOptions(authType); if (providerOptions.length === 0) { this.showStatus( authType === "oauth" ? "No subscription providers available." : "No API key providers available.", ); return; }
this.showSelector((done) => { const selector = new OAuthSelectorComponent( "login", this.session.modelRegistry.authStorage, providerOptions, async (providerId: string) => { done();
const providerOption = providerOptions.find((provider) => provider.id === providerId); if (!providerOption) { return; }
if (providerOption.authType === "oauth") { await this.showLoginDialog(providerOption.id, providerOption.name); } else if (providerOption.id === BEDROCK_PROVIDER_ID) { this.showBedrockSetupDialog(providerOption.id, providerOption.name); } else { await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); } }, () => { done(); this.showLoginAuthTypeSelector(); }, (providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId), ); return { component: selector, focus: selector }; }); }
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> { if (mode === "login") { this.showLoginAuthTypeSelector(); return; }
const providerOptions = this.getLogoutProviderOptions(); if (providerOptions.length === 0) { this.showStatus( "No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.", ); return; }
this.showSelector((done) => { const selector = new OAuthSelectorComponent( mode, this.session.modelRegistry.authStorage, providerOptions, async (providerId: string) => { done();
const providerOption = providerOptions.find((provider) => provider.id === providerId); if (!providerOption) { return; }
try { this.session.modelRegistry.authStorage.logout(providerOption.id); this.session.modelRegistry.refresh(); await this.updateAvailableProviderCount(); const message = providerOption.authType === "oauth" ? `Logged out of ${providerOption.name}` : `Removed stored API key for ${providerOption.name}. Environment variables and models.json config are unchanged.`; this.showStatus(message); } catch (error: unknown) { this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); } }, () => { done(); this.ui.requestRender(); }, ); return { component: selector, focus: selector }; }); }
private async completeProviderAuthentication( providerId: string, providerName: string, authType: "oauth" | "api_key", previousModel: Model<any> | undefined, ): Promise<void> { this.session.modelRegistry.refresh();
const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`;
let selectedModel: Model<any> | undefined; let selectionError: string | undefined; if (isUnknownModel(previousModel)) { const availableModels = this.session.modelRegistry.getAvailable(); const providerModels = availableModels.filter((model) => model.provider === providerId); if (!hasDefaultModelProvider(providerId)) { selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; } else if (providerModels.length === 0) { selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; } else { const defaultModelId = defaultModelPerProvider[providerId]; selectedModel = providerModels.find((model) => model.id === defaultModelId); if (!selectedModel) { selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; } else { try { await this.session.setModel(selectedModel); } catch (error: unknown) { selectedModel = undefined; const errorMessage = error instanceof Error ? error.message : String(error); selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`; } } } }
await this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); if (selectedModel) { this.showStatus(`${actionLabel}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`); this.checkDaxnutsEasterEgg(selectedModel); } else { this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}`); if (selectionError) { this.showError(selectionError); } } }
private showBedrockSetupDialog(providerId: string, providerName: string): void { const restoreEditor = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); this.ui.requestRender(); };
const dialog = new LoginDialogComponent( this.ui, providerId, () => restoreEditor(), providerName, "Amazon Bedrock setup", ); dialog.showInfo([ theme.fg("text", "Amazon Bedrock uses AWS credentials instead of a single API key."), theme.fg("text", "Configure an AWS profile, IAM keys, bearer token, or role-based credentials."), theme.fg("muted", "See:"), theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`), ]);
this.editorContainer.clear(); this.editorContainer.addChild(dialog); this.ui.setFocus(dialog); this.ui.requestRender(); }
private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> { const previousModel = this.session.model;
const dialog = new LoginDialogComponent( this.ui, providerId, (_success, _message) => { // Completion handled below }, providerName, );
this.editorContainer.clear(); this.editorContainer.addChild(dialog); this.ui.setFocus(dialog); this.ui.requestRender();
const restoreEditor = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); this.ui.requestRender(); };
try { const apiKey = (await dialog.showPrompt("Enter API key:")).trim(); if (!apiKey) { throw new Error("API key cannot be empty."); }
this.session.modelRegistry.authStorage.set(providerId, { type: "api_key", key: apiKey });
restoreEditor(); await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); } catch (error: unknown) { restoreEditor(); const errorMsg = error instanceof Error ? error.message : String(error); if (errorMsg !== "Login cancelled") { this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`); } } }
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> { return new Promise((resolve) => { const restoreDialog = () => { this.editorContainer.clear(); this.editorContainer.addChild(dialog); this.ui.setFocus(dialog); this.ui.requestRender(); }; const labels = prompt.options.map((option) => option.label); const selector = new ExtensionSelectorComponent( prompt.message, labels, (optionLabel) => { restoreDialog(); resolve(prompt.options.find((option) => option.label === optionLabel)?.id); }, () => { restoreDialog(); resolve(undefined); }, ); this.editorContainer.clear(); this.editorContainer.addChild(selector); this.ui.setFocus(selector); this.ui.requestRender(); }); }
private async showLoginDialog(providerId: string, providerName: string): Promise<void> { const providerInfo = this.session.modelRegistry.authStorage .getOAuthProviders() .find((provider) => provider.id === providerId); const previousModel = this.session.model;
// Providers that use callback servers (can paste redirect URL) const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
// Create login dialog component const dialog = new LoginDialogComponent( this.ui, providerId, (_success, _message) => { // Completion handled below }, providerName, );
// Show dialog in editor container this.editorContainer.clear(); this.editorContainer.addChild(dialog); this.ui.setFocus(dialog); this.ui.requestRender();
// Promise for manual code input (racing with callback server) let manualCodeResolve: ((code: string) => void) | undefined; let manualCodeReject: ((err: Error) => void) | undefined; const manualCodePromise = new Promise<string>((resolve, reject) => { manualCodeResolve = resolve; manualCodeReject = reject; });
// Restore editor helper const restoreEditor = () => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); this.ui.requestRender(); };
try { await this.session.modelRegistry.authStorage.login(providerId as OAuthProviderId, { onAuth: (info: { url: string; instructions?: string }) => { dialog.showAuth(info.url, info.instructions);
if (usesCallbackServer) { // Show input for manual paste, racing with callback dialog .showManualInput("Paste redirect URL below, or complete login in browser:") .then((value) => { if (value && manualCodeResolve) { manualCodeResolve(value); manualCodeResolve = undefined; } }) .catch(() => { if (manualCodeReject) { manualCodeReject(new Error("Login cancelled")); manualCodeReject = undefined; } }); } // For Anthropic: onPrompt is called immediately after },
onDeviceCode: (info) => { dialog.showDeviceCode(info); dialog.showWaiting("Waiting for authentication..."); },
onPrompt: async (prompt: { message: string; placeholder?: string }) => { return dialog.showPrompt(prompt.message, prompt.placeholder); },
onProgress: (message: string) => { dialog.showProgress(message); },
onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt),
onManualCodeInput: () => manualCodePromise,
signal: dialog.signal, });
// Success restoreEditor(); await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); } catch (error: unknown) { restoreEditor(); const errorMsg = error instanceof Error ? error.message : String(error); if (errorMsg !== "Login cancelled") { this.showError(`Failed to login to ${providerName}: ${errorMsg}`); } } }
// ========================================================================= // Command handlers // =========================================================================
private async handleReloadCommand(): Promise<void> { if (this.session.isStreaming) { this.showWarning("Wait for the current response to finish before reloading."); return; } if (this.session.isCompacting) { this.showWarning("Wait for compaction to finish before reloading."); return; }
this.resetExtensionUI();
const loader = new BorderedLoader( this.ui, theme, "Reloading keybindings, extensions, skills, prompts, themes...", { cancellable: false, }, ); const previousEditor = this.editor; this.editorContainer.clear(); this.editorContainer.addChild(loader); this.ui.setFocus(loader); this.ui.requestRender();
const dismissLoader = (editor: Component) => { loader.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(editor); this.ui.setFocus(editor); this.ui.requestRender(); };
try { await this.session.reload(); configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.keybindings.reload(); setRegisteredThemes(this.session.resourceLoader.getThemes().themes); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); const themeName = this.settingsManager.getTheme(); const themeResult = themeName ? setTheme(themeName, true) : { success: true }; if (!themeResult.success) { this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`); } const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor.setPaddingX(editorPaddingX); this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible); if (this.editor !== this.defaultEditor) { this.editor.setPaddingX?.(editorPaddingX); this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); } this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.setupAutocompleteProvider(); const runner = this.session.extensionRunner; this.setupExtensionShortcuts(runner); this.rebuildChatFromMessages(); dismissLoader(this.editor as Component); this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true, }); const modelsJsonError = this.session.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); } this.showStatus("Reloaded keybindings, extensions, skills, prompts, themes"); } catch (error) { dismissLoader(previousEditor as Component); this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); } }
private async handleExportCommand(text: string): Promise<void> { const parts = text.split(/\s+/); const outputPath = parts.length > 1 ? parts[1] : undefined;
try { if (outputPath?.endsWith(".jsonl")) { const filePath = this.session.exportToJsonl(outputPath); this.showStatus(`Session exported to: ${filePath}`); } else { const filePath = await this.session.exportToHtml(outputPath); this.showStatus(`Session exported to: ${filePath}`); } } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); } }
private async handleImportCommand(text: string): Promise<void> { const parts = text.split(/\s+/); if (parts.length < 2 || !parts[1]) { this.showError("Usage: /import <path.jsonl>"); return; } const inputPath = parts[1];
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); if (!confirmed) { this.showStatus("Import cancelled"); return; }
try { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); const result = await this.runtimeHost.importFromJsonl(inputPath); if (result.cancelled) { this.showStatus("Import cancelled"); return; } this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); } catch (error: unknown) { if (error instanceof MissingSessionCwdError) { const selectedCwd = await this.promptForMissingSessionCwd(error); if (!selectedCwd) { this.showStatus("Import cancelled"); return; } const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd); if (result.cancelled) { this.showStatus("Import cancelled"); return; } this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); return; } await this.handleFatalRuntimeError("Failed to import session", error); } }
private async handleShareCommand(): Promise<void> { // Check if gh is available and logged in try { const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; } } catch { this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); return; }
// Export to a temp file const tmpFile = path.join(os.tmpdir(), "session.html"); try { await this.session.exportToHtml(tmpFile); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; }
// Show cancellable loader, replacing the editor const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); this.editorContainer.clear(); this.editorContainer.addChild(loader); this.ui.setFocus(loader); this.ui.requestRender();
const restoreEditor = () => { loader.dispose(); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); try { fs.unlinkSync(tmpFile); } catch { // Ignore cleanup errors } };
// Create a secret gist asynchronously let proc: ReturnType<typeof spawn> | null = null;
loader.onAbort = () => { proc?.kill(); restoreEditor(); this.showStatus("Share cancelled"); };
try { const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data) => { stdout += data.toString(); }); proc.stderr?.on("data", (data) => { stderr += data.toString(); }); proc.on("close", (code) => resolve({ stdout, stderr, code })); });
if (loader.signal.aborted) return;
restoreEditor();
if (result.code !== 0) { const errorMsg = result.stderr?.trim() || "Unknown error"; this.showError(`Failed to create gist: ${errorMsg}`); return; }
// Extract gist ID from the URL returned by gh // gh returns something like: https://gist.github.com/username/GIST_ID const gistUrl = result.stdout?.trim(); const gistId = gistUrl?.split("/").pop(); if (!gistId) { this.showError("Failed to parse gist ID from gh output"); return; }
// Create the preview URL const previewUrl = getShareViewerUrl(gistId); this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); } catch (error: unknown) { if (!loader.signal.aborted) { restoreEditor(); this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); } } }
private async handleCopyCommand(): Promise<void> { const text = this.session.getLastAssistantText(); if (!text) { this.showError("No agent messages to copy yet."); return; }
try { await copyToClipboard(text); this.showStatus("Copied last agent message to clipboard"); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private handleNameCommand(text: string): void { const name = text.replace(/^\/name\s*/, "").trim(); if (!name) { const currentName = this.sessionManager.getSessionName(); if (currentName) { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); } else { this.showWarning("Usage: /name <name>"); } this.ui.requestRender(); return; }
this.session.setSessionName(name); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0)); this.ui.requestRender(); }
private handleSessionCommand(): void { const stats = this.session.getSessionStats(); const sessionName = this.sessionManager.getSessionName();
let info = `${theme.bold("Session Info")}\n\n`; if (sessionName) { info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; } info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; info += `${theme.bold("Tokens")}\n`; info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; if (stats.tokens.cacheRead > 0) { info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.tokens.cacheWrite > 0) { info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; } info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
if (stats.cost > 0) { info += `\n${theme.bold("Cost")}\n`; info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; }
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); }
private handleChangelogCommand(): void { const changelogPath = getChangelogPath(); const allEntries = parseChangelog(changelogPath);
const changelogMarkdown = allEntries.length > 0 ? allEntries .reverse() .map((e) => e.content) .join("\n\n") : "No changelog entries found.";
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); }
/** * Get capitalized display string for an app keybinding action. */ private getAppKeyDisplay(action: AppKeybinding): string { return keyDisplayText(action); }
/** * Get capitalized display string for an editor keybinding action. */ private getEditorKeyDisplay(action: Keybinding): string { return keyDisplayText(action); }
private handleHotkeysCommand(): void { // Navigation keybindings const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp"); const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown"); const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft"); const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight"); const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft"); const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight"); const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart"); const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd"); const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward"); const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward"); const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp"); const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown");
// Editing keybindings const submit = this.getEditorKeyDisplay("tui.input.submit"); const newLine = this.getEditorKeyDisplay("tui.input.newLine"); const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward"); const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward"); const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart"); const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd"); const yank = this.getEditorKeyDisplay("tui.editor.yank"); const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop"); const undo = this.getEditorKeyDisplay("tui.editor.undo"); const tab = this.getEditorKeyDisplay("tui.input.tab");
// App keybindings const interrupt = this.getAppKeyDisplay("app.interrupt"); const clear = this.getAppKeyDisplay("app.clear"); const exit = this.getAppKeyDisplay("app.exit"); const suspend = this.getAppKeyDisplay("app.suspend"); const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle"); const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); const followUp = this.getAppKeyDisplay("app.message.followUp"); const dequeue = this.getAppKeyDisplay("app.message.dequeue"); const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
let hotkeys = `**Navigation**| Key | Action ||-----|--------|| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) || \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word || \`${cursorLineStart}\` | Start of line || \`${cursorLineEnd}\` | End of line || \`${jumpForward}\` | Jump forward to character || \`${jumpBackward}\` | Jump backward to character || \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
**Editing**| Key | Action ||-----|--------|| \`${submit}\` | Send message || \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} || \`${deleteWordBackward}\` | Delete word backwards || \`${deleteWordForward}\` | Delete word forwards || \`${deleteToLineStart}\` | Delete to start of line || \`${deleteToLineEnd}\` | Delete to end of line || \`${yank}\` | Paste the most-recently-deleted text || \`${yankPop}\` | Cycle through the deleted text after pasting || \`${undo}\` | Undo |
**Other**| Key | Action ||-----|--------|| \`${tab}\` | Path completion / accept autocomplete || \`${interrupt}\` | Cancel autocomplete / abort streaming || \`${clear}\` | Clear editor (first) / exit (second) || \`${exit}\` | Exit (when editor is empty) || \`${suspend}\` | Suspend to background || \`${cycleThinkingLevel}\` | Cycle thinking level || \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models || \`${selectModel}\` | Open model selector || \`${expandTools}\` | Toggle tool output expansion || \`${toggleThinking}\` | Toggle thinking block visibility || \`${externalEditor}\` | Edit message in external editor || \`${followUp}\` | Queue follow-up message || \`${dequeue}\` | Restore queued messages || \`${pasteImage}\` | Paste image from clipboard || \`/\` | Slash commands || \`!\` | Run bash command || \`!!\` | Run bash command (excluded from context) |`;
// Add extension-registered shortcuts const extensionRunner = this.session.extensionRunner; const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); if (shortcuts.size > 0) { hotkeys += `**Extensions**| Key | Action ||-----|--------|`; for (const [key, shortcut] of shortcuts) { const description = shortcut.description ?? shortcut.extensionPath; const keyDisplay = formatKeyText(key, { capitalize: true }); hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; } }
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); }
private async handleClearCommand(): Promise<void> { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); try { const result = await this.runtimeHost.newSession(); if (result.cancelled) { return; } this.renderCurrentSessionState(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.ui.requestRender(); } catch (error: unknown) { await this.handleFatalRuntimeError("Failed to create session", error); } }
private handleDebugCommand(): void { const width = this.ui.terminal.columns; const height = this.ui.terminal.rows; const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath(); const debugData = [ `Debug output at ${new Date().toISOString()}`, `Terminal: ${width}x${height}`, `Total lines: ${allLines.length}`, "", "=== All rendered lines with visible widths ===", ...allLines.map((line, idx) => { const vw = visibleWidth(line); const escaped = JSON.stringify(line); return `[${idx}] (w=${vw}) ${escaped}`; }), "", "=== Agent messages (JSONL) ===", ...this.session.messages.map((msg) => JSON.stringify(msg)), "", ].join("\n");
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true }); fs.writeFileSync(debugLogPath, debugData);
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1), ); this.ui.requestRender(); }
private handleArminSaysHi(): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new ArminComponent(this.ui)); this.ui.requestRender(); }
private handleDementedDelves(): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new EarendilAnnouncementComponent()); this.ui.requestRender(); }
private handleDaxnuts(): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DaxnutsComponent(this.ui)); this.ui.requestRender(); }
private checkDaxnutsEasterEgg(model: { provider: string; id: string }): void { if (model.provider === "opencode" && model.id.toLowerCase().includes("kimi-k2.5")) { this.handleDaxnuts(); } }
private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> { const extensionRunner = this.session.extensionRunner;
// Emit user_bash event to let extensions intercept const eventResult = await extensionRunner.emitUserBash({ type: "user_bash", command, excludeFromContext, cwd: this.sessionManager.getCwd(), });
// If extension returned a full result, use it directly if (eventResult?.result) { const result = eventResult.result;
// Create UI component for display this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); if (this.session.isStreaming) { this.pendingMessagesContainer.addChild(this.bashComponent); this.pendingBashComponents.push(this.bashComponent); } else { this.chatContainer.addChild(this.bashComponent); }
// Show output and complete if (result.output) { this.bashComponent.appendOutput(result.output); } this.bashComponent.setComplete( result.exitCode, result.cancelled, result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, result.fullOutputPath, );
// Record the result in session this.session.recordBashResult(command, result, { excludeFromContext }); this.bashComponent = undefined; this.ui.requestRender(); return; }
// Normal execution path (possibly with custom operations) const isDeferred = this.session.isStreaming; this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
if (isDeferred) { // Show in pending area when agent is streaming this.pendingMessagesContainer.addChild(this.bashComponent); this.pendingBashComponents.push(this.bashComponent); } else { // Show in chat immediately when agent is idle this.chatContainer.addChild(this.bashComponent); } this.ui.requestRender();
try { const result = await this.session.executeBash( command, (chunk) => { if (this.bashComponent) { this.bashComponent.appendOutput(chunk); this.ui.requestRender(); } }, { excludeFromContext, operations: eventResult?.operations }, );
if (this.bashComponent) { this.bashComponent.setComplete( result.exitCode, result.cancelled, result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, result.fullOutputPath, ); } } catch (error) { if (this.bashComponent) { this.bashComponent.setComplete(undefined, false); } this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); }
this.bashComponent = undefined; this.ui.requestRender(); }
private async handleCompactCommand(customInstructions?: string): Promise<void> { const entries = this.sessionManager.getEntries(); const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) { this.showWarning("Nothing to compact (no messages yet)"); return; }
if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear();
try { await this.session.compact(customInstructions); } catch { // Ignore, will be emitted as an event } }
stop(): void { this.unregisterSignalHandlers(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.clearExtensionTerminalInputListeners(); this.footer.dispose(); this.footerDataProvider.dispose(); if (this.unsubscribe) { this.unsubscribe(); } if (this.isInitialized) { this.ui.stop(); this.isInitialized = false; } }}