Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
480 kB · 13048 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313103141031510316103171031810319103201032110322103231032410325103261032710328103291033010331103321033310334103351033610337103381033910340103411034210343103441034510346103471034810349103501035110352103531035410355103561035710358103591036010361103621036310364103651036610367103681036910370103711037210373103741037510376103771037810379103801038110382103831038410385103861038710388103891039010391103921039310394103951039610397103981039910400104011040210403104041040510406104071040810409104101041110412104131041410415104161041710418104191042010421104221042310424104251042610427104281042910430104311043210433104341043510436104371043810439104401044110442104431044410445104461044710448104491045010451104521045310454104551045610457104581045910460104611046210463104641046510466104671046810469104701047110472104731047410475104761047710478104791048010481104821048310484104851048610487104881048910490104911049210493104941049510496104971049810499105001050110502105031050410505105061050710508105091051010511105121051310514105151051610517105181051910520105211052210523105241052510526105271052810529105301053110532105331053410535105361053710538105391054010541105421054310544105451054610547105481054910550105511055210553105541055510556105571055810559105601056110562105631056410565105661056710568105691057010571105721057310574105751057610577105781057910580105811058210583105841058510586105871058810589105901059110592105931059410595105961059710598105991060010601106021060310604106051060610607106081060910610106111061210613106141061510616106171061810619106201062110622106231062410625106261062710628106291063010631106321063310634106351063610637106381063910640106411064210643106441064510646106471064810649106501065110652106531065410655106561065710658106591066010661106621066310664106651066610667106681066910670106711067210673106741067510676106771067810679106801068110682106831068410685106861068710688106891069010691106921069310694106951069610697106981069910700107011070210703107041070510706107071070810709107101071110712107131071410715107161071710718107191072010721107221072310724107251072610727107281072910730107311073210733107341073510736107371073810739107401074110742107431074410745107461074710748107491075010751107521075310754107551075610757107581075910760107611076210763107641076510766107671076810769107701077110772107731077410775107761077710778107791078010781107821078310784107851078610787107881078910790107911079210793107941079510796107971079810799108001080110802108031080410805108061080710808108091081010811108121081310814108151081610817108181081910820108211082210823108241082510826108271082810829108301083110832108331083410835108361083710838108391084010841108421084310844108451084610847108481084910850108511085210853108541085510856108571085810859108601086110862108631086410865108661086710868108691087010871108721087310874108751087610877108781087910880108811088210883108841088510886108871088810889108901089110892108931089410895108961089710898108991090010901109021090310904109051090610907109081090910910109111091210913109141091510916109171091810919109201092110922109231092410925109261092710928109291093010931109321093310934109351093610937109381093910940109411094210943109441094510946109471094810949109501095110952109531095410955109561095710958109591096010961109621096310964109651096610967109681096910970109711097210973109741097510976109771097810979109801098110982109831098410985109861098710988109891099010991109921099310994109951099610997109981099911000110011100211003110041100511006110071100811009110101101111012110131101411015110161101711018110191102011021110221102311024110251102611027110281102911030110311103211033110341103511036110371103811039110401104111042110431104411045110461104711048110491105011051110521105311054110551105611057110581105911060110611106211063110641106511066110671106811069110701107111072110731107411075110761107711078110791108011081110821108311084110851108611087110881108911090110911109211093110941109511096110971109811099111001110111102111031110411105111061110711108111091111011111111121111311114111151111611117111181111911120111211112211123111241112511126111271112811129111301113111132111331113411135111361113711138111391114011141111421114311144111451114611147111481114911150111511115211153111541115511156111571115811159111601116111162111631116411165111661116711168111691117011171111721117311174111751117611177111781117911180111811118211183111841118511186111871118811189111901119111192111931119411195111961119711198111991120011201112021120311204112051120611207112081120911210112111121211213112141121511216112171121811219112201122111222112231122411225112261122711228112291123011231112321123311234112351123611237112381123911240112411124211243112441124511246112471124811249112501125111252112531125411255112561125711258112591126011261112621126311264112651126611267112681126911270112711127211273112741127511276112771127811279112801128111282112831128411285112861128711288112891129011291112921129311294112951129611297112981129911300113011130211303113041130511306113071130811309113101131111312113131131411315113161131711318113191132011321113221132311324113251132611327113281132911330113311133211333113341133511336113371133811339113401134111342113431134411345113461134711348113491135011351113521135311354113551135611357113581135911360113611136211363113641136511366113671136811369113701137111372113731137411375113761137711378113791138011381113821138311384113851138611387113881138911390113911139211393113941139511396113971139811399114001140111402114031140411405114061140711408114091141011411114121141311414114151141611417114181141911420114211142211423114241142511426114271142811429114301143111432114331143411435114361143711438114391144011441114421144311444114451144611447114481144911450114511145211453114541145511456114571145811459114601146111462114631146411465114661146711468114691147011471114721147311474114751147611477114781147911480114811148211483114841148511486114871148811489114901149111492114931149411495114961149711498114991150011501115021150311504115051150611507115081150911510115111151211513115141151511516115171151811519115201152111522115231152411525115261152711528115291153011531115321153311534115351153611537115381153911540115411154211543115441154511546115471154811549115501155111552115531155411555115561155711558115591156011561115621156311564115651156611567115681156911570115711157211573115741157511576115771157811579115801158111582115831158411585115861158711588115891159011591115921159311594115951159611597115981159911600116011160211603116041160511606116071160811609116101161111612116131161411615116161161711618116191162011621116221162311624116251162611627116281162911630116311163211633116341163511636116371163811639116401164111642116431164411645116461164711648116491165011651116521165311654116551165611657116581165911660116611166211663116641166511666116671166811669116701167111672116731167411675116761167711678116791168011681116821168311684116851168611687116881168911690116911169211693116941169511696116971169811699117001170111702117031170411705117061170711708117091171011711117121171311714117151171611717117181171911720117211172211723117241172511726117271172811729117301173111732117331173411735117361173711738117391174011741117421174311744117451174611747117481174911750117511175211753117541175511756117571175811759117601176111762117631176411765117661176711768117691177011771117721177311774117751177611777117781177911780117811178211783117841178511786117871178811789117901179111792117931179411795117961179711798117991180011801118021180311804118051180611807118081180911810118111181211813118141181511816118171181811819118201182111822118231182411825118261182711828118291183011831118321183311834118351183611837118381183911840118411184211843118441184511846118471184811849118501185111852118531185411855118561185711858118591186011861118621186311864118651186611867118681186911870118711187211873118741187511876118771187811879118801188111882118831188411885118861188711888118891189011891118921189311894118951189611897118981189911900119011190211903119041190511906119071190811909119101191111912119131191411915119161191711918119191192011921119221192311924119251192611927119281192911930119311193211933119341193511936119371193811939119401194111942119431194411945119461194711948119491195011951119521195311954119551195611957119581195911960119611196211963119641196511966119671196811969119701197111972119731197411975119761197711978119791198011981119821198311984119851198611987119881198911990119911199211993119941199511996119971199811999120001200112002120031200412005120061200712008120091201012011120121201312014120151201612017120181201912020120211202212023120241202512026120271202812029120301203112032120331203412035120361203712038120391204012041120421204312044120451204612047120481204912050120511205212053120541205512056120571205812059120601206112062120631206412065120661206712068120691207012071120721207312074120751207612077120781207912080120811208212083120841208512086120871208812089120901209112092120931209412095120961209712098120991210012101121021210312104121051210612107121081210912110121111211212113121141211512116121171211812119121201212112122121231212412125121261212712128121291213012131121321213312134121351213612137121381213912140121411214212143121441214512146121471214812149121501215112152121531215412155121561215712158121591216012161121621216312164121651216612167121681216912170121711217212173121741217512176121771217812179121801218112182121831218412185121861218712188121891219012191121921219312194121951219612197121981219912200122011220212203122041220512206122071220812209122101221112212122131221412215122161221712218122191222012221122221222312224122251222612227122281222912230122311223212233122341223512236122371223812239122401224112242122431224412245122461224712248122491225012251122521225312254122551225612257122581225912260122611226212263122641226512266122671226812269122701227112272122731227412275122761227712278122791228012281122821228312284122851228612287122881228912290122911229212293122941229512296122971229812299123001230112302123031230412305123061230712308123091231012311123121231312314123151231612317123181231912320123211232212323123241232512326123271232812329123301233112332123331233412335123361233712338123391234012341123421234312344123451234612347123481234912350123511235212353123541235512356123571235812359123601236112362123631236412365123661236712368123691237012371123721237312374123751237612377123781237912380123811238212383123841238512386123871238812389123901239112392123931239412395123961239712398123991240012401124021240312404124051240612407124081240912410124111241212413124141241512416124171241812419124201242112422124231242412425124261242712428124291243012431124321243312434124351243612437124381243912440124411244212443124441244512446124471244812449124501245112452124531245412455124561245712458124591246012461124621246312464124651246612467124681246912470124711247212473124741247512476124771247812479124801248112482124831248412485124861248712488124891249012491124921249312494124951249612497124981249912500125011250212503125041250512506125071250812509125101251112512125131251412515125161251712518125191252012521125221252312524125251252612527125281252912530125311253212533125341253512536125371253812539125401254112542125431254412545125461254712548125491255012551125521255312554125551255612557125581255912560125611256212563125641256512566125671256812569125701257112572125731257412575125761257712578125791258012581125821258312584125851258612587125881258912590125911259212593125941259512596125971259812599126001260112602126031260412605126061260712608126091261012611126121261312614126151261612617126181261912620126211262212623126241262512626126271262812629126301263112632126331263412635126361263712638126391264012641126421264312644126451264612647126481264912650126511265212653126541265512656126571265812659126601266112662126631266412665126661266712668126691267012671126721267312674126751267612677126781267912680126811268212683126841268512686126871268812689126901269112692126931269412695126961269712698126991270012701127021270312704127051270612707127081270912710127111271212713127141271512716127171271812719127201272112722127231272412725127261272712728127291273012731127321273312734127351273612737127381273912740127411274212743127441274512746127471274812749127501275112752127531275412755127561275712758127591276012761127621276312764127651276612767127681276912770127711277212773127741277512776127771277812779127801278112782127831278412785127861278712788127891279012791127921279312794127951279612797127981279912800128011280212803128041280512806128071280812809128101281112812128131281412815128161281712818128191282012821128221282312824128251282612827128281282912830128311283212833128341283512836128371283812839128401284112842128431284412845128461284712848128491285012851128521285312854128551285612857128581285912860128611286212863128641286512866128671286812869128701287112872128731287412875128761287712878128791288012881128821288312884128851288612887128881288912890128911289212893128941289512896128971289812899129001290112902129031290412905129061290712908129091291012911129121291312914129151291612917129181291912920129211292212923129241292512926129271292812929129301293112932129331293412935129361293712938129391294012941129421294312944129451294612947129481294912950129511295212953129541295512956129571295812959129601296112962129631296412965129661296712968129691297012971129721297312974129751297612977129781297912980129811298212983129841298512986129871298812989129901299112992129931299412995129961299712998129991300013001130021300313004130051300613007130081300913010130111301213013130141301513016130171301813019130201302113022130231302413025130261302713028130291303013031130321303313034130351303613037130381303913040130411304213043130441304513046130471304813049import { randomUUID } from "node:crypto";import * as fs from "node:fs";import * as os from "node:os";import * as path from "node:path";import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";import { type Api, type AssistantMessage, type ImageContent, type Message, type Model, type ServiceTier, supportsFastMode, type ToolCall,} from "@earendil-works/pi-ai";import { BUILTIN_MCP_CATALOG } from "@earendil-works/pi-ai/mcp";import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/pi-ai/oauth";import type { AutocompleteItem, AutocompleteProvider, EditorComponent, Keybinding, KeyId, MarkdownTheme, OverlayHandle, OverlayOptions, SlashCommand,} from "@earendil-works/pi-tui";import { CombinedAutocompleteProvider, type Component, Container, isFocusable, Loader, type LoaderIndicatorOptions, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, truncateToWidth, visibleWidth,} from "@earendil-works/pi-tui";import { spawnSync } from "child_process";import { buildDaemonUpdateRestartReport, launchDaemonUpdateRestartCoordinator, resolveDaemonUpdateRestartSocketPath,} from "../../cli/daemon-update-restart.js";import { type CliSubprocessLaunchSpec, createCliSubprocessLaunchSpec, createUpdatedCliSubprocessLaunchSpec,} from "../../cli/subprocess-launch.js";import { APP_NAME, APP_TITLE, getAgentDir, getAgentTracesLogPath, getDebugLogPath, getLogsDir, getShareViewerUrl, SELF_UPDATE_INTERACTIVE_CHILD_ENV, SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE, VERSION,} from "../../config.js";import { AGENT_MESSAGE_RECEIVED_PREVIEW_LABEL, isAgentSessionMessage, startsAgentRun,} from "../../core/agent-messages.js";import { type AgentTracePreviewResult, type AgentTraceUploadAllResult, type AgentTraceUploadResult, getPrimeAgentTraceCredential, previewAgentTraceFile, uploadAgentTraceFile, uploadAllAgentTraces,} from "../../core/agent-traces.js";import { isNoModelsAvailableMessage } from "../../core/auth-guidance.js";import type { AuthCredential } from "../../core/auth-storage.js";import { estimateTokens } from "../../core/compaction/compaction.js";import type { ContextTreeNode } from "../../core/context-tree.js";import { type AgentCronJob, type AgentHeartbeatManagementAction, DEFAULT_HEARTBEAT_DELIVERY_MODE, parseHeartbeatCommand,} from "../../core/cron-jobs.js";import { DEFAULT_THINKING_LEVEL } from "../../core/defaults.js";import type { AutocompleteProviderFactory, ContextUsage, EditorFactory, ExtensionCommandContext, ExtensionContext, ExtensionRunner, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions,} from "../../core/extensions/index.js";import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";import { emptyGoalState, formatGoalUsage, GOAL_CONTEXT_PREVIEW_LABEL, type GoalState } from "../../core/goals.js";import type { KernelSentAgentMessage } from "../../core/kernel/index.js";import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js";import { logoutMcpAccount, type McpConnectionRecord, McpConnectionStore, type McpRemoveAccountResult,} from "../../core/mcp/connection-store.js";import { runMcpManagementCommand } from "../../core/mcp/mcp-command.js";import { accountStateFor, buildPluginViews, createConfiguredMcpProvider, isPasteableTokenService, type McpPasteCredential, type McpPluginView, type McpServiceDescriptor, mcpCredentialFieldPromptLabel, mcpCredentialKey, mcpLoginEligibility, mcpPasteCredential, nextMcpConnectionId, reservedMcpOwnership, resolveMcpOAuthIdentity, resolveServiceCatalogWithDiagnostics, verifyMcpConnection,} from "../../core/mcp/service-catalog.js";import { ASYNC_BASH_COMPLETION_PREVIEW_LABEL, bashOutputToText, COMPACTION_OUTCOME_CUSTOM_TYPE, type CustomMessage, createHeartbeatPromptMessage, createMcpConnectionOutcomeMessage, formatMcpConnectionOutcomeNotice, HEARTBEAT_PROMPT_PREVIEW_LABEL, isCompactionOutcomeMessage, isMcpConnectionOutcomeMessage, isRefinementOutcomeMessage, isSessionSlashCommandMessage, isSessionSlashCommandResultMessage, MCP_CONNECTION_OUTCOME_CUSTOM_TYPE, type McpDisconnectionOutcomeDetails, type McpDisconnectionState, type McpOutcomeDetails, REFINEMENT_OUTCOME_CUSTOM_TYPE, SESSION_SLASH_COMMAND_CUSTOM_TYPE, SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE,} from "../../core/messages.js";import { findExactModelReferenceMatch, resolveModelScopeFromModels } from "../../core/model-resolver.js";import { parseNewSessionCommand } from "../../core/new-session-command.js";import { PRIME_INFERENCE_PROVIDER_ID, resolvePrimeAgentTracesBaseUrl } from "../../core/prime-inference-auth.js";import { resolvePrimeInferencePostLoginModelAction } from "../../core/prime-inference-model-selection.js";import { formatCacheTtl, resolvePromptCacheTtl } from "../../core/prompt-cache.js";import { parseCommandArgs } from "../../core/prompt-templates.js";import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";import { SessionImportFileNotFoundError } from "../../core/session-import-errors.js";import { resolveSessionPath, SessionSelectorError, SessionSelectorNotFoundError } from "../../core/session-resolver.js";import type { McpServerConfig } from "../../core/settings-manager.js";import { parseSkillBlock } from "../../core/skill-blocks.js";import { BUILTIN_SLASH_COMMANDS, builtinSlashCommandTakesArgument, isBuiltinSlashCommandName, parseSlashCommand, resolveBuiltinSlashCommandName,} from "../../core/slash-commands.js";import { captureAgentCommandUsed, captureOnboardingCompleted, type TelemetryOnboardingOutcome,} from "../../core/telemetry.js";import { time } from "../../core/timings.js";import { type TruncationResult, truncateTail } from "../../core/tools/truncate.js";import { PRIME_COMPACT_BUTTERFLY_LOGO } from "../../themes/prime-logo.js";import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";import { spawnHidden, spawnSyncHidden } from "../../utils/child-process.js";import { copyToClipboard } from "../../utils/clipboard.js";import { readClipboardImage } from "../../utils/clipboard-image.js";import { parseGitUrl } from "../../utils/git.js";import { resizeImage } from "../../utils/image-resize.js";import { getCwdRelativePath } from "../../utils/paths.js";import { killTrackedDetachedChildren } from "../../utils/shell.js";import { ensureTool, ensureToolWithStatus, formatMissingRipgrepMessage } from "../../utils/tools-manager.js";import { checkForNewPiVersion, resolveUpdateChannel } from "../../utils/version-check.js";import type { AgentConnection, AgentConnectionExtensionUiRequest, AgentConnectionExtensionUiResponse, AgentConnectionHeartbeat, AgentConnectionModel, AgentConnectionModelCatalog, AgentConnectionQueuedMessageMutationStatus, AgentConnectionQueueState, AgentConnectionResourceDiagnostic, AgentConnectionResourceSnapshot, AgentConnectionRlmChildAgentSnapshot, AgentConnectionSessionContext, AgentConnectionSessionEvent, AgentConnectionSessionTreeNode, AgentConnectionSideQuestionEvent, AgentConnectionSlashCommand, AgentConnectionSnapshot, AgentConnectionSourceInfo, AgentConnectionState, AgentConnectionToolDefinition,} from "../agent-connection/index.js";import { AgentConnectionPromptAdmissionError } from "../agent-connection/index.js";import { getPublishedArgumentCompletions } from "../command-autocomplete.js";import type { SessionSummary } from "../daemon/daemon-session-list.js";import { getModelArgumentCompletions } from "../model-autocomplete.js";import { resolveContinuationShortcut } from "../shared/continuation-shortcut.js";import { checkForPackageUpdates, checkTmuxKeyboardSetup, formatPackageUpdateNotice, formatUpdateAvailableNotice,} from "../shared/startup-notices.js";import { AGENT_ACTIVITY_LABELS, AgentActivityTracker, formatTokenCount } from "./agent-activity.js";import { type AuthenticationResult, getAnthropicSubscriptionAuthWarning, ProviderAuthFlows } from "./auth-flows.js";import { AgentMessageComponent } from "./components/agent-message.js";import { ArminComponent } from "./components/armin.js";import { AssistantMessageComponent } from "./components/assistant-message.js";import { BashExecutionComponent } from "./components/bash-execution.js";import { BorderedLoader } from "./components/bordered-loader.js";import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";import { type FullPaneOverlayOptions, showFullPaneOverlay } from "./components/centered-overlay.js";import { CompactionOutcomeMessageComponent, MalformedCompactionOutcomeMessageComponent,} from "./components/compaction-outcome-message.js";import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";import { ConfigurationMenuComponent, type ConfigurationMenuTab } from "./components/configuration-menu.js";import { formatContextTree } from "./components/context-tree-format.js";import { createConversationSpacing, createShellCompletionComponent } from "./components/conversation-components.js";import { CountdownTimer } from "./components/countdown-timer.js";import { CustomEditor } from "./components/custom-editor.js";import { CustomMessageComponent } from "./components/custom-message.js";import { DaxnutsComponent } from "./components/daxnuts.js";import { DynamicBorder } from "./components/dynamic-border.js";import { EarendilAnnouncementComponent } from "./components/earendil-announcement.js";import { type FileChangeSummary, formatTotalChangeSummary, mergeTurnFileChanges } from "./components/edit-summary.js";import { ExtensionEditorComponent } from "./components/extension-editor.js";import { ExtensionInputComponent } from "./components/extension-input.js";import { ExtensionSelectorComponent } from "./components/extension-selector.js";import { FooterComponent } from "./components/footer.js";import { HeartbeatManagerComponent } from "./components/heartbeat-manager.js";import { InjectedPromptMessageComponent, isInjectedPromptMessage } from "./components/injected-prompt-message.js";import { formatConversationDetailStatus, formatKeyText, keyHint, keyText, rawKeyHint,} from "./components/keybinding-hints.js";import { MalformedMcpConnectionOutcomeMessageComponent, McpConnectionOutcomeMessageComponent,} from "./components/mcp-connection-outcome-message.js";import { McpTokenPastePanelComponent } from "./components/mcp-token-paste-panel.js";import { createMermaidMarkdownTransform } from "./components/mermaid.js";import type { AuthSelectorProvider } from "./components/oauth-selector.js";import { OnboardingChoiceComponent } from "./components/onboarding-choice.js";import { OnboardingPickerComponent } from "./components/onboarding-picker.js";import { PrimeOnboardingSplashComponent } from "./components/prime-onboarding-splash.js";import { PromptContextLine } from "./components/prompt-context-line.js";import { styleArgumentTokens } from "./components/prompt-highlight.js";import { MalformedRefinementOutcomeMessageComponent, RefinementOutcomeMessageComponent,} from "./components/refinement-outcome-message.js";import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";import { ServiceCatalogPickerComponent, type ServiceCatalogPickerOptions,} from "./components/service-catalog-picker.js";import { SettingsSelectorComponent } from "./components/settings-selector.js";import { SideQuestionComponent } from "./components/side-question.js";import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";import { isLeadingSlashCommand, SlashCommandMessageComponent, styleSlashCommandText,} from "./components/slash-command-message.js";import { SlashCommandResultMessageComponent } from "./components/slash-command-result-message.js";import { countRosterSubagentStatuses, countSubtreeSubagentStatuses, SubagentSummaryLine,} from "./components/subagent-summary-line.js";import { ThinkingSelectorComponent } from "./components/thinking-selector.js";import { selectLatestToolExpandHint, ToolExecutionComponent, type ToolExecutionDefinition,} from "./components/tool-execution.js";import { TopBar } from "./components/top-bar.js";import { TreeSelectorComponent } from "./components/tree-selector.js";import { UserMessageComponent } from "./components/user-message.js";import { UserMessageSelectorComponent } from "./components/user-message-selector.js";import { scopeHeartbeatsToSession } from "./heartbeat-scope.js";import { collectMarkedImages, evictImagesToBudget, formatImageMarker, imageMarkerIds, remapImageMarkers,} from "./image-markers.js";import type { InteractiveModeLocalSessionHost, InteractiveModeLocalToolRendererDefinition, InteractiveModeUiServices,} from "./interactive-mode-services.js";import { isOnboardingModelReady, type OnboardingStartupState, shouldRunOnboarding } from "./onboarding.js";import { evictPastesToBudget, formatPastedMarker, type PastedEntry, type PastedMode, pastedMarkerIds, reapSessionPastes, remapPastedMarkers, resolvePastesInText, toggledPastedMode,} from "./paste-files.js";import type { ClientPromptStashStore, PromptStash, PromptStashState } from "./prompt-stash-state.js";import { QueueSelection, type QueueSelectionItem } from "./queue-selection.js";import { formatResumeHint } from "./resume-hint.js";import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, type ThemeColor, theme,} from "./theme/theme.js";import { setWorkingPulseFrame, WORKING_ICON_INTERVAL_MS } from "./theme/working-icon.js";import { type FetchUsageResult, fetchUsageReports, renderUsageReportText, type SkippedProvider, type UsageReport,} from "./usage-report.js";
interface Expandable { setExpanded(expanded: boolean): void;}
interface PendingToolCallRenderInput { id: string; name: string; arguments: ToolCall["arguments"];}
const HEARTBEAT_LEGACY_PROMPT_MIN_TOLERANCE_MS = 15_000;const HEARTBEAT_LEGACY_PROMPT_MAX_TOLERANCE_MS = 120_000;const MODEL_CATALOG_REFRESH_TTL_MS = 60_000;const STARTUP_MODEL_WAIT_TIMEOUT_MS = 30_000;
export const START_HINTS = [ 'Try "refactor @<filepath>"', 'Try "fix bugs in @<filepath>"', 'Try "add tests for @<filepath>"', 'Try "explain how @<filepath> works"', 'Try "improve performance in @<filepath>"',] as const;
export function getRandomStartHint(random = Math.random): (typeof START_HINTS)[number] { return START_HINTS[Math.floor(random() * START_HINTS.length)] ?? START_HINTS[0];}function isLabeledQueuedPreview(message: string): boolean { return ( message.startsWith(`${HEARTBEAT_PROMPT_PREVIEW_LABEL}: `) || message.startsWith(`${GOAL_CONTEXT_PREVIEW_LABEL}: `) || message.startsWith(`${AGENT_MESSAGE_RECEIVED_PREVIEW_LABEL}: `) || message.startsWith(`${ASYNC_BASH_COMPLETION_PREVIEW_LABEL}: `) );}
export function formatQueuedMessagePreview(message: string, label: "Steering" | "Follow-up"): string { return isLabeledQueuedPreview(message) ? message : `${label}: ${message}`;}
export function styleQueuedMessagePreview( message: string, label: "Steering" | "Follow-up", isRecognizedSlashCommand: (name: string) => boolean,): string { const preview = formatQueuedMessagePreview(message, label); const styleDim = (segment: string) => theme.fg("dim", segment); if (!isLeadingSlashCommand(message, isRecognizedSlashCommand)) return styleArgumentTokens(preview, styleDim); const prefix = preview.slice(0, preview.length - message.length); return `${theme.fg("dim", prefix)}${styleSlashCommandText(message, (rest, includeBareSeparator) => styleArgumentTokens(rest, styleDim, includeBareSeparator), )}`;}
function isExpandable(obj: unknown): obj is Expandable { return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";}
interface AgentMessagesExpandable { setAgentMessagesExpanded(expanded: boolean): void;}
function hasAgentMessagesExpansion(obj: unknown): obj is AgentMessagesExpandable { return ( typeof obj === "object" && obj !== null && "setAgentMessagesExpanded" in obj && typeof (obj as AgentMessagesExpandable).setAgentMessagesExpanded === "function" );}
interface EditDiffsExpandable { setEditDiffsExpanded(expanded: boolean): void;}
function hasEditDiffsExpansion(obj: unknown): obj is EditDiffsExpandable { return ( typeof obj === "object" && obj !== null && "setEditDiffsExpanded" in obj && typeof (obj as EditDiffsExpandable).setEditDiffsExpanded === "function" );}
class ExpandableText extends Text implements Expandable { constructor( private readonly getCollapsedText: () => string, private readonly getExpandedText: () => string, expanded = false, paddingX = 0, paddingY = 0, ) { super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY); }
setExpanded(expanded: boolean): void { this.setText(expanded ? this.getExpandedText() : this.getCollapsedText()); }}
export function formatSplashCwd(cwd: string): string { const normalized = cwd.replace(/\\/g, "/"); const home = os.homedir().replace(/\\/g, "/"); if (home && normalized === home) { return "~"; } if (home && normalized.startsWith(`${home}/`)) { return `~${normalized.slice(home.length)}`; }
return normalized;}
function mergeSubagentSnapshot( previous: AgentConnectionRlmChildAgentSnapshot, incoming: AgentConnectionRlmChildAgentSnapshot,): AgentConnectionRlmChildAgentSnapshot { const active = incoming.status === "running" || incoming.status === "queued"; return { ...previous, ...incoming, parentId: incoming.parentId ?? previous.parentId, // Active updates may omit a previously known daemon session id, but a // terminal update without one means the child is no longer resident. activeSessionId: active ? (incoming.activeSessionId ?? previous.activeSessionId) : incoming.activeSessionId, // A completed retained child can become active again when it receives a // follow-up. Its RLM run status stays terminal, so activity must remain an // independent projection of the live session state. activity: active ? (incoming.activity ?? previous.activity) : incoming.activity, };}
export function truncatePathMiddle(value: string, width: number): string { if (visibleWidth(value) <= width) { return value; } if (width <= 1) { return truncateToWidth(value, width, ""); }
const ellipsis = "…"; const normalized = value.replace(/\\/g, "/"); const prefix = normalized.startsWith("~/") ? "~/" : normalized.startsWith("/") ? "/" : ""; const body = prefix ? normalized.slice(prefix.length) : normalized; const parts = body.split("/").filter((part) => part.length > 0); const last = parts.pop() ?? ""; const previous = parts.pop(); const suffix = previous ? `${previous}/${last}` : last; const candidate = `${prefix}${ellipsis}/${suffix}`; if (visibleWidth(candidate) <= width) { return candidate; }
return truncateToWidth(candidate, width);}
export interface BrandSplashMetadataLine { label: string; value: string;}
export interface BrandSplashHeaderOptions { logo?: string; topPadding?: boolean; getModelId?: () => string | undefined; getExtraMetadata?: () => readonly BrandSplashMetadataLine[]; /** Suppress the header entirely, e.g. while inline onboarding owns the top rows. */ getHidden?: () => boolean;}
/** Flow panels that can settle their pending step when unmounted early. */function isAbortablePanel(component: Component): component is Component & { abort(): void } { return typeof (component as { abort?: unknown }).abort === "function";}
export class BrandSplashHeader implements Component { private readonly logoRaw: string[]; private readonly logoCanvasWidth: number; private readonly gutter = 3;
constructor( private readonly version: string, private readonly getCwd: () => string | undefined, private readonly verboseInstructions?: string, private readonly options: BrandSplashHeaderOptions = {}, ) { this.logoRaw = (options.logo ?? PRIME_COMPACT_BUTTERFLY_LOGO).split("\n"); this.logoCanvasWidth = this.logoRaw.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); }
invalidate(): void { // Render output is derived from current theme/session state. }
render(width: number): string[] { if (this.options.getHidden?.()) { return []; } const safeWidth = Math.max(1, width); const paddingX = safeWidth > 1 ? 1 : 0; const contentWidth = Math.max(1, safeWidth - paddingX * 2); const showLogo = this.logoCanvasWidth > 0 && contentWidth - this.logoCanvasWidth - this.gutter >= 24; const metaWidth = showLogo ? contentWidth - this.logoCanvasWidth - this.gutter : contentWidth; const extraMetadata = this.options.getExtraMetadata?.() ?? []; const version = theme.fg("muted", `v${this.version}`); const titleText = "prime agent"; const title = theme.fg("text", titleText); const modelLabel = "model "; const cwdLabel = "cwd "; const cwd = this.getCwd(); const metaLines = [ ...(visibleWidth(`${titleText} v${this.version}`) <= metaWidth ? [`${title} ${version}`] : [title, version]), ...(this.options.getModelId ? [ `${theme.fg("dim", modelLabel)}${theme.fg( "muted", truncateToWidth( this.options.getModelId() ?? "—", Math.max(1, metaWidth - visibleWidth(modelLabel)), ), )}`, ] : []), ...extraMetadata.map(({ label, value }) => `${theme.fg("dim", `${label} `)}${theme.fg("muted", value)}`), ...(cwd === undefined ? [] : [ `${theme.fg("dim", cwdLabel)}${theme.fg("muted", truncatePathMiddle(formatSplashCwd(cwd), Math.max(1, metaWidth - visibleWidth(cwdLabel))))}`, ]), ]; const lines = this.options.topPadding ? [""] : []; const rowCount = Math.max(showLogo ? this.logoRaw.length : 0, metaLines.length); const metaStartIndex = showLogo ? Math.floor((rowCount - metaLines.length) / 2) : 0; for (let index = 0; index < rowCount; index++) { const logoLine = showLogo ? (this.logoRaw[index] ?? "") : ""; const logo = showLogo ? theme.fg("text", logoLine) + " ".repeat(this.logoCanvasWidth - visibleWidth(logoLine) + this.gutter) : ""; const metaIndex = index - metaStartIndex; const metaLine = metaIndex >= 0 && metaIndex < metaLines.length ? metaLines[metaIndex] : ""; const content = truncateToWidth(logo + metaLine, contentWidth); lines.push( " ".repeat(paddingX) + content + " ".repeat(Math.max(0, safeWidth - paddingX - visibleWidth(content))), ); }
if (this.verboseInstructions) { lines.push(" ".repeat(safeWidth)); for (const instruction of this.verboseInstructions.split("\n")) { const content = truncateToWidth(instruction, contentWidth); lines.push( " ".repeat(paddingX) + content + " ".repeat(Math.max(0, safeWidth - paddingX - visibleWidth(content))), ); } }
return lines; }}
type StartupPromptBarrierOutcome = "admitted" | "retained" | "lifecycle-cancelled";
type GoalAnnouncementSnapshot = { goalId?: string; status: GoalState["status"]; objective?: string; lastReason?: string; lastError?: string;};
type ModelFallbackWarningAction = "show" | "suppress";
/** The connection target a picker row dispatches to: a concrete endpoint and * its auth shape, or a settings-managed stdio server. */interface McpPickerTarget { url?: string; usesOAuth: boolean; bearerTokenEnvVar?: string; managedBySettings: boolean; transport?: "stdio"; name?: string;}
/** * What a picker action means for the picker chain that launched it. `ran` * marks an action that STARTED a real flow — a login or paste panel, a * verification, a removal, a settings change — so the chain re-enters the * right surface, rebuilt from the live stores. `ran: false` is a status-only * dead end: the transient line is the outcome and the chain ends, so an * action that cannot proceed never reopens the picker (no re-entry loop). */type McpPickerActionOutcome = { ran: boolean };
/** Where the accounts surface sends the picker chain when it returns. */type McpAccountsSurfaceExit = "closed" | "catalog";
/** * The settled outcome of one mounted catalog/accounts picker: a selected row, * a cancellation (Esc / stale settle — the surface closes), or "back" (the * left arrow / a sub-picker's Esc: hand control to the parent surface without * acting). */type McpPickerSelection = { status: "selected"; service: McpPluginView } | { status: "cancelled" } | { status: "back" };
/** * Connect targets keyed by serviceId: catalog services with a concrete HTTP * endpoint (built-ins keep their bundled endpoint unless the user configured * that id) and every user-declared server, HTTP or stdio. */function buildMcpPickerTargets( services: readonly McpServiceDescriptor[], userServers: Record<string, McpServerConfig>,): Map<string, McpPickerTarget> { const targets = new Map<string, McpPickerTarget>(); for (const service of services) { if ( service.transport.type === "http" && service.transport.url && (service.legacyBuiltin || !userServers[service.serviceId]) ) { targets.set(service.serviceId, { url: service.transport.url, usesOAuth: service.authStrategy === "oauth" || service.authStrategy === "unknown", managedBySettings: false, }); } } for (const [name, config] of Object.entries(userServers)) { if (services.some((entry) => entry.serviceId === name && entry.legacyBuiltin)) continue; if (config.type === "http") { targets.set(name, { url: config.url, usesOAuth: config.oauth === true, ...(config.bearerTokenEnvVar ? { bearerTokenEnvVar: config.bearerTokenEnvVar } : {}), managedBySettings: true, }); } else if (config.type === "stdio") { targets.set(name, { usesOAuth: false, managedBySettings: true, transport: "stdio", name, }); } } return targets;}
interface OnboardingSplashHandle { dismiss(): void;}
const THINKING_LEVEL_DESCRIPTIONS: Record<ThinkingLevel, string> = { off: "No reasoning", minimal: "Very brief reasoning", low: "Light reasoning", medium: "Moderate reasoning", high: "Deep reasoning", xhigh: "Very deep reasoning", max: "Maximum reasoning",};
const HEARTBEAT_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [ { value: "every ", label: "every <duration> <instruction>", description: "Set an interval, then add an instruction: /heartbeat every 10s Scan the logs", }, { value: "--steer ", label: "--steer <instruction>", description: "Deliver by interrupting the current turn (default)", }, { value: "--follow-up ", label: "--follow-up <instruction>", description: "Deliver as a follow-up after the current turn finishes", },];
const TRACES_ARGUMENT_COMPLETIONS: AutocompleteItem[] = [ { value: "status", label: "status", description: "Show trace sharing status" }, { value: "on", label: "on", description: "Enable automatic trace uploads" }, { value: "off", label: "off", description: "Disable automatic trace uploads" }, { value: "preview", label: "preview", description: "Preview the current session trace" }, { value: "upload", label: "upload", description: "Alias of upload-current" }, { value: "upload-current", label: "upload-current", description: "Upload the current session trace" }, { value: "upload-all", label: "upload-all", description: "Upload all persisted traces" }, { value: "login", label: "login", description: "Configure the Prime API key for trace uploads" },];
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
// Cap on retained pasted-image bytes (base64). Images are resized below the// inline limit before storing, so this holds many recent pastes; the oldest are// evicted past the cap to keep a long session bounded.const MAX_PASTED_IMAGE_BYTES = 64 * 1024 * 1024;const MAX_PASTED_FILE_BYTES = 64 * 1024 * 1024;const INITIAL_TRANSCRIPT_RENDER_MESSAGE_LIMIT = 400;// Coalesce at most this many heartbeats_changed refreshes into one refresh// promise; sustained churn must not hold rebindCurrentSession in a drain loop.const HEARTBEAT_REFRESH_DRAIN_LIMIT = 25;// A busy daemon can serialize a catalog fetch behind session work or worker// recovery for minutes; the TUI waits at most this long before keeping the// last catalog and letting the next heartbeats_changed event retry.export const HEARTBEAT_REFRESH_FETCH_TIMEOUT_MS = 10_000;// After a deadline expiry, retry the open manager's refresh on this short// cadence so the view always has a next scheduled retrieval even when no// heartbeats_changed event arrives and the catalog has no nextRunAt to// schedule from.export const HEARTBEAT_REFRESH_RETRY_DELAY_MS = 5_000;
/** Race a catalog fetch against the refresh deadline; resolves undefined on expiry. */async function fetchHeartbeatsWithinDeadline( fetch: Promise<AgentConnectionHeartbeat[]>,): Promise<AgentConnectionHeartbeat[] | undefined> { // The deadline can settle first; a late failure must not surface as an // unhandled rejection after this refresh has moved on. void fetch.catch(() => undefined); let timeout: ReturnType<typeof setTimeout> | undefined; try { return await Promise.race([ fetch, new Promise<undefined>((resolve) => { timeout = setTimeout(() => resolve(undefined), HEARTBEAT_REFRESH_FETCH_TIMEOUT_MS); timeout.unref?.(); }), ]); } finally { clearTimeout(timeout); }}
function initialRenderMessages(messages: AgentMessage[]): AgentMessage[] { if (messages.length <= INITIAL_TRANSCRIPT_RENDER_MESSAGE_LIMIT) { return messages; } const toolCallMessages = new Map<string, { index: number; message: Extract<AgentMessage, { role: "assistant" }> }>(); for (const [index, message] of messages.entries()) { if (message.role !== "assistant") { continue; } for (const content of message.content) { if (content.type === "toolCall") { toolCallMessages.set(content.id, { index, message }); } } }
const initialStartIndex = messages.length - INITIAL_TRANSCRIPT_RENDER_MESSAGE_LIMIT; for (let startIndex = initialStartIndex; startIndex < messages.length; startIndex++) { const visibleMessages = messages.slice(startIndex); const visibleToolCallIds = new Set<string>(); for (const message of visibleMessages) { if (message.role !== "assistant") { continue; } for (const content of message.content) { if (content.type === "toolCall") { visibleToolCallIds.add(content.id); } } }
const requiredToolCallIdsByMessage = new Map< number, { message: Extract<AgentMessage, { role: "assistant" }>; toolCallIds: Set<string> } >(); for (const message of visibleMessages) { if (message.role !== "toolResult" || visibleToolCallIds.has(message.toolCallId)) { continue; } const toolCallMessage = toolCallMessages.get(message.toolCallId); if (!toolCallMessage || toolCallMessage.index >= startIndex) { continue; } const requiredMessage = requiredToolCallIdsByMessage.get(toolCallMessage.index) ?? { message: toolCallMessage.message, toolCallIds: new Set<string>(), }; requiredMessage.toolCallIds.add(message.toolCallId); requiredToolCallIdsByMessage.set(toolCallMessage.index, requiredMessage); }
if (visibleMessages.length + requiredToolCallIdsByMessage.size > INITIAL_TRANSCRIPT_RENDER_MESSAGE_LIMIT) { continue; }
const requiredToolCallMessages = [...requiredToolCallIdsByMessage.entries()] .sort(([leftIndex], [rightIndex]) => leftIndex - rightIndex) .map(([, { message, toolCallIds }]) => ({ ...message, content: message.content.filter((content) => content.type !== "toolCall" || toolCallIds.has(content.id)), })); return omitOrphanToolResults([...requiredToolCallMessages, ...visibleMessages]); }
return [];}
function omitOrphanToolResults(messages: AgentMessage[]): AgentMessage[] { const renderedToolCallIds = new Set<string>(); const renderableMessages: AgentMessage[] = []; for (const message of messages) { if (message.role === "assistant") { for (const content of message.content) { if (content.type === "toolCall") { renderedToolCallIds.add(content.id); } } renderableMessages.push(message); } else if (message.role === "toolResult") { if (renderedToolCallIds.has(message.toolCallId)) { renderableMessages.push(message); } } else { renderableMessages.push(message); } } return renderableMessages;}
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);}
function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined { const value = payload[key]; return typeof value === "string" ? value : undefined;}
function getPayloadNumber(payload: Record<string, unknown>, key: string): number | undefined { const value = payload[key]; return typeof value === "number" && Number.isFinite(value) ? value : undefined;}
function getPayloadBoolean(payload: Record<string, unknown>, key: string): boolean | undefined { const value = payload[key]; return typeof value === "boolean" ? value : undefined;}
function getPayloadStringArray(payload: Record<string, unknown>, key: string): string[] | undefined { const value = payload[key]; if (value === undefined) { return undefined; } return Array.isArray(value) && value.every((item): item is string => typeof item === "string") ? value : undefined;}
function getPayloadNotifyType(payload: Record<string, unknown>, key: string): "info" | "warning" | "error" | undefined { const value = payload[key]; return value === "info" || value === "warning" || value === "error" ? value : undefined;}
function getPayloadWidgetPlacement( payload: Record<string, unknown>, key: string,): "aboveEditor" | "belowEditor" | undefined { const value = payload[key]; return value === "aboveEditor" || value === "belowEditor" ? value : undefined;}
function getPayloadWorkingIndicatorOptions( payload: Record<string, unknown>, key: string,): LoaderIndicatorOptions | undefined { const value = payload[key]; if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const optionsPayload = value as Record<string, unknown>; const frames = getPayloadStringArray(optionsPayload, "frames"); const intervalMs = getPayloadNumber(optionsPayload, "intervalMs"); return { ...(frames === undefined ? {} : { frames }), ...(intervalMs === undefined ? {} : { intervalMs }), };}
export function updateArgsIncludeSelf(args: readonly string[]): boolean { let selfFlag = false; let extensionsOnlyFlag = false; let positional: string | undefined; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === "--self") { selfFlag = true; } else if (arg === "--extensions") { extensionsOnlyFlag = true; } else if (arg === "--extension") { extensionsOnlyFlag = true; index++; } else if (arg === "--daemon-socket") { index++; } else if (arg && !arg.startsWith("-") && positional === undefined) { positional = arg; } } if (selfFlag) { return true; } if (extensionsOnlyFlag) { return false; } if (!positional) { return true; } const normalized = positional.toLowerCase(); return normalized === "self" || normalized === "pi" || normalized === APP_NAME.toLowerCase();}
function argsIncludeSessionSelection(args: readonly string[]): boolean { for (const arg of args) { if (arg === "--resume" || arg === "-r" || arg === "--continue" || arg === "-c" || arg === "--fork") { return true; } } return false;}
export function buildUpdateRelaunchArgs(args: readonly string[], sessionFile: string | undefined): string[] { const relaunchArgs = [...args]; if (sessionFile && !argsIncludeSessionSelection(relaunchArgs)) { relaunchArgs.push("--resume", sessionFile); } return relaunchArgs;}
type UpdateRelaunchExecve = (file: string, args: string[], environment: Record<string, string>) => never;
interface UpdateRelaunchExecOptions { platform: string; nodeVersion: string; cwd: string; previousCwd: string; environment: NodeJS.ProcessEnv; chdir: (directory: string) => void; execve?: UpdateRelaunchExecve;}
function execveFailureThrows(nodeVersion: string): boolean { // Before Node 26.1, a failed execve syscall aborts the process instead of throwing for the fallback below. const match = /^(\d+)\.(\d+)\./.exec(nodeVersion); if (!match) { return false; } const major = Number(match[1]); const minor = Number(match[2]); return major > 26 || (major === 26 && minor >= 1);}
export function tryExecUpdateRelaunch(launch: CliSubprocessLaunchSpec, options: UpdateRelaunchExecOptions): boolean { // Process replacement preserves the shell job and foreground terminal without retaining the old TUI. if ( !options.execve || options.platform === "win32" || options.platform === "os400" || !execveFailureThrows(options.nodeVersion) ) { return false; } const environment = Object.fromEntries( Object.entries(options.environment).filter((entry): entry is [string, string] => typeof entry[1] === "string"), ); options.chdir(options.cwd); try { options.execve(launch.command, [launch.command, ...launch.args], environment); } catch (error) { // A thrown execve must not leave the fallback's process on a changed cwd. options.chdir(options.previousCwd); throw error; } return true;}
export function buildUpdateChildArgs(args: readonly string[], daemonSocketPath: string): string[] { return args.includes("--daemon-socket") ? [...args] : [...args, "--daemon-socket", daemonSocketPath];}
export function resolveInteractiveUpdateDaemonSocketPath( args: readonly string[], activeDaemonSocketPath: string,): string { const socketFlagIndex = args.indexOf("--daemon-socket"); return socketFlagIndex === -1 ? activeDaemonSocketPath : (args[socketFlagIndex + 1] ?? activeDaemonSocketPath);}
export interface InteractiveInitialPrompt { text: string; images?: ImageContent[];}
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; /** One-off warning shown on startup. */ startupNotice?: string; /** Initial message to send on startup (can include @file content) */ initialMessage?: string; /** Images to attach to the initial message */ initialImages?: ImageContent[]; /** Additional text-only messages to send after the initial message. */ initialMessages?: string[]; /** Additional image-bearing prompts to send after the initial messages. */ initialPrompts?: InteractiveInitialPrompt[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; /** Agent execution boundary. InteractiveMode never talks directly to AgentSession for core execution. */ agentConnection: AgentConnection; /** Exact daemon socket to preserve across an interactive self-update restart. */ daemonSocketPath?: string; /** * Local-only host for in-process extension binding and callback-bearing session operations. * This must remain optional adapter glue, not a generic execution dependency. */ localSessionHost?: InteractiveModeLocalSessionHost; /** Bind extension handlers in the local session host. Disabled for daemon/gateway-backed clients. */ bindLocalSessionExtensions?: boolean; /** UI-local services used for settings, auth, resources, and rendering. Defaults to services from localSessionHost. */ uiServices?: InteractiveModeUiServices; /** Extra cleanup for externally-owned UI service hosts. Runs after the connection is disposed and before process exit. */ onShutdown?: () => void | Promise<void>; /** Allow returning from a full session to the agents view without stopping the daemon-owned agent. */ returnToAgentsView?: boolean; /** Enter fullscreen regardless of the persisted fullscreen preference. */ forceFullscreen?: boolean; /** * The agents view already surfaced global startup notices (app/extension updates, tmux setup), * so this session must not repeat them in its chat stream. Distinct from `returnToAgentsView`, * which also covers direct daemon attaches where the agents view was never shown. */ agentsViewOwnsStartupNotices?: boolean; /** Persisted RLM depth supplied by the daemon SessionSummary. */ sessionDepth?: number; /** Whether the unified daemon/catalog projection had any direct children. */ sessionHasChildren?: boolean; /** Client-owned stash store shared across chat views in this TUI process. */ promptStashStore?: ClientPromptStashStore; /** Initial stable session id used to scope prompt stash state. */ promptStashSessionId?: string; /** * Resource-loader state that finished loading after the first frame. Daemon * attaches start the extension reload without waiting for it, so themes and * extension-load warnings arrive here and are applied when they land. */ deferredResources?: Promise<{ themes: Theme[]; warnings: readonly string[] }>;}
export interface InteractiveModeRunResult { type: "agents_view" | "scoped_agents_view"; source: Pick<AgentConnectionState, "activeSessionId" | "sessionFile" | "sessionId" | "sessionName" | "cwd">;}
export function formatAgentDepthLabel(depth: number | undefined, hasChildren: boolean): string | undefined { if (depth === undefined || (depth === 0 && !hasChildren)) return undefined; return `depth ${depth}`;}
/** * Map a fixed verification category to user-readable wording. The persisted * record keeps only the category; this layer adds the friendly explanation. */const MCP_VERIFICATION_ISSUES: Record<string, string> = { "http-unauthorized": "the endpoint rejected the stored credentials (reconnect)", "verification-timeout": "the endpoint did not respond in time", "network-unreachable": "the endpoint could not be reached", "server-rejected-handshake": "the server rejected the MCP handshake", "http-error": "the endpoint returned an HTTP error", "invalid-response": "the endpoint returned an invalid MCP response", "verification-failed": "the handshake could not be completed", "credential-changed": "the stored credentials changed during verification",};
function formatMcpVerificationIssue(category: string | undefined): string { return MCP_VERIFICATION_ISSUES[category ?? ""] ?? "the endpoint did not complete an MCP handshake";}
/** * Which removal outcomes earn a durable "Disconnected" entry: only the three * that actually removed the stored credential. "logged-out" (partial save * failure), "failed", "missing", and the state-neutral "refused" keep their * honest transient lines — an entry would claim the disconnect is done. */function mcpDisconnectionState(result: McpRemoveAccountResult): McpDisconnectionState | undefined { switch (result) { case "removed": return "removed"; case "credential-only": return "credential-only"; case "preserved": return "preserved"; default: return undefined; }}
export class InteractiveMode { private static readonly EXIT_HINT_DURATION_MS = 2000; private static readonly ESCAPE_REPEAT_WINDOW_MS = 500;
private uiServices: InteractiveModeUiServices; private agentConnection: AgentConnection; private localSessionHost: InteractiveModeLocalSessionHost | undefined; private bindLocalSessionExtensions: boolean; private ui: TUI; private chatContainer: Container; private shortcutGuideContainer: Container; private pendingMessagesContainer: Container; private statusContainer: Container; private queuedMessagesContainer: Container; private sideQuestionContainer: Container; private defaultEditor: CustomEditor; private editor: EditorComponent; private readonly promptStashStore: ClientPromptStashStore | undefined; private promptStashSessionId: string | undefined; private promptStashState: PromptStashState; private editorComponentFactory: EditorFactory | undefined; private autocompleteProvider: AutocompleteProvider | undefined; private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; private fdPath: string | undefined; private mainContainer: Container; private mainViewContainer: Container; // prompt bar (editor + footer slot) — the only thing pinned to the bottom in fullscreen private promptDock: Container; // wraps the active footer so custom-footer swaps reflect in both layouts private footerSlot: Container; private fullscreenEnabled = false; 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 initializationPromise: Promise<void> | undefined; private onInputCallback?: (text: string | undefined) => void; private submittedInputBehavior: "steer" | "followUp" = "steer"; private latestEditorPromptStash: PromptStash | undefined; private pendingSubmittedPromptStash: PromptStash | undefined; private inputSubmissionGeneration = 0; private inputSubmissionsPending = 0; private pendingPromptStashReleases: { sessionId: string; state: PromptStashState }[] = []; private readonly retainedSubmissionGenerations = new WeakMap<PromptStash, number>(); private admitPendingStartupPrompts: (() => Promise<StartupPromptBarrierOutcome>) | undefined; private agentsViewRequest: InteractiveModeRunResult["type"] | undefined; private isReturningToAgentsView = false; private loadingAnimation: Loader | undefined = undefined; private workingMessage: string | undefined = undefined; private workingVisible = true; private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; private workingStartedAt: number | undefined = undefined; // Start of the in-flight run; survives loader teardown so the elapsed display doesn't reset on re-entry. private turnStartedAt: number | undefined = undefined; private workingTimer: NodeJS.Timeout | undefined = undefined; private pulseTimer: NodeJS.Timeout | undefined = undefined; private pulseFrame = 0; private readonly activityTracker = new AgentActivityTracker(); // activityTracker token count already folded into the context snapshot; only output beyond // this counts as live in-flight (keeps auto-retries from re-adding a failed attempt). private contextUsageTokenBaseline = 0; // Refresh ordering: a stale failure must never clobber a newer success. private contextUsageRefresh = { generation: 0, lastSuccessGeneration: 0 };
private ctrlCExitHintExpiresAt = 0; private ctrlCExitHintTimer: ReturnType<typeof setTimeout> | undefined = undefined; private escapeRepeatAction: "tree" | "clear" | undefined; private escapeRepeatExpiresAt = 0; private escapeRepeatTimer: ReturnType<typeof setTimeout> | undefined = undefined; private anthropicSubscriptionWarningShown = false;
private lastStatusSpacer: Spacer | undefined = undefined; private lastStatusText: Text | undefined = undefined; private lastGoalAnnouncement: GoalAnnouncementSnapshot | undefined = undefined; private trayTicker: NodeJS.Timeout | undefined = undefined;
private streamingComponent: AssistantMessageComponent | undefined = undefined; private streamingMessage: AssistantMessage | undefined = undefined; private sideQuestionComponent: SideQuestionComponent | undefined; private sideQuestionEvent: AgentConnectionSideQuestionEvent | undefined; private sideQuestionTurns: AgentConnectionSideQuestionEvent[] = []; private activeSideQuestionId: string | undefined; // Set while a ! bash command runs inside the side conversation: its // BashExecutionComponent renders inside the pane instead of the main chat. // bash_* events broadcast to every attached client, so runs correlate by // runId — the runId we generate here is echoed on our run's events. private sideQuestionBash: { runId: string; input: string; seedTranscript: boolean } | undefined; // The pane-mounted component of our own side run; bash_end seeds the side // transcript only when it ends this exact component. private sideQuestionBashComponent: BashExecutionComponent | undefined; // Holds the runId of a side bash abandoned at pane close: that run's // remaining bash_* events are swallowed (until its bash_end) instead of // leaking into the main transcript. private sideQuestionBashDiscarded: string | undefined;
// User bash execution tracking (! / !! prefix), driven by bash_* session events private activeBashComponent: BashExecutionComponent | undefined = undefined; private pendingBashComponents: BashExecutionComponent[] = [];
// Serializes session event handling; see subscribeToAgent private sessionEventQueue: Promise<void> = Promise.resolve(); // The in-flight initial transcript render, if any. Session resync/replacement // renders await it so they never interleave with a half-built transcript. private initialRenderPromise: Promise<void> | undefined = undefined; private sessionEventGeneration = 0; private fastModeToggleQueue: Promise<void> = Promise.resolve();
private pendingTools = new Map<string, ToolExecutionComponent>(); private ipythonToolComponents = new Map<string, ToolExecutionComponent>(); private lateIpythonSentAgentMessages = new Map<string, KernelSentAgentMessage[]>(); private pendingToolCreations = new Set<string>(); private startedToolCalls = new Set<string>(); private pendingToolGeneration = 0; private toolDefinitionCache = new Map<string, ToolExecutionDefinition | undefined>(); private agentRunFileChanges = new Map<string, FileChangeSummary>();
// One summary line below the editor, backed by the existing child-status stream. private subagentSummaryLine: SubagentSummaryLine; private subagentSnapshots = new Map<string, AgentConnectionRlmChildAgentSnapshot>(); private rlmNodeId: string | undefined; private rosterBar: { summaries(): SessionSummary[]; dispose(): Promise<void> } | undefined;
private toolOutputExpanded = false; /** Undefined follows the conversation detail level; a key press pins it. */ private agentMessagesExpanded: boolean | undefined; private editDiffsExpanded = false;
private hideThinkingBlock = true; private readonly mermaidMarkdownTransform = createMermaidMarkdownTransform({ getMode: () => this.settingsManager.getMermaidRenderingMode(), theme, });
private skillCommands = new Map<string, string>(); private connectionCommands: AgentConnectionSlashCommand[] = []; private connectionModelCatalog: AgentConnectionModel[] = []; private connectionConfiguredProviders = new Set<string>(); private connectionModelsFetchedAt = 0; private connectionModelsRefreshVersion = 0; private connectionModelsRefreshInFlight: { version: number; promise: Promise<AgentConnectionModel[]> } | undefined; private closeConfigurationMenu: (() => void) | undefined; private inlineAuthPanelClosers: ((reason?: "reset") => void)[] = []; private closeServiceCatalogPicker: (() => void) | undefined; private configurationModelSelection: Promise<void> | undefined; private connectionState: AgentConnectionState | undefined; private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined; private heartbeatCatalog: AgentConnectionHeartbeat[] = []; private heartbeatRefreshPromise: Promise<void> | undefined; private heartbeatRefreshRequested = false; private heartbeatManager: HeartbeatManagerComponent | undefined; private heartbeatManagerHandle: OverlayHandle | undefined; private heartbeatManagerRefreshTimer: ReturnType<typeof setTimeout> | undefined; private heartbeatManagerRefreshAt: number | undefined; private heartbeatManagerFetchRetryTimer: ReturnType<typeof setTimeout> | undefined; // Last catalog fetch failure surfaced in the open manager view; cleared by // the next successful apply and on manager open. private heartbeatCatalogFetchError: string | undefined; // Monotonic issue-order sequence for catalog writes (fetches and heartbeat // management actions). A fetch write sequenced behind an already-applied // newer one is dropped, so a late/older snapshot cannot revert newer state. private heartbeatCatalogSeq = 0; private heartbeatCatalogAppliedSeq = 0; private usageReports: UsageReport[] = []; private usageSkipped: SkippedProvider[] = []; private usageReportsFetchedAt = 0; private usageReportsRequest: Promise<FetchUsageResult> | undefined; private usageReportsAbortController: AbortController | undefined;
// Registry of images pasted this session, keyed by the `[image #N]` marker // shown to the user. Insertion-ordered; the bytes persist (bounded by // MAX_PASTED_IMAGE_BYTES) so a marker resolves to its image whenever the text // reappears — on submit, undo, history recall, retry, or dequeue. A submission // attaches only the images whose markers are present in the sent text. private pastedImages = new Map<number, ImageContent>(); private nextImageMarkerId = 1; private readonly pastedFiles = new Map<number, PastedEntry>(); private nextPastedMarkerId = 1; private recentPaste: { text: string; pasteId?: number; pastedId?: number; marker?: string } | undefined; private warnedRemotePasteFallback = false;
private unsubscribe?: () => void; private mcpConnectionStore?: McpConnectionStore; /** * MCP changes made while streaming/compacting; activated at the next safe boundary. * Connection outcomes carry structured details so the boundary renders the * durable outcome message instead of a transient status line. */ private pendingPostRunActivation: | { message: string; successMessage: string; connectionOutcome?: McpOutcomeDetails } | undefined; /** * Durable outcome for an MCP logout the generic /logout route just made: * the host owns the store op, the route reports, and the selector records * the entry after the single reload. */ private pendingMcpDisconnectionOutcome: McpDisconnectionOutcomeDetails | undefined; private signalCleanupHandlers: Array<() => void> = [];
private autoCompactionLoader: Loader | undefined = undefined; private refineLoader: Loader | undefined = undefined;
private retryLoader: Loader | undefined = undefined; private retryCountdown: CountdownTimer | undefined = undefined; private traceUploadAllAbortController: AbortController | undefined = undefined;
private readonly queueSelection = new QueueSelection(); private isApplyingQueueSelectionText = false; private queueMutationChain: Promise<void> = Promise.resolve(); private pendingQueueEdit: symbol | undefined; private pendingQueueMove = false;
private shutdownRequested = false; /** Set when the connection reports a terminal close; session commands must not end the window after it. */ private connectionClosed = false;
private extensionSelector: ExtensionSelectorComponent | undefined = undefined; private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputUnsubscribers = new Set<() => void>(); private activeConnectionExtensionUiRequests = new Map<string, { cancelLocal: () => void }>();
private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>(); private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>(); private widgetContainerAbove!: Container; private widgetContainerBelow!: Container;
// One-line recap of the agent's recent work, rendered just above the editor. private recapContainer!: Container; private sessionRecap: string | undefined;
private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
private headerContainer: Container; /** Pinned fullscreen top bar identifying the chat by name while scrolling. */ private topBar: TopBar; /** Cached session spend (USD) for the top bar, keyed to the session it was fetched for. */ private topBarCost: { sessionId?: string; total?: number } = {}; /** Stale-discard state for top bar cost refreshes (mirrors contextUsageRefresh). */ private topBarCostRefresh = { generation: 0, lastSuccessGeneration: 0 };
private builtInHeader: Component | undefined = undefined;
/** True while the inline onboarding block owns the top rows (header stays hidden). */ private onboardingUiActive = false;
/** The mounted onboarding block, so flow panels can render inside it. */ private onboardingSplash: PrimeOnboardingSplashComponent | undefined = undefined;
/** Tears the block down and settles the pending flow, e.g. on a session reset. */ private onboardingAbort: (() => void) | undefined = undefined;
/** Aborted when onboarding is torn down so in-flight steps stop waiting. */ private onboardingFlowAbort: AbortController | undefined = undefined;
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
private getLocalSessionHost(): InteractiveModeLocalSessionHost { if (!this.localSessionHost) { throw new Error("Local session host is not available in connection-backed interactive mode"); } return this.localSessionHost; } private get settingsManager() { return this.uiServices.settingsManager; } private get modelRegistry() { return this.uiServices.modelRegistry; }
constructor(private options: InteractiveModeOptions) { const uiServices = options.uiServices ?? options.localSessionHost?.createUiServices(); if (!uiServices) { throw new Error("InteractiveMode requires uiServices when no localSessionHost is supplied"); } this.uiServices = uiServices; this.agentConnection = options.agentConnection; this.promptStashStore = options.promptStashStore; this.promptStashSessionId = options.promptStashSessionId; this.promptStashState = this.promptStashStore && this.promptStashSessionId ? this.promptStashStore.forSession(this.promptStashSessionId) : {}; this.hydratePromptStash(); this.localSessionHost = options.localSessionHost; this.bindLocalSessionExtensions = options.bindLocalSessionExtensions ?? options.localSessionHost !== undefined; if (this.bindLocalSessionExtensions && !options.localSessionHost) { throw new Error("Local extension binding requires localSessionHost"); } this.agentConnection.onBeforeSessionInvalidate(() => { this.resetExtensionUI(); this.resetSideQuestion(); }); this.version = VERSION; this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.ui.onCopy = (text) => { void this.copyFullscreenSelection(text); }; this.headerContainer = new Container(); this.topBar = new TopBar({ getChatName: () => this.getCurrentSessionName() ?? path.basename(this.getCurrentCwd()), // Hide the cached spend unless it was fetched for the session now bound: // a pending or failed refresh must not attribute the previous // session's spend to the new chat. getCostUsd: () => this.topBarCost.sessionId === this.connectionState?.sessionId ? this.topBarCost.total : undefined, }); this.chatContainer = new Container(); this.shortcutGuideContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); this.queuedMessagesContainer = new Container(); this.sideQuestionContainer = new Container(); this.widgetContainerAbove = new Container(); this.widgetContainerBelow = new Container(); this.recapContainer = 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, isArgumentCommand: builtinSlashCommandTakesArgument, }); this.editor = this.defaultEditor; this.mainContainer = new Container(); this.mainViewContainer = new Container(); this.promptDock = new Container(); this.footerSlot = new Container(); this.mainViewContainer.addChild(this.chatContainer); this.mainViewContainer.addChild(this.shortcutGuideContainer); this.mainViewContainer.addChild(this.pendingMessagesContainer); this.mainViewContainer.addChild(this.statusContainer); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); this.subagentSummaryLine = new SubagentSummaryLine( () => this.getTrayLocationLabel(), () => this.getTrayContextLabel(), () => this.getTrayOverrideLabel(), () => this.isInlinePickerOpen(), ); this.subagentSummaryLine.setOpenable(this.options.returnToAgentsView === true); this.subagentSummaryLine.onOpen = () => void this.openScopedAgentsView(); this.subagentSummaryLine.onCancel = () => this.focusEditor(); this.subagentSummaryLine.onChatAction = (data) => this.handleSubagentSummaryChatAction(data); this.footerDataProvider = new FooterDataProvider(this.uiServices.getInitialCwd()); this.footer = new FooterComponent(this.footerDataProvider); this.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled()); this.setGoalAnnouncementBaseline(emptyGoalState());
setRegisteredThemes(this.uiServices.getThemes()); initTheme(this.settingsManager.getTheme(), true); }
private get promptStash(): PromptStash | undefined { return this.promptStashState.stash; }
private set promptStash(stash: PromptStash | undefined) { this.promptStashState.stash = stash; }
private hydratePromptStash(): void { for (const stash of [this.promptStash, ...(this.promptStashState?.queuedStashes ?? [])]) { if (!stash) continue; for (const [markerId, image] of stash.images ?? []) { this.pastedImages.set(markerId, image); this.nextImageMarkerId = Math.max(this.nextImageMarkerId, markerId + 1); } for (const markerId of imageMarkerIds(stash.text)) { this.nextImageMarkerId = Math.max(this.nextImageMarkerId, markerId + 1); } for (const [markerId, entry] of stash.pastedFiles ?? []) { this.pastedFiles.set(markerId, entry); this.nextPastedMarkerId = Math.max(this.nextPastedMarkerId, markerId + 1); } for (const markerId of pastedMarkerIds(stash.text)) { this.nextPastedMarkerId = Math.max(this.nextPastedMarkerId, markerId + 1); } } }
private bindPromptStashSession(sessionId: string): void { if (!this.promptStashStore || this.promptStashSessionId === sessionId) { return; } this.releasePromptStashSession(); this.promptStashSessionId = sessionId; this.promptStashState = this.promptStashStore.forSession(sessionId); this.hydratePromptStash(); }
private releasePromptStashSession(): void { if (this.inputSubmissionsPending > 0) { // Capture the pair: a rebind may repoint the fields before the deferred // release fires, and repeated rebinds/teardowns each defer their own pair. if ( this.promptStashSessionId && !this.pendingPromptStashReleases.some((pending) => pending.sessionId === this.promptStashSessionId) ) { this.pendingPromptStashReleases.push({ sessionId: this.promptStashSessionId, state: this.promptStashState, }); } return; } const pending = this.pendingPromptStashReleases; this.pendingPromptStashReleases = []; if (!this.promptStashStore) return; for (const release of pending) { this.promptStashStore.release(release.sessionId, release.state); } if (this.promptStashSessionId) { this.promptStashStore.release(this.promptStashSessionId, this.promptStashState); } }
private completeDeferredPromptStashRelease(): void { const pending = this.pendingPromptStashReleases; if (pending.length === 0) return; this.pendingPromptStashReleases = []; if (!this.promptStashStore) return; for (const release of pending) { this.promptStashStore.release(release.sessionId, release.state); } }
private getAutocompleteSourceTag(sourceInfo?: AgentConnectionSourceInfo): string | undefined { if (!sourceInfo) { return undefined; }
const scopePrefix = sourceInfo.scope === "user" ? "user" : sourceInfo.scope === "project" ? "project" : "temporary"; const source = sourceInfo.source.trim();
if (source === "builtin") { return "builtin"; }
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 getAutocompleteSourceLabel(sourceInfo?: AgentConnectionSourceInfo): string | undefined { const sourceTag = this.getAutocompleteSourceTag(sourceInfo); return sourceTag ? `#${sourceTag}` : undefined; }
private getBuiltInCommandConflictDiagnostics( commands: readonly AgentConnectionSlashCommand[], ): AgentConnectionResourceDiagnostic[] { return commands .filter((command) => command.source === "extension") .filter((command) => isBuiltinSlashCommandName(command.registeredName ?? command.name)) .map((command) => ({ type: "warning" as const, message: command.name === (command.registeredName ?? command.name) ? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.` : `Extension command '/${command.registeredName}' conflicts with built-in interactive command. Available as '/${command.name}'.`, path: command.sourceInfo.path, })); }
private isRecognizedSlashCommand(name: string): boolean { return isBuiltinSlashCommandName(name) || this.connectionCommands.some((command) => command.name === name); }
private createBaseAutocompleteProvider(): AutocompleteProvider { const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.filter( (command) => command.name !== "fast" || this.currentModelSupportsFastMode(), ).map((command) => ({ name: command.name, aliases: command.aliases, description: command.description, argumentHint: command.argumentHint, takesArgument: command.takesArgument === true, }));
const modelCommand = slashCommands.find((command) => command.name === "model"); if (modelCommand) { modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => getModelArgumentCompletions(prefix, this.getCachedModelCandidates()); }
const effortCommand = slashCommands.find((command) => command.name === "effort"); if (effortCommand) { effortCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => this.getThinkingLevelCompletions(prefix); const levels = this.getAvailableThinkingLevels(); if (levels.length > 0) { effortCommand.argumentHint = `[${levels.join("/")}]`; } }
const heartbeatCommand = slashCommands.find((command) => command.name === "heartbeat"); if (heartbeatCommand) { heartbeatCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => this.getHeartbeatArgumentCompletions(prefix); }
const tracesCommand = slashCommands.find((command) => command.name === "traces"); if (tracesCommand) { tracesCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => this.getTracesArgumentCompletions(prefix); }
const connectionCommands = this.connectionCommands; const templateCommands: SlashCommand[] = connectionCommands .filter((cmd) => cmd.source === "prompt") .map((cmd) => ({ name: cmd.name, description: cmd.description, sourceTag: this.getAutocompleteSourceLabel(cmd.sourceInfo), ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), }));
const extensionCommands: SlashCommand[] = connectionCommands .filter((cmd) => cmd.source === "extension") .filter((cmd) => !isBuiltinSlashCommandName(cmd.name)) .map((cmd) => { // A daemon-attached client has no local extension runner, so it completes // from the candidates the host published in the command catalog instead. const published = cmd.argumentCompletions; const getArgumentCompletions = this.bindLocalSessionExtensions ? this.getLocalSessionHost().getExtensionRunner().getCommand(cmd.name)?.getArgumentCompletions : published && published.length > 0 ? (prefix: string) => getPublishedArgumentCompletions(published, prefix) : undefined; return { name: cmd.name, description: cmd.description, sourceTag: this.getAutocompleteSourceLabel(cmd.sourceInfo), ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), getArgumentCompletions, }; });
this.skillCommands.clear(); const skillCommandList: SlashCommand[] = []; if (this.settingsManager.getEnableSkillCommands()) { for (const skill of connectionCommands.filter((cmd) => cmd.source === "skill")) { const commandName = skill.name; skillCommandList.push({ name: commandName, description: skill.description, sourceTag: this.getAutocompleteSourceLabel(skill.sourceInfo), }); } }
return new CombinedAutocompleteProvider( [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], this.getCurrentCwd(), 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); } }
async init(): Promise<void> { if (this.isInitialized) return;
this.registerSignalHandlers();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) // fd powers autocomplete, and rg is available for shell commands. const [fdPath, rgResult] = await Promise.all([ensureTool("fd"), ensureToolWithStatus("rg")]); time("init.ensureTools"); this.fdPath = fdPath;
this.ui.addChild(this.headerContainer);
// Compact butterfly beside runtime metadata. // The model/cwd are read through live getters, so they fill in once the // connection state loads (rebindCurrentSession below). Onboarding, when // required, renders as a full-screen overlay on top of this header. if (this.options.verbose || !this.settingsManager.getQuietStartup()) { // Verbose: include the full keybinding cheatsheet below the header. const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); const verboseInstructions = this.options.verbose ? [ hint("app.clear", "to interrupt"), rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), hint("app.input.clear", "to clear input"), hint("app.exit", "to exit (empty)"), hint("app.suspend", "to suspend"), keyHint("tui.editor.deleteToLineEnd", "to delete to end"), rawKeyHint("/effort", "to set thinking level"), hint("app.model.select", "to select model"), hint("app.tools.expand", "to cycle conversation detail"), hint("app.messages.expand", "to expand agent messages"), hint("app.edits.expand", "to expand edit diffs"), hint("app.subagents.focus", "to inspect subagents"), hint("app.editor.external", "for external editor"), hint("app.prompt.stash", "to stash prompt"), rawKeyHint("/", "for commands"), hint("app.message.followUp", "to queue follow-up"), hint("app.message.navigateOlder", "to browse queued messages"), hint("app.clipboard.pasteImage", "to paste image"), rawKeyHint("drop files", "to attach"), ].join("\n") : undefined; this.builtInHeader = new BrandSplashHeader(this.version, () => this.getCurrentCwd(), verboseInstructions, { topPadding: true, getModelId: () => this.getCurrentModelId(), getHidden: () => this.onboardingUiActive, getExtraMetadata: () => { const remoteHost = this.getCurrentRemoteHost(); return remoteHost ? [{ label: "host", value: remoteHost }] : []; }, }); this.headerContainer.addChild(this.builtInHeader); this.headerContainer.addChild(new Spacer(1)); } else { // Quiet startup: skip the splash and surrounding padding entirely. this.builtInHeader = new Text("", 0, 0); this.headerContainer.addChild(this.builtInHeader); }
this.mainContainer.addChild(this.mainViewContainer); this.renderWidgets(); this.mainContainer.addChild(this.widgetContainerAbove); this.renderRecap(); for (const container of this.getPromptContextContainers()) { this.mainContainer.addChild(container); } this.mainContainer.addChild(this.recapContainer); this.mainContainer.addChild(this.editorContainer); this.mainContainer.addChild(this.subagentSummaryLine); this.mainContainer.addChild(this.widgetContainerBelow); this.footerSlot.addChild(this.footer); this.mainContainer.addChild(this.footerSlot); for (const component of this.getPromptDockComponents()) { this.promptDock.addChild(component); } this.ui.addChild(this.mainContainer); this.ui.setFocus(this.editor);
this.setupKeyHandlers(); this.setupEditorSubmitHandler(); time("init.buildUi");
this.ui.start(); time("init.uiStart"); this.fullscreenEnabled = (this.options.forceFullscreen === true || this.settingsManager.getFullscreen()) && process.stdout.isTTY === true; if (this.fullscreenEnabled) { this.applyFullscreen(true); } this.isInitialized = true;
await this.rebindCurrentSession(); time("init.rebindCurrentSession");
await this.renderInitialMessages(); if (rgResult.status === "unavailable") { this.showWarning(formatMissingRipgrepMessage(rgResult)); } time("init.renderInitialMessages"); this.applyDeferredResources();
onThemeChange(() => { this.ui.invalidate(); this.updateEditorBorderColor(); this.ui.requestRender(); });
this.footerDataProvider.onBranchChange(() => { this.ui.requestRender(); });
// Needs a model-list round trip; the footer count must not hold up the first frame. void this.updateAvailableProviderCount(); time("init.providerCount"); }
/** * Refresh the top bar's cached session spend from the context tree. * Results for a replaced session, or superseded by a newer successful * refresh, are discarded — mirroring refreshConnectionContextUsage. */ private refreshTopBarCost(): void { // Partial-mode test harnesses skip the constructor, so the field // initializer may be absent there; the refresh is cosmetic and must // never crash a real flow on any `this`. this.topBarCostRefresh ??= { generation: 0, lastSuccessGeneration: 0 }; const refresh = this.topBarCostRefresh; const generation = ++refresh.generation; const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; void (async () => { try { const tree = await connection.getContextTree(); const total = tree?.totalUsage?.cost?.total; if ( typeof total !== "number" || !Number.isFinite(total) || generation < refresh.lastSuccessGeneration || this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId ) { return; } refresh.lastSuccessGeneration = generation; this.topBarCost = { sessionId, total }; this.ui.requestRender(); } catch { // Cost is cosmetic; a failed fetch keeps the previous value // (the session-keyed getter still hides cross-session leaks). } })(); }
/** * Apply resource-loader state that arrived after the first frame. Daemon * attaches start the extension reload without waiting for it, so themes and * extension-load warnings land here once the loader is done. */ private applyDeferredResources(): void { const deferred = this.options.deferredResources; if (!deferred) { return; } void deferred .then(({ themes, warnings }) => { setRegisteredThemes(themes); for (const warning of warnings) { this.showWarning(warning); } this.ui.invalidate(); this.ui.requestRender(); }) .catch(() => {}); }
private updateTerminalTitle(): void { const cwdBasename = path.basename(this.getCurrentCwd()); const sessionName = this.getCurrentSessionName(); if (sessionName) { this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`); } else { this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`); } }
async run(): Promise<InteractiveModeRunResult> { this.initializationPromise = this.init(); await this.initializationPromise; this.restorePromptStashOnOpen(); time("run.afterInit");
// Global, environment-scoped notices (app update, extension updates, tmux setup) // belong on the agents view, not in a conversation. When the agents view already // showed them, skip the checks here entirely. (This is narrower than // `returnToAgentsView`, which is also set for direct daemon attaches that never // rendered the agents view and still want the in-session fallback.) const ownsGlobalStartupNotices = !this.options.agentsViewOwnsStartupNotices; const newVersionPromise = ownsGlobalStartupNotices ? checkForNewPiVersion(this.version, this.settingsManager.getUpdateChannel()) : undefined; const packageUpdatesPromise = ownsGlobalStartupNotices ? checkForPackageUpdates({ cwd: this.getCurrentCwd(), agentDir: getAgentDir(), settingsManager: this.settingsManager, }) : undefined; const tmuxKeyboardWarningPromise = ownsGlobalStartupNotices ? checkTmuxKeyboardSetup() : undefined;
const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages, initialPrompts, } = this.options;
if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); }
if (this.options.startupNotice) { this.showWarning(this.options.startupNotice); }
const modelsJsonError = this.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); }
const startupPrompts: InteractiveInitialPrompt[] = [ ...(initialMessage ? [{ text: initialMessage, images: initialImages }] : []), ...(initialMessages ?? []).map((text) => ({ text })), ...(initialPrompts ?? []), ]; // One drive loop owns startup-prompt delivery: it retries on a 250ms cadence // while a model is missing or admission fails transiently, shows every // admission error, skips a prompt after three failed attempts, and retains // prompts when model loading never completes. // `startupPromptsSettled` is the user-submission barrier (startup prompts // stay ahead of user prompts). Its outcome distinguishes completed admission // from lifecycle cancellation so a resumed submit does not mutate torn-down // editor state or consume the client-owned durable stash. let startupPromptsDone = false; const startupAdmissionAbort = new AbortController(); let settleStartupPrompts = (_outcome: StartupPromptBarrierOutcome) => {}; const startupPromptsSettled = new Promise<StartupPromptBarrierOutcome>((resolve) => { settleStartupPrompts = (outcome) => { startupPromptsDone = true; resolve(outcome); }; }); /** Resolves false when the run lifecycle ended before the retry delay elapsed. */ const startupRetryDelay = (delayMs: number) => new Promise<boolean>((resolve) => { const timer = setTimeout(() => resolve(true), delayMs); timer.unref?.(); void startupPromptsSettled.then(() => { clearTimeout(timer); resolve(false); }); }); const deliverStartupPrompts = async () => { let failures = 0; const modelWaitStartedAt = Date.now(); for (let next = 0; next < startupPrompts.length; ) { // The run lifecycle can settle the barrier while a prompt is being // admitted; stop instead of prompting a session we already left. if (startupPromptsDone) return; if (!this.getCurrentModel()) { const remainingWait = STARTUP_MODEL_WAIT_TIMEOUT_MS - (Date.now() - modelWaitStartedAt); if (remainingWait <= 0) { this.retainStartupPromptDrafts(startupPrompts.slice(next)); this.showError( "No model became available for the startup prompt. Select one with /model, then retry the retained prompt.", ); settleStartupPrompts("retained"); return; } if (!(await startupRetryDelay(Math.min(250, remainingWait)))) return; continue; } const prompt = startupPrompts[next]!; try { await this.agentConnection.prompt(prompt.text, { images: prompt.images, streamingBehavior: next === 0 ? "steer" : "followUp", queueIfBusy: true, signal: startupAdmissionAbort.signal, }); failures = 0; next++; } catch (error) { if (startupPromptsDone || startupAdmissionAbort.signal.aborted) return; // An uncertain daemon admission may already be session-owned. Retrying // would duplicate it; only an acknowledged pre-ownership cancellation is safe. if (error instanceof AgentConnectionPromptAdmissionError && error.status === "owned") { failures = 0; next++; continue; } if (error instanceof AgentConnectionPromptAdmissionError && !error.cancelled) { // This attempt may already be session-owned, so never retry it. Preserve // it and every not-yet-attempted startup prompt in original order. this.retainStartupPromptDrafts(startupPrompts.slice(next)); this.showError(error.message); settleStartupPrompts("retained"); return; } const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; if (++failures < 3) { this.showError(errorMessage); if (!(await startupRetryDelay(250))) return; continue; } this.showError(`Skipping startup prompt after 3 failed attempts: ${errorMessage}`); failures = 0; next++; } } }; this.admitPendingStartupPrompts = startupPrompts.length > 0 ? () => startupPromptsSettled : undefined;
let deferredStartupNotificationsShown = false; const showDeferredStartupNotifications = () => { if (deferredStartupNotificationsShown) { return; } deferredStartupNotificationsShown = true;
// The agents view owns these for daemon sessions. When there is no agents view, // show them once at the top of a fresh session, but never append them under a // restored conversation where they read as disconnected clutter. if (!ownsGlobalStartupNotices || !this.isNewChat()) { return; }
void newVersionPromise ?.then((newVersion) => { if (newVersion) { this.showNewVersionNotification(newVersion); } }) .catch(() => {});
void packageUpdatesPromise ?.then((updates) => { if (updates.length > 0) { this.showPackageUpdateNotification(updates); } }) .catch(() => {});
void tmuxKeyboardWarningPromise ?.then((warning) => { if (warning) { this.showWarning(warning); } }) .catch(() => {}); };
let modelFallbackWarningShown = false; const showModelFallbackWarning = () => { if (modelFallbackWarningShown) { return; } const action = this.getModelFallbackWarningAction(modelFallbackMessage); modelFallbackWarningShown = true; if (action === "show" && modelFallbackMessage) { this.showWarning(modelFallbackMessage); } };
if (!this.isShuttingDown && !this.isReturningToAgentsView) { await this.runStartupOnboarding(); time("run.onboarding"); showDeferredStartupNotifications(); showModelFallbackWarning(); void this.maybeWarnAboutAnthropicSubscriptionAuth(); time("run.startupNotices"); void deliverStartupPrompts().then( () => settleStartupPrompts("admitted"), () => settleStartupPrompts("admitted"), ); }
// Enter/Alt+Enter submit directly through AgentConnection. Wait for the // lifecycle signal exactly once; a returned editor value has already been // admitted and must never be submitted again here. try { await this.getUserInput(); } finally { startupAdmissionAbort.abort(); settleStartupPrompts("lifecycle-cancelled"); this.admitPendingStartupPrompts = undefined; }
const state = this.connectionState; return { type: this.agentsViewRequest ?? "agents_view", source: { activeSessionId: state?.activeSessionId, sessionFile: state?.sessionFile, sessionId: state?.sessionId ?? this.promptStashSessionId ?? "", sessionName: state?.sessionName, cwd: state?.cwd ?? this.getCurrentCwd(), }, }; }
private getModelFallbackWarningAction(modelFallbackMessage: string | undefined): ModelFallbackWarningAction { if (!modelFallbackMessage) { return "suppress"; } // The no-models warning is a snapshot from whichever process created the // session; trust the live connection over it (e.g. credentials only // visible to the daemon, or added after the snapshot was taken). if (isNoModelsAvailableMessage(modelFallbackMessage) && this.getCurrentModel()) { return "suppress"; } return "show"; }
private getOnboardingState(): OnboardingStartupState { return { settingsManager: this.settingsManager, modelRegistry: this.modelRegistry, model: this.getCurrentModel(), }; }
private shouldRunOnboarding(): boolean { return shouldRunOnboarding(this.getOnboardingState()); }
private markOnboardingShown(): void { if (!this.settingsManager.getOnboardingShown()) { this.settingsManager.setOnboardingShown(true); } }
private async runStartupOnboarding(): Promise<boolean> { if (!this.shouldRunOnboarding()) { return false; }
const startedAt = Date.now(); let outcome: TelemetryOnboardingOutcome = "aborted"; try { // The flow reports completion itself: a user who already had a working // model would otherwise look "ready" straight after cancelling it, and // the questions they never saw would be skipped for good. const completed = await this.runOnboardingFlow(); outcome = completed && isOnboardingModelReady(this.getOnboardingState()) ? "success" : "aborted"; if (outcome === "success") { // Only a completed onboarding counts as seen: a cancelled sign-in // leaves the flag unset so the next launch retries the flow. this.markOnboardingShown(); await this.settingsManager.flush(); } return true; } catch (error) { outcome = "error"; throw error; } finally { const model = this.getCurrentModel(); const authStatus = model ? this.modelRegistry.getProviderAuthStatus(model.provider) : undefined; const storedCredential = model ? this.modelRegistry.authStorage.get(model.provider) : undefined; void captureOnboardingCompleted({ agentDir: getAgentDir(), settingsManager: this.settingsManager, durationMs: Date.now() - startedAt, outcome, provider: model?.provider, authSource: authStatus?.source, storedCredentialType: storedCredential?.type, }).catch(() => {}); } }
/** Runs the first-launch sequence. Resolves true only when every step ran. */ private async runOnboardingFlow(): Promise<boolean> { this.modelRegistry.refresh();
// Existing users (working model with configured auth) skip login and // the provider picker entirely. They see only the trace question, or // nothing at all when traces are already enabled. if (isOnboardingModelReady(this.getOnboardingState())) { if (this.settingsManager.getAgentTracesEnabled()) { return true; } const abort = new AbortController(); this.onboardingFlowAbort = abort; const splash = await this.showOnboardingSplash({ immediate: true }); if (!splash) { return false; } await this.askOnboardingTraceOptIn(); if (abort.signal.aborted) { return false; } splash.dismiss(); return true; }
const abort = new AbortController(); this.onboardingFlowAbort = abort; const splash = await this.showOnboardingSplash(); if (!splash) { return false; }
// One sequence for every first launch. Signing in is instant when a Prime // CLI token is already on disk, so users who arrive with credentials still // reach the same account, provider and trace questions. const authResult = await this.createAuthFlows().runPrimeInferenceLogin(); if (abort.signal.aborted || authResult.status !== "success") { splash.dismiss(); return false; }
await this.prepareForModelSelectionAfterLogin(authResult, abort.signal); if (abort.signal.aborted) { return false; } await this.askOnboardingProviders(abort.signal); if (abort.signal.aborted) { return false; } await this.askOnboardingTraceOptIn(); if (abort.signal.aborted) { return false; } splash.dismiss(); return true; }
/** * Optional step: connect more providers before the first chat. The picker * stays mounted between logins so several can be connected in one pass. */ private async askOnboardingProviders(signal: AbortSignal): Promise<void> { if (!this.onboardingSplash) { return; } const authFlows = this.createAuthFlows(); for (;;) { // A reset that cancels a provider login must end the question too, // otherwise the next picker opens in the editor and waits for input. // The signal is passed in: tearing the block down clears the field. if (signal.aborted) { return; } // One row per provider: a provider offering both a subscription and an // API key would otherwise appear twice under the same name. const options = [ ...new Map( authFlows .getLoginProviderOptions() .filter((option) => option.id !== PRIME_INFERENCE_PROVIDER_ID && option.category !== "service") .map((option) => [option.id, option]), ).values(), ]; if (options.length === 0) { return; } const items = options.map((option) => ({ id: option.id, label: option.name, ...(this.modelRegistry.getProviderAuthStatus(option.id).configured ? { connected: true } : {}), })); const picked = await new Promise<string | undefined>((resolve) => { let settled = false; let close: (() => void) | undefined; const settle = (id: string | undefined) => { if (settled) { return; } settled = true; close?.(); resolve(id); }; const picker = new OnboardingPickerComponent( items, (id) => settle(id), () => settle(undefined), () => settle(undefined), { prompt: "Connect other providers, or continue.", searchPlaceholder: "Search providers", note: "You can add providers anytime with /login.", onExit: () => void this.shutdown(), requestRender: () => this.ui.requestRender(), }, ); close = this.showInlineAuthPanel(picker, { onReset: () => settle(undefined) }); this.ui.requestRender(); }); if (!picked) { return; } const option = options.find((candidate) => candidate.id === picked); if (option) { await authFlows.loginProvider(option); } } }
/** Final onboarding question: trace collection, with a reminder it is reversible. */ private askOnboardingTraceOptIn(): Promise<void> { const splash = this.onboardingSplash; if (!splash) { return Promise.resolve(); } return new Promise<void>((resolve) => { let closed = false; let close: (() => void) | undefined; const finish = (enabled?: boolean) => { if (closed) { return; } closed = true; if (enabled !== undefined) { this.settingsManager.setAgentTracesEnabled(enabled); void this.settingsManager.flush(); } close?.(); resolve(); }; const choice = new OnboardingChoiceComponent( [{ label: "Share" }, { label: "Not now" }], (index) => finish(index === 0), () => finish(undefined), { onExit: () => void this.shutdown(), prompt: "Share agent traces with Prime Intellect?", description: "Trace sharing helps us train better open-source models and improve the open agent ecosystem for everyone.", note: "You can change this anytime with /traces.", requestRender: () => this.ui.requestRender(), }, ); close = this.showInlineAuthPanel(choice, { onReset: () => finish(undefined) }); this.ui.requestRender(); }); }
private getMarkdownThemeWithSettings(): MarkdownTheme { return { ...getMarkdownTheme(), codeBlockIndent: this.settingsManager.getCodeBlockIndent(), }; }
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; }
private formatContextPath(p: string): string { const cwd = path.resolve(this.getCurrentCwd()); const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p); const relativePath = getCwdRelativePath(absolutePath, cwd); if (relativePath !== undefined) { return relativePath; }
return this.formatDisplayPath(absolutePath); }
private getStartupExpansionState(): boolean { return this.options.verbose || this.toolOutputExpanded; }
private getShortPath(fullPath: string, sourceInfo?: AgentConnectionSourceInfo): string { const baseDir = sourceInfo?.baseDir; if (baseDir && this.isPackageSource(sourceInfo)) { const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath)); if ( relativePath && relativePath !== "." && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath) ) { return relativePath.replace(/\\/g, "/"); } }
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 getCompactPathLabel(resourcePath: string, sourceInfo?: AgentConnectionSourceInfo): string { const shortPath = this.getShortPath(resourcePath, sourceInfo); const normalizedPath = shortPath.replace(/\\/g, "/"); const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~"); if (segments.length > 0) { return segments[segments.length - 1]!; } return shortPath; }
private getCompactPackageSourceLabel(sourceInfo?: AgentConnectionSourceInfo): string { const source = sourceInfo?.source ?? ""; if (source.startsWith("npm:")) { return source.slice("npm:".length) || source; }
const gitSource = parseGitUrl(source); if (gitSource) { return gitSource.path || source; }
return source; }
private getCompactExtensionLabel(resourcePath: string, sourceInfo?: AgentConnectionSourceInfo): string { if (!this.isPackageSource(sourceInfo)) { return this.getCompactPathLabel(resourcePath, sourceInfo); }
const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo); if (!sourceLabel) { return this.getCompactPathLabel(resourcePath, sourceInfo); }
const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/"); const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath; const parsedPath = path.posix.parse(packagePath);
if (parsedPath.name === "index") { return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`; }
return `${sourceLabel}:${packagePath}`; }
private getCompactDisplayPathSegments(resourcePath: string): string[] { return this.formatDisplayPath(resourcePath) .replace(/\\/g, "/") .split("/") .filter((segment) => segment.length > 0 && segment !== "~"); }
private getCompactNonPackageExtensionLabel( resourcePath: string, index: number, allPaths: Array<{ path: string; segments: string[] }>, ): string { const segments = allPaths[index]?.segments; if (!segments || segments.length === 0) { return this.getCompactPathLabel(resourcePath); }
for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) { const candidate = segments.slice(-segmentCount).join("/"); const isUnique = allPaths.every((item, itemIndex) => { if (itemIndex === index) { return true; } return item.segments.slice(-segmentCount).join("/") !== candidate; });
if (isUnique) { return candidate; } }
return segments.join("/"); }
private getCompactExtensionLabels( extensions: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>, ): string[] { const nonPackageExtensions = extensions .map((extension) => { const segments = this.getCompactDisplayPathSegments(extension.path); const lastSegment = segments[segments.length - 1]; if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) { segments.pop(); } return { path: extension.path, sourceInfo: extension.sourceInfo, segments, }; }) .filter((extension) => !this.isPackageSource(extension.sourceInfo));
return extensions.map((extension) => { if (this.isPackageSource(extension.sourceInfo)) { return this.getCompactExtensionLabel(extension.path, extension.sourceInfo); }
const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path); if (nonPackageIndex === -1) { return this.getCompactPathLabel(extension.path, extension.sourceInfo); }
return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions); }); }
private getDisplaySourceInfo(sourceInfo?: AgentConnectionSourceInfo): { 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?: AgentConnectionSourceInfo): "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?: AgentConnectionSourceInfo): boolean { const source = sourceInfo?.source ?? ""; return source.startsWith("npm:") || source.startsWith("git:"); }
private buildScopeGroups(items: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>): Array<{ scope: "user" | "project" | "path"; paths: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>>; }> { const groups: Record< "user" | "project" | "path", { scope: "user" | "project" | "path"; paths: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>>; } > = { 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?: AgentConnectionSourceInfo }>; packages: Map<string, Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>>; }>, options: { formatPath: (item: { path: string; sourceInfo?: AgentConnectionSourceInfo }) => string; formatPackagePath: (item: { path: string; sourceInfo?: AgentConnectionSourceInfo }, 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, AgentConnectionSourceInfo>, ): AgentConnectionSourceInfo | 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?: AgentConnectionSourceInfo): 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 AgentConnectionResourceDiagnostic[], sourceInfos: Map<string, AgentConnectionSourceInfo>, ): string { const lines: string[] = [];
// Group collision diagnostics by name const collisions = new Map<string, AgentConnectionResourceDiagnostic[]>(); const otherDiagnostics: AgentConnectionResourceDiagnostic[] = [];
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)`, ), ); } } }
const formatMessageLines = (diagnostic: AgentConnectionResourceDiagnostic, indent: number): string[] => { const color = diagnostic.type === "error" ? "error" : "warning"; const prefix = " ".repeat(indent); return diagnostic.message.split("\n").map((line) => theme.fg(color, `${prefix}${line}`)); };
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(...formatMessageLines(d, 4)); } else { lines.push(...formatMessageLines(d, 2)); } }
return lines.join("\n"); }
private showLoadedResources(options?: { extensions?: Array<{ path: string; sourceInfo?: AgentConnectionSourceInfo }>; force?: boolean; showDiagnosticsWhenQuiet?: boolean; }): void { const showListing = options?.force === true || this.options.verbose === true; const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true; if (!showListing && !showDiagnostics) { return; }
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); const diagnosticsHeader = (name: string, diagnostics: readonly AgentConnectionResourceDiagnostic[]): string => { if (diagnostics.some((diagnostic) => diagnostic.type === "collision")) { return `${name} conflicts`; }
const errorCount = diagnostics.filter((diagnostic) => diagnostic.type === "error").length; const warningCount = diagnostics.filter((diagnostic) => diagnostic.type === "warning").length; if (errorCount > 0 && warningCount > 0) { return `${name} diagnostics`; } if (errorCount > 0) { return `${name} error${errorCount === 1 ? "" : "s"}`; } if (warningCount > 0) { return `${name} warning${warningCount === 1 ? "" : "s"}`; }
return `${name} diagnostics`; }; const formatCompactList = (items: string[], options?: { sort?: boolean }): string => { const labels = items.map((item) => item.trim()).filter((item) => item.length > 0); if (options?.sort !== false) { labels.sort((a, b) => a.localeCompare(b)); } return theme.fg("dim", ` ${labels.join(", ")}`); }; const addLoadedSection = ( name: string, collapsedBody: string, expandedBody = collapsedBody, color: ThemeColor = "mdHeading", ): void => { const section = new ExpandableText( () => `${sectionHeader(name, color)}\n${collapsedBody}`, () => `${sectionHeader(name, color)}\n${expandedBody}`, this.getStartupExpansionState(), 0, 0, ); this.chatContainer.addChild(section); this.chatContainer.addChild(new Spacer(1)); };
const resourceSnapshot = this.connectionResourceSnapshot; const skills = resourceSnapshot?.skills ?? []; const prompts = resourceSnapshot?.prompts ?? []; const loadedThemes = resourceSnapshot?.themes ?? []; const contextFiles = resourceSnapshot?.contextFiles ?? []; const extensions = options?.extensions ?? resourceSnapshot?.extensions ?? []; const sourceInfos = new Map<string, AgentConnectionSourceInfo>(); for (const extension of extensions) { if (extension.sourceInfo) { sourceInfos.set(extension.path, extension.sourceInfo); } } for (const skill of skills) { if (skill.sourceInfo) { sourceInfos.set(skill.filePath, skill.sourceInfo); } } for (const prompt of prompts) { if (prompt.sourceInfo) { sourceInfos.set(prompt.filePath, prompt.sourceInfo); } } for (const loadedTheme of loadedThemes) { if (loadedTheme.sourcePath && loadedTheme.sourceInfo) { sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo); } }
if (showListing) { if (contextFiles.length > 0) { this.chatContainer.addChild(new Spacer(1)); const contextList = contextFiles .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) .join("\n"); const contextCompactList = formatCompactList( contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)), { sort: false }, ); addLoadedSection("Context", contextCompactList, contextList); }
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), }); const skillCompactList = formatCompactList(skills.map((skill) => skill.name)); addLoadedSection("Skills", skillCompactList, skillList); }
if (prompts.length > 0) { const groups = this.buildScopeGroups( prompts.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), ); const templateByPath = new Map(prompts.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); }, }); const promptCompactList = formatCompactList(prompts.map((template) => `/${template.name}`)); addLoadedSection("Prompts", promptCompactList, templateList); }
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)), }); const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions)); addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading"); }
// Show loaded themes (excluding built-in) 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), }); const themeCompactList = formatCompactList( customThemes.map( (loadedTheme) => loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo), ), ); addLoadedSection("Themes", themeCompactList, themeList); } }
if (showDiagnostics) { const skillDiagnostics = resourceSnapshot?.diagnostics.skills ?? []; if (skillDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos); this.chatContainer.addChild( new Text( `${sectionHeader(diagnosticsHeader("Skill", skillDiagnostics), "warning")}\n${warningLines}`, 0, 0, ), ); this.chatContainer.addChild(new Spacer(1)); }
const promptDiagnostics = resourceSnapshot?.diagnostics.prompts ?? []; if (promptDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos); this.chatContainer.addChild( new Text( `${sectionHeader(diagnosticsHeader("Prompt", promptDiagnostics), "warning")}\n${warningLines}`, 0, 0, ), ); this.chatContainer.addChild(new Spacer(1)); }
const extensionDiagnostics: AgentConnectionResourceDiagnostic[] = [ ...(resourceSnapshot?.diagnostics.extensions ?? []), ];
if (this.bindLocalSessionExtensions) { const commandDiagnostics = this.getLocalSessionHost().getExtensionRunner().getCommandDiagnostics(); extensionDiagnostics.push(...commandDiagnostics); } extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.connectionCommands));
if (this.bindLocalSessionExtensions) { const shortcutDiagnostics = this.getLocalSessionHost().getExtensionRunner().getShortcutDiagnostics(); extensionDiagnostics.push(...shortcutDiagnostics); }
if (extensionDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos); this.chatContainer.addChild( new Text( `${sectionHeader(diagnosticsHeader("Extension", extensionDiagnostics), "warning")}\n${warningLines}`, 0, 0, ), ); this.chatContainer.addChild(new Spacer(1)); }
const themeDiagnostics = resourceSnapshot?.diagnostics.themes ?? []; if (themeDiagnostics.length > 0) { const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos); this.chatContainer.addChild( new Text( `${sectionHeader(diagnosticsHeader("Theme", themeDiagnostics), "warning")}\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 localSessionHost = this.getLocalSessionHost(); const uiContext = this.createExtensionUIContext(); await localSessionHost.bindExtensions({ uiContext, commandContextActions: { waitForIdle: () => this.agentConnection.waitForIdle(), newSession: async (options) => { this.stopWorkingLoader(); try { const result = options?.setup || options?.withSession ? await localSessionHost.newSession(options) : await this.agentConnection.newSession( options?.parentSession ? { parentSession: options.parentSession } : undefined, ); if (!result.cancelled) { await this.renderCurrentSessionState(); this.ui.requestRender(); } return result; } catch (error: unknown) { if (this.reportLostConnectionFailure(error)) return { cancelled: true }; return this.handleFatalRuntimeError("Failed to create session", error); } }, fork: async (entryId, options) => { try { const result = options?.withSession ? await localSessionHost.fork(entryId, options) : await this.agentConnection.fork(entryId, { position: options?.position }); if (!result.cancelled) { await this.renderCurrentSessionState(); this.editor.setText("selectedText" in result ? (result.selectedText ?? "") : ""); this.showStatus("Forked to new session"); } return { cancelled: result.cancelled }; } catch (error: unknown) { if (this.reportLostConnectionFailure(error)) return { cancelled: true }; return this.handleFatalRuntimeError("Failed to fork session", error); } }, navigateTree: async (targetId, options) => { const result = await this.agentConnection.navigateTree(targetId, { summarize: options?.summarize, customInstructions: options?.customInstructions, replaceInstructions: options?.replaceInstructions, label: options?.label, }); if (result.cancelled) { return { cancelled: true }; }
await this.renderTreeNavigation(result); return { cancelled: false }; }, switchSession: async (sessionPath, options) => { return this.handleResumeSession(sessionPath, options); }, reload: async () => { await this.handleReloadCommand(); }, }, shutdownHandler: () => { this.shutdownRequested = true; if (!this.isAgentStreaming()) { void this.shutdown(); } }, onError: (error) => { this.showExtensionError(error.extensionPath, error.error, error.stack); }, });
setRegisteredThemes(this.uiServices.getThemes()); await this.refreshConnectionCatalog(); this.setupAutocompleteProvider();
const extensionRunner = localSessionHost.getExtensionRunner(); this.setupExtensionShortcuts(extensionRunner); }
private applyRuntimeSettings(): void { this.footer.setAutoCompactEnabled( this.connectionState?.autoCompactionEnabled ?? this.settingsManager.getCompactionEnabled(), ); this.footerDataProvider.setCwd(this.getCurrentCwd()); 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 getConnectionQueue(): AgentConnectionQueueState { return { steering: [...(this.connectionState?.sessionActions.steering ?? [])], followUp: [...(this.connectionState?.sessionActions.followUps ?? [])], }; }
private async refreshConnectionCatalog(): Promise<void> { this.invalidateConnectionModelRefresh(); const [state, commands, modelCatalog, resources] = await Promise.all([ this.agentConnection.getState(), this.agentConnection.getCommands().catch(() => []), this.agentConnection.getModelCatalog(), this.agentConnection.getResourceSnapshot(), ]); this.applyConnectionStateSnapshot(state); this.connectionCommands = commands; this.applyConnectionModelCatalog(modelCatalog); this.connectionModelsFetchedAt = Date.now(); this.connectionResourceSnapshot = resources; }
private refreshHeartbeatCatalog(): Promise<void> { if (this.heartbeatRefreshPromise) { this.heartbeatRefreshRequested = true; return this.heartbeatRefreshPromise; } const connection = this.agentConnection; const refresh = (async () => { // Bound the drain loop: sustained heartbeats_changed churn must not // starve an awaiting rebind (and its transcript render) indefinitely. for (let drain = 0; drain <= HEARTBEAT_REFRESH_DRAIN_LIMIT; drain++) { this.heartbeatRefreshRequested = false; const fetchSeq = ++this.heartbeatCatalogSeq; const fetch = connection.listHeartbeats(); const heartbeats = await fetchHeartbeatsWithinDeadline(fetch); if (this.isShuttingDown || this.isReturningToAgentsView || this.agentConnection !== connection) return; if (heartbeats === undefined) { // The fetch deadline expired: keep the last catalog, retain the // in-flight result so the open view still converges when the // late answer lands, and arm a bounded retry so the open view // always has a next scheduled retrieval even if it never does. // A change event that arrived mid-fetch still re-triggers a // refresh through the follow-up scheduling below. this.retainHeartbeatCatalogFetch(fetch, connection, fetchSeq); this.armHeartbeatManagerFetchRetry(); return; } this.applyHeartbeatCatalog(heartbeats, fetchSeq); if (!this.heartbeatRefreshRequested) return; } })().finally(() => { if (this.heartbeatRefreshPromise === refresh) { this.heartbeatRefreshPromise = undefined; // The drain bound was hit with a newer heartbeats_changed // pending: schedule the follow-up refresh so the catalog // converges instead of staying stale until the next event. if (this.heartbeatRefreshRequested) { void this.refreshHeartbeatCatalog().catch(() => undefined); } } }); this.heartbeatRefreshPromise = refresh; return refresh; }
private applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[], seq?: number): void { // A sequenced fetch write that lost the race to a newer catalog write // (a later fetch or a management action) must not revert it. if (seq !== undefined) { if (seq < this.heartbeatCatalogAppliedSeq) return; this.heartbeatCatalogAppliedSeq = seq; } this.heartbeatCatalog = heartbeats; this.heartbeatCatalogFetchError = undefined; this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.ui.requestRender(); }
private getScopedHeartbeats(): AgentConnectionHeartbeat[] { return scopeHeartbeatsToSession(this.heartbeatCatalog, this.connectionState, this.subagentSnapshots.values()); }
private applyConnectionStateSnapshot(state: AgentConnectionState): void { this.bindPromptStashSession(state.sessionId); this.connectionState = state; this.scheduleHeartbeatManagerRefresh(); // Don't touch contextUsageTokenBaseline: a mid-stream snapshot reflects only completed // turns (the in-flight message isn't persisted yet), so the in-flight delta must keep // accumulating. The baseline is managed at turn end (refreshConnectionContextUsage) and // reset on a new user message. this.footer.setAutoCompactEnabled(state.autoCompactionEnabled); this.sessionRecap = state.recap; this.renderRecap(); this.updateWorkingPulse(); this.updateCacheTrayTimer(); }
private patchConnectionState(patch: Partial<AgentConnectionState>): void { if (!this.connectionState) { return; } const prevUsage = this.connectionState.contextUsage; this.connectionState = { ...this.connectionState, ...patch }; this.updateWorkingPulse(); this.syncTrayTicker(); if (patch.contextUsage !== undefined && patch.contextUsage !== prevUsage) { this.subagentSummaryLine.invalidate(); this.ui.requestRender(); } }
// Bake this attempt's output into the snapshot so the tray doesn't dip in the gap between // isStreaming clearing and the async refresh landing. private applyOptimisticContextUsage(): void { const snapshot = this.connectionState?.contextUsage; if (!snapshot || snapshot.tokens === null || snapshot.contextWindow <= 0) return; const completed = Math.max(0, this.activityTracker.getStatus().tokens - this.contextUsageTokenBaseline); if (completed <= 0) return; const tokens = snapshot.tokens + completed; this.patchConnectionState({ contextUsage: { ...snapshot, tokens, percent: (tokens / snapshot.contextWindow) * 100, }, }); }
/** Refresh the tray's context usage from the session after a turn or compaction completes. */ private async refreshConnectionContextUsage(): Promise<void> { const generation = ++this.contextUsageRefresh.generation; const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; const stats = await connection?.getSessionStats?.().catch(() => undefined); if (!stats) return; // Drop results superseded by a newer successful refresh as well as results for a replaced session. if ( generation < this.contextUsageRefresh.lastSuccessGeneration || this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId ) { return; } this.contextUsageRefresh.lastSuccessGeneration = generation; // Anything counted so far is now reflected in the snapshot; only later output is in-flight. this.contextUsageTokenBaseline = this.activityTracker.getStatus().tokens; this.patchConnectionState({ contextUsage: stats.contextUsage }); }
private harvestAssistantMessageUsage(message: AssistantMessage): void { const usage = message.usage; if (!usage) return; const cachedTokens = (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); const inputTokens = (usage.input ?? 0) + cachedTokens; if (inputTokens <= 0 && cachedTokens <= 0 && usage.output <= 0) return;
const contextWindow = this.connectionState?.contextUsage?.contextWindow ?? this.getCurrentModel()?.contextWindow ?? 0; if (contextWindow <= 0) return;
let promptCache = this.connectionState?.contextUsage?.promptCache; if (cachedTokens > 0) { const model = this.getCurrentModel(); const provider = message.provider || model?.provider || "unknown"; const modelId = message.model || model?.id || "unknown"; const supportsLong = model?.compat?.supportsLongCacheRetention !== false; const ttlInfo = resolvePromptCacheTtl(provider, modelId, undefined, supportsLong); promptCache = { cachedTokens, ttlSeconds: ttlInfo?.ttlSeconds, expiresAt: ttlInfo?.ttlSeconds ? Date.now() + ttlInfo.ttlSeconds * 1000 : undefined, deterministic: ttlInfo?.deterministic ?? false, status: "warm", }; }
if (inputTokens > 0) { const outputTokens = usage.output > 0 ? usage.output : Math.max(0, (this.activityTracker?.getStatus().tokens ?? 0) - (this.contextUsageTokenBaseline ?? 0)); const tokens = inputTokens + outputTokens; const prev = this.connectionState?.contextUsage; if (prev && prev.tokens === tokens && prev.promptCache?.cachedTokens === promptCache?.cachedTokens) { return; } this.contextUsageTokenBaseline = this.activityTracker?.getStatus().tokens ?? 0; this.patchConnectionState({ contextUsage: { tokens, contextWindow, percent: (tokens / contextWindow) * 100, promptCache, }, }); } else if ( promptCache && promptCache.cachedTokens !== this.connectionState?.contextUsage?.promptCache?.cachedTokens ) { this.patchConnectionState({ contextUsage: { tokens: this.connectionState?.contextUsage?.tokens ?? null, contextWindow, percent: this.connectionState?.contextUsage?.percent ?? null, promptCache, }, }); } }
private refreshQueueSelectionFromState(): void { const selected = this.queueSelection.selected; if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); } }
private updateConnectionStateFromEvent(event: AgentConnectionSessionEvent): void { if (!this.connectionState) { return; } switch (event.type) { case "agent_start": { const wasNewChat = this.isNewChat(); this.patchConnectionState({ isStreaming: true, activeToolNames: [] }); if (wasNewChat) { this.builtInHeader?.invalidate(); this.subagentSummaryLine.invalidate(); } break; } case "message_end": { const wasNewChat = this.isNewChat(); this.patchConnectionState({ messageCount: this.connectionState.messageCount + 1 }); if (wasNewChat) { this.builtInHeader?.invalidate(); this.subagentSummaryLine.invalidate(); } break; } case "agent_end": this.patchConnectionState({ isStreaming: false, activeToolNames: [] }); void this.maybeRunQueuedMcpActivation(); break; case "session_action_update": this.patchConnectionState({ sessionActions: event.actions }); this.refreshQueueSelectionFromState(); break; case "compaction_start": this.patchConnectionState({ isCompacting: true }); break; case "compaction_end": this.patchConnectionState({ isCompacting: false }); void this.maybeRunQueuedMcpActivation(); break; case "session_info_changed": this.patchConnectionState({ sessionName: event.name }); break; case "thinking_level_changed": this.patchConnectionState({ thinkingLevel: event.level }); break; case "service_tier_changed": this.patchConnectionState({ serviceTier: event.serviceTier }); break; case "auto_retry_start": this.patchConnectionState({ retryAttempt: event.attempt }); break; case "auto_retry_end": this.patchConnectionState({ retryAttempt: 0 }); break; case "goal_update": this.patchConnectionState({ goal: event.goal }); break; case "bash_start": this.patchConnectionState({ isBashRunning: true }); break; case "bash_end": this.patchConnectionState({ isBashRunning: false }); break; } }
private getCurrentCwd(): string { return this.connectionState?.cwd ?? this.uiServices.getInitialCwd(); }
private getCurrentSessionName(): string | undefined { return this.connectionState?.sessionName ?? this.uiServices.getInitialSessionName(); }
private applyAuthStaleEvent(event: Extract<AgentConnectionSessionEvent, { type: "auth_stale" }>): void { let marked = false; for (const token of event.sourceTokens ?? []) { marked = this.modelRegistry.markProviderAuthSourceStale(token) || marked; } if (!marked) { this.modelRegistry.markProviderAuthStale(event.provider); } this.footer.invalidate(); this.updateEditorBorderColor(); }
private getCurrentModel(): AgentConnectionModel | undefined { return this.connectionState?.model; }
private getCurrentModelId(): string | undefined { return this.getCurrentModel()?.id; }
private getCurrentRemoteHost(): string | undefined { return this.connectionState?.remoteHost; }
private isAgentStreaming(): boolean { return this.connectionState?.isStreaming ?? false; }
private isAgentCompacting(): boolean { return this.connectionState?.isCompacting ?? false; }
private isBashRunning(): boolean { return this.connectionState?.isBashRunning ?? false; }
private hasInterruptibleWork(): boolean { return ( this.isAgentStreaming() || this.isAgentCompacting() || this.isBashRunning() || this.getRetryAttempt() > 0 || this.connectionState?.sessionActions.active !== undefined || this.traceUploadAllAbortController !== undefined || this.sideQuestionEvent?.status === "running" ); }
private getRetryAttempt(): number { return this.connectionState?.retryAttempt ?? 0; }
private getQueuedActionCount(): number { return this.connectionState?.sessionActions.queuedCount ?? 0; }
private getGoalState(): GoalState { return this.connectionState?.goal ?? emptyGoalState(); }
private getConnectionContextUsage(): AgentConnectionState["contextUsage"] { const snapshot = this.connectionState?.contextUsage; if (!snapshot || snapshot.tokens === null || snapshot.contextWindow <= 0) { return snapshot; } // Add only the output produced since the snapshot was last refreshed. The activity // tracker accumulates across auto-retries within a turn, so subtract the baseline // captured at the last refresh to avoid re-adding a failed attempt's tokens. const inFlight = this.isAgentStreaming() ? Math.max(0, (this.activityTracker?.getStatus().tokens ?? 0) - (this.contextUsageTokenBaseline ?? 0)) : 0;
let promptCache = snapshot.promptCache; const streamUsage = this.isAgentStreaming() ? this.streamingMessage?.usage : undefined; const streamCached = streamUsage ? (streamUsage.cacheRead ?? 0) + (streamUsage.cacheWrite ?? 0) : 0; if (streamCached > 0) { const model = this.getCurrentModel(); const provider = this.streamingMessage?.provider || model?.provider || "unknown"; const modelId = this.streamingMessage?.model || model?.id || "unknown"; const supportsLong = model?.compat?.supportsLongCacheRetention !== false; const ttlInfo = resolvePromptCacheTtl(provider, modelId, undefined, supportsLong); promptCache = { cachedTokens: streamCached, ttlSeconds: ttlInfo?.ttlSeconds, expiresAt: ttlInfo?.ttlSeconds ? Date.now() + ttlInfo.ttlSeconds * 1000 : undefined, deterministic: ttlInfo?.deterministic ?? false, status: "warm", }; }
const streamInput = streamUsage ? (streamUsage.input ?? 0) + streamCached : 0; const baseTokens = streamInput > 0 ? streamInput : snapshot.tokens; const outputTokens = streamUsage && streamUsage.output > 0 ? streamUsage.output : inFlight; const tokens = streamInput > 0 ? baseTokens + outputTokens : snapshot.tokens + inFlight;
if (tokens === snapshot.tokens && promptCache === snapshot.promptCache) { return snapshot; } return { tokens, contextWindow: snapshot.contextWindow, percent: (tokens / snapshot.contextWindow) * 100, promptCache, } satisfies ContextUsage; }
private getScopedModelState(): AgentConnectionState["scopedModels"] { return this.connectionState?.scopedModels ?? []; }
private async rebindCurrentSession(): Promise<void> { this.unsubscribe?.(); this.unsubscribe = undefined; void this.rosterBar?.dispose(); this.rosterBar = undefined; if (this.localSessionHost) { this.uiServices = this.localSessionHost.createUiServices(); } this.toolDefinitionCache.clear(); this.applyRuntimeSettings(); if (this.bindLocalSessionExtensions) { await this.bindCurrentSessionExtensions(); } else { setRegisteredThemes(this.uiServices.getThemes()); // Best-effort: the catalog enriches the composer (commands, models, // resources). A transient control-plane failure must not abort the // rebind and leave the transcript unrendered. try { await this.refreshConnectionCatalog(); } catch { // Keep the previous catalog; reconnects re-fetch it. } this.setupAutocompleteProvider(); } this.subscribeToAgent(); await this.subscribeToRosterBar(); // A session_action_update in the unsubscribed gap above is lost; re-sync the queue post-subscription. // Best-effort: a transient control-plane failure must not abort the // rebind and leave the transcript unrendered. try { this.patchConnectionState({ sessionActions: (await this.agentConnection.getState()).sessionActions }); } catch { // Keep the attached snapshot's session actions; the next connection // state update re-syncs them. } this.refreshQueueSelectionFromState(); this.updatePendingMessagesDisplay(); // Optional tray metadata must not delay opening or leaving a chat. void this.refreshHeartbeatCatalog().catch(() => undefined); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); this.refreshTopBarCost(); this.setGoalAnnouncementBaseline(this.getGoalState()); this.syncGoalTray(this.getGoalState()); this.syncWorkingLoader(); }
/** * A command that fails only because the connection is terminal reports the error and * returns: the window must outlive the session, and the error names the recovery. */ private reportLostConnectionFailure(error: unknown): boolean { if (!this.connectionClosed) return false; this.showError(error instanceof Error ? error.message : String(error)); return true; }
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 resetCurrentSessionRenderState(options?: { clearPromptStash?: boolean }): void { this.chatContainer.clear(); this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); this.pendingQueueEdit = undefined; this.pendingQueueMove = false; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. this.queueSelection.reset(); if (options?.clearPromptStash) { this.promptStash = undefined; if (this.promptStashState) this.promptStashState.queuedStashes = undefined; } // Clear every editor's prompt history, draft text, and queues, then prune // any pasted images no longer referenced by the remaining stashed draft. this.defaultEditor.clearHistory?.(); this.defaultEditor.setText(""); if (this.editor !== this.defaultEditor) { this.editor.clearHistory?.(); this.editor.setText(""); } const keepImageIds = this.liveImageMarkerIds(); for (const markerId of this.pastedImages.keys()) { if (!keepImageIds.has(markerId)) { this.pastedImages.delete(markerId); } } this.streamingComponent = undefined; this.streamingMessage = undefined; // The discarded component's loader interval keeps firing otherwise; no // bash_end will reach it once the reference is dropped. this.activeBashComponent?.setComplete(undefined, true); this.activeBashComponent = undefined; // Likewise: the next session's view may never see this refine settle. this.discardRefineLoader(); this.pendingBashComponents = []; this.activityTracker.reset(); this.contextUsageTokenBaseline = 0; this.resetPendingToolState(); this.agentRunFileChanges.clear(); this.renderRecap(); this.ipythonToolComponents.clear(); this.lateIpythonSentAgentMessages.clear(); this.resetSubagentSummary(); this.setGoalAnnouncementBaseline(this.getGoalState()); this.syncGoalTray(this.getGoalState()); this.syncTrayTicker?.(); }
private resetPendingToolState(): void { this.pendingToolGeneration++; this.pendingTools.clear(); this.pendingToolCreations.clear(); this.startedToolCalls.clear(); }
private async renderCurrentSessionState(): Promise<void> { // Replacement events own the session-scoped command catalog. The daemon // sends that event before its command response, but its handler may still // be refreshing commands when the response resolves. await this.sessionEventQueue; this.resetCurrentSessionRenderState(); await this.renderInitialMessages(); this.updatePendingMessagesDisplay(); this.syncWorkingLoader(); }
private async refreshCommandCatalogForCurrentSession(): Promise<void> { try { this.connectionCommands = await this.agentConnection.getCommands(); } catch { this.connectionCommands = []; } this.setupAutocompleteProvider(); }
private async renderResyncedSession(snapshot: AgentConnectionSnapshot): Promise<void> { const bashFinished = this.isBashRunning() && !snapshot.state.isBashRunning; this.applyConnectionStateSnapshot(snapshot.state); this.refreshQueueSelectionFromState(); this.restoreTurnStartFromMessages(this.getSessionContextFromConnectionSnapshot(snapshot).messages); this.streamingComponent = undefined; this.streamingMessage = undefined; this.rlmNodeId = snapshot.parent?.childId; this.replaceSubagentSummary(snapshot.children); await this.renderSessionContext(this.getSessionContextFromConnectionSnapshot(snapshot), { clearChat: true, updateFooter: true, limitTranscript: true, }); await this.restoreStreamingMessageFromSnapshot(snapshot.streamingMessage); this.updatePendingMessagesDisplay(); if (bashFinished) { if (this.activeBashComponent) { this.activeBashComponent.setComplete(undefined, false); this.activeBashComponent = undefined; if (!snapshot.state.isStreaming) { this.flushPendingBashComponents(); } } // A transient side bash is not persisted in the session snapshot, so a // reconnect cannot replay its missed bash_end event. Release the pane's // local running state when the authoritative snapshot says bash ended. if (this.sideQuestionBash) { this.sideQuestionComponent?.finishBash(); this.sideQuestionBash = undefined; this.sideQuestionBashComponent = undefined; } this.sideQuestionBashDiscarded = undefined; } this.updateTerminalTitle(); this.refreshTopBarCost(); this.setGoalAnnouncementBaseline(this.getGoalState()); this.syncGoalTray(this.getGoalState()); this.syncWorkingLoader(); }
private getCachedToolDefinition(toolName: string): ToolExecutionDefinition | undefined { return this.toolDefinitionCache.get(toolName); }
private async loadToolDefinition(toolName: string): Promise<ToolExecutionDefinition | undefined> { if (this.toolDefinitionCache.has(toolName)) { return this.toolDefinitionCache.get(toolName); } const definition = this.createToolExecutionDefinition( toolName, await this.agentConnection.getToolDefinition(toolName), this.localSessionHost?.getToolRendererDefinition(toolName), ); this.toolDefinitionCache.set(toolName, definition); return definition; }
private getLatestStreamingToolCall(toolCallId: string): ToolCall | undefined { return this.streamingMessage?.content.find( (content): content is ToolCall => content.type === "toolCall" && content.id === toolCallId, ); }
private registerIpythonToolComponent(toolName: string, toolCallId: string, component: ToolExecutionComponent): void { if (toolName !== "ipython") { return; } this.ipythonToolComponents.set(toolCallId, component); for (const lateMessage of this.lateIpythonSentAgentMessages.get(toolCallId) ?? []) { component.appendSentAgentMessage(lateMessage); } }
private async getOrCreatePendingToolComponent( toolCall: PendingToolCallRenderInput, ): Promise<ToolExecutionComponent | undefined> { const existingComponent = this.pendingTools.get(toolCall.id); if (existingComponent) { existingComponent.updateArgs(toolCall.arguments); return existingComponent; } if (this.pendingToolCreations.has(toolCall.id)) { return undefined; }
this.pendingToolCreations.add(toolCall.id); const generation = this.pendingToolGeneration; try { const toolDefinition = await this.loadToolDefinition(toolCall.name); if (generation !== this.pendingToolGeneration) { // Pending tool state was reset (abort/error) while loading; drop the stale component. return undefined; } const latestToolCall = this.getLatestStreamingToolCall(toolCall.id) ?? toolCall; const componentAfterLoad = this.pendingTools.get(latestToolCall.id); if (componentAfterLoad) { componentAfterLoad.updateArgs(latestToolCall.arguments); return componentAfterLoad; }
const spacing = createConversationSpacing(this.chatContainer.children); const component = new ToolExecutionComponent( latestToolCall.name, latestToolCall.id, latestToolCall.arguments, { showImages: this.settingsManager.getShowImages(), shouldAddLeadingSpace: () => spacing.shouldAddLeadingSpace(true), }, toolDefinition, this.ui, this.getCurrentCwd(), ); component.setExpanded(this.toolOutputExpanded); component.setEditDiffsExpanded(this.editDiffsExpanded); component.setAgentMessagesExpanded(this.agentMessagesExpandedFor()); if (this.startedToolCalls.has(latestToolCall.id)) { component.markExecutionStarted(); } selectLatestToolExpandHint(this.chatContainer.children, component); this.chatContainer.addChild(component); this.pendingTools.set(latestToolCall.id, component); this.registerIpythonToolComponent(latestToolCall.name, latestToolCall.id, component); return component; } finally { this.pendingToolCreations.delete(toolCall.id); } }
private createToolExecutionDefinition( toolName: string, connectionDefinition: AgentConnectionToolDefinition | undefined, localRendererDefinition: InteractiveModeLocalToolRendererDefinition | undefined, ): ToolExecutionDefinition | undefined { if (!connectionDefinition && !localRendererDefinition) { return undefined; }
const definition: ToolExecutionDefinition = { ...(connectionDefinition ?? { name: toolName, label: toolName, description: "", parameters: {}, }), }; if (localRendererDefinition?.renderShell !== undefined) { definition.renderShell = localRendererDefinition.renderShell; } if (localRendererDefinition?.renderCall !== undefined) { definition.renderCall = localRendererDefinition.renderCall; } if (localRendererDefinition?.renderResult !== undefined) { definition.renderResult = localRendererDefinition.renderResult; } return definition; }
private async preloadToolDefinitions(toolNames: Iterable<string>): Promise<void> { const missingToolNames = Array.from(new Set(toolNames)).filter( (toolName) => !this.toolDefinitionCache.has(toolName), ); if (missingToolNames.length === 0) { return; } await Promise.all( missingToolNames.map(async (toolName) => { const definition = this.createToolExecutionDefinition( toolName, await this.agentConnection.getToolDefinition(toolName), this.localSessionHost?.getToolRendererDefinition(toolName), ); this.toolDefinitionCache.set(toolName, definition); }), ); }
private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void { const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); if (shortcuts.size === 0) return;
const localSessionHost = this.getLocalSessionHost(); const createContext = (ownerPath: string): ExtensionContext => ({ ...extensionRunner.createTimerBindings(ownerPath), ui: this.createExtensionUIContext(), hasUI: true, cwd: this.getCurrentCwd(), sessionManager: localSessionHost.getSessionManager(), modelRegistry: this.modelRegistry, model: this.getCurrentModel(), isIdle: () => !this.isAgentStreaming(), signal: localSessionHost.getAbortSignal(), abort: () => this.agentConnection.abort(), hasPendingMessages: () => this.getQueuedActionCount() > 0, shutdown: () => { this.shutdownRequested = true; }, getContextUsage: () => this.getConnectionContextUsage(), compact: (options) => { void (async () => { try { const result = await this.agentConnection.compact(options?.customInstructions); options?.onComplete?.(result); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); options?.onError?.(err); } })(); }, getSystemPrompt: () => localSessionHost.getSystemPrompt(), });
this.defaultEditor.onExtensionShortcut = (data: string) => { for (const [shortcutStr, shortcut] of shortcuts) { if (matchesKey(data, shortcutStr as KeyId)) { Promise.resolve(shortcut.handler(createContext(shortcut.extensionPath))).catch((err) => { this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`); }); return true; } } return false; }; }
private setExtensionStatus(key: string, text: string | undefined): void { this.footerDataProvider.setExtensionStatus(key, text); this.ui.requestRender(); }
private getWorkingLoaderMessage(): string { const elapsed = this.workingStartedAt === undefined ? undefined : this.formatWorkingElapsed(Date.now() - this.workingStartedAt); const status = this.activityTracker.getStatus(); // The subagent count/recaps live in the tree above the loader, so the loader // message itself no longer repeats "N subagents running". if (!this.isAgentStreaming()) { return ""; } if (this.workingMessage !== undefined) { // Extensions and tool bootstrap own the message; keep the plain "<message> <elapsed>" form. return elapsed === undefined ? this.workingMessage : `${this.workingMessage} ${elapsed}`; } const parts: string[] = [AGENT_ACTIVITY_LABELS[status.activity]]; if (elapsed !== undefined) { parts.push(elapsed); } if (status.tokens > 0) { parts.push(`${status.direction === "down" ? "↓" : "↑"} ${formatTokenCount(status.tokens)} tokens`); } return parts.join(" · "); }
private createWorkingLoader(): Loader { return new Loader( this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), this.getWorkingLoaderMessage(), this.workingIndicatorOptions, ); }
private formatWorkingElapsed(elapsedMs: number): string { const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); if (totalSeconds < 60) { return `${totalSeconds}s`; } const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes < 60) { return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; if (hours < 24) { return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; } const days = Math.floor(hours / 24); const remainingHours = hours % 24; return `${days}d ${remainingHours.toString().padStart(2, "0")}h ${remainingMinutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; }
private updateWorkingLoaderMessage(): void { this.loadingAnimation?.setMessage(this.getWorkingLoaderMessage()); }
private startWorkingTimer(): void { if (this.workingTimer) { clearInterval(this.workingTimer); } this.workingTimer = setInterval(() => this.updateWorkingLoaderMessage(), 1000); this.workingTimer.unref?.(); }
// Recover the in-flight run's start from a restored transcript so the elapsed timer survives re-attach. private restoreTurnStartFromMessages(messages: readonly AgentMessage[]): void { this.turnStartedAt = undefined; if (!this.isAgentStreaming()) return; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]!; if (startsAgentRun(message)) { this.turnStartedAt = message.timestamp; } else if (message.role === "assistant" && message.stopReason !== "toolUse") { break; } } if (this.turnStartedAt !== undefined && this.workingStartedAt !== undefined) { this.workingStartedAt = this.turnStartedAt; this.updateWorkingLoaderMessage(); } }
private startWorkingLoader(): void { this.stopWorkingLoader(); this.workingStartedAt = this.turnStartedAt ?? Date.now(); this.loadingAnimation = this.createWorkingLoader(); this.statusContainer.addChild(this.loadingAnimation); this.startWorkingTimer(); }
private stopWorkingLoader(): void { if (this.workingTimer) { clearInterval(this.workingTimer); this.workingTimer = undefined; } this.workingStartedAt = undefined; if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); }
private updateWorkingPulse(): void { const active = this.isAgentStreaming() || this.chatContainer.children.some( (component) => component instanceof ToolExecutionComponent && component.hasRunningBackgroundShell(), ); if (!active) { this.stopWorkingPulse(); return; } if (!this.pulseTimer) { this.pulseTimer = setInterval(() => this.tickWorkingPulse(), WORKING_ICON_INTERVAL_MS); this.pulseTimer.unref?.(); } }
private tickWorkingPulse(): void { this.updateWorkingPulse(); this.pulseFrame += 1; setWorkingPulseFrame(this.pulseFrame); this.ui.requestRender(); }
private stopWorkingPulse(): void { if (this.pulseTimer) { clearInterval(this.pulseTimer); this.pulseTimer = undefined; } }
private shouldShowWorkingLoader(): boolean { // Background subagents (agent turn done, asyncio tasks still running) would // otherwise show a textless spinner; the subagent tree above the loader carries // that state, so the loader only shows while the main agent is itself streaming. return this.workingVisible && this.isAgentStreaming(); }
// Reconcile the loader with current state for transitions that fire no live // agent_start edge (returning from agents view, resuming mid-stream). private startCompactionLoader( reason: "manual" | "requested" | "overflow" | "threshold", customInstructions?: string, ): void { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } // Keep editor active; submissions are queued during compaction. // Fully stop the working loader (not just detach) so it isn't orphaned. this.stopWorkingLoader(); this.statusContainer.clear(); const cancelHint = `(${keyText("app.clear")} to cancel)`; const focus = customInstructions ? ` (focus: ${truncateToWidth(customInstructions, 60, "…")})` : ""; const label = reason === "manual" ? `Compacting context${focus}... ${cancelHint}` : reason === "requested" ? `Agent requested compaction, compacting context${focus}... ${cancelHint}` : `${reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; this.autoCompactionLoader = new Loader( this.ui, (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), label, ); this.statusContainer.addChild(this.autoCompactionLoader); this.ui.requestRender(); }
/** Live status for a user-issued /refine, mirroring the compaction loader. */ private startRefineLoader(): void { this.stopWorkingLoader(); this.statusContainer.clear(); this.refineLoader = new Loader( this.ui, (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), "Refining continual harness state...", ); this.statusContainer.addChild(this.refineLoader); this.ui.requestRender(); }
private stopRefineLoader(): void { if (!this.refineLoader) return; this.discardRefineLoader(); this.statusContainer.clear(); this.syncWorkingLoader(); }
/** Stops and removes the loader without remounting old-session state. */ private discardRefineLoader(): void { if (!this.refineLoader) return; this.refineLoader.stop(); this.statusContainer.removeChild(this.refineLoader); this.refineLoader = undefined; }
private syncWorkingLoader(): void { // A compaction that started before this client attached (or while another // view was open) has no start-event edge; restore its loader from state. if (!this.autoCompactionLoader && this.isAgentCompacting()) { this.startCompactionLoader("manual"); return; } // Compaction/retry own the status container while active; don't fight them. if (this.autoCompactionLoader || this.retryLoader) { return; } // Remount the refine loader if another owner (e.g. a compaction) cleared it. if (this.refineLoader) { if (!this.statusContainer.children.includes(this.refineLoader)) { this.statusContainer.clear(); this.statusContainer.addChild(this.refineLoader); } return; } if (this.shouldShowWorkingLoader()) { // A bare `loadingAnimation != null` check isn't proof it's on screen: // other paths clear statusContainer without nulling it, orphaning the // loader. Re-attach unless it is actually mounted. if (!this.loadingAnimation || !this.statusContainer.children.includes(this.loadingAnimation)) { this.startWorkingLoader(); } } else if (this.loadingAnimation) { this.stopWorkingLoader(); } this.ui.requestRender(); }
private setWorkingVisible(visible: boolean): void { this.workingVisible = visible; if (!visible) { this.stopWorkingLoader(); this.ui.requestRender(); return; } if (this.shouldShowWorkingLoader() && !this.loadingAnimation) { this.statusContainer.clear(); this.startWorkingLoader(); } this.ui.requestRender(); }
private setWorkingIndicator(options?: LoaderIndicatorOptions): void { this.workingIndicatorOptions = options; this.loadingAnimation?.setIndicator(options); this.ui.requestRender(); }
private setHiddenThinkingLabel(_label?: string): void { // Retain the extension/daemon UI hook; conversation thinking no longer has a heading. }
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)) { 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 { 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 { // Close inline auth panels before the configuration menu so a restored // menu is still torn down by the closeConfigurationMenu call below. // Innermost panels close first, ending at the pre-login content. for (const close of this.inlineAuthPanelClosers.splice(0).reverse()) { close("reset"); } // A reset mid-onboarding leaves the block with no flow to host: tear it // down too, so the overlay, its animation and the pending step all end. this.onboardingAbort?.(); this.closeConfigurationMenu?.(); this.closeServiceCatalogPicker?.(); this.cancelActiveConnectionExtensionUiRequests(); this.closeHeartbeatManager(); 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.refreshTopBarCost(); this.workingMessage = undefined; this.workingVisible = true; this.setWorkingIndicator(); if (this.loadingAnimation) { this.updateWorkingLoaderMessage(); } }
private static readonly MAX_WIDGET_LINES = 10;
private renderWidgets(): void { if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true); this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false); this.ui.requestRender(); }
private renderRecap(): void { if (!this.recapContainer) return; this.recapContainer.clear(); const showChanges = !this.isAgentStreaming() && this.agentRunFileChanges.size > 0; if (showChanges) { this.recapContainer.addChild( new TruncatedText(formatTotalChangeSummary([...this.agentRunFileChanges.values()]), 1, 0), ); } this.recapContainer.addChild( new PromptContextLine( () => this.sessionRecap, (maxWidth) => this.getPromptContextLabel(maxWidth), ), ); this.ui.requestRender(); }
private renderWidgetContainer( container: Container, widgets: Map<string, Component & { dispose?(): void }>, leadingSpacer: boolean, ): void { container.clear();
if (widgets.size === 0) { return; }
if (leadingSpacer) { container.addChild(new Spacer(1)); } for (const component of widgets.values()) { container.addChild(component); } }
private setExtensionFooter( factory: | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) | undefined, ): void { if (this.customFooter?.dispose) { this.customFooter.dispose(); }
if (this.customFooter) { this.footerSlot.removeChild(this.customFooter); } else { this.footerSlot.removeChild(this.footer); }
if (factory) { this.customFooter = factory(this.ui, theme, this.footerDataProvider); this.footerSlot.addChild(this.customFooter); } else { this.customFooter = undefined; this.footerSlot.addChild(this.footer); }
this.ui.requestRender(); }
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; }
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) { this.customHeader = factory(this.ui, theme); if (isExpandable(this.customHeader)) { this.customHeader.setExpanded(this.toolOutputExpanded); } 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 { this.customHeader = undefined; if (isExpandable(this.builtInHeader)) { this.builtInHeader.setExpanded(this.toolOutputExpanded); } 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(); }
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.updateWorkingLoaderMessage(); } }, 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), }; }
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 }, );
this.editorContainer.clear(); this.editorContainer.addChild(this.extensionSelector); this.ui.setFocus(this.extensionSelector); this.ui.requestRender(); }); }
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(); }
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; }
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(); }); }
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(); }
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(); }); }
private hideExtensionEditor(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionEditor = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); }
private setCustomEditorComponent(factory: EditorFactory | undefined): void { this.editorComponentFactory = factory; // Snapshot the current editor before replacing it. Paste markers are only // meaningful while their originating editor still owns the paste snapshot. const currentEditor = this.editor; const currentPromptStash = this.snapshotPromptStashFrom(currentEditor, currentEditor.getText());
this.editorContainer.clear();
if (factory) { // Create the custom editor with tui, theme, and keybindings const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
// Restore before wiring the shared onChange callback: setText may emit a // change, and an empty custom editor cannot reconstruct the old snapshot. const canRestorePasteSnapshot = currentPromptStash.pasteSnapshot === undefined || newEditor.restorePasteSnapshot !== undefined; newEditor.setText( canRestorePasteSnapshot ? currentPromptStash.text : (currentPromptStash.expandedText ?? currentPromptStash.text), ); if (currentPromptStash.pasteSnapshot && newEditor.restorePasteSnapshot) { newEditor.restorePasteSnapshot(currentPromptStash.pasteSnapshot); }
// Wire up callbacks from the default editor. onChange snapshots the // active editor while it still owns paste markers and attachments, so // submit remains exact even when an editor clears before calling onSubmit. newEditor.onSubmit = this.defaultEditor.onSubmit; newEditor.onChange = this.defaultEditor.onChange;
// 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.onPasteText) { customEditor.onPasteText = (text: string) => this.defaultEditor.onPasteText?.(text); } if (!customEditor.onPasteRecorded) { customEditor.onPasteRecorded = (info: { pasteId?: number; text: string; marker?: string }) => this.defaultEditor.onPasteRecorded?.(info); } if (!customEditor.onMoveBelowPrompt) { customEditor.onMoveBelowPrompt = () => this.defaultEditor.onMoveBelowPrompt?.(); } if (!customEditor.onAgentsBack) { customEditor.onAgentsBack = () => this.defaultEditor.onAgentsBack?.(); } 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 the default editor with the same rich snapshot (or expanded // fallback text if this editor implementation cannot restore it). const canRestorePasteSnapshot = currentPromptStash.pasteSnapshot === undefined || this.defaultEditor.restorePasteSnapshot !== undefined; this.defaultEditor.setText( canRestorePasteSnapshot ? currentPromptStash.text : (currentPromptStash.expandedText ?? currentPromptStash.text), ); if (currentPromptStash.pasteSnapshot && this.defaultEditor.restorePasteSnapshot) { this.defaultEditor.restorePasteSnapshot(currentPromptStash.pasteSnapshot); } this.editor = this.defaultEditor; } this.latestEditorPromptStash = currentPromptStash;
this.editorContainer.addChild(this.editor as Component); this.ui.setFocus(this.editor as Component); this.ui.requestRender(); }
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(); }
private setupKeyHandlers(): void { this.defaultEditor.getHeaderLine = () => this.getQueueSelectionHeader(); // 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 = () => { this.handleEscape(); };
this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); this.defaultEditor.onAction("app.interrupt", () => this.handleInterruptKey()); this.defaultEditor.onAction("app.shortcuts", () => this.showShortcutGuide()); this.defaultEditor.onCtrlD = () => this.handleCtrlD(); this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ());
// Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => { void this.handleDebugCommand(); }; this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); this.defaultEditor.onAction("app.model.cycleForward", () => this.handleModelCycle("forward")); this.defaultEditor.onAction("app.model.cycleBackward", () => this.handleModelCycle("backward")); this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.messages.expand", () => this.toggleAgentMessageExpansion()); this.defaultEditor.onAction("app.edits.expand", () => this.toggleEditDiffExpansion()); this.defaultEditor.onAction("app.subagents.focus", () => this.focusSubagentSummary()); this.defaultEditor.onAction("app.heartbeats.open", () => { this.showHeartbeatManager(); }); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); this.defaultEditor.onAction("app.editor.pasteFile", () => this.toggleRecentPaste()); this.defaultEditor.onAction("app.editor.pasteSecret", () => this.convertRecentPasteToFile("secret")); this.defaultEditor.onAction("app.prompt.stash", () => this.handlePromptStash()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.navigateOlder", () => this.browseQueueSelection(-1)); this.defaultEditor.onAction("app.message.navigateNewer", () => this.browseQueueSelection(1)); this.defaultEditor.onAction("app.message.moveEarlier", () => this.moveQueueSelection(-1)); this.defaultEditor.onAction("app.message.moveLater", () => this.moveQueueSelection(1)); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); this.defaultEditor.onAction("app.session.tree", () => { void this.showTreeSelector(); }); this.defaultEditor.onAction("app.session.fork", () => { void this.showUserMessageSelector(); }); this.defaultEditor.onAction("app.session.resume", () => { void this.requestAgentsView(); }); this.defaultEditor.onAgentsBack = () => this.handleAgentsBack(); this.defaultEditor.onMoveBelowPrompt = () => this.focusSubagentSummary();
this.defaultEditor.onChange = (text: string) => { if (text.length > 0 && !this.isApplyingQueueSelectionText) { this.latestEditorPromptStash = this.snapshotPromptStashFrom(this.editor, text); } if (this.escapeRepeatAction && !this.isApplyingQueueSelectionText) { this.clearEscapeRepeat(); } if (text.length > 0) { this.clearCtrlCExitHint(); } };
this.defaultEditor.onPasteImage = () => { this.handleClipboardImagePaste(); };
this.defaultEditor.onPasteText = (pastedText: string) => { return this.handlePasteText(pastedText); }; this.defaultEditor.onPasteRecorded = (info) => { this.recentPaste = { text: info.text, pasteId: info.pasteId, marker: info.marker, }; }; }
private snapshotPromptStashFrom(editor: EditorComponent, text: string): PromptStash { const pasteSnapshot = editor.getPasteSnapshot?.(); const images = this.getPromptStashImages(text); const pasted = this.getPromptStashPastedFiles(text); return { text, expandedText: pasteSnapshot ? (editor.getExpandedText?.() ?? text) : undefined, pasteSnapshot, ...(images.length > 0 ? { images } : {}), ...(pasted.length > 0 ? { pastedFiles: pasted } : {}), }; }
private snapshotPromptStash(text: string): PromptStash { return this.snapshotPromptStashFrom(this.editor, text); }
private restorePromptStashOnOpen(): void { if (!this.promptStash?.restoreOnOpen) return; // Land the restore notice in its own status block: init may have just posted // a notice (e.g. compaction) that showStatus would otherwise replace. this.lastStatusText = undefined; this.lastStatusSpacer = undefined; this.restorePromptStashIfEditorEmpty(); }
private stashDraftForAgentsView(): void { const text = this.editor.getText(); if (!text.trim()) return; // Head of the durable queue so it restores first on return; an existing // manual stash stays queued behind it and keeps its manual-stash semantics. const existing = [this.promptStashState.stash, ...(this.promptStashState.queuedStashes ?? [])].filter( (stash): stash is PromptStash => stash !== undefined, ); this.promptStashState.stash = { ...this.snapshotPromptStash(text), restoreOnOpen: true }; this.promptStashState.queuedStashes = existing.length > 0 ? existing : undefined; }
private handlePromptStash(): void { const text = this.editor.getText(); if (!text.trim()) { if (!this.restorePromptStashIfEditorEmpty()) { this.showStatus("No prompt to stash"); } return; } if (this.promptStash !== undefined) { this.showStatus("Prompt stash already has a draft"); return; } this.promptStash = this.snapshotPromptStash(text); this.editor.setText(""); this.showStatus("Stashed prompt"); }
private restorePromptStashIfEditorEmpty(stash = this.promptStash): boolean { if (stash === undefined || this.editor.getText().trim()) { return false; } if (this.promptStash !== stash) { return false; } this.promptStash = this.promptStashState?.queuedStashes?.shift(); if (this.promptStashState?.queuedStashes?.length === 0) this.promptStashState.queuedStashes = undefined; const canRestorePasteSnapshot = stash.pasteSnapshot === undefined || this.editor.restorePasteSnapshot !== undefined; this.editor.setText(canRestorePasteSnapshot ? stash.text : (stash.expandedText ?? stash.text)); if (stash.pasteSnapshot && this.editor.restorePasteSnapshot) { this.editor.restorePasteSnapshot(stash.pasteSnapshot); } this.latestEditorPromptStash = this.snapshotPromptStash(this.editor.getText()); this.showStatus("Restored stashed prompt"); return true; }
private retainSubmittedDraft( stash: PromptStash, submissionGeneration: number, state: PromptStashState = this.promptStashState, ): void { this.retainedSubmissionGenerations.set(stash, submissionGeneration); const ordered = [state.stash, ...(state.queuedStashes ?? [])].filter( (candidate): candidate is PromptStash => candidate !== undefined, ); const insertAt = ordered.findIndex((candidate) => { const generation = this.retainedSubmissionGenerations.get(candidate); return generation !== undefined && generation > submissionGeneration; }); ordered.splice(insertAt === -1 ? ordered.length : insertAt, 0, stash); state.stash = ordered.shift(); state.queuedStashes = ordered.length > 0 ? ordered : undefined; }
private retainStartupPromptDrafts(prompts: readonly InteractiveInitialPrompt[]): void { // Reserve every marker visible anywhere in the retained batch before assigning // any image. This prevents an early prompt's attachment from making a literal // marker in a later prompt resolve to the wrong image. const reserved = new Set(this.pastedImages.keys()); const reservedPasteds = new Set(this.pastedFiles.keys()); for (const stash of [this.promptStash, ...(this.promptStashState.queuedStashes ?? [])]) { if (stash) { for (const markerId of imageMarkerIds(stash.text)) reserved.add(markerId); for (const markerId of pastedMarkerIds(stash.text)) reservedPasteds.add(markerId); } } for (const prompt of prompts) { for (const markerId of imageMarkerIds(prompt.text)) reserved.add(markerId); for (const markerId of pastedMarkerIds(prompt.text)) reservedPasteds.add(markerId); } for (const markerId of reserved) { this.nextImageMarkerId = Math.max(this.nextImageMarkerId, markerId + 1); } for (const markerId of reservedPasteds) { this.nextPastedMarkerId = Math.max(this.nextPastedMarkerId, markerId + 1); } const allocateMarker = () => { while (reserved.has(this.nextImageMarkerId)) this.nextImageMarkerId++; const markerId = this.nextImageMarkerId++; reserved.add(markerId); return markerId; }; const allocatePastedMarker = () => { while (reservedPasteds.has(this.nextPastedMarkerId)) this.nextPastedMarkerId++; const markerId = this.nextPastedMarkerId++; reservedPasteds.add(markerId); return markerId; };
const retained: PromptStash[] = []; for (const prompt of prompts) { let text = prompt.text; // A startup prompt owns only the images passed with it. Remap literal // markers that already name registry data so restoring this draft cannot // accidentally attach an old or another prompt's image. const literalRemaps = new Map<number, number>(); for (const markerId of imageMarkerIds(text)) { if (this.pastedImages.has(markerId) && !literalRemaps.has(markerId)) { literalRemaps.set(markerId, allocateMarker()); } } text = remapImageMarkers(text, literalRemaps);
const literalPastedRemaps = new Map<number, number>(); for (const markerId of pastedMarkerIds(text)) { if (this.pastedFiles.has(markerId) && !literalPastedRemaps.has(markerId)) { literalPastedRemaps.set(markerId, allocatePastedMarker()); } } text = remapPastedMarkers(text, literalPastedRemaps);
const images: Array<readonly [number, ImageContent]> = []; for (const image of prompt.images ?? []) { const markerId = allocateMarker(); images.push([markerId, image]); text += `${text.length > 0 && !/\s$/.test(text) ? " " : ""}${formatImageMarker(markerId)}`; } retained.push({ text, ...(images.length > 0 ? { images } : {}), }); for (const [markerId, image] of images) this.pastedImages.set(markerId, image); }
// Startup drafts form the head of the durable queue. Preserve any older // client draft after them, and let submissions released by the barrier append. const existing = [this.promptStashState.stash, ...(this.promptStashState.queuedStashes ?? [])].filter( (stash): stash is PromptStash => stash !== undefined, ); const ordered = [...retained, ...existing]; this.promptStashState.stash = ordered.shift(); this.promptStashState.queuedStashes = ordered.length > 0 ? ordered : undefined; }
private getPromptStashImages(text: string): readonly (readonly [number, ImageContent])[] { const images: Array<readonly [number, ImageContent]> = []; for (const markerId of imageMarkerIds(text)) { const image = this.pastedImages.get(markerId); if (image) { images.push([markerId, image]); } } return images; }
private async handleClipboardImagePaste(): Promise<void> { try { const image = await readClipboardImage(); if (!image) { return; }
// Resize down to the inline image size limit, mirroring the CLI @file // path, so large screenshots don't exceed provider limits. Fall back to // the raw bytes if resizing is unavailable. const raw: ImageContent = { type: "image", data: Buffer.from(image.bytes).toString("base64"), mimeType: image.mimeType, }; const resized = await resizeImage(raw); const attachment: ImageContent = resized ? { type: "image", data: resized.data, mimeType: resized.mimeType } : raw;
// Register the image and insert a visible marker. The image is attached to // the prompt as multimodal content rather than written to disk, so a vision // model receives it directly. const markerId = this.nextImageMarkerId++; this.rememberPastedImage(markerId, attachment); this.editor.insertTextAtCursor?.(formatImageMarker(markerId)); this.ui.requestRender();
const model = this.getCurrentModel(); if (model && !model.input.includes("image")) { this.showStatus("Current model does not support images; the attachment will be omitted."); } } catch { // Silently ignore clipboard errors (may not have permission, etc.) } }
/** * Record a pasted image, evicting the oldest entries once the retained bytes * exceed {@link MAX_PASTED_IMAGE_BYTES} so a long session stays bounded. The * just-added image and any whose marker is still referenced (editor or queues) * are never evicted, so a live marker never loses its image. */ private rememberPastedImage(id: number, image: ImageContent): void { this.pastedImages.set(id, image); const keep = this.liveImageMarkerIds(); keep.add(id); evictImagesToBudget(this.pastedImages, (img) => img.data.length, MAX_PASTED_IMAGE_BYTES, keep); }
/** * Marker ids still reachable — current editor text, prompt history (recallable * with the up arrow), the compaction queue, and the connection queue. These are * never evicted so a recall or resend never finds a marker with no image. */ private liveImageMarkerIds(): Set<number> { const ids = new Set<number>(); const add = (text: string) => { for (const markerId of imageMarkerIds(text)) { ids.add(markerId); } }; add(this.editor.getText()); for (const stash of [this.promptStash, ...(this.promptStashState?.queuedStashes ?? [])]) { if (stash) add(stash.text); } for (const entry of this.editor.getHistory?.() ?? []) { add(entry); } const queue = this.getConnectionQueue(); for (const msg of [...queue.steering, ...queue.followUp]) { add(msg); } return ids; }
/** * The images whose `[image #N]` markers are present in `text`, or undefined if * none. Read-only: the registry is never cleared here, so deleting a marker * simply drops its image while restoring the marker (undo, history, retry, * dequeue) brings it back. Marker presence in the sent text is the single * source of truth. * * Resolved against the current model: if it has no image input, attachments * are dropped here (matching the paste-time hint) rather than sent and * downgraded downstream. */ private collectImagesFor(text: string): ImageContent[] | undefined { const model = this.getCurrentModel(); if (model && !model.input.includes("image")) { return undefined; } const images = collectMarkedImages(this.pastedImages, text); return images.length > 0 ? images : undefined; }
private hasPastedImagesFor(text: string): boolean { return imageMarkerIds(text).some((id) => this.pastedImages.has(id)); }
private getPromptStashPastedFiles(text: string): readonly (readonly [number, PastedEntry])[] { const pasted: Array<readonly [number, PastedEntry]> = []; for (const markerId of pastedMarkerIds(text)) { const entry = this.pastedFiles.get(markerId); if (entry) { pasted.push([markerId, entry]); } } return pasted; }
private handlePasteText(pastedText: string): boolean { if (this.getCurrentRemoteHost()) { if (!this.warnedRemotePasteFallback) { this.warnedRemotePasteFallback = true; this.showWarning( "Remote daemon detected: pasting inline because local files are not shared with the remote host.", ); } return false; } const thresholdBytes = this.settingsManager.getPasteThresholdBytes(); const thresholdLines = this.settingsManager.getPasteThresholdLines(); const byteLength = Buffer.byteLength(pastedText, "utf-8"); const lineCount = pastedText.split("\n").length; if (byteLength >= thresholdBytes || lineCount >= thresholdLines) { const id = this.nextPastedMarkerId++; const entry: PastedEntry = { id, content: pastedText, mode: "blob" }; this.rememberPastedFile(id, entry); const marker = formatPastedMarker(id); this.editor.insertTextAtCursor?.(marker); this.recentPaste = { text: pastedText, pastedId: id, marker }; this.ui.requestRender(); return true; } return false; }
private toggleRecentPaste(): void { const pastedId = this.recentPaste?.pastedId; const entry = pastedId === undefined ? undefined : this.pastedFiles.get(pastedId); if (entry) { entry.mode = toggledPastedMode(entry.mode); this.showStatus(`Pasted #${entry.id} set to ${entry.mode} mode`); this.ui.requestRender(); return; } this.convertRecentPasteToFile("blob"); }
private convertRecentPasteToFile(mode: PastedMode): void { if (this.getCurrentRemoteHost()) { if (!this.warnedRemotePasteFallback) { this.warnedRemotePasteFallback = true; this.showWarning("Remote daemon detected: cannot write a local file for a remote daemon."); } return; } if (!this.recentPaste) { this.showStatus("No recent paste to move into a file"); return; } const currentText = this.editor.getText(); if (this.recentPaste.pastedId !== undefined) { const entry = this.pastedFiles.get(this.recentPaste.pastedId); if (entry && currentText.includes(formatPastedMarker(entry.id))) { entry.mode = mode; this.showStatus(`Pasted #${entry.id} set to ${mode} mode`); this.ui.requestRender(); return; } } if (this.recentPaste.pasteId !== undefined) { const pasteId = this.recentPaste.pasteId; const markerPattern = new RegExp(`\\[paste #${pasteId}( (?:\\+\\d+ lines|\\d+ chars))?\\]`); if (markerPattern.test(currentText)) { const content = this.editor.getPaste?.(pasteId) ?? this.recentPaste.text; this.editor.deletePaste?.(pasteId); const id = this.nextPastedMarkerId++; const entry: PastedEntry = { id, content, mode }; this.rememberPastedFile(id, entry); const marker = formatPastedMarker(id); this.editor.setText(currentText.replace(markerPattern, marker)); this.recentPaste = { text: content, pastedId: id, marker }; this.showStatus(`Moved the paste to a file (pasted #${id}, ${mode})`); this.ui.requestRender(); return; } } if (this.recentPaste.text && currentText.includes(this.recentPaste.text)) { const content = this.recentPaste.text; const id = this.nextPastedMarkerId++; const entry: PastedEntry = { id, content, mode }; this.rememberPastedFile(id, entry); const marker = formatPastedMarker(id); const idx = currentText.lastIndexOf(content); const newText = currentText.slice(0, idx) + marker + currentText.slice(idx + content.length); this.editor.setText(newText); this.recentPaste = { text: content, pastedId: id, marker }; this.showStatus(`Moved the paste into a file (pasted #${id}, ${mode})`); this.ui.requestRender(); return; } this.showStatus("Recent paste no longer found in editor"); }
private rememberPastedFile(id: number, entry: PastedEntry): void { this.pastedFiles.set(id, entry); const keep = this.livePastedMarkerIds(); keep.add(id); evictPastesToBudget(this.pastedFiles, (s) => s.content.length, MAX_PASTED_FILE_BYTES, keep); }
private livePastedMarkerIds(): Set<number> { const ids = new Set<number>(); const add = (text: string) => { for (const markerId of pastedMarkerIds(text)) { ids.add(markerId); } }; add(this.editor.getText()); for (const stash of [this.promptStash, ...(this.promptStashState?.queuedStashes ?? [])]) { if (stash) add(stash.text); } for (const entry of this.editor.getHistory?.() ?? []) { add(entry); } const queue = this.getConnectionQueue(); for (const msg of [...queue.steering, ...queue.followUp]) { add(msg); } return ids; }
private resolvePastesForSubmission(text: string): string { if (pastedMarkerIds(text).length === 0) { return text; } if (this.getCurrentRemoteHost()) { if (!this.warnedRemotePasteFallback) { this.warnedRemotePasteFallback = true; this.showWarning( "Remote daemon detected: expanding the paste inline because local files are not shared with the remote host.", ); } let fallbackText = text; for (const [id, entry] of this.pastedFiles) { fallbackText = fallbackText.replaceAll(formatPastedMarker(id), entry.content); } return fallbackText; } const resolved = resolvePastesInText(text, this.pastedFiles, { sessionId: this.connectionState?.sessionId ?? "default", agentDir: getAgentDir(), }); return resolved.text; }
private async handleSideQuestion(question: string): Promise<void> { if (!question) { this.showWarning("Usage: /btw <question>"); return; } if (this.activeSideQuestionId) { this.showWarning("Wait for the current side question to finish or cancel it first."); return; }
// Turns already answered in the open pane seed the follow-up's context. const previousTurns = this.sideQuestionTurns .filter((turn) => turn.answer) .map((turn) => ({ question: turn.question, answer: turn.answer })); const event: AgentConnectionSideQuestionEvent = { id: randomUUID(), question, answer: "", status: "running", }; this.activeSideQuestionId = event.id; this.sideQuestionEvent = event; this.sideQuestionTurns.push(event); if (this.sideQuestionComponent) { this.sideQuestionComponent.addTurn(event); } else { this.sideQuestionComponent = new SideQuestionComponent(event, this.settingsManager.getEditorPaddingX()); this.sideQuestionComponent.setExpanded(this.toolOutputExpanded); this.sideQuestionContainer.addChild(new Spacer(1)); this.sideQuestionContainer.addChild(this.sideQuestionComponent); } this.ui.requestRender();
try { await this.agentConnection.startSideQuestion( event.id, question, previousTurns.length > 0 ? previousTurns : undefined, ); } catch (error) { this.handleSideQuestionEvent({ ...event, status: "error", errorMessage: error instanceof Error ? error.message : String(error), }); } }
private handleSideQuestionEvent(event: AgentConnectionSideQuestionEvent): void { if (event.id === this.activeSideQuestionId && event.status !== "running") { this.activeSideQuestionId = undefined; } if (event.id !== this.sideQuestionEvent?.id || !this.sideQuestionComponent) { return; } this.sideQuestionEvent = event; const turnIndex = this.sideQuestionTurns.findIndex((turn) => turn.id === event.id); if (turnIndex !== -1) { this.sideQuestionTurns[turnIndex] = event; } this.sideQuestionComponent.update(event); this.ui.requestRender(); }
private finishSideQuestionBash( event: Extract<AgentConnectionSessionEvent, { type: "bash_end" }>, rawOutput: string, ): void { // Release the pane's running state before any early return so the cancel // hint cannot stay stuck if the pending state was already cleared. this.sideQuestionComponent?.finishBash(); const bash = this.sideQuestionBash; if (!bash) { return; } this.sideQuestionBash = undefined; // The pane already rendered the run; this only seeds follow-up turns. if (!bash.seedTranscript || event.cancelled || event.errorMessage) { return; } const truncation = truncateTail(rawOutput); const output = truncation.content.replace(/\n+$/, ""); this.sideQuestionTurns.push({ id: `side-bash-${randomUUID()}`, question: bash.input, answer: bashOutputToText({ output, exitCode: event.exitCode, cancelled: false, truncated: event.truncated || truncation.truncated, fullOutputPath: event.fullOutputPath, }), status: "complete", }); }
private clearSideQuestion(options: { abort?: boolean } = {}): void { const event = this.sideQuestionEvent; if (options.abort && event?.status === "running") { this.abortSideQuestion(event.id); } if (this.sideQuestionBash) { // A side-conversation bash run dies with its pane. Its bash_* events may // still be in flight (even bash_start), so swallow them until bash_end. const ownsRunningBash = this.sideQuestionBashComponent !== undefined; this.sideQuestionBashDiscarded = this.sideQuestionBash.runId; this.sideQuestionBash = undefined; this.sideQuestionBashComponent = undefined; // abort_bash is session-scoped. Before our matching bash_start arrives, // another client may own the slot, so only abort a run we have observed. if (ownsRunningBash) { void this.agentConnection.abortBash().catch(() => undefined); } } this.sideQuestionEvent = undefined; this.sideQuestionTurns = []; this.sideQuestionComponent = undefined; this.sideQuestionContainer.clear(); if (this.isInitialized) { this.ui.requestRender(); } }
private resetSideQuestion(): void { this.clearSideQuestion({ abort: true }); this.activeSideQuestionId = undefined; }
private abortSideQuestion(id: string, reportError = false): void { void this.agentConnection .abortSideQuestion(id) .then((aborted) => { if (!aborted && this.activeSideQuestionId === id) { this.activeSideQuestionId = undefined; } }) .catch((error) => { if (reportError) { this.showError(error instanceof Error ? error.message : String(error)); } }); }
private async renderTreeNavigation(result: { editorText?: string }): Promise<void> { this.clearSideQuestion({ abort: true }); this.chatContainer.clear(); await this.renderInitialMessages(); if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } this.showStatus("Navigated to selected point"); }
private setupEditorSubmitHandler(): void { this.defaultEditor.onSubmit = async (text: string) => { const streamingBehavior = this.submittedInputBehavior; this.submittedInputBehavior = "steer"; if (this.queueSelection?.isBrowsing && !this.pendingQueueEdit) { const targetLane = streamingBehavior === "followUp" ? "followUp" : "steering"; try { if (await this.applyQueueSelection(text, targetLane)) return; } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; } } text = text.trim(); if (!text) return; const submissionGeneration = ++this.inputSubmissionGeneration; this.inputSubmissionsPending++; this.clearShortcutGuide(); // A barrier wait can resume after /new repointed the live session fields. const submissionStashState = this.promptStashState; const submissionSessionId = this.promptStashSessionId; const promptStashToRestore = this.promptStash; const liveEditorText = this.editor.getText(); const submittedDraft = this.pendingSubmittedPromptStash ?? (liveEditorText.trim() ? this.snapshotPromptStash(liveEditorText) : this.latestEditorPromptStash); this.pendingSubmittedPromptStash = undefined; let restorePromptStashAfterSubmit = true; let submissionOutcome: StartupPromptBarrierOutcome = "admitted";
try { const slashCommand = parseSlashCommand(text); const commandName = slashCommand ? resolveBuiltinSlashCommandName(slashCommand.name) : undefined; const commandArgs = slashCommand?.args ?? ""; const canonicalCommandText = commandName ? `/${commandName}${commandArgs ? ` ${commandArgs}` : ""}` : text;
// Slash commands are disabled while a side conversation is open: they // act on the main session, which is confusing mid-side-chat. The notice // renders as a pane response and never reaches the model. A reply that // merely starts with "/" (e.g. an absolute path) is not a command and // falls through to the side-conversation capture below. if ( this.sideQuestionComponent && slashCommand !== undefined && (isBuiltinSlashCommandName(slashCommand.name) || this.connectionCommands.some((command) => command.name === slashCommand.name)) ) { this.editor.addToHistory?.(text); this.sideQuestionComponent.addTurn({ id: `side-notice-${randomUUID()}`, question: text, answer: "Slash commands are not available in side conversations. Press esc to return to the main thread.", status: "complete", }); this.ui.requestRender(); return; } if (commandName) { void captureAgentCommandUsed({ agentDir: getAgentDir(), settingsManager: this.settingsManager, commandName, }).catch(() => {}); }
if (commandName === "btw") { this.editor.setText(""); await this.handleSideQuestion(commandArgs); return; } if (commandName === "settings") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /settings"); return; } await this.showSettingsSelector(); this.editor.setText(""); return; } if (commandName === "scoped-models") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /scoped-models"); return; } this.editor.setText(""); await this.showModelsSelector(); return; } if (commandName === "model") { const searchTerm = commandArgs || undefined; this.editor.setText(""); await this.handleModelCommand(searchTerm); return; } if (commandName === "effort") { this.editor.setText(""); this.handleEffortCommand(commandArgs); return; } if (commandName === "fast") { this.editor.setText(""); if (commandArgs) { this.showError("Usage: /fast"); } else { this.handleFastCommand(); } return; } if (commandName === "export") { await this.handleExportCommand(canonicalCommandText); this.editor.setText(""); return; } if (commandName === "import") { await this.handleImportCommand(canonicalCommandText); this.editor.setText(""); return; } if (commandName === "share") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /share"); return; } await this.handleShareCommand(); this.editor.setText(""); return; } if (commandName === "copy") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /copy"); return; } await this.handleCopyCommand(); this.editor.setText(""); return; } if (commandName === "name") { await this.handleNameCommand(canonicalCommandText); this.editor.setText(""); return; } if (commandName === "rlm-max-depth") { this.editor.setText(""); await this.handleRlmMaxDepthCommand(commandArgs); return; } if (commandName === "session") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /session"); return; } this.echoLocalCommand(text); await this.handleSessionCommand(); this.editor.setText(""); return; } if (commandName === "system-prompt") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /system-prompt"); return; } this.echoLocalCommand(text); await this.handleSystemPromptCommand(); this.editor.setText(""); return; } if (commandName === "traces") { await this.handleTracesCommand(canonicalCommandText); this.editor.setText(""); return; } if (commandName === "logs") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /logs"); return; } this.echoLocalCommand(text); this.handleLogsCommand(); this.editor.setText(""); return; } if (commandName === "heartbeat") { await this.handleHeartbeatCommand(canonicalCommandText); this.editor.setText(""); return; } if (commandName === "heartbeats") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /heartbeats"); return; } this.editor.setText(""); await this.showHeartbeatManager(); return; } if (commandName === "changelog") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /changelog"); return; } this.echoLocalCommand(text); this.handleChangelogCommand(); this.editor.setText(""); return; } if (commandName === "hotkeys") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /hotkeys"); return; } this.echoLocalCommand(text); this.handleHotkeysCommand(); this.editor.setText(""); return; } if (commandName === "fork") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /fork"); return; } this.editor.setText(""); await this.showUserMessageSelector(); return; } if (commandName === "clone") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /clone"); return; } this.editor.setText(""); await this.handleCloneCommand(); return; } if (commandName === "tree") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /tree"); return; } this.editor.setText(""); restorePromptStashAfterSubmit = false; await this.showTreeSelector(); return; } if (commandName === "login") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /login"); return; } this.editor.setText(""); await this.showConfigurationMenu("providers"); return; } if (commandName === "logout") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /logout"); return; } this.editor.setText(""); await this.showLogoutSelector(); return; } if (commandName === "mcp") { this.editor.setText(""); await this.handleMcpCommand(commandArgs); return; } if (commandName === "plugins") { this.editor.setText(""); await this.handlePluginsCommand(commandArgs); return; } if (slashCommand?.name === "clear") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /clear"); } else { this.editor.setText(""); await this.handleClearCommand(); } return; } if (slashCommand?.name === "new") { let options: ReturnType<typeof parseNewSessionCommand>; try { options = parseNewSessionCommand(text.slice(4)); } catch (error) { this.editor.setText(text); this.showError(error instanceof Error ? error.message : String(error)); return; } this.editor.setText(""); await this.handleClearCommand(options); return; } if (commandName === "resume") { this.editor.setText(""); await this.handleResumeCommand(commandArgs); return; } if (commandName === "reload") { const arg = commandArgs?.trim(); if (arg && arg !== "models") { this.editor.setText(text); this.showError("Usage: /reload [models]"); return; } this.editor.setText(""); await this.handleReloadCommand(arg); return; } if (commandName === "usage") { this.echoLocalCommand(text); if (commandArgs) this.showWarning("Usage: /usage"); this.handleUsageCommand(); this.editor.setText(""); return; } if (commandName === "context") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /context"); return; } this.echoLocalCommand(text); await this.handleContextCommand(); this.editor.setText(""); return; } if (commandName === "nightly") { this.editor.setText(""); const nightlyArg = commandArgs?.trim().toLowerCase(); if (nightlyArg === "status") { const channel = resolveUpdateChannel(this.version, this.settingsManager.getUpdateChannel()); const source = this.settingsManager.getUpdateChannel() ? "set in settings" : "inferred from the running version"; this.showStatus(`Updates follow the ${channel} channel (${source}). v${this.version} installed.`); return; } if (nightlyArg === "off" || nightlyArg === "stable") { this.settingsManager.setUpdateChannel("stable"); this.showStatus( "Updates now follow the stable channel. Run /update to install the latest stable release.", ); return; } if (nightlyArg && nightlyArg !== "on") { this.showError("Usage: /nightly [on|off|status]"); return; } if (this.isAgentCompacting() || this.isAgentStreaming() || this.isBashRunning()) { this.showWarning("Wait for the current work to finish before updating."); return; } // The update command owns the nightly warning, the channel switch, and the // busy-session confirmation, so declining either leaves settings untouched. await this.handleUpdateCommand("--self --nightly"); return; } if (commandName === "update") { this.editor.setText(""); const updateArgs = parseCommandArgs(commandArgs); if ( !updateArgsIncludeSelf(updateArgs) && (this.isAgentCompacting() || this.isAgentStreaming() || this.isBashRunning()) ) { this.showWarning("Wait for the current work to finish before updating."); return; } await this.handleUpdateCommand(commandArgs); return; } if (commandName === "fullscreen") { this.editor.setText(""); const arg = commandArgs?.trim().toLowerCase(); if (arg && arg !== "on" && arg !== "off") { this.showError("Usage: /fullscreen [on|off]"); return; } const enable = arg === "on" ? true : arg === "off" ? false : !this.fullscreenEnabled; this.setFullscreenMode(enable); return; } if (commandName === "debug") { if (commandArgs) { this.editor.setText(text); this.showError("Usage: /debug"); return; } await 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 === "/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) { // Bare ! / !! is bash mode with nothing to run; don't send it as a prompt return; } if (this.isBashRunning()) { this.showWarning( `A bash command is already running. Press ${keyText("app.clear")} to cancel it first.`, ); return; } // A streaming side turn blocks bash just like it blocks follow-up // replies: overlapping pane turns would seed out of order. if (this.sideQuestionComponent && this.activeSideQuestionId) { this.editor.setText(text); this.showWarning("Wait for the current side question to finish or cancel it first."); return; } // Inside a side conversation the command runs inside the pane (its // bash_start event mounts the usual BashExecutionComponent there), // stays out of the main-session context, and (for !, not !!) seeds // follow-up side questions. const sideBash = this.sideQuestionComponent ? { runId: randomUUID(), input: text, seedTranscript: !isExcluded } : undefined; if (sideBash) { this.sideQuestionBash = sideBash; } else { this.clearSideQuestion({ abort: true }); } this.editor.addToHistory?.(text); this.editor.setText(""); // Optimistic: bash_start only fires after extension dispatch, and the // clear key must already route to abortBash in that window. this.patchConnectionState({ isBashRunning: true }); try { await this.agentConnection.executeBash(command, { excludeFromContext: isExcluded || sideBash !== undefined, ...(sideBash ? { transient: true, runId: sideBash.runId } : {}), }); } catch (error) { // Re-sync rather than assume idle: the rejection may mean another // client's bash run already holds the slot. try { const state = await this.agentConnection.getState(); this.patchConnectionState({ isBashRunning: state.isBashRunning }); } catch { this.patchConnectionState({ isBashRunning: false }); } if (this.sideQuestionBash === sideBash) { this.sideQuestionBash = undefined; } if (sideBash && this.sideQuestionBashDiscarded === sideBash.runId) { // The pane discarded this run, but it never started, so no // bash_end will arrive to consume the marker. this.sideQuestionBashDiscarded = undefined; } this.showError(error instanceof Error ? error.message : String(error)); } return; }
// An open side-question pane captures replies as follow-up side // questions; ! bash routed above and slash commands were rejected // earlier with a notice. Esc returns to the main thread. if (this.sideQuestionComponent) { // A follow-up submitted mid-bash would seed the transcript ahead of // the output it reacts to; make it wait like a running side turn. // The editor cleared its buffer before onSubmit fired, so blocked // paths put the draft back rather than merely skip clearing it. if (this.sideQuestionBash) { this.editor.setText(text); this.showWarning("Wait for the running command to finish or cancel it first."); return; } if (this.activeSideQuestionId) { this.editor.setText(text); await this.handleSideQuestion(text); return; } // Side questions are text-only end to end; a reply with pasted // images gets an in-pane notice instead of silently dropping them. if (this.hasPastedImagesFor(text)) { this.editor.setText(text); this.sideQuestionComponent.addTurn({ id: `side-notice-${randomUUID()}`, question: text, answer: "Images are not supported in side conversations. Press esc to return to the main thread.", status: "complete", }); this.ui.requestRender(); return; } this.editor.addToHistory?.(text); await this.handleSideQuestion(text); return; }
this.clearSideQuestion({ abort: true }); this.flushPendingBashComponents(); const images = this.collectImagesFor(text); this.editor.addToHistory?.(text); this.editor.setText(""); const promptStashAfterClear = this.promptStash; submissionOutcome = (await this.admitPendingStartupPrompts?.()) ?? "admitted"; // Retention is not admission. Startup drafts were inserted synchronously // before the barrier settled, so append this blocked submission behind them // and never let it prompt or overtake them. if (submissionOutcome === "retained") { this.retainSubmittedDraft(submittedDraft ?? { text }, submissionGeneration); return; } // The barrier also settles when the run lifecycle ends; a submit resumed // by teardown must neither prompt nor mutate the editor/durable stash. if ( submissionOutcome === "lifecycle-cancelled" || this.isShuttingDown || this.isReturningToAgentsView || this.agentsViewRequest || this.promptStashSessionId !== submissionSessionId ) { // The editor is already torn down, but its shared session stash outlives // this view. Preserve the submitted draft in the stash of the session it // was typed for, without overwriting an explicit older stash. this.retainSubmittedDraft(submittedDraft ?? { text }, submissionGeneration, submissionStashState); submissionOutcome = "lifecycle-cancelled"; return; } try { const promptToSend = this.resolvePastesForSubmission(text); const { message: submittedPrompt, ...continuationOptions } = resolveContinuationShortcut( promptToSend, (images?.length ?? 0) > 0, ); await this.agentConnection.prompt(submittedPrompt, { streamingBehavior, queueIfBusy: true, images, ...continuationOptions, }); } catch (error) { // Generation guards editor ownership, not draft durability: a stale // rejection must be retained rather than overwrite newer input or vanish. const rejectedDraft = submittedDraft ?? { text }; const canRestore = !this.isShuttingDown && !this.isReturningToAgentsView && !this.agentsViewRequest && submissionGeneration === this.inputSubmissionGeneration && this.editor.getText().length === 0; if (canRestore) { const canRestorePasteSnapshot = rejectedDraft.pasteSnapshot === undefined || this.editor.restorePasteSnapshot !== undefined; this.editor.setText( canRestorePasteSnapshot ? rejectedDraft.text : (rejectedDraft.expandedText ?? rejectedDraft.text), ); if (rejectedDraft.pasteSnapshot && this.editor.restorePasteSnapshot) { this.editor.restorePasteSnapshot(rejectedDraft.pasteSnapshot); } this.latestEditorPromptStash = this.snapshotPromptStash(this.editor.getText()); if (this.promptStash === promptStashAfterClear) this.promptStash = promptStashToRestore; } else { this.retainSubmittedDraft(rejectedDraft, submissionGeneration, submissionStashState); } this.showError(error instanceof Error ? error.message : String(error)); return; } this.updatePendingMessagesDisplay(); this.ui.requestRender(); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } finally { if (this.isShuttingDown || this.isReturningToAgentsView || this.agentsViewRequest) { submissionOutcome = "lifecycle-cancelled"; } if ( submissionOutcome === "admitted" && restorePromptStashAfterSubmit && promptStashToRestore !== undefined && submissionGeneration === this.inputSubmissionGeneration ) { this.restorePromptStashIfEditorEmpty(promptStashToRestore); } this.inputSubmissionsPending--; if (this.inputSubmissionsPending === 0 && this.pendingPromptStashReleases.length > 0) { this.completeDeferredPromptStashRelease(); } } }; }
private async subscribeToRosterBar(): Promise<void> { if (!this.agentConnection.subscribeAgentRoster) return; try { this.rosterBar = await this.agentConnection.subscribeAgentRoster(() => { this.updateSubagentSummaryLine(); this.ui.requestRender(); }); } catch { this.rosterBar = undefined; } this.updateSubagentSummaryLine(); }
private subscribeToAgent(): void { this.unsubscribe = this.agentConnection.subscribe(async (event) => { try { if (event.type === "session_event") { // Connection adapters dispatch without awaiting, so serialize events. // Replacement advances the generation before entering this queue, which // prevents already-queued source events from mutating the target UI. const generation = this.sessionEventGeneration; const run = this.sessionEventQueue.then(() => generation === this.sessionEventGeneration ? this.handleEvent(event.event) : undefined, ); this.sessionEventQueue = run.catch(() => {}); await run; } else if (event.type === "session_replaced") { this.connectionClosed = false; const generation = ++this.sessionEventGeneration; const run = this.sessionEventQueue.then(async () => { if (generation !== this.sessionEventGeneration) return; // Let an in-flight initial render finish before clearing the // chat for the new session. await this.initialRenderPromise?.catch(() => undefined); if (generation !== this.sessionEventGeneration) return; this.resetSideQuestion(); this.resetExtensionUI(); this.applyConnectionStateSnapshot(event.state); this.resetCurrentSessionRenderState(); await this.rebindCurrentSession(); await this.renderInitialMessages(); this.ui.requestRender(); }); this.sessionEventQueue = run.catch(() => {}); await run; } else if (event.type === "session_resynced") { this.connectionClosed = false; const generation = this.sessionEventGeneration; const run = this.sessionEventQueue.then(async () => { if (generation !== this.sessionEventGeneration) return false; await this.refreshCommandCatalogForCurrentSession?.(); if (generation !== this.sessionEventGeneration) return false; // Never rebuild the transcript while an initial render is // mid-flight; queue behind it instead. await this.initialRenderPromise?.catch(() => undefined); if (generation !== this.sessionEventGeneration) return false; await this.renderResyncedSession(event.snapshot); return true; }); this.sessionEventQueue = run.then(() => undefined).catch(() => {}); if (await run) this.ui.requestRender(); } else if (event.type === "session_status") { this.sessionRecap = event.recap; this.patchConnectionState({ recap: event.recap }); this.renderRecap(); this.refreshTopBarCost(); } else if (event.type === "side_question_event") { this.handleSideQuestionEvent(event.event); } else if (event.type === "extension_ui_request") { await this.handleConnectionExtensionUiRequest(event.request); } else if (event.type === "connection_status") { if (event.status === "connected") { this.connectionClosed = false; this.clearStatus(); if (event.buildIdMismatch) { this.showStatus( `The Prime Agent daemon updated to ${event.buildIdMismatch.daemon}. Reopen this window to use the updated client.`, "warning", ); } await this.refreshHeartbeatCatalog(); } else if (event.status === "reconnecting") { this.showStatus("session is restarting - waiting for the daemon", "warning"); } } else if (event.type === "heartbeats_changed") { await this.refreshHeartbeatCatalog(); } else if (event.type === "closed") { this.connectionClosed = true; // The connection is terminal: a stale "restarting" banner would outlive the truth. this.clearStatus(); this.showError(event.error ?? "Agent connection closed"); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }); }
private async handleConnectionExtensionUiRequest(request: AgentConnectionExtensionUiRequest): Promise<void> { let response: AgentConnectionExtensionUiResponse | undefined; const expectsResponse = this.expectsConnectionExtensionUiResponse(request);
try { if (expectsResponse) { let cancelLocal: (response: AgentConnectionExtensionUiResponse) => void = () => {}; const cancelled = new Promise<AgentConnectionExtensionUiResponse>((resolve) => { cancelLocal = resolve; }); this.activeConnectionExtensionUiRequests.set(request.id, { cancelLocal: () => cancelLocal({ cancelled: true }), }); response = await Promise.race([this.resolveConnectionExtensionUiRequest(request), cancelled]); } else { response = await this.resolveConnectionExtensionUiRequest(request); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); response = { cancelled: true }; }
if (response === undefined) { this.activeConnectionExtensionUiRequests.delete(request.id); return; }
if (!this.activeConnectionExtensionUiRequests.delete(request.id)) { return; }
try { await this.agentConnection.respondToExtensionUiRequest(request.id, response); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private expectsConnectionExtensionUiResponse(request: AgentConnectionExtensionUiRequest): boolean { return ( request.method === "select" || request.method === "confirm" || request.method === "input" || request.method === "editor" ); }
private cancelActiveConnectionExtensionUiRequests(): void { const requestIds = [...this.activeConnectionExtensionUiRequests.keys()]; for (const requestId of requestIds) { const activeRequest = this.activeConnectionExtensionUiRequests.get(requestId); if (!activeRequest) { continue; } this.activeConnectionExtensionUiRequests.delete(requestId); activeRequest.cancelLocal(); void this.agentConnection.respondToExtensionUiRequest(requestId, { cancelled: true }).catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); } }
private async resolveConnectionExtensionUiRequest( request: AgentConnectionExtensionUiRequest, ): Promise<AgentConnectionExtensionUiResponse | undefined> { const { payload } = request; switch (request.method) { case "select": { const title = getPayloadString(payload, "title"); const options = getPayloadStringArray(payload, "options"); if (!title || !options) { return { cancelled: true }; } const value = await this.showExtensionSelector(title, options, { timeout: getPayloadNumber(payload, "timeout"), }); return value === undefined ? { cancelled: true } : { value }; } case "confirm": { const title = getPayloadString(payload, "title"); const message = getPayloadString(payload, "message"); if (!title || message === undefined) { return { cancelled: true }; } const confirmed = await this.showExtensionConfirm(title, message, { timeout: getPayloadNumber(payload, "timeout"), }); return { confirmed }; } case "input": { const title = getPayloadString(payload, "title"); if (!title) { return { cancelled: true }; } const value = await this.showExtensionInput(title, getPayloadString(payload, "placeholder"), { timeout: getPayloadNumber(payload, "timeout"), }); return value === undefined ? { cancelled: true } : { value }; } case "editor": { const title = getPayloadString(payload, "title"); if (!title) { return { cancelled: true }; } const value = await this.showExtensionEditor(title, getPayloadString(payload, "prefill")); return value === undefined ? { cancelled: true } : { value }; } case "notify": { const message = getPayloadString(payload, "message"); if (message) { this.showExtensionNotify(message, getPayloadNotifyType(payload, "notifyType")); } return undefined; } case "setStatus": { const key = getPayloadString(payload, "statusKey"); if (key) { this.setExtensionStatus(key, getPayloadString(payload, "statusText")); } return undefined; } case "setWorkingMessage": { this.workingMessage = getPayloadString(payload, "message"); if (this.loadingAnimation) { this.updateWorkingLoaderMessage(); } return undefined; } case "setWorkingVisible": { const visible = getPayloadBoolean(payload, "visible"); if (visible !== undefined) { this.setWorkingVisible(visible); } return undefined; } case "setWorkingIndicator": { this.setWorkingIndicator(getPayloadWorkingIndicatorOptions(payload, "options")); return undefined; } case "setHiddenThinkingLabel": { this.setHiddenThinkingLabel(getPayloadString(payload, "label")); return undefined; } case "setWidget": { const key = getPayloadString(payload, "widgetKey"); if (key) { const placement = getPayloadWidgetPlacement(payload, "widgetPlacement"); this.setExtensionWidget( key, getPayloadStringArray(payload, "widgetLines"), placement ? { placement } : undefined, ); } return undefined; } case "setTitle": { const title = getPayloadString(payload, "title"); if (title) { this.ui.terminal.setTitle(title); } return undefined; } case "setEditorText": { const text = getPayloadString(payload, "text"); if (text !== undefined) { this.editor.setText(text); } return undefined; } default: this.showStatus(`Unsupported extension UI request: ${request.method}`); return undefined; } }
private async handleEvent(event: AgentConnectionSessionEvent): Promise<void> { if (!this.isInitialized) { await this.init(); }
this.footer.invalidate(); this.updateConnectionStateFromEvent(event); // A new user message resets the activity tracker to 0, so the in-flight baseline must // reset with it. (agent_start on auto-retry does not reset the tracker.) if (event.type === "message_start" && (event.message.role === "user" || isAgentSessionMessage(event.message))) { this.contextUsageTokenBaseline = 0; this.clearShortcutGuide(); this.agentRunFileChanges.clear(); this.renderRecap();
const contextWindow = this.connectionState?.contextUsage?.contextWindow ?? this.getCurrentModel()?.contextWindow ?? 0; if (contextWindow > 0 && this.connectionState?.contextUsage?.tokens !== null) { const promptTokens = estimateTokens(event.message); const prevTokens = this.connectionState?.contextUsage?.tokens ?? 0; const tokens = prevTokens + promptTokens; this.patchConnectionState({ contextUsage: { tokens, contextWindow, percent: (tokens / contextWindow) * 100, promptCache: this.connectionState?.contextUsage?.promptCache, }, }); } } this.activityTracker.handleEvent(event); this.updateWorkingLoaderMessage();
switch (event.type) { case "agent_start": this.resetPendingToolState(); this.renderRecap(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } if (this.retryCountdown) { this.retryCountdown.dispose(); this.retryCountdown = undefined; } if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; } this.stopWorkingLoader(); if (this.workingVisible) { this.startWorkingLoader(); } this.ui.requestRender(); break;
case "session_action_update": { this.updatePendingMessagesDisplay(); this.ui.requestRender(); break; }
case "session_info_changed": this.updateTerminalTitle(); this.refreshTopBarCost(); this.footer.invalidate(); this.ui.requestRender(); break;
case "thinking_level_changed": this.footer.invalidate(); this.subagentSummaryLine.invalidate(); this.updateEditorBorderColor(); break;
case "service_tier_changed": this.footer.invalidate(); this.subagentSummaryLine.invalidate(); break;
case "bash_start": { if (this.sideQuestionBashDiscarded !== undefined) { if (event.runId === this.sideQuestionBashDiscarded) { // The discarded side run now owns the bash slot. Abort only // after matching its identity so a foreign run is never killed. void this.agentConnection.abortBash().catch(() => undefined); break; } // A different run claimed the slot, so the discarded run lost the // race and can never start (its execute_bash will reject); render // this run normally instead of swallowing it. this.sideQuestionBashDiscarded = undefined; } const ownSideBash = this.sideQuestionBash !== undefined && event.runId === this.sideQuestionBash.runId; if (event.transient && !ownSideBash) { // Another client's side-conversation run: it renders only in that // client's pane, never in this window's chat. break; } const component = new BashExecutionComponent(event.command, this.ui, event.excludeFromContext, { suppressLeadingSpace: this.chatContainer.children.at(-1) instanceof AgentMessageComponent, }); component.setExpanded(this.expansionStateFor(component)); if (ownSideBash && this.sideQuestionComponent) { // Same component as the main thread, mounted inside the pane. this.sideQuestionComponent.addBash(component); this.sideQuestionBashComponent = component; } else if (this.isAgentStreaming()) { this.pendingMessagesContainer.addChild(component); this.pendingBashComponents.push(component); } else { this.chatContainer.addChild(component); } this.activeBashComponent = component; this.ui.requestRender(); break; }
case "bash_output": if (this.sideQuestionBashDiscarded !== undefined) { break; } if (this.activeBashComponent) { this.activeBashComponent.appendOutput(event.chunk); this.ui.requestRender(); } break;
case "bash_end": { if (this.sideQuestionBashDiscarded !== undefined) { // Only the discarded run's own end consumes the marker; bash_start // already cleared it for any other run that claimed the slot. this.sideQuestionBashDiscarded = undefined; this.activeBashComponent = undefined; this.ui.requestRender(); break; } const component = this.activeBashComponent; if (component) { if (event.errorMessage) { component.setFailed(event.errorMessage); } else { component.setComplete( event.exitCode, event.cancelled, event.truncated ? ({ truncated: true } as TruncationResult) : undefined, event.fullOutputPath, ); } this.activeBashComponent = undefined; } else if (event.errorMessage && !event.transient) { // Transient failures surface in the owning client's pane, not here. this.showError(`Bash command failed: ${event.errorMessage}`); } // Seed the side transcript only when our own pane-mounted run ended. if (component !== undefined && component === this.sideQuestionBashComponent) { this.sideQuestionBashComponent = undefined; this.finishSideQuestionBash(event, component.getOutput()); } this.ui.requestRender(); break; }
case "message_start": // The run's first starter anchors the elapsed display; mid-turn steering must not restart it. if (this.turnStartedAt === undefined && startsAgentRun(event.message)) { this.turnStartedAt = event.message.timestamp; if (this.workingStartedAt !== undefined) { this.workingStartedAt = event.message.timestamp; this.updateWorkingLoaderMessage(); } } if (event.message.role === "custom") { if (isSessionSlashCommandMessage(event.message) && event.message.details.command.name === "refine") { this.startRefineLoader(); } // The /refine result row is the user refine's settle edge; refine_complete // alone can belong to an agent/auto refinement the queued /refine waited on. if ( isSessionSlashCommandResultMessage(event.message) && event.message.details.command.name === "refine" ) { this.stopRefineLoader(); } this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "assistant") { this.startAssistantStreamingMessage(event.message); this.ui.requestRender(); } break;
case "message_update": if (event.message.role === "assistant") { this.streamingMessage = event.message; this.ensureAssistantStreamingComponent(event.message).updateContent(this.streamingMessage, true); this.harvestAssistantMessageUsage(event.message);
for (const content of this.streamingMessage.content) { if (content.type === "toolCall") { await this.getOrCreatePendingToolComponent(content); } } this.ui.requestRender(); } break;
case "message_end": if (event.message.role === "user") break; if (event.message.role === "assistant") { this.streamingMessage = event.message; this.harvestAssistantMessageUsage(event.message); let errorMessage: string | undefined; if (this.streamingMessage.stopReason === "aborted") { const retryAttempt = this.getRetryAttempt(); const elapsedSuffix = this.workingStartedAt === undefined ? "" : ` · ${this.formatWorkingElapsed(Date.now() - this.workingStartedAt)}`; errorMessage = retryAttempt > 0 ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}${elapsedSuffix}` : `Operation aborted${elapsedSuffix}`; this.streamingMessage.errorMessage = errorMessage; } this.ensureAssistantStreamingComponent(event.message).updateContent(this.streamingMessage, false);
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.resetPendingToolState(); } 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": { this.startedToolCalls.add(event.toolCallId); let component = this.pendingTools.get(event.toolCallId); if (!component) { component = await this.getOrCreatePendingToolComponent({ id: event.toolCallId, name: event.toolName, arguments: event.args, }); } if (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, durationMs: event.durationMs }); this.pendingTools.delete(event.toolCallId); this.startedToolCalls.delete(event.toolCallId); const snapshot = this.connectionState?.contextUsage; if (event.result && snapshot && typeof snapshot.tokens === "number") { const chars = typeof event.result === "string" ? event.result.length : JSON.stringify(event.result).length; const toolTokens = Math.ceil(chars / 4); if (toolTokens > 0) { const tokens = snapshot.tokens + toolTokens; this.patchConnectionState({ contextUsage: { ...snapshot, tokens, percent: (tokens / snapshot.contextWindow) * 100, }, }); } } this.ui.requestRender(); } break; }
case "ipython_sent_agent_message": { const messages = this.lateIpythonSentAgentMessages.get(event.toolCallId) ?? []; if (!messages.some((message) => message.id === event.message.id)) { messages.push(event.message); this.lateIpythonSentAgentMessages.set(event.toolCallId, messages); } this.ipythonToolComponents.get(event.toolCallId)?.appendSentAgentMessage(event.message); this.ui.requestRender(); break; }
case "turn_end": mergeTurnFileChanges(this.agentRunFileChanges, event.message, event.toolResults, this.getCurrentCwd()); void this.refreshConnectionContextUsage(); break;
case "agent_end": if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } this.turnStartedAt = undefined; this.refreshTopBarCost(); // Drops the loader; background subagents are shown by the tree, not the loader. this.syncWorkingLoader(); if (this.streamingComponent) { if (this.streamingMessage) { this.streamingComponent.updateContent(this.streamingMessage, false); } else { this.chatContainer.removeChild(this.streamingComponent); } this.streamingComponent = undefined; this.streamingMessage = undefined; } this.flushPendingBashComponents(); this.resetPendingToolState(); this.renderRecap();
this.applyOptimisticContextUsage(); // Auto-compaction can start server-side while this event is being handled. // Do not hold its start event behind a stats RPC; stale refreshes are discarded. void this.refreshConnectionContextUsage();
await this.checkShutdownRequested();
this.ui.requestRender(); break;
case "compaction_start": { this.startCompactionLoader(event.reason, event.customInstructions); break; }
case "compaction_end": { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } if (this.autoCompactionLoader) { this.autoCompactionLoader.stop(); this.autoCompactionLoader = undefined; this.statusContainer.clear(); } // Restore the working loader if streaming/subagents still warrant it. this.syncWorkingLoader(); if (event.aborted) { if (event.reason === "manual") this.showError("Compaction cancelled"); } else if (event.result) { try { await this.rebuildChatFromMessages(); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.showError(`Compaction succeeded, but the transcript could not be refreshed: ${message}`); } await this.refreshConnectionContextUsage(); this.footer.invalidate(); } else if (event.errorMessage && event.reason === "manual") { if (event.errorSeverity === "warning") this.showWarning(event.errorMessage); else this.showError(event.errorMessage); } this.ui.requestRender(); break; }
case "auto_retry_start": { this.stopWorkingLoader(); this.statusContainer.clear(); this.retryCountdown?.dispose(); const cancelHint = `(${keyText("app.clear")} to cancel)`; const retryMessage = event.reason === "backup" ? () => `Primary model unavailable (${event.errorMessage}) — retrying on backup model ${event.backupModel ?? "unknown"}... ${cancelHint}` : event.reason === "usage" ? (seconds: number) => `Waiting for provider usage to recover (${event.attempt}/${event.maxAttempts}), next check in ${seconds}s... ${cancelHint}` : event.reason === "unavailable" ? (seconds: number) => `Waiting for provider to recover (${event.attempt}/${event.maxAttempts}), next check in ${seconds}s... ${cancelHint}` : (seconds: number) => `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... ${cancelHint}`; this.retryLoader = new Loader( this.ui, (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), retryMessage(Math.ceil(event.delayMs / 1000)), ); this.retryCountdown = new CountdownTimer( event.delayMs, this.ui, (seconds) => { this.retryLoader?.setMessage(retryMessage(seconds)); }, () => { this.retryCountdown = undefined; }, ); this.statusContainer.addChild(this.retryLoader); this.ui.requestRender(); break; }
case "auto_retry_end": { if (this.retryCountdown) { this.retryCountdown.dispose(); this.retryCountdown = undefined; } if (this.retryLoader) { this.retryLoader.stop(); this.retryLoader = undefined; this.statusContainer.clear(); } // Restore the working loader if streaming/subagents still warrant it. this.syncWorkingLoader(); // 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"}`); } else if (event.restoredModel) { this.showStatus(`Primary provider recovered — back on ${event.restoredModel}`); } this.ui.requestRender(); break; }
case "auth_stale": { this.applyAuthStaleEvent(event); this.ui.requestRender(); break; }
case "rlm_child_update": this.updateSubagentSummary(event.child); break;
case "goal_update": this.handleGoalUpdate(event.goal); break;
case "refine_failed": // This event has no request identity; the matching command result settles its loader. this.showError(`Refinement failed: ${event.error}`); break;
case "refine_complete": break; } }
private startAssistantStreamingMessage(message: AssistantMessage): void { this.streamingComponent = new AssistantMessageComponent( undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), { expanded: this.toolOutputExpanded, precededByToolActivity: createConversationSpacing(this.chatContainer.children).precededByToolActivity, mermaidTransform: this.mermaidMarkdownTransform, cwd: this.getCurrentCwd(), }, ); this.streamingMessage = message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage, true); }
private ensureAssistantStreamingComponent(message: AssistantMessage): AssistantMessageComponent { let component = this.streamingComponent; if (!component) { this.startAssistantStreamingMessage(message); component = this.streamingComponent; } if (!component) { throw new Error("Failed to create assistant streaming component"); } return component; }
private handleGoalUpdate(goal: GoalState): void { this.syncGoalTray(goal); if (this.shouldAnnounceGoalUpdate(goal)) { this.showStatus(this.formatGoalStatus(goal)); } else { this.ui.requestRender(); } }
private syncGoalTray(goal: GoalState): void { this.subagentSummaryLine.invalidate(); this.updateGoalTrayTimer(goal); }
private syncTrayTicker(): void { const goalActive = this.getGoalState()?.status === "active"; const cache = this.getConnectionContextUsage()?.promptCache; const cacheActive = cache?.status === "warm" && cache.expiresAt !== undefined && cache.expiresAt > Date.now();
if (goalActive || cacheActive) { if (!this.trayTicker) { this.trayTicker = setInterval(() => { this.subagentSummaryLine.invalidate(); this.ui.requestRender(); this.syncTrayTicker(); }, 1000); this.trayTicker.unref?.(); } return; } this.stopTrayTicker(); }
private stopTrayTicker(): void { if (!this.trayTicker) { return; } clearInterval(this.trayTicker); this.trayTicker = undefined; }
private updateGoalTrayTimer(_goal: GoalState): void { this.syncTrayTicker(); }
private setGoalAnnouncementBaseline(goal: GoalState): void { this.lastGoalAnnouncement = this.goalAnnouncementSnapshot(goal); }
private goalAnnouncementSnapshot(goal: GoalState): GoalAnnouncementSnapshot { return { goalId: goal.goalId, status: goal.status, objective: goal.objective, lastReason: goal.lastReason, lastError: goal.lastError, }; }
private shouldAnnounceGoalUpdate(goal: GoalState): boolean { const previous = this.lastGoalAnnouncement; const next = this.goalAnnouncementSnapshot(goal); this.lastGoalAnnouncement = next; if (!previous) { return goal.status !== "idle"; } if (previous.status !== next.status) { return true; } if (previous.goalId !== next.goalId) { return goal.status !== "idle"; } switch (goal.status) { case "active": return false; case "paused": case "budget_limited": case "complete": return previous.lastReason !== next.lastReason; case "error": return previous.lastError !== next.lastError; case "idle": return false; default: { const _exhaustive: never = goal.status; return _exhaustive; } } }
private formatGoalStatus(goal: GoalState): string { const usage = formatGoalUsage(goal); const usageText = usage ? ` (${usage})` : ""; switch (goal.status) { case "idle": return "No active goal"; case "active": return goal.objective ? `Goal${this.formatGoalDetailSuffix(goal.objective, visibleWidth("Goal"))}` : "Pursuing goal"; case "paused": return goal.lastReason ? `Goal paused${this.formatGoalDetailSuffix(goal.lastReason, visibleWidth("Goal paused"))}` : "Goal paused (/goal resume)"; case "budget_limited": if (goal.lastReason) { const prefix = `Goal budget limited${usageText}`; return prefix + this.formatGoalDetailSuffix(goal.lastReason, visibleWidth(prefix)); } return `Goal budget limited${usageText}`; case "complete": return goal.lastReason ? `Goal complete${this.formatGoalDetailSuffix(goal.lastReason, visibleWidth("Goal complete"))}` : "Goal complete"; case "error": return goal.lastError ? `Goal error${this.formatGoalDetailSuffix(goal.lastError, visibleWidth("Goal error"))}` : "Goal error"; default: { const _exhaustive: never = goal.status; return _exhaustive; } } }
private formatGoalDetailSuffix(value: string | undefined, prefixWidth: number): string { const detail = value?.replace(/\s+/g, " ").trim(); if (!detail) { return ""; } const availableWidth = Math.min(120, Math.max(1, this.ui.terminal.columns - prefixWidth - 2)); if (availableWidth < 8) { return ""; } return `: ${truncateToWidth(detail, availableWidth)}`; }
private seedSubagentSummary(children: readonly AgentConnectionRlmChildAgentSnapshot[] | undefined): void { for (const child of children ?? []) { // Live updates can arrive before the initial snapshot; do not replace them // with the snapshot's older state. if (!this.subagentSnapshots.has(child.id) && child.status !== "cancelled") { this.subagentSnapshots.set(child.id, child); } } this.refreshSubagentSummary(); }
private replaceSubagentSummary(children: readonly AgentConnectionRlmChildAgentSnapshot[] | undefined): void { const next = new Map<string, AgentConnectionRlmChildAgentSnapshot>(); for (const child of children ?? []) { if (child.status === "cancelled") continue; const previous = this.subagentSnapshots.get(child.id); next.set(child.id, previous ? mergeSubagentSnapshot(previous, child) : child); } this.subagentSnapshots = next; this.refreshSubagentSummary(); }
private updateSubagentSummary(child: AgentConnectionRlmChildAgentSnapshot): void { // "cancelled" also covers never-bound terminal runs; AgentSession owns that rule. if (child.status === "cancelled") { this.removeSubagentSnapshot(child.id); } else { const previous = this.subagentSnapshots.get(child.id); this.subagentSnapshots.set(child.id, previous ? mergeSubagentSnapshot(previous, child) : child); } this.refreshSubagentSummary(); }
private refreshSubagentSummary(): void { this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.updateWorkingPulse(); this.syncWorkingLoader(); this.updateWorkingLoaderMessage(); this.ui.requestRender(); }
private updateSubagentSummaryLine(): void { const rosterSummaries = this.rosterBar?.summaries(); // A client-owned session has no row on the public roster; only then do the // snapshots carry the bar. A public parent with zero roster children shows zero. const sessionOnRoster = rosterSummaries?.some((row) => row.sessionId === this.connectionState?.sessionId) === true; this.subagentSummaryLine.setSubagentCounts( rosterSummaries && sessionOnRoster ? countRosterSubagentStatuses(rosterSummaries, { activeSessionId: this.connectionState?.activeSessionId, sessionId: this.connectionState?.sessionId, sessionFile: this.connectionState?.sessionFile, }) : countSubtreeSubagentStatuses(this.subagentSnapshots.values(), this.rlmNodeId), ); if (!this.subagentSummaryLine.isSelectable() && this.subagentSummaryLine.focused) this.focusEditor(); }
private removeSubagentSnapshot(id: string): void { this.subagentSnapshots.delete(id); for (const child of [...this.subagentSnapshots.values()]) { if (child.parentId === id) this.removeSubagentSnapshot(child.id); } }
private resetSubagentSummary(): void { this.subagentSnapshots.clear(); this.rlmNodeId = undefined; this.updateSubagentSummaryLine(); this.scheduleHeartbeatManagerRefresh(); // Clearing snapshots can drop the last running subagent; reconcile the // pulse and loader so neither lingers when nothing is in flight. this.updateWorkingPulse(); this.syncWorkingLoader(); }
private focusEditor(): void { this.ui.setFocus(this.editor); this.ui.requestRender(); }
private focusSubagentSummary(): boolean { if (this.isInlinePickerOpen() || !this.subagentSummaryLine.isSelectable() || this.getTrayOverrideLabel()) return false; this.ui.setFocus(this.subagentSummaryLine); this.ui.requestRender(); return true; }
private async openScopedAgentsView(): Promise<void> { if (!this.options.returnToAgentsView) { this.focusEditor(); this.showStatus( "The agents view needs a daemon-hosted session; start normally (without --no-session) to browse sessions", ); return; } await this.returnToAgentsView("scoped_agents_view"); }
private handleSubagentSummaryChatAction(data: string): void { if (this.keybindings.matches(data, "app.tools.expand")) { this.toggleToolOutputExpansion(); return; } if (this.keybindings.matches(data, "app.messages.expand")) { this.toggleAgentMessageExpansion(); return; } // A raw "\n" is a newline for the editor, not ctrl+j. if (data !== "\n" && this.keybindings.matches(data, "app.edits.expand")) { this.toggleEditDiffExpansion(); return; } this.focusEditor(); this.editor.handleInput(data); }
// Pickers mount either as overlays (model/provider/MCP menu) or in place of // the editor (thinking, settings, and scoped-model selectors); the tray stays // hidden while either surface is up. Autocomplete keeps focus in the editor. private isInlinePickerOpen(): boolean { const editorAutocomplete = this.editor instanceof CustomEditor && this.editor.focused && this.editor.isShowingAutocomplete(); if (this.ui.hasOverlay() && !editorAutocomplete) return true; const editorChild = this.editorContainer.children[0]; return editorChild !== undefined && editorChild !== this.editor; }
private getTrayOverrideLabel(): string | undefined { if (this.isInlinePickerOpen()) return undefined; if (this.isCtrlCExitHintVisible()) { const clearKey = keyText("app.clear"); return clearKey ? `Press ${clearKey} again to exit` : "Press again to exit"; } const text = this.editor.getExpandedText?.() ?? this.editor.getText(); if (!this.isAgentStreaming() || !text.trim()) { return undefined; } return `${keyText("app.message.followUp")} to queue message`; }
private getTrayLocationLabel(): string | undefined { if (this.isInlinePickerOpen()) return undefined; const sessionDepth = this.options.sessionDepth; const hasChildren = this.options.sessionHasChildren === true || (this.subagentSnapshots?.size ?? 0) > 0; // Depth is subagent-session context: a root session (depth 0) never shows // a depth label, even while its children run. const depthLabel = sessionDepth ? formatAgentDepthLabel(sessionDepth, hasChildren) : undefined; const agentsHint = this.getAgentsViewTrayHint(); const remoteHost = this.getCurrentRemoteHost(); const hostLabel = remoteHost ? `${theme.fg("dim", "on")} ${theme.fg("accent", remoteHost)}` : undefined; return [agentsHint, depthLabel, hostLabel].filter((label): label is string => label !== undefined).join(" "); }
private isNewChat(): boolean { return (this.connectionState?.messageCount ?? 0) === 0 && this.connectionState?.isStreaming !== true; }
private getPromptContextLabel(maxWidth: number): string | undefined { if (maxWidth < 1) return undefined; // Onboarding owns the screen; chat chrome under it reads as clutter. if (this.onboardingUiActive) return undefined; return theme.fg( "dim", truncateToWidth(formatConversationDetailStatus(this.toolOutputExpanded, this.editDiffsExpanded), maxWidth, ""), ); }
private getAgentsViewTrayHint(): string | undefined { if (!this.options.returnToAgentsView) { return undefined; } return keyHint("app.agents.back", "manage"); }
// The summary line already hides the tray while a picker is open, so the label // providers stay pure and hand back whatever they know. /** Model id with its effort and fast-tier suffix; no usage, so callers can add their own. */ private formatModelLabel(): string | undefined { const model = this.getCurrentModel(); if (!model) return undefined; const providerPrefix = `${model.provider}/`; const modelId = model.id.startsWith(providerPrefix) ? model.id.slice(providerPrefix.length) : model.id; const effort = model.reasoning ? this.connectionState?.thinkingLevel : undefined; const parts = [effort ? `${modelId}:${effort.toLowerCase()}` : modelId]; if (this.connectionState?.serviceTier === "priority") { parts.push("fast"); } return parts.join(" · "); }
private getModelContextLabel(maxWidth = Number.MAX_SAFE_INTEGER): string | undefined { if (maxWidth < 1) return undefined; const parts: string[] = []; const modelLabel = this.formatModelLabel(); if (modelLabel) parts.push(modelLabel); const usage = this.getConnectionContextUsage(); const cache = usage?.promptCache; const isWarm = cache?.status === "warm" && typeof cache.cachedTokens === "number" && cache.cachedTokens > 0; if (usage && typeof usage.tokens === "number" && typeof usage.percent === "number") { parts.push( isWarm ? `${formatTokenCount(cache.cachedTokens)}/${formatTokenCount(usage.tokens)} cached (${Math.round(usage.percent)}%)` : `${formatTokenCount(usage.tokens)} (${Math.round(usage.percent)}%)`, ); } if (parts.length === 0) return undefined; // Plain text: the tray line owns its own styling, and this label is also // truncated to the caller's width. return truncateToWidth(parts.join(" · "), maxWidth, ""); }
private getTrayContextLabel(): string | undefined { const goalLabel = this.getTrayGoalLabel(); const heartbeatLabel = this.getTrayHeartbeatLabel(); const modelLabel = this.getModelContextLabel(); const cacheLabel = this.getTrayCacheLabel(this.getConnectionContextUsage()); return ( [goalLabel, heartbeatLabel, modelLabel, cacheLabel].filter((label) => label !== undefined).join(" · ") || undefined ); }
private getTrayCacheLabel(usage: AgentConnectionState["contextUsage"]): string | undefined { const cache = usage?.promptCache; if (!cache || cache.status === "mismatch" || cache.status === "cold") { return undefined; } if (cache.status === "warm") { if (cache.expiresAt !== undefined && cache.expiresAt > 0) { const remainingSeconds = Math.max(0, Math.floor((cache.expiresAt - Date.now()) / 1000)); if (remainingSeconds <= 0) { return undefined; } return `ttl ${formatCacheTtl(remainingSeconds)}`; } } return undefined; }
private updateCacheTrayTimer(): void { this.syncTrayTicker(); }
private getTrayHeartbeatLabel(): string | undefined { const heartbeats = this.getScopedHeartbeats(); if (heartbeats.length === 0) { return undefined; } const paused = heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length; const count = `${heartbeats.length} heartbeat${heartbeats.length === 1 ? "" : "s"}`; const pausedLabel = paused ? ` · ${paused} paused` : ""; const shortcut = keyText("app.heartbeats.open"); return `${count}${pausedLabel}${shortcut ? ` (${shortcut})` : ""}`; }
private getTrayGoalLabel(): string | undefined { const goal = this.getGoalState(); switch (goal.status) { case "active": return `Pursuing goal (${this.formatGoalElapsed(goal.timeUsedSeconds)})`; case "paused": return `Goal paused (${this.formatGoalElapsed(goal.timeUsedSeconds)})`; case "budget_limited": return `Goal budget limited (${this.formatGoalElapsed(goal.timeUsedSeconds)})`; case "idle": case "complete": case "error": return undefined; default: { const _exhaustive: never = goal.status; return _exhaustive; } } }
private formatGoalElapsed(seconds: number): string { const totalSeconds = Math.max(0, Math.trunc(seconds)); if (totalSeconds < 60) { return `${totalSeconds}s`; } const minutes = Math.floor(totalSeconds / 60); const remainingSeconds = totalSeconds % 60; if (minutes < 60) { return `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`; }
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(""); }
private createLegacyHeartbeatPromptMessage( message: Message, textContent: string, ): ReturnType<typeof createHeartbeatPromptMessage> | undefined { const heartbeat = this.connectionState?.heartbeat; if ( message.role !== "user" || !heartbeat || !this.isTextOnlyUserMessage(message) || textContent.trim() !== heartbeat.prompt.trim() || !this.isLikelyHeartbeatPromptTimestamp(heartbeat, message.timestamp) ) { return undefined; }
return createHeartbeatPromptMessage(heartbeat, message.timestamp); }
private isTextOnlyUserMessage(message: Message): boolean { if (message.role !== "user") { return false; } if (typeof message.content === "string") { return true; } return message.content.every((content) => content.type === "text"); }
private isLikelyHeartbeatPromptTimestamp(job: AgentCronJob, timestamp: number): boolean { const directRunTimes = [job.lastRunAt, job.nextRunAt] .map((value) => (value ? Date.parse(value) : Number.NaN)) .filter((value) => Number.isFinite(value)); const tolerance = this.heartbeatLegacyPromptToleranceMs(job); if (directRunTimes.some((runAt) => Math.abs(timestamp - runAt) <= tolerance)) { return true; } return false; }
private heartbeatLegacyPromptToleranceMs(job: AgentCronJob): number { const intervalMs = job.schedule.intervalMs; if (!intervalMs || intervalMs <= 0) { return HEARTBEAT_LEGACY_PROMPT_MAX_TOLERANCE_MS; } return Math.min( HEARTBEAT_LEGACY_PROMPT_MAX_TOLERANCE_MS, Math.max(HEARTBEAT_LEGACY_PROMPT_MIN_TOLERANCE_MS, intervalMs / 3), ); }
/** * 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, tone: "dim" | "warning" = "dim"): 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(tone, message)); this.ui.requestRender(); return; }
const spacer = new Spacer(1); const text = new Text(theme.fg(tone, message), 1, 0); this.chatContainer.addChild(spacer); this.chatContainer.addChild(text); this.lastStatusSpacer = spacer; this.lastStatusText = text; this.ui.requestRender(); }
private clearStatus(): 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.chatContainer.removeChild(last); this.chatContainer.removeChild(secondLast); this.lastStatusText = undefined; this.lastStatusSpacer = undefined; this.ui.requestRender(); } }
private async copyFullscreenSelection(text: string): Promise<void> { try { await copyToClipboard(text); this.showStatus("Copied selection to clipboard"); } catch (error) { this.showError(`Failed to copy selection: ${error instanceof Error ? error.message : String(error)}`); } }
// Local slash commands (/context, /system-prompt, …) print into the chat // without round-tripping through the agent, so no user message event echoes // the typed command. Render the turn ourselves, mirroring the "user" case // above, so the output is anchored to a visible command instead of floating. private echoLocalCommand(text: string): void { if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } this.chatContainer.addChild( new UserMessageComponent(text, this.getMarkdownThemeWithSettings(), (name) => this.isRecognizedSlashCommand(name), ), ); }
private addMessageToEditorHistory(message: AgentMessage): void { if (message.role !== "user") { return; } const textContent = this.getUserMessageText(message); if (textContent && !this.createLegacyHeartbeatPromptMessage(message, textContent)) { this.editor.addToHistory?.(textContent); } }
private createDisplayedCustomMessageComponent(message: CustomMessage): Component { if (isSessionSlashCommandMessage(message)) return new SlashCommandMessageComponent(message.content); if (isSessionSlashCommandResultMessage(message)) return new SlashCommandResultMessageComponent(message); if ( message.customType === SESSION_SLASH_COMMAND_CUSTOM_TYPE || message.customType === SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE ) { return new UserMessageComponent("[Malformed session command message]", this.getMarkdownThemeWithSettings()); } if (isCompactionOutcomeMessage(message)) return new CompactionOutcomeMessageComponent(message); if (message.customType === COMPACTION_OUTCOME_CUSTOM_TYPE) { return new MalformedCompactionOutcomeMessageComponent(); } if (isMcpConnectionOutcomeMessage(message)) return new McpConnectionOutcomeMessageComponent(message); if (message.customType === MCP_CONNECTION_OUTCOME_CUSTOM_TYPE) { return new MalformedMcpConnectionOutcomeMessageComponent(); } if (isRefinementOutcomeMessage(message)) return new RefinementOutcomeMessageComponent(message); if (message.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE) { return new MalformedRefinementOutcomeMessageComponent(); } if (isAgentSessionMessage(message)) { return new AgentMessageComponent(message, this.getMarkdownThemeWithSettings(), { shouldAddLeadingSpace: createConversationSpacing(this.chatContainer.children).shouldAddLeadingSpace, }); } const shellCompletion = createShellCompletionComponent(message, this.chatContainer.children); if (shellCompletion) return shellCompletion; if (isInjectedPromptMessage(message)) { return new InjectedPromptMessageComponent(message, this.getMarkdownThemeWithSettings()); } return new CustomMessageComponent( message, this.bindLocalSessionExtensions ? this.getLocalSessionHost().getExtensionRunner().getMessageRenderer(message.customType) : undefined, this.getMarkdownThemeWithSettings(), ); }
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext, { suppressLeadingSpace: this.chatContainer.children.at(-1) instanceof AgentMessageComponent, }); component.setExpanded(this.expansionStateFor(component)); 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 component = this.createDisplayedCustomMessageComponent(message); if (isExpandable(component)) { component.setExpanded(this.expansionStateFor(component)); } if (hasAgentMessagesExpansion(component)) { component.setAgentMessagesExpanded(this.agentMessagesExpandedFor()); } if (hasEditDiffsExpansion(component)) { component.setEditDiffsExpanded(this.editDiffsExpanded); } if (isSessionSlashCommandMessage(message) && this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } this.chatContainer.addChild(component); } break; } case "compactionSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.expansionStateFor(component)); this.chatContainer.addChild(component); break; } case "branchSummary": { this.chatContainer.addChild(new Spacer(1)); const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.expansionStateFor(component)); this.chatContainer.addChild(component); break; } case "user": { const textContent = this.getUserMessageText(message); if (textContent) { const heartbeatMessage = this.createLegacyHeartbeatPromptMessage(message, textContent); if (heartbeatMessage) { if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } const component = new InjectedPromptMessageComponent( heartbeatMessage, this.getMarkdownThemeWithSettings(), ); component.setExpanded(this.expansionStateFor(component)); this.chatContainer.addChild(component); break; }
if (this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } const skillBlock = parseSkillBlock(textContent); if (skillBlock) { const component = new SkillInvocationMessageComponent( skillBlock, this.getMarkdownThemeWithSettings(), ); component.setExpanded(this.expansionStateFor(component)); this.chatContainer.addChild(component); if (skillBlock.userMessage) { const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), (name) => this.isRecognizedSlashCommand(name), ); this.chatContainer.addChild(userComponent); } } else { const userComponent = new UserMessageComponent( textContent, this.getMarkdownThemeWithSettings(), (name) => this.isRecognizedSlashCommand(name), ); this.chatContainer.addChild(userComponent); } if (options?.populateHistory) { this.editor.addToHistory?.(textContent); } } break; } case "assistant": { const assistantComponent = new AssistantMessageComponent( message, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), { expanded: this.toolOutputExpanded, precededByToolActivity: createConversationSpacing(this.chatContainer.children).precededByToolActivity, mermaidTransform: this.mermaidMarkdownTransform, cwd: this.getCurrentCwd(), }, ); this.chatContainer.addChild(assistantComponent); break; } case "toolResult": { 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 * @param options.clearChat Clear the current transcript immediately before rendering * @param options.limitTranscript Limit transcript replay to the recent tail */ private orderMessagesForTranscript(messages: AgentMessage[]): AgentMessage[] { const summaryIndex = messages.findIndex((message) => message.role === "compactionSummary"); if (summaryIndex === -1) return messages; const summary = messages[summaryIndex]; if (summary.role !== "compactionSummary") return messages; const remaining = messages.filter((_, index) => index !== summaryIndex); if (Number.isSafeInteger(summary.retainedMessageCount) && summary.retainedMessageCount! >= 0) { const boundary = Math.min(summary.retainedMessageCount!, remaining.length); return [...remaining.slice(0, boundary), summary, ...remaining.slice(boundary)]; }
// Compatibility for summaries created before retainedMessageCount was added. const retained: AgentMessage[] = []; const later: AgentMessage[] = []; for (const message of remaining) { (message.timestamp < summary.timestamp ? retained : later).push(message); } return [...retained, summary, ...later]; }
private async renderSessionContext( sessionContext: AgentConnectionSessionContext, options: { updateFooter?: boolean; populateHistory?: boolean; clearChat?: boolean; limitTranscript?: boolean; } = {}, ): Promise<void> { this.resetPendingToolState(); const transcriptMessages = this.orderMessagesForTranscript(sessionContext.messages); const messagesToRender = options.limitTranscript ? initialRenderMessages(transcriptMessages) : transcriptMessages; this.ipythonToolComponents.clear(); this.lateIpythonSentAgentMessages.clear(); const renderedPendingTools = new Map<string, ToolExecutionComponent>(); const toolNames: string[] = []; for (const message of messagesToRender) { if (message.role !== "assistant") { continue; } for (const content of message.content) { if (content.type === "toolCall") { toolNames.push(content.name); } } } // Tool definitions only enrich rendering: components fall back to // cached or missing definitions. A transient control-plane failure here // must not abort the render (a resync render aborts into an empty chat). let toolDefinitionWarning: string | undefined; try { await this.preloadToolDefinitions(toolNames); } catch (error) { toolDefinitionWarning = `Could not load tool definitions: ${ error instanceof Error ? error.message : String(error) }`; }
if (options.clearChat) { this.chatContainer.clear(); }
// Shown after clearChat so rebuild/resync renders keep the warning. if (toolDefinitionWarning) { this.showWarning(toolDefinitionWarning); }
if (options.updateFooter) { this.footer.invalidate(); this.updateEditorBorderColor(); }
if (options.populateHistory) { for (const message of sessionContext.messages) { this.addMessageToEditorHistory(message); } }
const renderOptions = { ...options, populateHistory: false };
if (messagesToRender.length < sessionContext.messages.length) { this.chatContainer.addChild( new Text( theme.fg( "dim", `Showing latest ${messagesToRender.length} of ${sessionContext.messages.length} messages for faster open.`, ), 1, 0, ), ); this.chatContainer.addChild(new Spacer(1)); }
for (const message of messagesToRender) { // One unrenderable message must not abort the whole transcript: // the chat container may already be cleared for this render. try { // 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 spacing = createConversationSpacing(this.chatContainer.children); const component = new ToolExecutionComponent( content.name, content.id, content.arguments, { showImages: this.settingsManager.getShowImages(), includeImageDimensions: false, shouldAddLeadingSpace: () => spacing.shouldAddLeadingSpace(true), }, this.getCachedToolDefinition(content.name), this.ui, this.getCurrentCwd(), ); component.setExpanded(this.expansionStateFor(component)); component.setEditDiffsExpanded(this.editDiffsExpanded); component.setAgentMessagesExpanded(this.agentMessagesExpandedFor()); selectLatestToolExpandHint(this.chatContainer.children, component); this.chatContainer.addChild(component); this.registerIpythonToolComponent(content.name, content.id, component);
if (message.stopReason === "aborted" || message.stopReason === "error") { let errorMessage: string; if (message.stopReason === "aborted") { const retryAttempt = this.getRetryAttempt(); errorMessage = retryAttempt > 0 ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` : message.errorMessage && message.errorMessage !== "Request was aborted" ? message.errorMessage : "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) { // Delete first: a throwing updateResult must not leave the // completed result reported as still pending. renderedPendingTools.delete(message.toolCallId); component.updateResult(message); } } else { // All other messages use standard rendering this.addMessageToChat(message, renderOptions); } } catch (error) { // Render an inline placeholder instead of losing the transcript. this.chatContainer.addChild( new Text( theme.fg( "warning", `Failed to render a ${message.role} message: ${ error instanceof Error ? error.message : String(error) }`, ), 1, 0, ), ); } }
this.updateWorkingPulse(); for (const [toolCallId, component] of renderedPendingTools) { component.setIncludeImageDimensions(true); this.pendingTools.set(toolCallId, component); } this.ui.requestRender(); }
async renderInitialMessages(): Promise<void> { // Serialize with any in-flight initial render: resync/replacement handlers // re-enter this method, and two concurrent transcript builds race the // chat container. const previousRender = this.initialRenderPromise; const render = (async () => { await previousRender?.catch(() => undefined); const snapshot = await this.agentConnection.getInitialSnapshot(); const context = this.getSessionContextFromConnectionSnapshot(snapshot); const state = snapshot.state; const streamingMessage = snapshot.streamingMessage; this.rlmNodeId = snapshot.parent?.childId; this.seedSubagentSummary(snapshot.children); this.applyConnectionStateSnapshot(state); this.restoreTurnStartFromMessages(context.messages); await this.renderSessionContext(context, { clearChat: true, updateFooter: true, populateHistory: true, limitTranscript: true, }); await this.restoreStreamingMessageFromSnapshot(streamingMessage); this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
// Show compaction info if session was compacted const compactionCount = state.compactionCount; if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.showStatus(`Session compacted ${times}`); } })(); this.initialRenderPromise = render; try { await render; } finally { if (this.initialRenderPromise === render) { this.initialRenderPromise = undefined; // Release deferred events even if the last queued render failed. Do not // await listener delivery: session-replaced handlers share that queue. void this.agentConnection.flushBufferedSessionEvents?.(); } } }
private async restoreStreamingMessageFromSnapshot(message: AgentMessage | undefined): Promise<void> { if (message?.role === "assistant") { this.startAssistantStreamingMessage(message); for (const content of message.content) { if (content.type === "toolCall") { this.startedToolCalls.add(content.id); await this.getOrCreatePendingToolComponent(content); } } } }
private getSessionContextFromConnectionSnapshot(snapshot: AgentConnectionSnapshot): AgentConnectionSessionContext { if (snapshot.sessionContext) { return snapshot.sessionContext; } return { messages: snapshot.messages, thinkingLevel: snapshot.state.thinkingLevel, serviceTier: snapshot.state.serviceTier, model: snapshot.state.model ? { provider: snapshot.state.model.provider, modelId: snapshot.state.model.id } : null, }; }
async getUserInput(): Promise<string | undefined> { if (this.agentsViewRequest) { return undefined; } return new Promise((resolve) => { this.onInputCallback = (text: string | undefined) => { this.onInputCallback = undefined; resolve(text); }; }); }
private async rebuildChatFromMessages(): Promise<void> { const context = await this.agentConnection.getSessionContext(); await this.renderSessionContext(context, { clearChat: true, limitTranscript: true }); }
private handleEscape(): void { this.clearCtrlCExitHint(); if (this.sideQuestionEvent) { this.clearEscapeRepeat(); this.clearSideQuestion({ abort: true }); return; } const action = this.takeEscapeRepeatAction(); if (action === "tree") { void this.showTreeSelector(); return; } if (action === "clear") { this.clearInputBar(); return; }
this.armEscapeRepeat(this.hasInterruptibleWork() || this.editor.getText().length === 0 ? "tree" : "clear"); this.interruptOrClearInput(); }
private armEscapeRepeat(action: "tree" | "clear"): void { this.clearEscapeRepeat(); this.escapeRepeatAction = action; this.escapeRepeatExpiresAt = Date.now() + InteractiveMode.ESCAPE_REPEAT_WINDOW_MS; this.escapeRepeatTimer = setTimeout(() => { this.clearEscapeRepeat(); }, InteractiveMode.ESCAPE_REPEAT_WINDOW_MS); this.escapeRepeatTimer.unref?.(); }
private takeEscapeRepeatAction(): "tree" | "clear" | undefined { if (!this.escapeRepeatAction || this.escapeRepeatExpiresAt <= Date.now()) { this.clearEscapeRepeat(); return undefined; } const action = this.escapeRepeatAction; this.clearEscapeRepeat(); return action; }
private clearEscapeRepeat(): void { if (this.escapeRepeatTimer) { clearTimeout(this.escapeRepeatTimer); this.escapeRepeatTimer = undefined; } this.escapeRepeatAction = undefined; this.escapeRepeatExpiresAt = 0; }
private handleCtrlC(): void { this.clearEscapeRepeat(); if (this.isCtrlCExitHintVisible()) { void this.shutdown(); return; } this.handleInterruptKey(); }
private handleInterruptKey(): void { this.clearEscapeRepeat(); this.interruptOrClearInput(); this.showCtrlCExitHint(); }
private interruptOrClearInput(): void { this.traceUploadAllAbortController?.abort(new Error("Trace upload cancelled")); if (this.sideQuestionEvent?.status === "running") { this.abortSideQuestion(this.sideQuestionEvent.id, true); } if (this.getRetryAttempt() > 0) { void this.agentConnection.abortRetry(); } if (this.isAgentCompacting()) { void this.agentConnection.abortCompaction(); void this.agentConnection.abortBranchSummary(); } if (this.isBashRunning()) { void this.agentConnection.abortBash(); } if (this.isAgentStreaming()) { // The queue is preserved server-side; draining resumes on the next // submit or queued-message edit. void this.agentConnection.abort().catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); } }
private showCtrlCExitHint(): void { if (this.ctrlCExitHintTimer) { clearTimeout(this.ctrlCExitHintTimer); } this.ctrlCExitHintExpiresAt = Date.now() + InteractiveMode.EXIT_HINT_DURATION_MS; this.ctrlCExitHintTimer = setTimeout(() => { this.ctrlCExitHintTimer = undefined; if (!this.isCtrlCExitHintVisible()) { this.ctrlCExitHintExpiresAt = 0; this.subagentSummaryLine.invalidate(); this.ui.requestRender(); } }, InteractiveMode.EXIT_HINT_DURATION_MS); this.ctrlCExitHintTimer.unref?.(); this.subagentSummaryLine.invalidate(); this.ui.requestRender(); }
private clearCtrlCExitHint(options: { render?: boolean } = {}): void { if (!this.ctrlCExitHintTimer && this.ctrlCExitHintExpiresAt === 0) { return; } if (this.ctrlCExitHintTimer) { clearTimeout(this.ctrlCExitHintTimer); this.ctrlCExitHintTimer = undefined; } this.ctrlCExitHintExpiresAt = 0; if (options.render !== false) { this.subagentSummaryLine.invalidate(); this.ui.requestRender(); } }
private isCtrlCExitHintVisible(): boolean { return this.ctrlCExitHintExpiresAt > Date.now(); }
private handleCtrlD(): void { 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(); this.clearCtrlCExitHint({ render: false });
// Fetch while the connection is still alive; exit must not fail on a stats error. const sessionStats = await this.agentConnection.getSessionStats().catch(() => undefined);
// 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(); try { await this.agentConnection.dispose(); } finally { await this.options.onShutdown?.(); } if (this.settingsManager.getPasteCleanup()) { const activeSessionId = this.connectionState?.sessionId; if (activeSessionId) { const reaped = reapSessionPastes(activeSessionId); if (reaped.length > 0) { console.log(`Removed ${reaped.length} pasted file(s) for session ${activeSessionId}`); } } } const resumeHint = formatResumeHint(sessionStats); if (resumeHint) { console.log(resumeHint); } process.exit(0); }
/** * Tear down the session's terminal UI before handing the terminal back to the * agents view. Drains in-flight Kitty/SSH key-release sequences so they don't * leak into the parent UI, then stops the renderer and theme watcher. Safe to * call from a crash path too; idempotent via stop(). */ async teardownSessionUi(options: { preserveAltScreen?: boolean } = {}): Promise<void> { await this.ui.terminal.drainInput(1000).catch(() => undefined); this.releasePromptStashSession(); this.stop({ preserveAltScreen: options.preserveAltScreen }); stopThemeWatcher(); }
private handleAgentsBack(): boolean { if (this.editor.getText().trim()) { return false; } if (!this.options.returnToAgentsView) { void this.requestAgentsView(); return true; } void this.returnToAgentsView(); return true; }
private async requestAgentsView(): Promise<void> { if (!this.options.returnToAgentsView) { this.showStatus( "The agents view needs a daemon-hosted session; start normally (without --no-session) to browse sessions", ); return; } await this.returnToAgentsView(); }
private async returnToAgentsView(request: InteractiveModeRunResult["type"] = "agents_view"): Promise<void> { if (this.isShuttingDown || this.isReturningToAgentsView) return; this.isReturningToAgentsView = true; // Keep startup's connection alive without blocking an explicit shutdown. await this.initializationPromise?.catch(() => undefined); if (this.isShuttingDown) return; this.isShuttingDown = true; this.stashDraftForAgentsView(); this.unregisterSignalHandlers();
await this.teardownSessionUi({ preserveAltScreen: true }); let handoffComplete = false; try { try { await this.agentConnection.dispose(); } finally { await this.options.onShutdown?.(); this.agentsViewRequest = request; this.onInputCallback?.(undefined); handoffComplete = true; } } finally { if (!handoffComplete) { this.ui.terminal.leaveAltScreen(); this.ui.terminal.showCursor(); } } }
private emergencyTerminalExit(): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); killTrackedDetachedChildren(); // The terminal is gone. Do not run normal shutdown because TUI and // extension cleanup can write restore sequences and re-trigger EIO. process.exit(129); }
/** * 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 = () => { if (signal === "SIGHUP") { this.emergencyTerminalExit(); } killTrackedDetachedChildren(); void this.shutdown(); }; process.prependListener(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); }
const terminalErrorHandler = (error: Error) => { if (isDeadTerminalError(error)) { this.emergencyTerminalExit(); } throw error; }; process.stdout.on("error", terminalErrorHandler); process.stderr.on("error", terminalErrorHandler); this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); }
private unregisterSignalHandlers(): void { for (const cleanup of this.signalCleanupHandlers) { cleanup(); } this.signalCleanupHandlers = []; }
private handleCtrlZ(): void { if (process.platform === "win32") { this.showStatus("Suspend to background is not supported on Windows"); return; }
// 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(); // ui.stop() left the alt screen before suspending; re-enter it if (this.fullscreenEnabled) { this.applyFullscreen(true); } 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 editorText = this.editor.getText(); const text = (this.editor.getExpandedText?.() ?? editorText).trim(); if (this.queueSelection?.isBrowsing && !this.pendingQueueEdit) { await this.applyQueueSelection(text, "followUp").catch((error) => this.showError(error instanceof Error ? error.message : String(error)), ); return; } if (!text || !this.editor.onSubmit) return;
// Unlike Enter, Alt+Enter does not go through Editor.submitValue(), so // capture and clear synchronously before an async/local handler can yield. this.pendingSubmittedPromptStash = this.snapshotPromptStash(editorText); this.editor.setText(""); this.submittedInputBehavior = "followUp"; // onSubmit consumes the behavior flag and bumps the generation synchronously; // capture the generation so an older failed submit never clobbers newer // typing or submissions. const submission = this.editor.onSubmit(text); this.submittedInputBehavior = "steer"; const submissionGeneration = this.inputSubmissionGeneration; try { await submission; } catch (error) { if (submissionGeneration === this.inputSubmissionGeneration && this.editor.getText().length === 0) { this.editor.setText(text); } throw error; } }
private refreshQueueSelectionAt( queue: AgentConnectionQueueState, selected: QueueSelectionItem, index: number, ): void { const dropped = this.queueSelection.refreshAt(queue, selected.lane, index, selected.text); if (dropped !== undefined && this.editor.getText() === selected.text) { this.setEditorTextFromQueueSelection(dropped); } }
private browseQueueSelection(direction: -1 | 1): void { if (this.pendingQueueEdit || this.pendingQueueMove) return; const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); if (text === undefined) return; this.setEditorTextFromQueueSelection(text); this.ui.requestRender(); }
/** Serializes queue mutations so rapid keypresses never race each other or use stale indices. */ private enqueueQueueMutation<T>(run: () => Promise<T>): Promise<T> { const next = this.queueMutationChain.then(run, run); this.queueMutationChain = next.then( () => undefined, () => undefined, ); return next; }
private moveQueueSelection(direction: -1 | 1): void { if (this.pendingQueueEdit || !this.queueSelection.selected) return; const sessionGeneration = this.sessionEventGeneration; void this.enqueueQueueMutation(async () => { if (sessionGeneration !== this.sessionEventGeneration) return; const selected = this.queueSelection.selected; if (!selected) return; this.pendingQueueMove = true; const actionsBefore = this.connectionState?.sessionActions; try { const status = await this.agentConnection.mutateQueuedMessage( selected.lane, selected.index, selected.text, { type: "move", direction, }, ); if (sessionGeneration !== this.sessionEventGeneration) return; await this.sessionEventQueue; if (sessionGeneration !== this.sessionEventGeneration) return; // The move's event can land after the response; mirror it locally (events assign a fresh sessionActions). if (status === "applied" && actionsBefore && this.connectionState?.sessionActions === actionsBefore) { const queue = this.getConnectionQueue(); const lane = queue[selected.lane]; const target = selected.index + direction; if (lane[selected.index] === selected.text && target >= 0 && target < lane.length) { [lane[selected.index], lane[target]] = [lane[target] as string, selected.text]; this.patchConnectionState({ sessionActions: { ...actionsBefore, steering: queue.steering, followUps: queue.followUp }, }); this.updatePendingMessagesDisplay(); } } this.refreshQueueSelectionAt( this.getConnectionQueue(), selected, status === "applied" ? selected.index + direction : selected.index, ); if (status === "applied") this.ui.requestRender(); else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; reorder not applied"); } finally { this.pendingQueueMove = false; } }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { this.refreshQueueSelectionFromState(); this.showError(error instanceof Error ? error.message : String(error)); } }); }
/** * Applies the edited text to the selected queued message. Returns true when * the submission was consumed (the caller must not treat it as a new prompt). * Empty text deletes; otherwise replaces, moving the item to `targetLane`. */ private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise<boolean> { if (this.pendingQueueEdit || !this.queueSelection.selected) return Promise.resolve(false); const pendingQueueEdit = Symbol("pending-queue-edit"); this.pendingQueueEdit = pendingQueueEdit; const sessionGeneration = this.sessionEventGeneration; const submissionGeneration = this.inputSubmissionGeneration; const trimmed = text.trim(); const mutation = trimmed.length === 0 ? ({ type: "delete" } as const) : ({ type: "replace", text: trimmed, images: this.collectQueueReplaceImages(trimmed), lane: targetLane, } as const); const editorTextBefore = this.editor.getText(); const discardStaleSelection = (): boolean => { if (sessionGeneration === this.sessionEventGeneration) return false; if (this.pendingQueueEdit === pendingQueueEdit) { this.pendingQueueEdit = undefined; this.queueSelection.reset(); if (submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore) { this.setEditorTextFromQueueSelection(""); } this.ui.requestRender(); } return true; }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; const selected = this.queueSelection.selected; let status: AgentConnectionQueuedMessageMutationStatus; if (selected) { try { status = await this.agentConnection.mutateQueuedMessage( selected.lane, selected.index, selected.text, mutation, ); } catch (error) { if (discardStaleSelection()) return true; // The editor was already cleared by Enter; restore the edit before surfacing the error. const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (editorUntouched) { this.setEditorTextFromQueueSelection(text); } if (!this.queueSelection.isBrowsing) { this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); } throw error; } } else { status = "rejected"; } if (discardStaleSelection()) return true; const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); } else { // Enter submissions clear the editor before onSubmit runs; restore the // edit so a failed mutation never swallows it. if (editorUntouched) this.setEditorTextFromQueueSelection(text); if (!this.queueSelection.isBrowsing) { this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); } if (status === "invalid") this.showStatus("Edited command is not a valid session command; edit kept in the editor"); else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); else this.showStatus("Queue changed; edit kept in the editor"); } this.updatePendingMessagesDisplay(); this.ui.requestRender(); return true; }).finally(() => { if (this.pendingQueueEdit === pendingQueueEdit) { this.pendingQueueEdit = undefined; // Queue events were not reconciled while the edit was pending; drop a now-stale selection. this.refreshQueueSelectionFromState(); } }); }
/** * Images for a queue replace: undefined preserves the server's images (some * markers cannot be resolved by this client), [] clears, a list replaces. */ private collectQueueReplaceImages(text: string): ImageContent[] | undefined { const markers = [...new Set(imageMarkerIds(text))]; if (markers.length === 0) return []; const resolved = markers.map((markerId) => this.pastedImages.get(markerId)); return resolved.every((image) => image !== undefined) ? resolved.map((image) => ({ ...(image as ImageContent) })) : undefined; }
private setEditorTextFromQueueSelection(text: string): void { this.isApplyingQueueSelectionText = true; try { this.editor.setText(text); } finally { this.isApplyingQueueSelectionText = false; } }
private getQueueSelectionHeader(): string | undefined { const selected = this.queueSelection.selected; if (!selected) return undefined; const lane = selected.lane === "steering" ? "steering" : "follow-up"; const older = this.getAppKeyDisplay("app.message.navigateOlder"); const newer = this.getAppKeyDisplay("app.message.navigateNewer"); const earlier = this.getAppKeyDisplay("app.message.moveEarlier"); const later = this.getAppKeyDisplay("app.message.moveLater"); const queue = this.getAppKeyDisplay("app.message.followUp"); return theme.fg( "dim", `${lane} ${selected.index + 1} · ${older}/${newer} browse · ${earlier}/${later} reorder · enter steers · ${queue} queues · empty deletes`, ); }
private updateEditorBorderColor(): void { const editorTheme = getEditorTheme(); this.editor.borderColor = editorTheme.borderColor; this.editor.backgroundColor = editorTheme.backgroundColor; this.ui.requestRender(); }
private getPromptContextContainers(): Container[] { return [this.queuedMessagesContainer, this.sideQuestionContainer]; }
private getPromptDockComponents(): Component[] { return [this.recapContainer, this.editorContainer, this.subagentSummaryLine, this.footerSlot]; }
/** Enter or leave fullscreen rendering without touching the persisted setting. */ private applyFullscreen(enabled: boolean): void { if (enabled) { if (!process.stdout.isTTY) return; this.ui.enterFullscreen({ scroll: [ this.headerContainer, this.mainViewContainer, this.widgetContainerAbove, ...this.getPromptContextContainers(), this.widgetContainerBelow, ], dock: this.promptDock, pin: this.topBar, mouse: this.settingsManager.getFullscreenMouse(), }); } else { this.ui.exitFullscreen(); } }
private setFullscreenMode(enabled: boolean): void { this.settingsManager.setFullscreen(enabled); if (enabled && !process.stdout.isTTY) { this.fullscreenEnabled = false; this.showStatus("Fullscreen rendering requires an interactive terminal"); return; } this.fullscreenEnabled = enabled; this.applyFullscreen(enabled); const followKey = this.getEditorKeyDisplay("tui.viewport.follow"); this.showStatus( enabled ? `Fullscreen rendering on — wheel/pageUp scroll, ${followKey} follows output` : "Fullscreen rendering off", ); }
private toggleToolOutputExpansion(): void { this.setChatDetail(this.toolOutputExpanded ? "overview" : this.editDiffsExpanded ? "all" : "details"); }
private toggleAgentMessageExpansion(): void { // Flips the effective state, so the first press always changes what is on screen. this.agentMessagesExpanded = !this.agentMessagesExpandedFor(); this.applyChatExpansion(); }
private toggleEditDiffExpansion(): void { this.editDiffsExpanded = !this.editDiffsExpanded; this.applyChatExpansion(); }
private setToolsExpanded(expanded: boolean): void { this.setChatDetail(expanded ? "all" : "overview"); }
/** Expansion state for a chat component: agent messages toggle separately from tools. */ private expansionStateFor(component: unknown): boolean { return component instanceof AgentMessageComponent ? this.agentMessagesExpandedFor() : this.toolOutputExpanded; }
private agentMessagesExpandedFor(): boolean { return this.agentMessagesExpanded ?? this.toolOutputExpanded; }
/** Presentation only: never rewrite messages, settings, or the session trace. */ private setChatDetail(detail: "overview" | "details" | "all"): void { this.toolOutputExpanded = detail === "all"; this.editDiffsExpanded = detail !== "overview"; this.hideThinkingBlock = detail === "overview"; this.applyChatExpansion(); }
private applyChatExpansion(): void { const activeHeader = this.customHeader ?? this.builtInHeader; if (isExpandable(activeHeader)) { activeHeader.setExpanded(this.toolOutputExpanded); } for (const child of new Set([ ...this.chatContainer.children, ...this.pendingBashComponents, this.activeBashComponent, this.sideQuestionComponent, ])) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(this.hideThinkingBlock); } if (isExpandable(child)) { child.setExpanded(this.expansionStateFor(child)); } if (hasAgentMessagesExpansion(child)) { child.setAgentMessagesExpanded(this.agentMessagesExpandedFor()); } if (hasEditDiffsExpansion(child)) { child.setEditDiffsExpanded(this.editDiffsExpanded); } } // Expanding/collapsing changes blocks above the viewport, which would // otherwise force a full redraw that scrolls to the top and replays the // whole transcript. Keep the user anchored at their current position. // Fullscreen frames have no scrollback to preserve. if (this.ui.isFullscreen()) { this.ui.requestRender(); } else { this.ui.requestRenderPreservingViewport(); } }
private openExternalEditor(): 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(" ");
// Spawn editor synchronously with inherited stdio for interactive editing const result = spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit", shell: process.platform === "win32", });
// On successful exit (status 0), replace editor content if (result.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(); // ui.stop() left fullscreen so the editor got a clean terminal if (this.fullscreenEnabled) { this.applyFullscreen(true); } // Force full re-render since external editor uses alternate screen this.ui.requestRender(true); } }
private clearInputBar(): void { this.clearEscapeRepeat(); this.clearCtrlCExitHint({ render: false }); // Leaving browse mode restores the stashed draft instead of arming an // accidental empty-submit delete of the selected queued message. this.editor.setText(this.queueSelection.hasDraft ? this.queueSelection.reset() : ""); 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.ui.requestRender(); }
showWarning(warningMessage: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(theme.fg("warning", `⚠ ${warningMessage}`), 1, 0)); this.ui.requestRender(); }
showNewVersionNotification(newVersion: string): void { this.chatContainer.addChild(new Text(formatUpdateAvailableNotice(newVersion), 1, 0)); this.ui.requestRender(); }
showPackageUpdateNotification(packages: string[]): void { this.chatContainer.addChild(new Text(formatPackageUpdateNotice(packages), 1, 0)); this.ui.requestRender(); }
private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { return this.getConnectionQueue(); }
private updatePendingMessagesDisplay(): void { // pendingMessagesContainer holds only in-flight bash output for the current // turn, so it stays above the execution indicator. clear() detaches the // components but they stay tracked in pendingBashComponents until flushed. this.pendingMessagesContainer.clear(); for (const component of this.pendingBashComponents) { this.pendingMessagesContainer.addChild(component); } // Queued steering/follow-up previews are future turns, so they render in // their own container below the execution indicator and recap. this.queuedMessagesContainer.clear(); const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); const hasQueuedMessages = steeringMessages.length > 0 || followUpMessages.length > 0; if (hasQueuedMessages) { this.queuedMessagesContainer.addChild(new Spacer(1)); for (const message of steeringMessages) { const text = styleQueuedMessagePreview(message, "Steering", (name) => this.isRecognizedSlashCommand(name)); this.queuedMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } for (const message of followUpMessages) { const text = styleQueuedMessagePreview(message, "Follow-up", (name) => this.isRecognizedSlashCommand(name)); this.queuedMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } const dequeueHint = this.getAppKeyDisplay("app.message.navigateOlder"); const hintText = theme.fg("dim", `╰─ ${dequeueHint} to browse and edit queued messages`); this.queuedMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } }
private flushPendingBashComponents(): void { for (const component of this.pendingBashComponents) { this.pendingMessagesContainer.removeChild(component); this.chatContainer.addChild(component); } this.pendingBashComponents = []; }
/** * 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 showFullPaneOverlay(component: Component, options: number | FullPaneOverlayOptions = 80): OverlayHandle { return showFullPaneOverlay(this.ui, component, options); }
private async showSettingsSelector(): Promise<void> { let state: AgentConnectionState; try { state = await this.agentConnection.getState(); this.applyConnectionStateSnapshot(state); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; } this.showSelector((done) => { const selector = new SettingsSelectorComponent( { autoCompact: state.autoCompactionEnabled, idleEvictionMinutes: this.settingsManager.getIdleEvictionMinutes(), showImages: this.settingsManager.getShowImages(), autoResizeImages: this.settingsManager.getImageAutoResize(), blockImages: this.settingsManager.getBlockImages(), enableSkillCommands: this.settingsManager.getEnableSkillCommands(), enableBuiltinSkills: this.settingsManager.getEnableBuiltinSkills(), steeringMode: state.steeringMode, followUpMode: state.followUpMode, transport: this.settingsManager.getTransport(), thinkingLevel: state.thinkingLevel, availableThinkingLevels: state.availableThinkingLevels, currentTheme: this.settingsManager.getTheme() || "prime", availableThemes: getAvailableThemes(), mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(), 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(), fullscreen: this.fullscreenEnabled, warnings: this.settingsManager.getWarnings(), }, { onAutoCompactChange: (enabled) => { this.patchConnectionState({ autoCompactionEnabled: enabled }); void this.agentConnection.setAutoCompactionEnabled(enabled).catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); this.footer.setAutoCompactEnabled(enabled); }, onIdleEvictionMinutesChange: (value) => { this.settingsManager.setIdleEvictionMinutes(value); }, onShowImagesChange: (enabled) => { this.settingsManager.setShowImages(enabled); for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setShowImages(enabled); } } }, onAutoResizeImagesChange: (enabled) => { this.settingsManager.setImageAutoResize(enabled); }, onBlockImagesChange: (blocked) => { this.settingsManager.setBlockImages(blocked); }, onEnableSkillCommandsChange: (enabled) => { this.settingsManager.setEnableSkillCommands(enabled); this.setupAutocompleteProvider(); }, onEnableBuiltinSkillsChange: (enabled) => { this.settingsManager.setEnableBuiltinSkills(enabled); void this.handleReloadCommand(); }, onSteeringModeChange: (mode) => { this.patchConnectionState({ steeringMode: mode }); void this.agentConnection.setSteeringMode(mode).catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }, onFollowUpModeChange: (mode) => { this.patchConnectionState({ followUpMode: mode }); void this.agentConnection.setFollowUpMode(mode).catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }, onTransportChange: (transport) => { void this.agentConnection.setTransport(transport).catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }, onThinkingLevelChange: (level) => { void this.agentConnection .setThinkingLevel(level) .then(() => { this.patchConnectionState({ thinkingLevel: level }); this.footer.invalidate(); this.updateEditorBorderColor(); }) .catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }, 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(); } }, onMermaidRenderingModeChange: (mode) => { this.settingsManager.setMermaidRenderingMode(mode); this.chatContainer.invalidate(); this.ui.requestRender(); }, onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, 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); }, onFullscreenChange: (enabled) => { this.setFullscreenMode(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 { const authFlows = this.createAuthFlows(); const providerOptions = authFlows.getLoginProviderOptions(); if (!(await this.ensureModelProviderConfigured(model, authFlows, providerOptions))) return; await this.completeModelSelection(model); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } return; }
this.showModelSelector(searchTerm); }
private async findExactModelMatch(searchTerm: string): Promise<Model<Api> | undefined> { const cachedMatch = findExactModelReferenceMatch(searchTerm, this.getCachedModelCandidates()); if (cachedMatch) { return cachedMatch; }
const refreshPromise = this.getModelSelectorRefreshPromise({ force: true }); if (!refreshPromise) { return undefined; }
try { return findExactModelReferenceMatch(searchTerm, await refreshPromise); } catch { return undefined; } }
private async applySelectedModel(model: AgentConnectionModel): Promise<void> { const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; await connection.setModel(model.provider, model.id); const state = await connection.getState(); if ( this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId || (sessionId !== undefined && state.sessionId !== sessionId) ) { return; } this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.applyModelSwitchUiState(state, model); }
/** Patch model-derived connection state and refresh the UI that reads it. */ private applyModelSwitchUiState( state: Pick<AgentConnectionState, "model" | "serviceTier" | "availableThinkingLevels" | "contextUsage">, fallbackModel: AgentConnectionModel, ): void { this.patchConnectionState({ model: state.model ?? fallbackModel, serviceTier: state.serviceTier, availableThinkingLevels: state.availableThinkingLevels, contextUsage: state.contextUsage, }); this.footer.invalidate(); this.subagentSummaryLine.invalidate(); this.updateEditorBorderColor(); // Rebuild so the /effort argument hint reflects the new model's levels. this.setupAutocompleteProvider(); }
private async completeModelSelection(model: AgentConnectionModel): Promise<void> { this.showStatus(`Switching model: ${model.id}`); await this.applySelectedModel(model); this.showStatus(`Model: ${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); this.checkDaxnutsEasterEgg(model); }
private async ensureModelProviderConfigured( model: AgentConnectionModel, authFlows: ProviderAuthFlows, providerOptions: ReadonlyArray<AuthSelectorProvider>, ): Promise<boolean> { if (this.isModelProviderConfigured(model)) return true;
const provider = providerOptions.find( (option) => option.id === model.provider && (option.category ?? "provider") === "provider", ); if (!provider) { this.showError(`Authentication for ${model.provider} must be configured externally.`); return false; }
const result = await authFlows.loginProvider(provider); if (result.status !== "success") return false;
this.invalidateConnectionModels(); await this.getConnectionAvailableModels(); if (this.isModelProviderConfigured(model)) return true;
this.showError(`Authentication completed, but ${model.provider} is still unavailable.`); return false; }
private isModelProviderConfigured(model: AgentConnectionModel): boolean { return this.connectionConfiguredProviders.has(model.provider) || this.modelRegistry.hasConfiguredAuth(model); }
private applyConnectionModelCatalog(catalog: AgentConnectionModelCatalog): void { this.connectionModelCatalog = [...catalog.models]; this.connectionConfiguredProviders = new Set(catalog.configuredProviders); }
private getAvailableConnectionModels(): AgentConnectionModel[] { return this.connectionModelCatalog.filter((model) => this.connectionConfiguredProviders.has(model.provider)); }
private async getConnectionAvailableModels(): Promise<AgentConnectionModel[]> { const inFlight = this.connectionModelsRefreshInFlight; if (inFlight && inFlight.version === this.connectionModelsRefreshVersion) { return [...(await inFlight.promise)]; }
const version = this.connectionModelsRefreshVersion; const promise = this.agentConnection.getModelCatalog().then((catalog) => { if (version !== this.connectionModelsRefreshVersion) { return this.getAvailableConnectionModels(); } this.applyConnectionModelCatalog(catalog); this.connectionModelsFetchedAt = Date.now(); return this.getAvailableConnectionModels(); }); this.connectionModelsRefreshInFlight = { version, promise };
try { return [...(await promise)]; } finally { if (this.connectionModelsRefreshInFlight?.promise === promise) { this.connectionModelsRefreshInFlight = undefined; } } }
private async getConnectionModelCatalog(): Promise<AgentConnectionModel[]> { await this.getConnectionAvailableModels(); return [...this.connectionModelCatalog]; }
private getCachedModelCandidates(): AgentConnectionModel[] { const modelsById = new Map<string, AgentConnectionModel>(); for (const scoped of this.getScopedModelState()) { modelsById.set(`${scoped.model.provider}/${scoped.model.id}`, scoped.model); } for (const model of this.connectionModelCatalog) { modelsById.set(`${model.provider}/${model.id}`, model); } return [...modelsById.values()]; }
private getModelSelectorRefreshPromise( options: { force?: boolean } = {}, ): Promise<AgentConnectionModel[]> | undefined { const refreshCatalog = () => this.getConnectionAvailableModels().then(() => this.getCachedModelCandidates()); if (this.connectionModelsRefreshInFlight) { return refreshCatalog(); } if (options.force || this.connectionModelsFetchedAt === 0) { return refreshCatalog(); } if (Date.now() - this.connectionModelsFetchedAt > MODEL_CATALOG_REFRESH_TTL_MS) { return refreshCatalog(); } return undefined; }
private invalidateConnectionModelRefresh(): void { this.connectionModelsRefreshVersion++; this.connectionModelsRefreshInFlight = undefined; }
private invalidateConnectionModels(): void { this.connectionConfiguredProviders = new Set(); this.connectionModelsFetchedAt = 0; this.invalidateConnectionModelRefresh(); }
private async refreshConnectionModelsAfterAuthChange(): Promise<void> { const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; this.invalidateConnectionModels(); await this.getConnectionAvailableModels(); const state = await connection.getState(); if ( this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId || (sessionId !== undefined && state.sessionId !== sessionId) ) { return; } this.patchConnectionState({ model: state.model, scopedModels: state.scopedModels, serviceTier: state.serviceTier, availableThinkingLevels: state.availableThinkingLevels, }); this.subagentSummaryLine.invalidate(); this.setupAutocompleteProvider(); }
private async getModelCandidates(): Promise<AgentConnectionModel[]> { const scopedModels = this.getScopedModelState(); if (scopedModels.length > 0) { return scopedModels.map((scoped) => scoped.model); }
try { return await this.getConnectionAvailableModels(); } catch { return []; } }
private getScopedModelsFromModelIds( enabledIds: readonly string[], allModels: readonly AgentConnectionModel[], ): AgentConnectionState["scopedModels"] { const modelsById = new Map(allModels.map((model) => [`${model.provider}/${model.id}`, model])); const selectedIds = new Set<string>(); const scopedModels: AgentConnectionState["scopedModels"] = [];
for (const id of enabledIds) { if (selectedIds.has(id)) { continue; }
const model = modelsById.get(id); if (!model) { continue; }
selectedIds.add(id); scopedModels.push({ model }); }
return scopedModels; }
private async updateAvailableProviderCount(): Promise<void> { const models = await this.getModelCandidates(); const uniqueProviders = new Set(models.map((m) => m.provider)); this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); if (this.isInitialized) { this.ui.requestRender(); } }
private async maybeWarnAboutAnthropicSubscriptionAuth( model: Model<any> | undefined = this.getCurrentModel(), ): Promise<void> { if (this.settingsManager.getWarnings().anthropicExtraUsage === false) { return; } if (this.anthropicSubscriptionWarningShown) { return; } const warning = await getAnthropicSubscriptionAuthWarning(this.modelRegistry, model); if (!warning) { return; } this.anthropicSubscriptionWarningShown = true; this.showWarning(warning); }
private getAvailableThinkingLevels(): ThinkingLevel[] { const levels = this.connectionState?.availableThinkingLevels ?? []; const supportsThinking = levels.length > 0 && !(levels.length === 1 && levels[0] === "off"); return supportsThinking ? levels : []; }
private getThinkingLevelCompletions(prefix: string): AutocompleteItem[] | null { const levels = this.getAvailableThinkingLevels(); if (levels.length === 0) return null; const current = this.connectionState?.thinkingLevel; const term = prefix.trim().toLowerCase(); const matches = term ? levels.filter((level) => level.startsWith(term)) : levels; if (matches.length === 0) return null; return matches.map((level) => ({ value: level, label: level, description: level === current ? `${THINKING_LEVEL_DESCRIPTIONS[level]} (current)` : THINKING_LEVEL_DESCRIPTIONS[level], })); }
private getHeartbeatArgumentCompletions(prefix: string): AutocompleteItem[] | null { const term = prefix.trim().toLowerCase(); const filtered = term ? HEARTBEAT_ARGUMENT_COMPLETIONS.filter( (item) => item.value.toLowerCase().startsWith(term) || item.label.toLowerCase().startsWith(term), ) : HEARTBEAT_ARGUMENT_COMPLETIONS; return filtered.length === 0 ? null : filtered; }
private getTracesArgumentCompletions(prefix: string): AutocompleteItem[] | null { const term = prefix.trim().toLowerCase(); const filtered = term ? TRACES_ARGUMENT_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(term)) : TRACES_ARGUMENT_COMPLETIONS; return filtered.length === 0 ? null : filtered; }
private currentModelSupportsFastMode(): boolean { const model = this.getCurrentModel(); return model !== undefined && supportsFastMode(model); }
private handleFastCommand(): void { const unavailableMessage = "Fast mode requires GPT-5.4, GPT-5.5, or GPT-5.6 with ChatGPT or OpenAI API key authentication"; if (!this.currentModelSupportsFastMode()) { this.showStatus(unavailableMessage); return; } const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; this.fastModeToggleQueue = this.fastModeToggleQueue .then(async () => { if (this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId) { return; } if (!this.currentModelSupportsFastMode()) { this.showStatus(unavailableMessage); return; } const enabled = this.connectionState?.serviceTier === "priority"; const serviceTier: ServiceTier = enabled ? "default" : "priority"; await connection.setServiceTier(serviceTier); if (this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId) { return; } const state = await connection.getState(); if ( this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId || state.sessionId !== sessionId ) { return; } this.patchConnectionState({ serviceTier: state.serviceTier }); this.footer.invalidate(); this.subagentSummaryLine.invalidate(); this.showStatus(`Fast mode: ${state.serviceTier === "priority" ? "on" : "off"}`); }) .catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }
private handleEffortCommand(arg: string): void { const levels = this.getAvailableThinkingLevels(); if (levels.length === 0) { this.showStatus("Current model does not support thinking"); return; } const requested = arg.trim().toLowerCase(); if (!requested) { this.showThinkingSelector(levels); return; } if (!levels.includes(requested as ThinkingLevel)) { this.showError(`Unknown thinking level '${requested}'. Available: ${levels.join(", ")}`); return; } this.applyThinkingLevel(requested as ThinkingLevel); }
private showThinkingSelector(levels: ThinkingLevel[] = this.getAvailableThinkingLevels()): void { const currentLevel = this.connectionState?.thinkingLevel ?? levels[0]; if (!currentLevel) { this.showStatus("Current model does not support thinking"); return; } this.showSelector((done) => { const selector = new ThinkingSelectorComponent( currentLevel, levels, (level) => { done(); this.applyThinkingLevel(level); }, () => { done(); this.ui.requestRender(); }, ); return { component: selector, focus: selector.getSelectList() }; }); }
private applyThinkingLevel(level: ThinkingLevel): Promise<boolean> { return this.agentConnection .setThinkingLevel(level) .then(() => { this.patchConnectionState({ thinkingLevel: level }); this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Thinking level: ${level}`); return true; }) .catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); return false; }); }
private handleModelCycle(direction: "forward" | "backward"): void { const connection = this.agentConnection; const sessionId = this.connectionState?.sessionId; void connection .cycleModel(direction) .then(async (result) => { if (!result) { this.showStatus("No other models available to cycle"); return; } const state = await connection.getState(); if ( this.agentConnection !== connection || this.connectionState?.sessionId !== sessionId || (sessionId !== undefined && state.sessionId !== sessionId) ) { return; } this.applyModelSwitchUiState(state, result.model); this.showStatus(`Model: ${result.model.provider}/${result.model.id}`); }) .catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }
private showModelSelector(initialSearchInput?: string): void { void this.showConfigurationMenu("models", initialSearchInput); }
private showConfigurationMenu(initialTab: ConfigurationMenuTab, initialModelSearch?: string): Promise<void> { if (this.configurationModelSelection) return this.configurationModelSelection; this.closeConfigurationMenu?.(); this.closeServiceCatalogPicker?.(); const modelCatalog = this.getCachedModelCandidates(); const authFlows = this.createAuthFlows(); const providerOptions = authFlows.getLoginProviderOptions();
return new Promise((resolve) => { let settled = false; let busy = false; let menu: ConfigurationMenuComponent; const restoreEditor = () => { if (!this.editorContainer.children.includes(menu)) return; this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.ui.setFocus(this.editor); this.ui.requestRender(); }; const focus = () => { if (!settled && this.editorContainer.children.includes(menu)) this.ui.setFocus(menu); }; const finish = () => { if (settled) return; settled = true; restoreEditor(); if (this.closeConfigurationMenu === finish) this.closeConfigurationMenu = undefined; resolve(); }; const refreshModels = (force: boolean) => { const refreshPromise = this.getModelSelectorRefreshPromise({ force }); if (!refreshPromise) return; void refreshPromise .then((models) => { if (!settled) menu.updateModels(this.getCurrentModel(), models, this.connectionConfiguredProviders); }) .catch((error) => { if (!settled) this.showError(error instanceof Error ? error.message : String(error)); }); }; const authenticate = (provider: AuthSelectorProvider, tab: "providers" | "mcp-connections") => { if (settled || busy) return; busy = true; void authFlows .loginProvider(provider) .then(async (authResult) => { if (settled) return; focus(); menu.refreshAuthentication(); if (authResult.status !== "success") return;
if (tab === "mcp-connections") { if (!authResult.providerId.startsWith("mcp:")) return; // The guarded operation already verified and scheduled activation. finish(); return; }
await this.prepareForModelSelectionAfterLogin(authResult); if (settled) return; menu.updateModels( this.getCurrentModel(), this.getCachedModelCandidates(), this.connectionConfiguredProviders, ); refreshModels(true); }) .catch((error) => { focus(); if (!settled) this.showError(error instanceof Error ? error.message : String(error)); }) .finally(() => { busy = false; }); };
menu = new ConfigurationMenuComponent({ initialTab, tui: this.ui, authStorage: this.modelRegistry.authStorage, providerOptions, modelRegistry: this.modelRegistry, currentModel: this.getCurrentModel(), scopedModels: this.getScopedModelState(), availableModels: modelCatalog, configuredProviders: this.connectionConfiguredProviders, recentModels: this.settingsManager.getRecentModels(), initialModelSearch, thinkingLevel: this.getCurrentModel()?.reasoning ? this.connectionState?.thinkingLevel : (this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL), getRows: () => Math.max(1, Math.min(20, this.ui.terminal.rows - 3)), requestRender: () => this.ui.requestRender(), onSelectProvider: (provider) => authenticate(provider, "providers"), onSelectMcpConnection: (provider) => authenticate(provider, "mcp-connections"), onSelectModel: (model, thinkingLevel) => { if (settled || busy) return; busy = true; void (async () => { let completed = false; let selectionInFlight: Promise<void> | undefined; try { const ready = await this.ensureModelProviderConfigured(model, authFlows, providerOptions); if (settled) return; focus(); menu.refreshAuthentication(); menu.updateModels( this.getCurrentModel(), this.getCachedModelCandidates(), this.connectionConfiguredProviders, ); if (!ready) return; menu.setBusy(true); const selection = (async () => { await this.completeModelSelection(model); if (settled) return false; return thinkingLevel === undefined || (await this.applyThinkingLevel(thinkingLevel)); })(); selectionInFlight = selection.then( () => {}, () => {}, ); this.configurationModelSelection = selectionInFlight; completed = await selection; } catch (error) { focus(); if (!settled) this.showError(error instanceof Error ? error.message : String(error)); } finally { busy = false; if (this.configurationModelSelection === selectionInFlight) this.configurationModelSelection = undefined; menu.setBusy(false); if (completed) finish(); } })(); }, onCancel: finish, }); this.closeConfigurationMenu = finish; this.editorContainer.clear(); this.editorContainer.addChild(menu); focus(); this.ui.requestRender(); refreshModels(initialModelSearch !== undefined); }); }
private async showModelsSelector(): Promise<void> { let allModels: AgentConnectionModel[]; try { allModels = await this.getConnectionModelCatalog(); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; }
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.getScopedModelState(); const hasSessionScope = sessionScopedModels.length > 0;
// Build enabled model IDs from session state or settings let currentEnabledIds: string[] | null = null;
if (hasSessionScope) { // Use current session's scoped models currentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`); } else { // Fall back to settings const patterns = this.settingsManager.getEnabledModels(); if (patterns !== undefined && patterns.length > 0) { const scopedModels = resolveModelScopeFromModels(patterns, allModels); currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`); } }
// Helper to update session's scoped models (session-only, no persist) const updateSessionModels = async (enabledIds: string[] | null) => { currentEnabledIds = enabledIds === null ? null : [...enabledIds]; if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) { const scopedModels = this.getScopedModelsFromModelIds(enabledIds, allModels); await this.agentConnection.setScopedModels(scopedModels); this.patchConnectionState({ scopedModels }); } else { // All enabled or none enabled = no filter await this.agentConnection.setScopedModels([]); this.patchConnectionState({ scopedModels: [] }); } 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 async showUserMessageSelector(): Promise<void> { let userMessages: Array<{ entryId: string; text: string }>; try { userMessages = await this.agentConnection.getUserMessagesForForking(); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; }
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.agentConnection.fork(entryId); if (result.cancelled) { done(); this.ui.requestRender(); return; }
await 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> { try { const { leafId } = await this.agentConnection.getSessionTree(); if (!leafId) { this.showStatus("Nothing to clone yet"); return; }
const result = await this.agentConnection.fork(leafId, { position: "at" }); if (result.cancelled) { this.ui.requestRender(); return; }
await this.renderCurrentSessionState(); this.editor.setText(""); this.showStatus("Cloned to new session"); } catch (error: unknown) { this.showError(error instanceof Error ? error.message : String(error)); } }
private async showTreeSelector(initialSelectedId?: string): Promise<void> { let tree: AgentConnectionSessionTreeNode[]; let realLeafId: string | null; try { const sessionTree = await this.agentConnection.getSessionTree(); tree = sessionTree.tree; realLeafId = sessionTree.leafId; } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; } 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 void 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; if (wantsSummary) { this.chatContainer.addChild(new Spacer(1)); summaryLoader = new Loader( this.ui, (spinner) => theme.fg("muted", spinner), (text) => theme.fg("muted", text), `Summarizing branch... (${keyText("app.clear")} to cancel)`, ); this.statusContainer.addChild(summaryLoader); this.ui.requestRender(); }
try { const result = await this.agentConnection.navigateTree(entryId, { summarize: wantsSummary, customInstructions, });
if (result.aborted) { // Summarization aborted - re-show tree selector with same selection this.showStatus("Branch summarization cancelled"); void this.showTreeSelector(entryId); return; } if (result.cancelled) { this.showStatus("Navigation cancelled"); return; }
await this.renderTreeNavigation(result); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } finally { if (summaryLoader) { summaryLoader.stop(); this.statusContainer.clear(); } } }, () => { done(); this.ui.requestRender(); }, (entryId, label) => { void this.agentConnection .setSessionEntryLabel(entryId, label) .then(() => { this.ui.requestRender(); }) .catch((error) => { this.showError(error instanceof Error ? error.message : String(error)); }); }, initialSelectedId, initialFilterMode, ); return { component: selector, focus: selector }; }); }
private async handleResumeCommand(args: string): Promise<void> { const selector = args.trim(); if (!selector) { await this.requestAgentsView(); return; } let sessionPath: string; try { sessionPath = (await resolveSessionPath(selector, this.getCurrentCwd(), this.connectionState?.sessionDir)) .path; } catch (error) { if (error instanceof SessionSelectorError) { const suggestion = error instanceof SessionSelectorNotFoundError && error.suggestion ? ` Did you mean '${error.suggestion}'?` : ""; this.showError(`${error.message}.${suggestion}`); return; } throw error; } await this.handleResumeSession(sessionPath); }
private async handleResumeSession( sessionPath: string, options?: Parameters<ExtensionCommandContext["switchSession"]>[1], ): Promise<{ cancelled: boolean }> { this.stopWorkingLoader(); try { const result = options?.withSession ? await this.getLocalSessionHost().switchSession(sessionPath, { withSession: options.withSession, }) : await this.agentConnection.switchSession(sessionPath); if (result.cancelled) { return result; } await 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 = options?.withSession ? await this.getLocalSessionHost().switchSession(sessionPath, { cwdOverride: selectedCwd, withSession: options.withSession, }) : await this.agentConnection.switchSession(sessionPath, { cwdOverride: selectedCwd }); if (result.cancelled) { return result; } await this.renderCurrentSessionState(); this.showStatus("Resumed session in current cwd"); return result; } if (this.reportLostConnectionFailure(error)) return { cancelled: true }; return this.handleFatalRuntimeError("Failed to resume session", error); } }
private showOnboardingSplash(options?: { immediate?: boolean }): Promise<OnboardingSplashHandle | undefined> { return new Promise((resolve) => { let settled = false; let dismissed = false; let handle: OverlayHandle | undefined; let selector: PrimeOnboardingSplashComponent | undefined; const settle = (result: OnboardingSplashHandle | undefined) => { if (settled) { return; } settled = true; resolve(result); }; const dismiss = () => { if (dismissed) { return; } dismissed = true; selector?.dispose(); handle?.hide(); this.onboardingSplash = undefined; this.onboardingAbort = undefined; this.onboardingFlowAbort = undefined; this.onboardingUiActive = false; this.builtInHeader?.invalidate(); this.ui.requestRender(); }; selector = new PrimeOnboardingSplashComponent( () => { // The field keeps animating behind the flow panels; only dismissal // stops it. settle({ dismiss }); }, { getRows: () => this.ui.terminal.rows, // Nothing else owns Ctrl+C yet, so the block exits the app itself. onExit: () => void this.shutdown(), requestRender: () => this.ui.requestRender(), immediate: options?.immediate, }, ); // The block owns the pane while onboarding runs: the brand header hides // so the two marks never stack, and flow panels mount inside the block. this.onboardingAbort = () => { // Cancel the running step first: after Enter the splash promise is // already settled, so dismissing alone would leave the login waiting // on an unmounted dialog and remount later panels in the prompt dock. this.onboardingFlowAbort?.abort(); dismiss(); settle(undefined); }; this.onboardingUiActive = true; this.onboardingSplash = selector; this.builtInHeader?.invalidate(); handle = this.ui.showOverlay(selector, { width: "100%", maxHeight: "100%", row: 0, col: 0, }); // Immediate mode: settle without waiting for the user to press Enter // on the login action. The flow panels mount directly under the brand // mark with no welcome text or action row flash. if (options?.immediate) { settle({ dismiss }); } }); }
private createAuthFlows(): ProviderAuthFlows { const showAuthPanel = (component: Component, options?: { heading?: string; onReset?: () => void }) => this.showInlineAuthPanel(component, options); return new ProviderAuthFlows({ ui: this.ui, modelRegistry: this.modelRegistry, showStatus: (message) => this.showStatus(message), showError: (message) => this.showError(message), showAuthPanel, exitApp: () => void this.shutdown(), getAuthPanelRows: () => Math.max(1, Math.min(20, this.ui.terminal.rows - 3)), // Onboarding renders its own heading above the panel and asks its // questions in the onboarding selection language. isOnboardingSurface: () => this.onboardingUiActive, getAvailableModels: () => this.getConnectionAvailableModels(), onAuthChanged: async () => { await this.refreshConnectionModelsAfterAuthChange(); await this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); }, onLoginCompleted: () => { void this.maybeWarnAboutAnthropicSubscriptionAuth(); }, onMcpAccountLogout: (providerId) => this.logoutMcpAccount(providerId), onMcpAccountLogin: async (providerId) => { const name = providerId.slice("mcp:".length); const { resolved, result } = await this.connectMcpAccountByName(name); if (!resolved) { this.showStatus(`No endpoint configured for ${name}; add it with /mcp add or connect from /plugins.`); } return result; }, }); }
/** * Mount a provider-auth panel inline in place of the prompt area, matching * the inline pickers. Returns a callback that unmounts the panel and * restores the previous content and focus. Closers are tracked in a stack * because in-flow selectors mount on top of the login dialog; * resetExtensionUI tears the whole stack down on session resets. Each * closer runs once, so a reset cannot stomp a picker opened afterwards. */ private showInlineAuthPanel( component: Component, options?: { heading?: string; onReset?: () => void }, ): (reason?: "reset") => void { // Onboarding owns the top of the screen: mount the panel inside its block // rather than down in the prompt dock. const splash = this.onboardingSplash; if (splash) { splash.setPanel(component, options?.heading); this.ui.setFocus(component); this.ui.requestRender(); let splashPanelClosed = false; const closeSplashPanel = (reason?: "reset") => { if (splashPanelClosed) return; splashPanelClosed = true; const index = this.inlineAuthPanelClosers.indexOf(closeSplashPanel); if (index !== -1) { this.inlineAuthPanelClosers.splice(index, 1); } // A reset unmounts the panel without its flow finishing: settle the // step the caller is awaiting, or it waits on a dead panel forever. if (reason === "reset") { options?.onReset?.(); if (isAbortablePanel(component)) { component.abort(); } } splash.setPanel(undefined); // The splash ignores keys while a panel is mounted, so focus has to // land on whichever panel the pop restored, not on the block itself. this.ui.setFocus(splash.getActivePanel() ?? splash); this.ui.requestRender(); }; this.inlineAuthPanelClosers.push(closeSplashPanel); return closeSplashPanel; } const previousChildren = [...this.editorContainer.children]; const previousFocus = previousChildren.find((child) => isFocusable(child) && child.focused) ?? this.editor; this.editorContainer.clear(); this.editorContainer.addChild(component); this.ui.setFocus(component); this.ui.requestRender(); let closed = false; const close = (reason?: "reset") => { if (closed) return; closed = true; if (reason === "reset") { options?.onReset?.(); if (isAbortablePanel(component)) { component.abort(); } } const index = this.inlineAuthPanelClosers.indexOf(close); if (index !== -1) { this.inlineAuthPanelClosers.splice(index, 1); } this.editorContainer.clear(); for (const child of previousChildren) { this.editorContainer.addChild(child); } this.ui.setFocus(previousFocus); this.ui.requestRender(); }; this.inlineAuthPanelClosers.push(close); return close; }
/** * The ENTIRE MCP account logout, owned by ONE connection-store critical * section (store->auth ordering): verified credential deletion AND * pending-attempt cancellation happen under the store's file lock BEFORE * the generic /logout route reports anything. The route delegates here * instead of calling authStorage.logout first — a plain logout could be * defeated by a concurrent finalize re-creating the credential after it. * * Id resolution is exact-first: a local service id may itself contain * "--", so a staged key maps back ONLY through its actually recorded * attempt nonce. */ private async logoutMcpAccount(providerId: string): Promise<McpRemoveAccountResult> { // The shared exported one-op handler: same code the reviewer's route // tests drive, no copied literals. const store = this.getMcpConnectionStore(); const connectionId = providerId.startsWith("mcp:") ? providerId.slice("mcp:".length) : providerId; // Read the display label BEFORE the op: a "removed" outcome deletes the // record, so afterwards only the id survives. const label = store.get(connectionId)?.label ?? connectionId; const result = await logoutMcpAccount(providerId, store, this.modelRegistry.authStorage); // The route reports next; the durable entry waits for the reload the // selector already runs, so the logout stays ONE store op and ONE reload. const removal = mcpDisconnectionState(result); this.pendingMcpDisconnectionOutcome = removal ? { kind: "disconnect", label, removal, connectionId } : undefined; return result; }
private async prepareForModelSelectionAfterLogin( authResult: AuthenticationResult, abortSignal?: AbortSignal, ): Promise<boolean> { // A reset rebinds the session; selection prepared for the old one must // not be applied to the new one. if (abortSignal?.aborted) { return false; } const currentModel = this.getCurrentModel(); // The agent core uses unknown/unknown as its no-model sentinel. const selectedModel = currentModel?.provider === "unknown" && currentModel.id === "unknown" ? undefined : currentModel; let action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, this.modelRegistry); if (!action.openModelPicker) { return false; }
if (!selectedModel) { try { const availableModels = await this.getConnectionAvailableModels(); action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, { find: (provider, modelId) => availableModels.find((model) => model.provider === provider && model.id === modelId) ?? this.modelRegistry.find(provider, modelId), }); } catch { // Preserve the registry fallback so selection can still report a specific failure below. } }
if (action.fallbackModel) { if (abortSignal?.aborted) { return false; } try { await this.applySelectedModel(action.fallbackModel); await this.settingsManager.flush(); } catch (error) { this.showError( `Prime Inference login succeeded, but the default model could not be selected: ${error instanceof Error ? error.message : String(error)}`, ); } } else if (!selectedModel) { this.showError("Prime Inference login succeeded, but the default GLM 5.3 model is unavailable."); }
return true; }
private async handleMcpCommand(args: string | undefined): Promise<void> { const argv = parseCommandArgs((args ?? "").trim()); const [sub, server] = argv; if (!sub) { await this.showServiceCatalogPicker(); return; }
const authStorage = this.modelRegistry.authStorage; const isAuthed = (name: string) => authStorage.get(`mcp:${name}`) !== undefined; if (sub === "login") { if (!server || argv.length !== 2) { this.showError("Usage: /mcp login <name> (e.g. /mcp login linear)"); return; } // /mcp login routes through the ONE guarded connect operation (claim // -> staged OAuth -> guarded finalize): the raw dialog that wrote the // final credential directly is gone. const { resolved } = await this.connectMcpAccountByName(server); if (!resolved) { this.showError(`No endpoint configured for ${server}; add it with /mcp add or connect from /plugins.`); } return; }
if (sub === "logout") { if (!server || argv.length !== 2) { this.showError("Usage: /mcp logout <name>"); return; } if (!isAuthed(server)) { this.showStatus(`${server} is not connected.`); return; } // Read the display label BEFORE the op: a removed record leaves only // the id the user typed. const logoutLabel = this.getMcpConnectionStore().get(server)?.label ?? server; // Credential AND record removal in one store-locked step (the same // lock ordering finalize uses): no orphan credential can survive. const loggedOut = await this.getMcpConnectionStore().removeAccount({ connectionId: server, authCleanup: (connectionId) => { // Disk-authoritative logout: remove() swallows persistence // errors, so a failed auth-file write could report the logout // done while the credential survives. removeVerified throws // instead (the honest "failed" path) and returns whether a // credential was actually removed from disk. return authStorage.removeVerified(mcpCredentialKey(connectionId)); }, }); // Honest outcomes: removed/credential-only changed durable state, // missing is a no-op, logged-out keeps the durable logout but says // the record save failed, and failed is state-neutral (the cleanup // may or may not have run — it never claims a specific state). if (loggedOut === "failed") { this.showWarning(`The change could not be saved; try logging out ${server} again.`); return; } if (loggedOut === "logged-out") { this.showWarning( `Logged out ${server}, but the change could not be saved. It may still appear in the list; try again to finish cleanup.`, ); return; } if (loggedOut === "missing") { this.showStatus(`${server} is no longer connected.`); return; } const removal = mcpDisconnectionState(loggedOut); if (removal) { await this.completeMcpConnectionOutcome({ kind: "disconnect", label: logoutLabel, removal, connectionId: server, }); return; } await this.reloadAfterMcpChange(`Disconnected ${server}.`); return; }
try { const result = await runMcpManagementCommand(argv, this.settingsManager, this.modelRegistry.authStorage); if (result.changed && result.serverChange?.verb === "removed") { await this.removeMcpConnectionRecord(result.serverChange.name); } if (result.changed && result.serverChange) { const { name, transport, verb, usesOAuth } = result.serverChange; const hasMcpProviderRefresh = this.uiServices.refreshMcpProviders !== undefined; this.uiServices.refreshMcpProviders?.(); const successMessage = verb === "removed" ? `Removed MCP server "${name}" (${transport}). It is no longer available through mcp.` : usesOAuth ? `${verb === "replaced" ? "Replaced" : "Added"} MCP server "${name}" (${transport}). ${hasMcpProviderRefresh ? "Run" : "Restart Prime Agent, then run"} /mcp login ${name} to connect.` : `${verb === "replaced" ? "Replaced" : "Added"} MCP server "${name}" (${transport}). Available next turn through mcp.`; await this.reloadAfterMcpChange(usesOAuth ? successMessage : result.message, successMessage); } else if (result.action === "list") { const builtins = BUILTIN_MCP_CATALOG.map( (entry) => `${entry.label} (${entry.server}): ${isAuthed(entry.server) ? "connected" : "not connected"}`, ).join("\n"); this.showStatus( `Built-in MCP integrations:\n${builtins}\n\nUser-configured MCP servers:\n${result.message}`, ); } else { this.showStatus(result.message); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private getMcpConnectionStore(): McpConnectionStore { this.mcpConnectionStore ??= McpConnectionStore.open(path.join(getAgentDir(), "mcp-connections.json")); return this.mcpConnectionStore; }
private async removeMcpConnectionRecord(connectionId: string): Promise<void> { const store = this.getMcpConnectionStore(); store.remove(connectionId); await store.flush().catch(() => undefined); }
/** * External-service cards for the /plugins picker. Uses the SAME resolver the * host uses (built-in catalog + declared local sources from settings) and * re-reads the shared connection records from disk, so the UI never renders * a stale in-process cache. */ private buildServiceCatalogViews(): { services: readonly McpServiceDescriptor[]; views: McpPluginView[]; diagnostics: string[]; } { // Fresh records first: the resolver pins vanished-source services from them. this.getMcpConnectionStore().load(); const resolution = resolveServiceCatalogWithDiagnostics( this.settingsManager.getMcpCatalogSources(), this.getMcpConnectionStore().records(), ); const views = buildPluginViews({ services: resolution.descriptors, userServers: this.settingsManager.getGlobalMcpServers(), authStorage: this.modelRegistry.authStorage, connectionStore: this.getMcpConnectionStore(), }); return { services: resolution.descriptors, views, diagnostics: resolution.diagnostics }; }
/** * Catalog resolution WITHOUT reloading the store: mid-flow admission * keeps this process's pending (unflushed) record writes visible — a * load() here would wipe in-memory state a queued op still owns. The * authoritative cross-process gate stays the claim/reserve/finalize ops * under the store's file lock. */ private resolveCurrentServiceCatalog(): readonly McpServiceDescriptor[] { return resolveServiceCatalogWithDiagnostics( this.settingsManager.getMcpCatalogSources(), this.getMcpConnectionStore().records(), ).descriptors; }
private getMcpLoginEligibility( connectionId: string, catalogServiceId = connectionId, addAccount = false, locked?: { record: McpConnectionRecord }, explicitLogin = false, precomputedService?: McpServiceDescriptor, ): ReturnType<typeof mcpLoginEligibility> { const services = precomputedService ? [precomputedService] : this.resolveCurrentServiceCatalog(); const record = locked ? locked.record : this.getMcpConnectionStore().get(connectionId); const service = services.find((entry) => entry.serviceId === (record?.serviceId ?? catalogServiceId)); const userServers = this.settingsManager.getGlobalMcpServers() ?? {}; return mcpLoginEligibility({ connectionId, service, record, addAccount, explicitLogin, userConfig: userServers[connectionId], reservedConfig: service ? userServers[service.serviceId] : undefined, credential: this.modelRegistry.authStorage.getVerified(mcpCredentialKey(connectionId)), }); }
private async handlePluginsCommand(args: string | undefined): Promise<void> { await this.showServiceCatalogPicker((args ?? "").trim() || undefined); }
private selectServiceCatalogRow( views: readonly McpPluginView[], options: Omit<ServiceCatalogPickerOptions, "getRows"> = {}, ): Promise<McpPickerSelection> { this.closeConfigurationMenu?.(); this.closeServiceCatalogPicker?.(); return new Promise((resolve) => { this.showSelector((done) => { let settled = false; let wentBack = false; const finish = (selection?: McpPluginView) => { if (settled) return; settled = true; const ownsEditor = this.editorContainer.children.includes(picker); if (ownsEditor) done(); if (this.closeServiceCatalogPicker === close) this.closeServiceCatalogPicker = undefined; this.ui.requestRender(); // A stale selection settles as cancellation without replacing the // next picker or starting an operation from its old row. if (!ownsEditor) return resolve({ status: "cancelled" }); if (wentBack) return resolve({ status: "back" }); resolve(selection ? { status: "selected", service: selection } : { status: "cancelled" }); }; const close = () => finish(); // "Back" is a surface transition, never a cancellation: the // accounts menu's left arrow and the account sub-picker's Esc both // hand control to the parent surface with nothing acted on. An // externally forced close (a newer picker taking over, teardown) // still settles as cancellation. const back = () => { wentBack = true; finish(); }; const picker = new ServiceCatalogPickerComponent(views, finish, options.cancelBack ? back : close, { ...options, getRows: () => Math.max(1, Math.min(20, this.ui.terminal.rows - 3)), ...(options.back ? { onBack: back } : {}), }); this.closeServiceCatalogPicker = close; return { component: picker, focus: picker }; }); }); }
private async showServiceCatalogPicker(initialSearch?: string): Promise<void> { // The picker CHAIN, not a one-shot (Kevin, live testing): an action that // ran lands the user back in the right surface — that service's // accounts menu while it still owns an account, otherwise the catalog — // never the bare prompt. Every entry rebuilds views, targets, and // routing from the LIVE catalog + connection store, so a removed // account is gone and a fresh one shows; Esc anywhere closes the whole // chain. let surface: { kind: "catalog"; search?: string } | { kind: "accounts"; service: McpPluginView } = { kind: "catalog", search: initialSearch, }; while (true) { const { services, views, diagnostics } = this.buildServiceCatalogViews(); // Wiring problems (a declared local source that vanished, duplicate ids, // truncation) are visible, never silent. if (diagnostics.length > 0) { this.showWarning(`Service catalog notice: ${diagnostics[0]}`); } // Connect targets keyed by serviceId; user-declared servers resolve from settings. const userServers = this.settingsManager.getGlobalMcpServers() ?? {}; const targets = buildMcpPickerTargets(services, userServers); // Freeze the displayed intent: guidance must never become a mutation // when settings change while the picker is open. const settingsActions = new Map< string, "disable local server" | "settings guidance" | "verify" | "manage saved account" >(); for (const [name, config] of Object.entries(userServers)) { if (views.find((view) => view.serviceId === name)?.source !== "user") continue; if (config.type === "stdio") { settingsActions.set(name, config.enabled === false ? "settings guidance" : "disable local server"); } else if (!config.oauth) { const hasSavedAccount = this.getMcpConnectionStore().get(name) !== undefined || this.modelRegistry.authStorage.getVerified(mcpCredentialKey(name)) !== undefined; const pending = config.enabled !== false && views.find((view) => view.serviceId === name)?.connectionStatus === "pending"; settingsActions.set( name, hasSavedAccount ? "manage saved account" : pending ? "verify" : "settings guidance", ); } } if (surface.kind === "accounts") { // The accounts surface handed the chain back: re-enter through the // same freshly built targets every catalog entry uses. const exit = await this.showAccountPickerForService( surface.service, targets.get(surface.service.serviceId), { knownIds: new Set<string>([...services.map((entry) => entry.serviceId), ...Object.keys(userServers)]), }, ); if (exit === "closed") return; surface = { kind: "catalog" }; continue; } const catalogSelection = await this.selectServiceCatalogRow(views, { ...(surface.search !== undefined ? { initialSearch: surface.search } : {}), getRowPresentation: (view) => { const action = settingsActions.get(view.serviceId); return action ? { action } : undefined; }, }); // The catalog is the chain's root: there is no parent surface to go // back to, so any non-selection ends the chain. if (catalogSelection.status !== "selected") return; const service = catalogSelection.service; // Every configured id is off-limits for new account ids. const knownIds = new Set<string>([...services.map((entry) => entry.serviceId), ...Object.keys(userServers)]); try { const settingsAction = settingsActions.get(service.serviceId); if (settingsAction === "settings guidance") { this.showStatus( service.setupHint ?? `${service.label} is configured through settings; manage it with /mcp or your settings file.`, ); return; } if (settingsAction === "disable local server" || settingsAction === "verify") { const { ran } = await this.connectServiceFromPicker(service, targets.get(service.serviceId)); // A blocked action (status-only dead end) ends the chain: its // transient message is the outcome, and reopening forever would // trap the user — an action that cannot proceed never re-enters. if (!ran) return; const disableReentry = this.mcpAccountsReentrySurface(service.serviceId); surface = disableReentry ? { kind: "accounts", service: disableReentry.service } : { kind: "catalog" }; continue; } if (settingsAction === "manage saved account" || service.connectionIds.length > 0) { const accountService = settingsAction === "manage saved account" ? { ...service, connectionIds: [service.serviceId] } : service; surface = { kind: "accounts", service: accountService }; continue; } const { ran } = await this.connectServiceFromPicker(service, targets.get(service.serviceId), { knownIds }); if (!ran) return; const reentry = this.mcpAccountsReentrySurface(service.serviceId); surface = reentry ? { kind: "accounts", service: reentry.service } : { kind: "catalog" }; } catch { this.showError("MCP connection action did not complete. Try again."); return; } } }
/** * Account management for a service with at least one existing account * (Kevin, live testing): the menu is exactly one "Reconnect" row, one * "Disconnect" row, and an "Add another account" row that allocates a new * id and runs a fresh login — a second account never overwrites the first. * * One account acts directly: Reconnect runs the SAME re-verify path (never * a disconnect) and Disconnect the same remove path. Several accounts add * a second picker that picks the connection id first ("linear", * "linear-2"); Esc or left there returns to this menu with nothing acted * on, and the chosen account runs the action a single account would. * * Re-entry: after an action that ran, the SAME accounts menu is rebuilt * from the live stores and shown again — a disconnected account is gone, a * fresh one shows, statuses refresh — until the service loses its last * account, at which point the chain hands the user back to the catalog. * Esc, blocked status-only actions, and failures close the whole chain; * the left arrow goes back to the catalog, the chain's parent surface. */ private async showAccountPickerForService( service: McpPluginView, target: McpPickerTarget | undefined, options: { knownIds: Set<string> }, ): Promise<McpAccountsSurfaceExit> { let currentService = service; let currentTarget = target; while (true) { const catalogServiceId = currentService.serviceId; const settingsOnly = currentService.source === "user" && !currentService.usesOAuth; const definition = this.buildServiceCatalogViews().services.find( (entry) => entry.serviceId === catalogServiceId, ); const ownership = reservedMcpOwnership( definition, this.settingsManager.getGlobalMcpServers()?.[catalogServiceId], ); const accountCards: McpPluginView[] = []; const accountTargets = new Map<string, McpPickerTarget | undefined>(); // Per-account acting rows, keyed by connection id: a single account's // menu rows and every multi-account sub-picker row come from the SAME // per-account state computation — only the label differs. const reconnectRows = new Map<string, McpPluginView>(); const disconnectRows = new Map<string, McpPluginView>(); for (const connectionId of currentService.connectionIds) { // Centralized per-account state: credential binding, expiry, and the // record combine through the SAME computation as the plugin aggregate // and the inventory — the picker never trusts a raw record.status. const admission = this.getMcpLoginEligibility(connectionId, catalogServiceId); const accountTarget = currentTarget && admission.endpoint ? { ...currentTarget, url: admission.endpoint } : currentTarget; accountTargets.set(connectionId, accountTarget); const state = settingsOnly ? { status: currentService.connectionStatus, setupHint: currentService.setupHint, toolCount: currentService.toolCount, } : accountStateFor({ connectionId, endpoint: accountTarget?.url ?? "", authStorage: this.modelRegistry.authStorage, connectionStore: this.getMcpConnectionStore(), usesOAuth: currentService.usesOAuth, }); const blocked = ownership.status !== "canonical" || currentService.connectionStatus === "disabled"; const status = blocked ? currentService.connectionStatus : state.status; const loginPending = "loginPending" in state && state.loginPending === true; const repairHint = admission.repair && admission.endpoint ? `Saved endpoint: ${new URL(admission.endpoint).origin}${new URL(admission.endpoint).pathname}` : undefined; if (settingsOnly) { // Settings-managed servers are single-account by construction: // pending re-verifies, anything else only shows management // guidance, and remove keeps the server settings and its // environment token ("remove saved data"). accountCards.push({ ...currentService, serviceId: connectionId, label: status === "pending" ? "Reconnect" : `Manage ${connectionId}`, connectionIds: [connectionId], connectionStatus: status, connectable: !blocked && !loginPending && admission.allowed && (status === "error" || status === "not_connected"), loginPending, setupHint: blocked ? currentService.setupHint : !admission.allowed ? admission.setupHint : [repairHint, state.setupHint].filter(Boolean).join(" · ") || undefined, ...(state.toolCount !== undefined ? { toolCount: state.toolCount } : {}), }); accountCards.push({ ...currentService, serviceId: connectionId, label: `Remove saved data for ${connectionId}`, connectionIds: [connectionId], connectionStatus: status, connectable: false, removeAction: true, }); continue; } // The sub-picker rows carry the account id; the single-account menu // relabels them below ("Reconnect"/"Disconnect", Kevin, live // testing). Enter keeps running the SAME re-verify path — // verifyMcpConnection plus the existing retry outcome — and never // disconnects; the disconnect row is the explicit remove action, // state-independent. reconnectRows.set(connectionId, { ...currentService, serviceId: connectionId, label: connectionId, connectionIds: [connectionId], connectionStatus: status, connectable: !blocked && !loginPending && admission.allowed && (status === "error" || status === "not_connected"), loginPending, setupHint: blocked ? currentService.setupHint : !admission.allowed ? admission.setupHint : [repairHint, state.setupHint].filter(Boolean).join(" · ") || undefined, ...(state.toolCount !== undefined ? { toolCount: state.toolCount } : {}), }); disconnectRows.set(connectionId, { ...currentService, serviceId: connectionId, label: connectionId, connectionIds: [connectionId], connectionStatus: status, connectable: false, removeAction: true, }); } if (!settingsOnly && currentService.connectionIds.length === 1) { // Single account (Kevin, live testing): Enter acts on it directly — // no id suffix on the labels, no sub-picker. const only = currentService.connectionIds[0]!; const reconnect = reconnectRows.get(only); if (reconnect) accountCards.push({ ...reconnect, label: "Reconnect" }); const disconnect = disconnectRows.get(only); if (disconnect) accountCards.push({ ...disconnect, label: "Disconnect" }); } else if (!settingsOnly && currentService.connectionIds.length > 1) { // Several accounts (Kevin, live testing): ONE Reconnect row and ONE // Disconnect row for the whole service — Enter opens the account // sub-picker that picks the connection id first. accountCards.push({ ...currentService, label: "Reconnect", connectionIds: [...currentService.connectionIds], connectable: false, }); accountCards.push({ ...currentService, label: "Disconnect", connectionIds: [...currentService.connectionIds], connectable: false, removeAction: true, }); } if ( currentService.usesOAuth && currentTarget?.usesOAuth && !currentService.loginPending && this.getMcpLoginEligibility(catalogServiceId, catalogServiceId, true, undefined, false, definition).allowed ) { accountCards.push({ ...currentService, label: "Add another account", connectionIds: [], connectionStatus: "not_connected", connectable: true, setupHint: undefined, }); } // Token services keep a paste entry point: a stored token the handshake // rejected needs a NEW paste, not a re-verify of the same value. if (definition && isPasteableTokenService(definition) && !currentService.loginPending) { accountCards.push({ ...currentService, label: "Paste a new token", connectionIds: [], connectionStatus: "not_connected", connectable: false, pasteToken: true, setupHint: undefined, }); } const selection = await this.selectServiceCatalogRow(accountCards, { // Kevin (live testing): "Accounts — Cloudflare" reads as a caption; // the menu is just the service, with the description above the // options instead of under the list. title: `${currentService.label} MCP`, mode: "accounts", description: settingsOnly ? "Saved account data for a settings-managed server. Removing it keeps the server settings and environment token." : currentService.description, // The accounts redesign renders no trailing status or per-row detail; // the hint names the action and the description block carries the // panel-level explanation. getRowPresentation: (row) => settingsOnly ? row.removeAction ? { action: "remove saved data" } : { action: row.connectionStatus === "pending" ? "verify" : "settings guidance" } : undefined, // Kevin (live testing): left arrow goes BACK to the /mcp catalog — // the picker chain's parent surface. Esc still closes the chain. back: true, }); if (selection.status === "cancelled") return "closed"; if (selection.status === "back") return "catalog"; let card = selection.service; if (card.connectionIds.length > 1) { // The multi-account Reconnect/Disconnect chooser (Kevin, live // testing): a second picker in the same choice-list shape picks // WHICH account first. Esc or left there returns to this menu // without acting — no status noise — and the chosen account runs // the same action a single account would, so the picker never // nests deeper than this. const actionRows = card.removeAction === true ? disconnectRows : reconnectRows; const chosen = await this.selectServiceCatalogRow([...actionRows.values()], { title: "Accounts", mode: "accounts", // The accounts menu one surface up already carries the service // description; the sub-picker is just the account list. description: "", back: true, cancelBack: true, closeHint: "back", }); // Esc and left are the user's "back": return to the accounts // menu, freshly rebuilt, with nothing acted on. An externally // forced close (a newer picker taking over, teardown) settles as // cancellation and ends the chain — the same rule every other // surface follows. if (chosen.status === "back") continue; if (chosen.status === "cancelled") return "closed"; card = chosen.service; } try { if (settingsOnly && !card.removeAction && card.connectionStatus !== "pending") { this.showStatus( card.setupHint ?? `${currentService.label} is configured through settings; manage it with /mcp or your settings file.`, ); return "closed"; } const { ran } = card.connectionIds.length === 0 ? await this.connectServiceFromPicker(card, currentTarget, { catalogServiceId, addAccount: true, knownIds: options.knownIds, }) : await this.connectServiceFromPicker(card, accountTargets.get(card.serviceId), { catalogServiceId }); // A blocked action (status-only dead end) ends the chain: its // transient message is the outcome, and reopening forever would // trap the user — an action that cannot proceed never re-enters. if (!ran) return "closed"; } catch { this.showError("MCP connection action did not complete. Try again."); return "closed"; } // The action ran: land back in the RIGHT surface, freshly rebuilt // from the live catalog + connection store. When the last account // is gone, hand the chain back to the catalog. const reentry = this.mcpAccountsReentrySurface(catalogServiceId); if (!reentry) return "catalog"; currentService = reentry.service; currentTarget = reentry.target; } }
/** * The re-entry surface after a picker action on `catalogServiceId` (Kevin, * live testing): stay in that service's accounts menu while it still owns * an account, otherwise fall back to the catalog. Everything is rebuilt * from the LIVE catalog + connection store — the re-entered menu must not * show a row the action just removed, and a freshly added account must * show. Settings-managed servers keep saved data manageable even when the * core view carries no connectionIds — the same discovery that routes a * catalog row to "manage saved account". */ private mcpAccountsReentrySurface( catalogServiceId: string, ): { service: McpPluginView; target: McpPickerTarget | undefined } | undefined { const { services, views } = this.buildServiceCatalogViews(); const view = views.find((entry) => entry.serviceId === catalogServiceId); if (!view) return undefined; const target = buildMcpPickerTargets(services, this.settingsManager.getGlobalMcpServers() ?? {}).get( catalogServiceId, ); if (view.source === "user" && !view.usesOAuth) { const hasSavedAccount = this.getMcpConnectionStore().get(catalogServiceId) !== undefined || this.modelRegistry.authStorage.getVerified(mcpCredentialKey(catalogServiceId)) !== undefined; return hasSavedAccount ? { service: { ...view, connectionIds: [catalogServiceId] }, target } : undefined; } return view.connectionIds.length > 0 ? { service: view, target } : undefined; } /** * Route a user-facing MCP login by NAME (/mcp login, the generic /login * service options, the config menu) through the ONE guarded connect * operation: the account is claimed under the store's file lock first, * the OAuth credential stages, and the guarded finalize commits it — a * concurrent logout can cancel us and a late callback can never * reactivate or clobber the account. Resolution and authentication outcomes * are separate; a cancelled login never reports success. */ private async connectMcpAccountByName(name: string): Promise<{ resolved: boolean; result: AuthenticationResult }> { const { services } = this.buildServiceCatalogViews(); const record = this.getMcpConnectionStore().get(name); const service = services.find((entry) => entry.serviceId === (record?.serviceId ?? name)) ?? services.find((entry) => entry.aliases.includes(name)); const connectionId = record ? name : (service?.serviceId ?? name); const config = this.settingsManager.getGlobalMcpServers()?.[connectionId]; const credential = this.modelRegistry.authStorage.getVerified(mcpCredentialKey(connectionId)); if (!service && !record && !config && !credential) return { resolved: false, result: { status: "failed" } }; const admission = service || config || record ? this.getMcpLoginEligibility(connectionId, service?.serviceId ?? connectionId, false, undefined, true) : { allowed: true, endpoint: undefined as string | undefined }; const endpoint = admission.endpoint ?? (config?.type === "http" ? config.url : undefined) ?? record?.endpoint ?? ""; if (!admission.allowed || !endpoint) { this.showStatus(admission.setupHint ?? "This service cannot be connected automatically."); return { resolved: true, result: { status: "failed" } }; } const { result } = await this.guardedMcpLogin( { serviceId: connectionId, label: record?.label ?? service?.label ?? connectionId, connectionStatus: record?.status ?? "not_connected", connectionIds: record ? [connectionId] : [], connectable: true, usesOAuth: true, source: service ? "catalog" : "user", }, { url: endpoint, usesOAuth: true, managedBySettings: config !== undefined && !service?.legacyBuiltin, }, { catalogServiceId: record?.serviceId ?? service?.serviceId ?? connectionId, explicitLogin: true, }, ); return { resolved: true, result }; }
private async connectServiceFromPicker( service: McpPluginView, target: McpPickerTarget | undefined, options: { catalogServiceId?: string; addAccount?: boolean; knownIds?: ReadonlySet<string>; } = {}, ): Promise<McpPickerActionOutcome> { if (target?.transport === "stdio" && target.name) { const serverName = target.name; const config = this.settingsManager.getGlobalMcpServers()?.[serverName]; if (!config) { this.showStatus(`${service.label} is no longer present in settings.`); return { ran: false }; } if (config.type !== "stdio" || config.enabled === false) { this.showStatus( `${service.label} is disabled. Re-enable or remove it with /mcp, or edit your settings file.`, ); return { ran: false }; } this.settingsManager.setGlobalMcpServer(serverName, { ...config, enabled: false }, true); await this.settingsManager.flush(); this.uiServices.refreshMcpProviders?.(); await this.reloadAfterMcpChange( `Disabled local server ${service.label}. Manage it with /mcp or your settings file.`, ); return { ran: true }; } // Explicit per-account remove: credential AND record removal in ONE // store-locked step (the same lock ordering finalize uses), then reload. if (service.removeAction === true) { // Read the label before the op: a "removed" outcome deletes the record. const removeLabel = this.getMcpConnectionStore().get(service.serviceId)?.label ?? service.label; const removed = await this.getMcpConnectionStore().removeAccount({ connectionId: service.serviceId, authCleanup: (connectionId) => { // Disk-authoritative logout: remove() swallows persistence // errors, so a failed auth-file write could report the logout // done while the credential survives. removeVerified throws // instead (the honest "failed" path) and returns whether a // credential was actually removed from disk. return this.modelRegistry.authStorage.removeVerified(mcpCredentialKey(connectionId)); }, }); // Honest outcomes: removed/credential-only removed the account (the // credential-only case still had a credential to log out), missing is // a no-op, logged-out keeps the durable logout but says the record // save failed, and failed is state-neutral (the cleanup may or may // not have run — it never claims a specific state). if (removed === "failed") { this.showWarning(`The change could not be saved; try removing account ${service.serviceId} again.`); return { ran: true }; } if (removed === "logged-out") { this.showWarning( `Logged out account ${service.serviceId}, but the change could not be saved. It may still appear in the list; try again to finish cleanup.`, ); return { ran: true }; } if (removed === "missing") { this.showStatus(`Account ${service.serviceId} is no longer present.`); return { ran: true }; } // The explicit Remove row is the picker's primary disconnect gesture, // so it records the same durable entry the /logout routes do instead // of a status line that scrolls away (Kevin, live testing). const removal = mcpDisconnectionState(removed); if (removal) { await this.completeMcpConnectionOutcome({ kind: "disconnect", label: removeLabel, removal, connectionId: service.serviceId, }); return { ran: true }; } await this.reloadAfterMcpChange(`Removed account ${service.serviceId}.`); return { ran: true }; } if (service.loginPending || this.getMcpConnectionStore().get(service.serviceId)?.attemptId !== undefined) { this.showStatus("Login in progress. Finish it or remove the account to cancel."); return { ran: false }; } // A requires-setup token service connects through the inline paste // panel: nothing is stored until the panel completes, and the stored // token is verified with a real handshake before anything claims // Connected. if (service.pasteToken === true) { return this.pasteTokenForService(service, target, { ...(options.catalogServiceId ? { catalogServiceId: options.catalogServiceId } : {}), }); } const currentServices = this.resolveCurrentServiceCatalog(); const catalog = currentServices.find( (entry) => entry.serviceId === (options.catalogServiceId ?? service.serviceId), ); const ownership = reservedMcpOwnership( catalog, this.settingsManager.getGlobalMcpServers()?.[catalog?.serviceId ?? service.serviceId], ); if (ownership.status !== "canonical" || service.connectionStatus === "disabled") { this.showStatus(ownership.setupHint ?? service.setupHint ?? "Disabled in settings."); return { ran: false }; } if (service.connectionStatus === "connected" && target?.managedBySettings && !service.usesOAuth) { this.showStatus( `${service.label} is configured through settings; manage it with /mcp remove ${service.serviceId}.`, ); return { ran: false }; } // Connected and pending accounts retry verification explicitly — no // login needed, the grant already exists; "retry from /plugins" must // actually work. Enter on the account NAME row therefore never // disconnects a working account (Kevin, live testing): removal is the // explicit Remove row's job, and this is the only reachable branch for a // connected account coming out of either picker. if ((service.connectionStatus === "pending" || service.connectionStatus === "connected") && target?.url) { let retried: McpConnectionRecord | undefined; try { retried = await verifyMcpConnection({ authStorage: this.modelRegistry.authStorage, connectionStore: this.getMcpConnectionStore(), connectionId: service.serviceId, serviceId: options.catalogServiceId ?? service.serviceId, label: service.label, endpoint: target.url, usesOAuth: target.usesOAuth, ...(target.bearerTokenEnvVar ? { bearerTokenEnvVar: target.bearerTokenEnvVar } : {}), }); } catch { retried = undefined; } await this.completeMcpConnectionOutcome( retried?.status === "connected" ? { label: service.label, source: "retry", verification: "connected", ...(retried.toolCount !== undefined ? { toolCount: retried.toolCount } : {}), } : retried ? { label: service.label, source: "retry", verification: "unverified", issue: formatMcpVerificationIssue(retried.lastError), ...(retried.lastError ? { issueCategory: retried.lastError } : {}), } : { label: service.label, source: "retry", verification: "unsaved" }, ); return { ran: true }; }
// Actual connects route through the ONE guarded OAuth operation — // separate from the picker's disconnect/verify actions above, so LOGIN // intent (from any route) never dispatches a disconnect by record // status. const login = await this.guardedMcpLogin(service, target, options); return { ran: login.ran }; }
/** * The ONE guarded MCP OAuth login operation (initial connect, reconnect, * add account, /mcp login, the generic /login service options, the config * menu): claim/reserve under the store's file lock, staged OAuth, guarded * finalize, verification. Never dispatches on the record's status — the * picker's remove/verify actions live in connectServiceFromPicker. * Reports success only after the finalize committed. */ private async guardedMcpLogin( service: McpPluginView, target: McpPickerTarget | undefined, options: { catalogServiceId?: string; addAccount?: boolean; knownIds?: ReadonlySet<string>; explicitLogin?: boolean; } = {}, ): Promise<{ ran: boolean; result: AuthenticationResult }> { if (!service.connectable || !target) { this.showStatus(service.setupHint ?? `${service.label} cannot be connected automatically in this build.`); return { ran: false, result: { status: "failed" } }; } const currentServices = this.resolveCurrentServiceCatalog(); const definition = currentServices.find( (entry) => entry.serviceId === (options.catalogServiceId ?? service.serviceId), ); const userConfig = this.settingsManager.getGlobalMcpServers()?.[service.serviceId]; const admission = definition || userConfig ? this.getMcpLoginEligibility( service.serviceId, options.catalogServiceId ?? service.serviceId, options.addAccount === true, undefined, options.explicitLogin === true, ) : { allowed: true, endpoint: target.url }; const targetUrl = admission.endpoint; if (!admission.allowed || !targetUrl || target.url !== targetUrl || !target.usesOAuth) { this.showStatus(admission.setupHint ?? "The connection target changed. Open the catalog again to review it."); return { ran: false, result: { status: "failed" } }; } // EVERY user-facing MCP login is guarded (initial connect, reconnect, // add account): the account is claimed under the store's file lock first // — a durable pending reservation for a fresh id, a nonce claim on an // existing record — so a concurrent logout can cancel us and a late // OAuth callback can never reactivate or clobber the account. let connectionId = service.serviceId; let attemptId: string | undefined; let stagedServerId: string | undefined; // The full on-disk identity a guarded REPLACE expects to swap out — // captured for EVERY login, even without a record: legacy // credential-only accounts can hold a grant with no record, and a // reconnect must replace it without clearing it first. let expectedOldCredential: AuthCredential | undefined; if (options.addAccount === true) { const store = this.getMcpConnectionStore(); const taken = (id: string): boolean => store.get(id) !== undefined || this.modelRegistry.authStorage.get(mcpCredentialKey(id)) !== undefined || (options.knownIds?.has(id) ?? false) || this.settingsManager.getGlobalMcpServers()?.[id] !== undefined; const usedIds = new Set<string>(); for (let attempt = 0; attempt < 20; attempt++) { const candidate = nextMcpConnectionId(service.serviceId, (id) => taken(id) || usedIds.has(id)); const now = Date.now(); const nonce = randomUUID(); // Durable, commit-gated reservation: the pending marker (with the // opaque attempt nonce) only counts once the record write commits. const won = await store.reserveConnectionId({ connectionId: candidate, serviceId: options.catalogServiceId ?? service.serviceId, endpoint: targetUrl, label: `${service.label} (${candidate})`, status: "pending", createdAt: now, updatedAt: now, attemptId: nonce, }); if (won) { connectionId = candidate; attemptId = nonce; break; } usedIds.add(candidate); } if (attemptId === undefined) { this.showStatus(`Could not allocate a free account id for ${service.label}.`); return { ran: true, result: { status: "failed" } }; } } else { const store = this.getMcpConnectionStore(); const nonce = randomUUID(); // Disk-authoritative identity capture (never the per-instance cache): // an intentional replacement CASes against exactly this value, and a // newer external writer refuses instead of being clobbered. expectedOldCredential = this.modelRegistry.authStorage.getVerified(mcpCredentialKey(connectionId)); const existing = store.get(connectionId); if (existing === undefined) { const now = Date.now(); const won = await store.reserveConnectionId({ connectionId, serviceId: options.catalogServiceId ?? service.serviceId, endpoint: targetUrl, label: service.label, status: "pending", createdAt: now, updatedAt: now, attemptId: nonce, }); if (!won) { this.showStatus(`${service.label} is already being connected from another client.`); return { ran: true, result: { status: "failed" } }; } } else { const claimed = await store.claimConnectionId({ connectionId, attemptId: nonce, isStillCurrent: (record) => { const current = this.getMcpLoginEligibility( connectionId, options.catalogServiceId ?? service.serviceId, false, { record }, options.explicitLogin === true, ); return current.allowed && current.endpoint === targetUrl; }, }); if (!claimed) { this.showStatus(`${service.label} is already being connected from another client.`); return { ran: true, result: { status: "failed" } }; } } attemptId = nonce; } // The OAuth credential ALWAYS stages under a per-attempt key; the real // account key is only written by the guarded finalize below. stagedServerId = `${connectionId}--${attemptId}`; const releaseAttempt = async (): Promise<boolean> => { let discarded = false; try { this.modelRegistry.authStorage.removeVerified(mcpCredentialKey(stagedServerId)); discarded = true; } catch { // Keep recovery data if staged credential deletion fails. } unregisterOAuthProvider(mcpCredentialKey(stagedServerId)); if (discarded) this.showStatus( `The login result was discarded. Account settings for ${connectionId} were kept; reconnect or remove the account from /plugins.`, ); // Never delete a shell on cancellation: an external auth writer can // change the account key at any moment, outside the store lock. const released = await this.getMcpConnectionStore().releaseClaim({ connectionId, attemptId }); if (!released || !discarded) { this.showWarning( `Could not confirm login cleanup for ${connectionId}. Account settings and any remaining credentials were kept; retry from /plugins or remove the account.`, ); } return released && discarded; }; // The SERVICE's display name, never the picked row's label: the accounts // picker's "Add another account" row would otherwise name the outcome // "Connected Add another account (linear-2)" (Kevin, live testing). const serviceLabel = definition?.label ?? service.label; const parentServiceId = options.catalogServiceId ?? service.serviceId; const loginLabel = connectionId === parentServiceId ? serviceLabel : `${serviceLabel} (${connectionId})`; // ONE login-time client identity, shared by the staged login and the // post-finalize real-id registration: the engine pins the client // identity on the stored credential and refuses drift at refresh, so // both registrations must resolve the exact same config. Settings // identity applies to user-owned servers; reserved builtins never take // a user-configured client identity. const parentConfig = this.settingsManager.getGlobalMcpServers()?.[options.catalogServiceId ?? service.serviceId]; const loginIdentity = definition?.legacyBuiltin ? {} : resolveMcpOAuthIdentity(parentConfig); let result: AuthenticationResult; try { registerOAuthProvider( createConfiguredMcpProvider({ server: stagedServerId, label: loginLabel, url: targetUrl, identity: loginIdentity, reviewedScopes: definition?.reviewedScopes, clientRegistration: definition?.clientRegistration, }), ); result = await this.createAuthFlows().runMcpLogin(stagedServerId, loginLabel); } catch { result = { status: "failed" }; } if (result.status !== "success") { const cleaned = await releaseAttempt(); if (cleaned) this.showStatus( `Login did not complete. Account settings for ${connectionId} were kept; reconnect or remove the account from /plugins.`, ); return cleaned ? { ran: true, result } : { ran: true, result: { status: "failed" } }; } { const stagedKey = mcpCredentialKey(stagedServerId); const realKey = mcpCredentialKey(connectionId); // Guarded commit: the staged credential moves to the REAL account key // under the store's file lock, only while our claim is still ours. // A FRESH account (no existing grant) moves set-if-absent — a // bystander credential is never clobbered. A REPLACEMENT (reconnect, // or a legacy grant with no record) CASes against the captured full // identity; a newer external credential refuses. When the record // write fails, the compensate runs under the SAME lock and RESTORES // the previous credential (never delete-only, never clobbering a // newer writer); a failed rollback surfaces recovery-required with // the credential RETAINED. let movedCredential: AuthCredential | undefined; let replacedPrevious: AuthCredential | undefined; const finalization = await this.getMcpConnectionStore().finalizeAttempt({ connectionId, attemptId, commit: (record) => { // Atomic, disk-authoritative move under the AUTH backend's // own file lock: the store's lock cannot cover an ordinary // login in another process, so the conditional move reads // CURRENT on-disk data. const move = expectedOldCredential === undefined ? this.modelRegistry.authStorage.moveStagedCredential(stagedKey, realKey) : this.modelRegistry.authStorage.replaceStagedCredential( stagedKey, realKey, expectedOldCredential, ); if (move.status === "occupied") { // A newer external credential - or a deleted old grant - // owns the account key: fail CLOSED and preserve the // account shell in the cleanup. throw new Error("account key occupied by a newer login"); } if (move.status === "nothing") throw new Error("staged login credential is missing"); if (move.status === "replaced") { movedCredential = move.credential; replacedPrevious = expectedOldCredential; } else if (move.status === "moved") { movedCredential = move.credential; } // Pending-verification: the commit CONSUMES the nonce and // clears the previous verification state — an old Connected // status never carries onto a newly replaced grant. const { attemptId: _consumedNonce, verifiedAt: _oldVerifiedAt, toolCount: _oldToolCount, lastError: _oldLastError, ...pendingRecord } = record; return { ...pendingRecord, status: "pending", updatedAt: Date.now() }; }, compensate: () => { if (!movedCredential) { // Never moved: nothing of ours to undo. return { status: "failed" }; } if (replacedPrevious !== undefined) { // RESTORE the previous credential (never delete-only) via // a full-identity CAS: a newer writer is never clobbered. this.modelRegistry.authStorage.replaceCredentialIfMatches(realKey, movedCredential, replacedPrevious); return { status: "failed" }; } // Fresh-account rollback: restore ours to the staged slot only // when it is empty, then delete it from the real slot only // when it is still exactly ours — a credential written by // anyone else is never deleted. this.modelRegistry.authStorage.restoreCredentialIfAbsent(stagedKey, movedCredential); this.modelRegistry.authStorage.removeIfCredentialMatches(realKey, movedCredential); }, }); if (finalization === "recovery-required") { // Explicit recovery state: the commit or its rollback failed and // partial state may remain. RETAIN the credential wherever it // lives (staged key or real key) — never discard recovery data — // and tell the user exactly that. this.showWarning( `The login for account ${connectionId} could not be committed. The credential is retained and can be recovered; retry from /plugins or remove the placeholder account.`, ); return { ran: true, result: { status: "failed" } }; } if (finalization !== "committed") { this.showStatus( `The account ${connectionId} was logged out or replaced during login, or the change could not be saved; login was not committed.`, ); await releaseAttempt(); return { ran: true, result: { status: "failed" } }; } // Committed: the real id now owns the credential — register its // provider and drop the staged registration. registerOAuthProvider( createConfiguredMcpProvider({ server: connectionId, label: `${service.label} (${connectionId})`, url: targetUrl, identity: loginIdentity, reviewedScopes: definition?.reviewedScopes, clientRegistration: definition?.clientRegistration, }), ); unregisterOAuthProvider(mcpCredentialKey(stagedServerId)); } // A stored token is not "Connected": verify with a real MCP handshake. The // record's serviceId stays the catalog id; the connectionId is the account. let verification: McpConnectionRecord | undefined; try { verification = await verifyMcpConnection({ authStorage: this.modelRegistry.authStorage, connectionStore: this.getMcpConnectionStore(), connectionId, serviceId: options.catalogServiceId ?? service.serviceId, label: loginLabel, endpoint: targetUrl, usesOAuth: target.usesOAuth, ...(target.bearerTokenEnvVar ? { bearerTokenEnvVar: target.bearerTokenEnvVar } : {}), }); } catch { verification = undefined; } const accountScoped = options.addAccount === true; await this.completeMcpConnectionOutcome( verification?.status === "connected" ? { label: loginLabel, source: "login", verification: "connected", ...(accountScoped ? { connectionId, addedAccount: true } : {}), ...(verification.toolCount !== undefined ? { toolCount: verification.toolCount } : {}), } : verification ? { label: loginLabel, source: "login", verification: "unverified", issue: formatMcpVerificationIssue(verification.lastError), ...(verification.lastError ? { issueCategory: verification.lastError } : {}), ...(accountScoped ? { connectionId, addedAccount: true } : {}), } : { label: loginLabel, source: "login", verification: "unsaved", ...(accountScoped ? { connectionId, addedAccount: true } : {}), }, ); return { ran: true, result: { status: "success", providerId: mcpCredentialKey(connectionId), providerName: loginLabel, authType: "oauth", kind: "service", }, }; }
/** * The inline paste flow for requires-setup token services: prompt every * required credential field in order (masked), store ONE static-token * credential under the SAME credential key OAuth uses, then verify with a * real MCP handshake. Esc cancels with nothing stored; a verification * failure keeps the decision honest (unverified, never Connected). No * fail-closed rule is relaxed: setup field ids are never read as env vars, * the credential is bound to this exact id and endpoint, and * reserved/shadowed names still fail closed. */ private async pasteTokenForService( service: McpPluginView, target: McpPickerTarget | undefined, options: { catalogServiceId?: string } = {}, ): Promise<McpPickerActionOutcome> { const serviceId = options.catalogServiceId ?? service.serviceId; const definition = this.resolveCurrentServiceCatalog().find((entry) => entry.serviceId === serviceId); if (!definition || !isPasteableTokenService(definition)) { this.showStatus(service.setupHint ?? `${service.label} cannot be connected by pasting a token.`); return { ran: false }; } const ownership = reservedMcpOwnership( definition, this.settingsManager.getGlobalMcpServers()?.[definition.serviceId], ); if (ownership.status !== "canonical" || service.connectionStatus === "disabled") { this.showStatus(ownership.setupHint ?? "Disabled in settings."); return { ran: false }; } // The paste flow serves CATALOG definitions; a settings-managed server // owns its id and keeps its settings-managed authentication. if (target?.managedBySettings) { this.showStatus(`${service.label} is configured through settings; manage it with /mcp or your settings file.`); return { ran: false }; } if ( definition.transport.type !== "http" || !definition.transport.url || target?.url !== definition.transport.url ) { this.showStatus("The connection target changed. Open the catalog again to review it."); return { ran: false }; } const connectionId = definition.serviceId; if (this.getMcpConnectionStore().get(connectionId)?.attemptId !== undefined) { this.showStatus("Login in progress. Finish it or remove the account to cancel."); return { ran: false }; } // The ONE credential the runtime sends as the bearer. isPasteableTokenService // above already established that this resolves, so it is read, not re-checked. const credential = mcpPasteCredential(definition); if (!credential) return { ran: false }; const value = await this.promptForMcpTokenValues(definition, credential); // Esc (or an empty submit): nothing stored, no record, no status line — // the paste panel ran, so the surface that opened it re-enters. if (!value) return { ran: true }; // Stored ONLY in the agent credential store (auth.json), under the SAME // key OAuth uses — never settings.json, never a status line or log. this.modelRegistry.authStorage.set(mcpCredentialKey(connectionId), { type: "mcp_static_token", endpoint: definition.transport.url, bearer: value, bearerFieldId: credential.field.id, createdAt: Date.now(), }); // A stored token is not "Connected": verify with a real MCP handshake // that uses the stored token as the bearer. let verification: McpConnectionRecord | undefined; try { verification = await verifyMcpConnection({ authStorage: this.modelRegistry.authStorage, connectionStore: this.getMcpConnectionStore(), connectionId, serviceId: definition.serviceId, label: service.label, endpoint: definition.transport.url, usesOAuth: false, staticToken: true, }); } catch { verification = undefined; } // The SAME durable outcome entry the OAuth path records: success only // from a verified handshake, unverified/unsaved otherwise — the token // value itself never appears in the entry. await this.completeMcpConnectionOutcome( verification?.status === "connected" ? { label: service.label, source: "paste", verification: "connected", ...(verification.toolCount !== undefined ? { toolCount: verification.toolCount } : {}), } : verification ? { label: service.label, source: "paste", verification: "unverified", issue: formatMcpVerificationIssue(verification.lastError), ...(verification.lastError ? { issueCategory: verification.lastError } : {}), } : { label: service.label, source: "paste", verification: "unsaved" }, ); // Success or failure, the paste RAN: the re-entry decision is state, // not the verification verdict. return { ran: true }; }
/** * The masked inline prompt for a service's ONE credential — the same inline * surface as the OAuth login panel (showInlineAuthPanel), in the * #2331/#2340 panel language. Resolves with the pasted value, or undefined * when the user cancelled. The raw value never leaves this seam: no status * line, no log, no transcript entry, and no rendered line contains it. */ private promptForMcpTokenValues( definition: McpServiceDescriptor, credential: McpPasteCredential, ): Promise<string | undefined> { return new Promise((resolve) => { let close: (() => void) | undefined; const panel = new McpTokenPastePanelComponent({ serviceLabel: definition.label, ...(definition.setup.reason ? { reason: definition.setup.reason } : {}), field: { id: credential.field.id, label: mcpCredentialFieldPromptLabel(definition, credential.field), }, onSubmit: (value) => { close?.(); resolve(value); }, onCancel: () => { close?.(); resolve(undefined); }, }); close = this.showInlineAuthPanel(panel); }); }
private async reloadAfterMcpChange(message: string, successMessage = message): Promise<void> { if (this.isAgentStreaming() || this.isAgentCompacting()) { this.queueMcpActivationForNextBoundary(message, successMessage); return; } const reloaded = await this.handleReloadCommand(); if (reloaded) { this.showStatus(successMessage); } else { this.showWarning(`${message} The change remains saved, but it is not active in this session.`); } }
/** * Connect AND disconnect outcomes are durable chat entries, not transient * status lines: reload the session, then record the outcome where it stays * visible. */ private async completeMcpConnectionOutcome(details: McpOutcomeDetails): Promise<void> { const notice = formatMcpConnectionOutcomeNotice(details); if (this.isAgentStreaming() || this.isAgentCompacting()) { this.queueMcpActivationForNextBoundary(notice, notice, details); return; } const reloaded = await this.handleReloadCommand(); await this.appendMcpConnectionOutcome({ ...details, activation: reloaded ? "active" : "inactive" }); }
private async appendMcpConnectionOutcome(details: McpOutcomeDetails): Promise<void> { const message = createMcpConnectionOutcomeMessage(details); try { await this.agentConnection.appendCustomMessage({ customType: message.customType, content: message.content, display: message.display, details: message.details, }); } catch { // The connection change itself is already saved; never fail the flow // over a transcript entry — fall back to the transient line. this.showWarning(message.content); } }
/** * Defer an MCP change's activation to the next safe boundary (agent run end and * compaction end both check); the user never has to run /reload manually. */ private queueMcpActivationForNextBoundary( message: string, successMessage = message, connectionOutcome?: McpOutcomeDetails, ): void { this.pendingPostRunActivation = { message, successMessage, ...(connectionOutcome ? { connectionOutcome } : {}) }; this.showStatus(`${message} It will activate automatically when the current turn finishes.`); }
private async maybeRunQueuedMcpActivation(): Promise<void> { if (!this.pendingPostRunActivation) return; if (this.isAgentStreaming() || this.isAgentCompacting()) return; const pending = this.pendingPostRunActivation; this.pendingPostRunActivation = undefined; const reloaded = await this.handleReloadCommand(); if (pending.connectionOutcome) { await this.appendMcpConnectionOutcome({ ...pending.connectionOutcome, activation: reloaded ? "active" : "inactive", }); return; } if (reloaded) { this.showStatus(pending.successMessage); } else { this.showWarning(`${pending.message} The change remains saved, but it is not active in this session.`); } }
private async showLogoutSelector(): Promise<void> { // Only reload when an MCP integration was actually removed (its skill must // be disabled); a cancelled or non-MCP logout needs no reload. this.pendingMcpDisconnectionOutcome = undefined; const loggedOut = await this.createAuthFlows().runLogout(); const outcome = this.pendingMcpDisconnectionOutcome; this.pendingMcpDisconnectionOutcome = undefined; if (!loggedOut?.startsWith("mcp:")) return; if (outcome) { // The outcome path reloads itself, then leaves the durable entry; a // refused/partial logout falls back to the reload-only behaviour. await this.completeMcpConnectionOutcome(outcome); return; } await this.handleReloadCommand(); }
private async handleUpdateCommand(args: string): Promise<void> { const entrypoint = process.argv[1]; if (!entrypoint) { this.showError("Cannot determine current CLI entrypoint for update"); return; }
const updateArgs = parseCommandArgs(args); const includesSelf = updateArgsIncludeSelf(updateArgs); const updateCwd = this.getCurrentCwd(); const daemonSocketPath = resolveInteractiveUpdateDaemonSocketPath( updateArgs, resolveDaemonUpdateRestartSocketPath(this.options.daemonSocketPath), ); const updateChildArgs = includesSelf ? buildUpdateChildArgs(updateArgs, daemonSocketPath) : updateArgs; this.stopWorkingLoader(); await this.ui.terminal.drainInput(1000).catch(() => undefined); this.ui.stop();
const updateEnv = includesSelf ? { ...process.env, [SELF_UPDATE_INTERACTIVE_CHILD_ENV]: "1" } : process.env; const updateLaunch = createCliSubprocessLaunchSpec(["update", ...updateChildArgs]); const updateResult = spawnSync(updateLaunch.command, updateLaunch.args, { stdio: "inherit", cwd: updateCwd, env: updateEnv, }); const updateExitCode = updateResult.status ?? (updateResult.signal ? 1 : 0); const selfUpdateNotAttempted = includesSelf && !updateResult.error && updateExitCode === SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE;
if (includesSelf && !selfUpdateNotAttempted) { const relaunchArgs = buildUpdateRelaunchArgs(process.argv.slice(2), this.connectionState?.sessionFile); if (updateResult.error) { console.error(`Update failed: ${updateResult.error.message}`); console.error(`Relaunching ${APP_NAME}...`); } else if (updateExitCode !== 0) { console.error( updateResult.signal ? `Update terminated by signal ${updateResult.signal}` : `Update exited with code ${updateExitCode}`, ); console.error(`Relaunching ${APP_NAME}...`); } this.stop(); await this.agentConnection.dispose().catch(() => undefined); try { await this.options.onShutdown?.(); } catch { // The update already completed; do not block relaunch on local teardown. } if (!updateResult.error && updateExitCode === 0) { try { const status = await launchDaemonUpdateRestartCoordinator({ socketPath: daemonSocketPath, agentDir: getAgentDir(), cwd: updateCwd, originActiveSessionId: this.connectionState?.activeSessionId, }); const report = buildDaemonUpdateRestartReport(status); for (const message of report.info) { console.log(message); } for (const warning of report.warnings) { console.error(`Warning: ${warning}`); } } catch (error: unknown) { console.error( `Warning: updated, but could not coordinate the daemon restart (${error instanceof Error ? error.message : String(error)}).`, ); } } const relaunch = createUpdatedCliSubprocessLaunchSpec(relaunchArgs); const updateProcess = process as NodeJS.Process & { execve?: UpdateRelaunchExecve }; try { if ( tryExecUpdateRelaunch(relaunch, { platform: process.platform, nodeVersion: process.versions.node, cwd: updateCwd, previousCwd: process.cwd(), environment: process.env, chdir: (directory) => process.chdir(directory), execve: updateProcess.execve, }) ) { return; } } catch (error: unknown) { console.error( `Could not replace the current ${APP_NAME} process (${error instanceof Error ? error.message : String(error)}). Falling back to a child relaunch.`, ); } const relaunchResult = spawnSync(relaunch.command, relaunch.args, { stdio: "inherit", cwd: updateCwd, env: process.env, }); if (relaunchResult.error) { console.error(`Failed to relaunch ${APP_NAME}: ${relaunchResult.error.message}`); process.exit(1); } process.exit(relaunchResult.status ?? (relaunchResult.signal ? 1 : 0)); }
this.ui.start(); if (this.fullscreenEnabled) { this.applyFullscreen(true); } this.ui.requestRender(true);
// The updater ran in a child process and may have persisted settings, for example the // update channel, without installing anything. Pick those up before reporting. await this.settingsManager.reload().catch(() => undefined);
if (selfUpdateNotAttempted) { this.showStatus(`Update did not change ${APP_NAME}. Reloading resources...`); await this.handleReloadCommand(); return; } if (updateResult.error) { this.showError(`Update failed: ${updateResult.error.message}`); return; } if (updateExitCode !== 0) { this.showError( updateResult.signal ? `Update terminated by signal ${updateResult.signal}` : `Update exited with code ${updateExitCode}`, ); return; } this.showStatus("Packages updated. Reloading resources..."); await this.handleReloadCommand(); }
private async handleReloadCommand(commandArgs?: string): Promise<boolean> { if (this.isAgentStreaming()) { this.showWarning("Wait for the current response to finish before reloading."); return false; } if (this.isAgentCompacting()) { this.showWarning("Wait for compaction to finish before reloading."); return false; }
this.resetExtensionUI();
const isModelsOnly = commandArgs === "models"; const reloadBox = new Container(); const borderColor = (s: string) => theme.fg("border", s); reloadBox.addChild(new DynamicBorder(borderColor)); reloadBox.addChild(new Spacer(1)); reloadBox.addChild( new Text( theme.fg( "muted", isModelsOnly ? "Reloading models from models.dev and models.json..." : "Reloading keybindings, extensions, skills, prompts, themes, models...", ), 1, 0, ), ); reloadBox.addChild(new Spacer(1)); reloadBox.addChild(new DynamicBorder(borderColor));
const previousEditor = this.editor; this.editorContainer.clear(); this.editorContainer.addChild(reloadBox); this.ui.setFocus(reloadBox); this.ui.requestRender(true); await new Promise((resolve) => process.nextTick(resolve));
const dismissReloadBox = (editor: Component) => { this.editorContainer.clear(); this.editorContainer.addChild(editor); this.ui.setFocus(editor); this.ui.requestRender(); };
try { await this.agentConnection.reload(); this.pendingPostRunActivation = undefined; this.toolDefinitionCache.clear(); this.keybindings.reload(); const activeHeader = this.customHeader ?? this.builtInHeader; if (isExpandable(activeHeader)) { activeHeader.setExpanded(this.toolOutputExpanded); } setRegisteredThemes(this.uiServices.getThemes()); 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()); await this.refreshConnectionCatalog(); this.setupAutocompleteProvider(); if (this.bindLocalSessionExtensions) { const runner = this.getLocalSessionHost().getExtensionRunner(); this.setupExtensionShortcuts(runner); } await this.rebuildChatFromMessages(); dismissReloadBox(this.editor as Component); this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true, }); const modelsJsonError = this.modelRegistry.getError(); if (modelsJsonError) { this.showError(`models.json error: ${modelsJsonError}`); } this.showStatus( isModelsOnly ? "Reloaded models from models.dev and models.json" : "Reloaded keybindings, extensions, skills, prompts, themes, models", ); return true; } catch (error) { dismissReloadBox(previousEditor as Component); this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); return false; } }
private async handleExportCommand(text: string): Promise<void> { const outputPath = this.getPathCommandArgument(text, "/export");
try { if (outputPath?.endsWith(".jsonl")) { const filePath = await this.agentConnection.exportToJsonl(outputPath); this.showStatus(`Session exported to: ${filePath}`); } else { const filePath = await this.agentConnection.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 getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined { if (text === command) { return undefined; } if (!text.startsWith(`${command} `)) { return undefined; }
const argsString = text.slice(command.length + 1).trimStart(); if (!argsString) { return undefined; }
const firstChar = argsString[0]; if (firstChar === '"' || firstChar === "'") { const closingQuoteIndex = argsString.indexOf(firstChar, 1); if (closingQuoteIndex < 0) { return undefined; } return argsString.slice(1, closingQuoteIndex); }
const firstWhitespaceIndex = argsString.search(/\s/); if (firstWhitespaceIndex < 0) { return argsString; } return argsString.slice(0, firstWhitespaceIndex); }
private async handleImportCommand(text: string): Promise<void> { const inputPath = this.getPathCommandArgument(text, "/import"); if (!inputPath) { this.showError("Usage: /import <path.jsonl>"); return; }
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); if (!confirmed) { this.showStatus("Import cancelled"); return; }
try { this.stopWorkingLoader(); const result = await this.agentConnection.importFromJsonl(inputPath); if (result.cancelled) { this.showStatus("Import cancelled"); return; } await 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.agentConnection.importFromJsonl(inputPath, selectedCwd); if (result.cancelled) { this.showStatus("Import cancelled"); return; } await this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); return; } if (error instanceof SessionImportFileNotFoundError) { this.showError(`Failed to import session: ${error.message}`); return; } if (this.reportLostConnectionFailure(error)) 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 = spawnSyncHidden("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.agentConnection.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 spawnHidden> | 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 = spawnHidden("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 = await this.agentConnection.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 async handleNameCommand(text: string): Promise<void> { const name = text.replace(/^\/name\s*/, "").trim(); if (!name) { const currentName = this.getCurrentSessionName(); 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; }
await this.agentConnection.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 async handleRlmMaxDepthCommand(args: string): Promise<void> { const tokens = args ? args.split(/\s+/) : []; if (tokens.length === 0) { try { const status = await this.agentConnection.getRlmMaxDepthStatus(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text(theme.fg("dim", `RLM max depth: ${status.maxDepth} (${status.source})`), 1, 0), ); this.ui.requestRender(); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } return; }
const global = tokens[1] === "--global"; if (tokens.length > (global ? 2 : 1) || !/^\d+$/.test(tokens[0] ?? "")) { this.showWarning("Usage: /rlm-max-depth [<non-negative integer> [--global]]"); return; } const maxDepth = Number(tokens[0]); if (!Number.isSafeInteger(maxDepth)) { this.showWarning("RLM max depth must be a non-negative integer."); return; }
try { const result = await this.agentConnection.setRlmMaxDepth(maxDepth, { global }); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( theme.fg( "dim", `RLM max depth set: ${result.maxDepth}${result.globalSaved ? " and saved as global default" : ""}`, ), 1, 0, ), ); this.ui.requestRender(); if (result.globalError) { this.showError( `RLM max depth set for this chat, but the global default was not saved: ${result.globalError}`, ); } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private async handleSessionCommand(): Promise<void> { const stats = await this.agentConnection.getSessionStats(); const sessionName = this.getCurrentSessionName();
let info = `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 += `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.fg("dim", "Use /context for token, cost, and context usage.");
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); }
private handleLogsCommand(): void { const logsDir = getLogsDir(); let info = `Logs\n\n`; info += `${theme.fg("dim", "Directory:")} ${logsDir}\n\n`;
let files: string[] = []; try { if (fs.existsSync(logsDir)) { files = fs.readdirSync(logsDir).filter((name) => !name.startsWith(".")); } } catch { // Fall through to the empty-state line below. } if (files.length === 0) { info += `${theme.fg("dim", "No logs written yet.")}\n`; } else { for (const name of files.sort()) { let size = ""; try { size = ` ${theme.fg("dim", `(${(fs.statSync(path.join(logsDir, name)).size / 1024).toFixed(1)} KB)`)}`; } catch { // Skip the size if the file vanished between readdir and stat. } info += `${theme.fg("dim", "•")} ${name}${size}\n`; } } info += `\n${theme.fg("dim", "Daemon crashes log to <socket>.log; agent-open failures log to client-errors.log.")}`;
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); }
private async handleSystemPromptCommand(): Promise<void> { const prompt = await this.agentConnection.getSystemPrompt(); const header = `System Prompt ${theme.fg("dim", `(${prompt.length} chars)`)}`;
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(header, 1, 0)); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(prompt, 1, 0)); this.ui.requestRender(); }
private formatTraceUploadResult(result: AgentTraceUploadResult): string { switch (result.status) { case "uploaded": return `Trace uploaded (${result.bytesStored.toLocaleString()} bytes).`; case "disabled": return "Trace sharing is disabled."; case "unchanged": return "Trace is already uploaded; no new content since the last upload."; case "missing_credentials": return "Trace sharing needs a Prime API key. Run /traces login."; case "no_session_file": return "Current session has no persisted trace yet."; case "empty_session": return "Current session trace is empty."; case "invalid_session": return `Trace upload skipped: ${result.message}.`; case "too_large": return `Trace upload skipped: session file is ${result.size.toLocaleString()} bytes; limit is ${result.maxBytes.toLocaleString()} bytes.`; case "failed": if (result.statusCode === 404) { return "Trace upload endpoint was not found. The platform API may not be deployed yet, or PRIME_AGENT_TRACES_BASE_URL points at the wrong API."; } return `Trace upload failed: ${result.statusCode ? `HTTP ${result.statusCode}: ` : ""}${result.message}. See ${getAgentTracesLogPath()} for details.`; } }
private async uploadCurrentTraceOnce(): Promise<AgentTraceUploadResult> { const state = await this.agentConnection.getState(); return uploadAgentTraceFile({ sessionFile: state.sessionFile, authStorage: this.modelRegistry.authStorage, settingsManager: this.settingsManager, requireEnabled: false, reloadConfig: false, }); }
private async previewCurrentTrace(): Promise<void> { const state = await this.agentConnection.getState(); const result = await previewAgentTraceFile({ sessionFile: state.sessionFile }); let info: string; switch (result.status) { case "no_session_file": info = "Trace preview is unavailable until the current session has a persisted assistant response."; break; case "empty_session": info = "The current trace is empty."; break; case "invalid_session": case "failed": info = `Trace preview failed: ${result.message}.`; break; case "ready": info = this.formatTracePreview(result); break; } this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); }
private formatTracePreview(result: Extract<AgentTracePreviewResult, { status: "ready" }>): string { const lines = [ "Trace Preview", theme.fg("dim", "Nothing has been uploaded by this command."), "", `${theme.fg("dim", "File:")} ${result.sessionFile}`, `${theme.fg("dim", "Size:")} ${result.size.toLocaleString()} bytes`, `${theme.fg("dim", "Uploadable:")} ${result.uploadable ? "Yes" : `No (limit ${result.maxBytes.toLocaleString()} bytes)`}`, `${theme.fg("dim", "Endpoint:")} ${result.endpoint}`, `${theme.fg("dim", "Session ID:")} ${result.sessionId}`, `${theme.fg("dim", "Trace ID:")} ${result.traceId}`, ]; if (result.parentSessionId) { lines.push(`${theme.fg("dim", "Parent session:")} ${result.parentSessionId}`); } if (result.gitRepo) { lines.push(`${theme.fg("dim", "Git repository:")} ${result.gitRepo}`); } if (result.gitCommit) { lines.push(`${theme.fg("dim", "Git commit:")} ${result.gitCommit}`); } lines.push("", "Raw JSONL payload preview"); if (result.contentPreview) { lines.push(result.contentPreview); if (result.truncated) { lines.push("", theme.fg("dim", "Preview truncated; upload sends the complete file.")); } } else { lines.push(theme.fg("dim", "Payload omitted because the trace exceeds the upload limit.")); } return lines.join("\n"); }
private async uploadAllTraces(sessionDir?: string, signal?: AbortSignal): Promise<AgentTraceUploadAllResult> { return uploadAllAgentTraces({ authStorage: this.modelRegistry.authStorage, settingsManager: this.settingsManager, sessionDir, requireEnabled: false, reloadConfig: false, signal, onProgress: ({ completed, total }) => { if (total > 0 && (completed === 0 || completed === total || completed % 10 === 0)) { this.showStatus( `Uploading traces: ${completed.toLocaleString()}/${total.toLocaleString()} (${keyText("app.clear")} to cancel)`, ); } }, }); }
private async handleTracesCommand(text: string): Promise<void> { const command = text .replace(/^\/traces\b/, "") .trim() .toLowerCase() || "status";
if (command === "status") { await this.settingsManager.reload().catch(() => undefined); const credential = await getPrimeAgentTraceCredential(this.modelRegistry.authStorage); const state = await this.agentConnection.getState(); const info = [ "Trace Sharing", "", `${theme.fg("dim", "Automatic uploads:")} ${this.settingsManager.getAgentTracesEnabled() ? "Enabled" : "Disabled"}`, `${theme.fg("dim", "Credential:")} ${credential?.label ?? "Not configured"}`, `${theme.fg("dim", "Endpoint:")} ${resolvePrimeAgentTracesBaseUrl()}`, `${theme.fg("dim", "Session file:")} ${state.sessionFile ?? "In-memory"}`, "", theme.fg( "dim", "Commands: /traces on, /traces off, /traces preview, /traces upload-current, /traces upload-all, /traces login", ), ].join("\n"); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); return; }
if (command === "off" || command === "disable") { this.settingsManager.setAgentTracesEnabled(false); await this.settingsManager.flush(); this.showStatus("Trace sharing disabled."); return; }
if (command === "login") { await this.createAuthFlows().runPrimeAgentTracesLogin(); return; }
if (command === "preview") { await this.previewCurrentTrace(); return; }
if (command === "on" || command === "enable") { let credential = await getPrimeAgentTraceCredential(this.modelRegistry.authStorage); if (!credential) { const authResult = await this.createAuthFlows().runPrimeAgentTracesLogin(); if (authResult.status !== "success") { return; } credential = await getPrimeAgentTraceCredential(this.modelRegistry.authStorage); } if (!credential) { this.showError("Trace sharing needs a Prime API key."); return; }
this.settingsManager.setAgentTracesEnabled(true); await this.settingsManager.flush(); const uploadResult = await this.uploadCurrentTraceOnce(); const uploadMessage = uploadResult.status === "no_session_file" || uploadResult.status === "empty_session" ? "Current session will upload after the first assistant response." : this.formatTraceUploadResult(uploadResult); this.showStatus(`Trace sharing enabled. ${uploadMessage}`); return; }
if (command === "upload" || command === "upload-current") { const credential = await getPrimeAgentTraceCredential(this.modelRegistry.authStorage); if (!credential) { this.showError("Trace sharing needs a Prime API key. Run /traces login."); return; } const uploadResult = await this.uploadCurrentTraceOnce(); const message = this.formatTraceUploadResult(uploadResult); if (uploadResult.status === "failed") { this.showError(message); } else { this.showStatus(message); } return; }
if (command === "upload-all") { const credential = await getPrimeAgentTraceCredential(this.modelRegistry.authStorage); if (!credential) { this.showError("Trace sharing needs a Prime API key. Run /traces login."); return; } if (this.traceUploadAllAbortController) { this.showWarning("A trace upload is already running. Cancel it before starting another."); return; } const state = await this.agentConnection.getState(); const abortController = new AbortController(); this.traceUploadAllAbortController = abortController; let result: AgentTraceUploadAllResult; try { result = await this.uploadAllTraces(state.sessionDir, abortController.signal); } finally { if (this.traceUploadAllAbortController === abortController) { this.traceUploadAllAbortController = undefined; } } if (abortController.signal.aborted) { this.showStatus("Trace upload cancelled."); return; } if (result.total === 0) { this.showStatus("No persisted traces were found."); return; } const summary = [ `Uploaded ${result.uploaded.toLocaleString()} of ${result.total.toLocaleString()} traces`, result.skipped > 0 ? `${result.skipped.toLocaleString()} skipped` : undefined, result.failed > 0 ? `${result.failed.toLocaleString()} failed` : undefined, `${result.bytesStored.toLocaleString()} bytes stored`, ] .filter((part): part is string => part !== undefined) .join("; "); if (result.failed > 0) { this.showWarning(`${summary}. See ${getAgentTracesLogPath()} for details.`); } else { this.showStatus(`${summary}.`); } return; }
this.showWarning("Usage: /traces [status|on|off|preview|upload|upload-current|upload-all|login]"); }
/** * Render immediately. Network quota calls run in the background so /usage * remains usable while the agent is streaming or running tools. */ private handleUsageCommand(): void { const width = Math.max(60, Math.min(this.ui.terminal.columns - 2, 120)); const output = new Text( renderUsageReportText({ reports: this.usageReports, skipped: this.usageSkipped }, undefined, width, { loading: true, fetchedAt: this.usageReportsFetchedAt || undefined, }), 1, 0, ); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(output); this.ui.requestRender();
const refresh = this.refreshUsageReport(); refresh .then(({ reports, skipped, contextTree }) => { if (this.isShuttingDown) return; output.setText( renderUsageReportText({ reports, skipped }, contextTree, width, { fetchedAt: this.usageReportsFetchedAt || undefined, }), ); this.ui.requestRender(); }) .catch((error) => { output.setText( renderUsageReportText({ reports: this.usageReports, skipped: this.usageSkipped }, undefined, width, { fetchedAt: this.usageReportsFetchedAt || undefined, }), ); this.ui.requestRender(); this.showWarning(`Usage refresh failed: ${error instanceof Error ? error.message : String(error)}`); }); }
private async refreshUsageReport(): Promise<{ reports: UsageReport[]; skipped: SkippedProvider[]; contextTree?: ContextTreeNode; }> { if (!this.usageReportsRequest) { const abortController = new AbortController(); this.usageReportsAbortController = abortController; this.usageReportsRequest = fetchUsageReports(this.modelRegistry.authStorage, { signal: abortController.signal, }).finally(() => { this.usageReportsRequest = undefined; if (this.usageReportsAbortController === abortController) this.usageReportsAbortController = undefined; }); } const [reportsResult, contextResult] = await Promise.allSettled([ this.usageReportsRequest, this.agentConnection.getContextTree(), ]); if (reportsResult.status === "rejected") throw reportsResult.reason; this.usageReports = reportsResult.value.reports; this.usageSkipped = reportsResult.value.skipped; this.usageReportsFetchedAt = Date.now(); return { reports: this.usageReports, skipped: this.usageSkipped, contextTree: contextResult.status === "fulfilled" ? contextResult.value : undefined, }; }
private async handleContextCommand(): Promise<void> { let info: string; try { const tree = await this.agentConnection.getContextTree(); const width = Math.max(60, Math.min(this.ui.terminal.columns - 2, 120)); info = formatContextTree(tree, width); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); return; }
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(info, 1, 0)); this.ui.requestRender(); }
private async handleHeartbeatCommand(text: string): Promise<void> { try { const command = parseHeartbeatCommand(text); switch (command.type) { case "status": { const heartbeat = await this.agentConnection.getHeartbeat(); this.patchConnectionState({ heartbeat: heartbeat ?? null }); await this.refreshHeartbeatCatalog(); this.showHeartbeat(heartbeat); return; } case "set": { const heartbeat = await this.agentConnection.setHeartbeat( command.schedule, command.instruction, command.deliveryMode, ); this.patchConnectionState({ heartbeat }); await this.refreshHeartbeatCatalog(); this.showStatus( `Heartbeat set\nDelivery: ${heartbeat.deliveryMode ?? DEFAULT_HEARTBEAT_DELIVERY_MODE}\nNext run: ${heartbeat.nextRunAt ?? "-"}`, ); return; } case "pause": { const heartbeat = await this.agentConnection.updateHeartbeat("pause"); if (!heartbeat) { this.showStatus("No active heartbeat"); return; } this.patchConnectionState({ heartbeat }); await this.refreshHeartbeatCatalog(); this.showStatus("Heartbeat paused"); return; } case "resume": { const heartbeat = await this.agentConnection.updateHeartbeat("resume"); if (!heartbeat) { this.showStatus("No active heartbeat"); return; } this.patchConnectionState({ heartbeat }); await this.refreshHeartbeatCatalog(); this.showStatus(`Heartbeat resumed\nNext run: ${heartbeat.nextRunAt ?? "-"}`); return; } case "clear": { const heartbeat = await this.agentConnection.updateHeartbeat("clear"); if (!heartbeat) { this.showStatus("No active heartbeat"); return; } this.patchConnectionState({ heartbeat: null }); await this.refreshHeartbeatCatalog(); this.showStatus("Heartbeat cleared"); return; } } } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); } }
private showHeartbeatManager(): void { if (this.heartbeatManagerHandle) { this.heartbeatManagerHandle.focus(); return; } // Stale-while-revalidate: open immediately with the cached catalog. A // busy session can take minutes to answer the fetch, and the overlay // must not wait for it; the background refresh updates the open view // when fresh data lands (the component reads the catalog per render). // A failed fetch surfaces inside the view instead of silently showing a // possibly-stale catalog; each open starts with a clean notice state. this.heartbeatCatalogFetchError = undefined; void this.refreshHeartbeatCatalog().catch((error) => this.onHeartbeatCatalogFetchError(error)); const manager = new HeartbeatManagerComponent({ getHeartbeats: () => this.getScopedHeartbeats(), getFetchError: () => this.heartbeatCatalogFetchError, getRows: () => this.ui.terminal.rows, onAction: (heartbeat, action) => this.manageHeartbeat(heartbeat, action), onClose: () => this.closeHeartbeatManager(), requestRender: () => this.ui.requestRender(), }); this.heartbeatManager = manager; this.heartbeatManagerHandle = this.showFullPaneOverlay(manager, { fullWidth: true, suspendFullscreenMouse: true, }); this.scheduleHeartbeatManagerRefresh(); }
private closeHeartbeatManager(): void { this.clearHeartbeatManagerRefreshTimer(); this.clearHeartbeatManagerFetchRetryTimer(); this.heartbeatManagerHandle?.hide(); this.heartbeatManagerHandle = undefined; this.heartbeatManager = undefined; this.ui.requestRender(); }
private scheduleHeartbeatManagerRefresh(): void { if (!this.heartbeatManager) { this.clearHeartbeatManagerRefreshTimer(); return; } const nextRunAt = this.getScopedHeartbeats() .filter((heartbeat) => heartbeat.job.status === "active" && heartbeat.job.nextRunAt) .map((heartbeat) => Date.parse(heartbeat.job.nextRunAt!)) .filter(Number.isFinite) .sort((left, right) => left - right)[0]; if (nextRunAt === undefined) { this.clearHeartbeatManagerRefreshTimer(); return; } const untilNextRun = nextRunAt - Date.now(); const delay = untilNextRun > 0 ? Math.min(60_000, untilNextRun + 250) : 5_000; const refreshAt = Date.now() + delay; // Subagent snapshots re-derive this schedule constantly; keep an earlier // pending refresh instead of re-arming, or an overdue heartbeat's 5s // fallback would be postponed for as long as children stay busy. if ( this.heartbeatManagerRefreshTimer && this.heartbeatManagerRefreshAt !== undefined && this.heartbeatManagerRefreshAt <= refreshAt ) { return; } this.clearHeartbeatManagerRefreshTimer(); this.heartbeatManagerRefreshAt = refreshAt; this.heartbeatManagerRefreshTimer = setTimeout(() => { this.heartbeatManagerRefreshTimer = undefined; this.heartbeatManagerRefreshAt = undefined; if (!this.heartbeatManager) { return; } void this.refreshHeartbeatCatalog().catch(() => this.scheduleHeartbeatManagerRefresh()); }, delay); this.heartbeatManagerRefreshTimer.unref?.(); }
private clearHeartbeatManagerRefreshTimer(): void { if (this.heartbeatManagerRefreshTimer) { clearTimeout(this.heartbeatManagerRefreshTimer); this.heartbeatManagerRefreshTimer = undefined; } this.heartbeatManagerRefreshAt = undefined; }
private retainHeartbeatCatalogFetch( fetch: Promise<AgentConnectionHeartbeat[]>, connection: AgentConnection, seq: number, ): void { // The deadline expired while this fetch was still in flight: apply the // late result when it lands so the open view converges even without a // further heartbeats_changed event. Bounded by the same context guards // as the drain loop and by the write sequence, so a superseded result // cannot revert newer catalog state; a late failure is swallowed (the // retry chain owns recovery from it). void fetch .then((heartbeats) => { if (this.isShuttingDown || this.isReturningToAgentsView || this.agentConnection !== connection) { return; } this.applyHeartbeatCatalog(heartbeats, seq); }) .catch(() => undefined); }
private armHeartbeatManagerFetchRetry(): void { // Guarantee the open manager always has a next scheduled retrieval // after a deadline expiry; a still-armed retry keeps the earliest slot. if (!this.heartbeatManager || this.heartbeatManagerFetchRetryTimer) return; this.heartbeatManagerFetchRetryTimer = setTimeout(() => { this.heartbeatManagerFetchRetryTimer = undefined; if (!this.heartbeatManager) return; void this.refreshHeartbeatCatalog().catch((error) => this.onHeartbeatCatalogFetchError(error)); }, HEARTBEAT_REFRESH_RETRY_DELAY_MS); this.heartbeatManagerFetchRetryTimer.unref?.(); }
private clearHeartbeatManagerFetchRetryTimer(): void { if (this.heartbeatManagerFetchRetryTimer) { clearTimeout(this.heartbeatManagerFetchRetryTimer); this.heartbeatManagerFetchRetryTimer = undefined; } }
private onHeartbeatCatalogFetchError(error: unknown): void { // Stale-while-revalidate opens the manager even when the fetch fails; // surface the failure in the view instead of silently showing a stale // catalog, and keep a next retrieval scheduled. this.heartbeatCatalogFetchError = error instanceof Error ? error.message : String(error); this.armHeartbeatManagerFetchRetry(); this.scheduleHeartbeatManagerRefresh(); this.ui.requestRender(); }
private async manageHeartbeat( heartbeat: AgentConnectionHeartbeat, action: AgentHeartbeatManagementAction, ): Promise<void> { // Sequence the action by issue order so an older in-flight fetch // cannot later revert its result; the action itself is authoritative // and always applies. const actionSeq = ++this.heartbeatCatalogSeq; const updated = await this.agentConnection.manageHeartbeat( heartbeat.job.activeSessionId, heartbeat.job.id, action, ); if (updated.source === "heartbeat" && updated.activeSessionId === this.connectionState?.activeSessionId) { this.patchConnectionState({ heartbeat: action === "stop" ? null : updated }); } this.heartbeatCatalogAppliedSeq = Math.max(this.heartbeatCatalogAppliedSeq, actionSeq); const remaining = this.heartbeatCatalog.filter((entry) => entry.job.id !== updated.id); this.applyHeartbeatCatalog( updated.status === "active" || updated.status === "paused" ? [...remaining, { ...heartbeat, job: updated }] : remaining, ); void this.refreshHeartbeatCatalog().catch(() => undefined); }
private showHeartbeat(job: AgentCronJob | undefined): void { if (!job) { this.showStatus("No active heartbeat"); return; } const next = job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : "-"; const last = job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "-"; const lines = [ "Heartbeat", "", `${theme.fg("dim", "Status:")} ${job.status}`, `${theme.fg("dim", "Every:")} ${job.schedule.expression}`, `${theme.fg("dim", "Delivery:")} ${job.deliveryMode ?? DEFAULT_HEARTBEAT_DELIVERY_MODE}`, `${theme.fg("dim", "Instruction:")} ${job.prompt}`, `${theme.fg("dim", "Next:")} ${next}`, `${theme.fg("dim", "Last:")} ${last}`, `${theme.fg("dim", "Runs:")} ${job.runCount}`, ]; if (job.lastError) { lines.push(`${theme.fg("dim", "Error:")} ${job.lastError}`); } this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(lines.join("\n"), 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.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(); }
private capitalizeKey(key: string): string { return key .split("/") .map((k) => k .split("+") .map((part) => (part === "esc" ? part : part.charAt(0).toUpperCase() + part.slice(1))) .join("+"), ) .join("/"); }
private getAppKeyDisplay(action: AppKeybinding): string { return this.capitalizeKey(keyText(action)); }
private getEditorKeyDisplay(action: Keybinding): string { return this.capitalizeKey(keyText(action)); }
private getShortcutGuide(): string { const tab = this.getEditorKeyDisplay("tui.input.tab"); const newLine = this.getEditorKeyDisplay("tui.input.newLine"); const clearInput = this.getAppKeyDisplay("app.input.clear"); const shortcutsKey = this.getAppKeyDisplay("app.shortcuts"); const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const promptStash = this.getAppKeyDisplay("app.prompt.stash"); const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
return `**Prompt**\`!\` shell mode · \`/\` commands · \`@\` file paths\`${tab}\` complete paths · \`${newLine}\` new line\`${clearInput}\` interrupt · press twice to rewind or clear the prompt
**Controls**\`${selectModel}\` select model · \`/effort\` set reasoning · \`${expandTools}\` overview → thinking + diffs → all output\`${promptStash}\` stash prompt · \`${externalEditor}\` edit in \`$EDITOR\`\`${pasteImage}\` paste image
**Help**${shortcutsKey ? `\`${shortcutsKey}\` quick shortcuts · ` : ""}\`/hotkeys\` full reference`; }
private getHotkeysGuide(): string { 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"); 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"); const clear = this.getAppKeyDisplay("app.clear"); const clearInput = this.getAppKeyDisplay("app.input.clear"); const interrupt = this.getAppKeyDisplay("app.interrupt"); const shortcutsKey = this.getAppKeyDisplay("app.shortcuts"); const exit = this.getAppKeyDisplay("app.exit"); const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); const focusSubagents = this.getAppKeyDisplay("app.subagents.focus"); const manageHeartbeats = this.getAppKeyDisplay("app.heartbeats.open"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const promptStash = this.getAppKeyDisplay("app.prompt.stash"); const followUp = this.getAppKeyDisplay("app.message.followUp"); const browseQueue = this.getAppKeyDisplay("app.message.navigateOlder"); const reorderQueue = `${this.getAppKeyDisplay("app.message.moveEarlier")} / ${this.getAppKeyDisplay("app.message.moveLater")}`; const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); const viewportPageUp = this.getEditorKeyDisplay("tui.viewport.pageUp"); const viewportPageDown = this.getEditorKeyDisplay("tui.viewport.pageDown"); const viewportTop = this.getEditorKeyDisplay("tui.viewport.top"); const viewportFollow = this.getEditorKeyDisplay("tui.viewport.follow");
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 || \`${clearInput}\` | Clear input / cancel autocomplete || \`${clear}\` | Interrupt current operation (first) / exit (second) |${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}${shortcutsKey ? `| \`${shortcutsKey}\` | Show quick shortcuts |\n` : ""}| \`${exit}\` | Exit (when editor is empty) || \`${selectModel}\` | Open model selector || \`${expandTools}\` | Cycle overview → thinking + diffs → all output || \`${focusSubagents}\` | Focus the subagent summary / open the scoped agents view || \`${manageHeartbeats}\` | Manage heartbeats || \`${externalEditor}\` | Edit message in external editor || \`${promptStash}\` | Stash or restore draft prompt || \`${followUp}\` | Queue follow-up message || \`${browseQueue}\` | Browse and edit queued messages || \`${reorderQueue}\` | Reorder the selected queued message || \`${pasteImage}\` | Paste image from clipboard || \`/\` | Slash commands |
**Fullscreen mode (\`/fullscreen\`)**| Key | Action ||-----|--------|| \`${viewportPageUp}\` / \`${viewportPageDown}\` | Scroll transcript by page || \`${viewportTop}\` | Scroll to top || \`${viewportFollow}\` | Scroll to bottom and follow output || mouse wheel | Scroll transcript || mouse drag | Select and copy text || mouse click on link | Open link in browser |`;
const shortcuts = this.bindLocalSessionExtensions ? this.getLocalSessionHost().getExtensionRunner().getShortcuts(this.keybindings.getEffectiveConfig()) : undefined; if (shortcuts && shortcuts.size > 0) { hotkeys += `**Extensions**| Key | Action ||-----|--------|`; for (const [key, shortcut] of shortcuts) { const description = shortcut.description ?? shortcut.extensionPath; hotkeys += `| \`${formatKeyText(key)}\` | ${description} |\n`; } }
return hotkeys; }
private showShortcutGuide(): void { const hotkeys = this.getShortcutGuide();
this.shortcutGuideContainer.clear(); this.shortcutGuideContainer.addChild(new Spacer(1)); this.shortcutGuideContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); this.ui.requestRender(); }
private handleHotkeysCommand(): void { const hotkeys = this.getHotkeysGuide();
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); this.ui.requestRender(); }
private clearShortcutGuide(): void { if (this.shortcutGuideContainer.children.length === 0) { return; } this.shortcutGuideContainer.clear(); this.ui.requestRender(); }
private async handleClearCommand(options: { name?: string; prompt?: string } = {}): Promise<void> { this.stopWorkingLoader(); const previousSessionId = this.connectionState?.sessionId; const retainedImages = options.prompt ? this.getPromptStashImages(options.prompt) : []; const retainedPasteds = options.prompt ? this.getPromptStashPastedFiles(options.prompt) : []; const restorePrompt = () => { if (!options.prompt) return; for (const [id, image] of retainedImages) this.pastedImages.set(id, image); for (const [id, entry] of retainedPasteds) this.pastedFiles.set(id, entry); this.editor.setText(options.prompt); }; let created = false; try { const result = await this.agentConnection.newSession(); if (result.cancelled) { restorePrompt(); return; } created = true; this.pastedFiles.clear(); for (const [id, entry] of retainedPasteds) this.pastedFiles.set(id, entry); if (previousSessionId && this.settingsManager.getPasteCleanup()) { const reaped = reapSessionPastes(previousSessionId); if (reaped.length > 0) { this.showStatus(`Removed ${reaped.length} pasted file(s) from the previous session`); } } await this.renderCurrentSessionState(); for (const [id, image] of retainedImages) this.pastedImages.set(id, image); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.ui.requestRender(); const images = options.prompt ? this.collectImagesFor(options.prompt) : undefined; if (options.name) await this.agentConnection.setSessionName(options.name); if (options.prompt) { this.editor.addToHistory?.(options.prompt); const promptToSend = this.resolvePastesForSubmission(options.prompt); await this.agentConnection.prompt(promptToSend, { images }); } } catch (error: unknown) { if (!created) { if (this.reportLostConnectionFailure(error)) return; await this.handleFatalRuntimeError("Failed to create session", error); return; } restorePrompt(); this.showError(error instanceof Error ? error.message : String(error)); } }
private async handleDebugCommand(): Promise<void> { const width = this.ui.terminal.columns; const height = this.ui.terminal.rows; const allLines = this.ui.render(width); try { const messages = await this.agentConnection.getMessages(); 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) ===", ...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(); } catch (error: unknown) { this.showError(`Failed to write debug log: ${error instanceof Error ? error.message : String(error)}`); } }
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(); } }
stop(options: { preserveAltScreen?: boolean } = {}): void { this.usageReportsAbortController?.abort(); this.unregisterSignalHandlers(); this.clearCtrlCExitHint({ render: false }); this.clearEscapeRepeat(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } this.stopWorkingLoader(); this.discardRefineLoader(); this.stopWorkingPulse(); this.stopTrayTicker(); this.closeHeartbeatManager(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); this.footerDataProvider.dispose(); if (this.unsubscribe) { this.unsubscribe(); } void this.rosterBar?.dispose(); this.rosterBar = undefined; if (this.isInitialized) { this.ui.stop({ preserveAltScreen: options.preserveAltScreen, flushFullscreen: options.preserveAltScreen ? false : undefined, }); this.isInitialized = false; } }}