char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ba4e2f4
main
1import { effect , Signal } from "@char/aftercare" ; 2import type { Message , Usage } from "@earendil-works/pi-ai" ; 3import type { 4AgentState , 5ConversationView , 6EntryRecord , 7InboxState , 8LiveState , 9UsageState , 10} from "@earendil-works/pi-durable" ; 11import type { ProjectIndex } from "@berry/api/projects.ts" ; 12import type { ExtensionStartup } from "@berry/api/extension-startup.ts" ; 13import type { JsonValue } from "@berry/api" ; 14import type { HeaderContext } from "@berry/api/web.ts" ; 15import { userContent } from "@berry/api/images.ts" ; 16import type { ShellState } from "@berry/api/shell.ts" ; 17import { api , connected , watch } from "./client.ts" ; 18import { composer , go } from "./composer.tsx" ; 19import { 20configurationEditor , 21type ConfigurationLocation , 22type ConfigurationSchema , 23liveChoices , 24} from "./configuration.tsx" ; 25import { inbox } from "./inbox.tsx" ; 26import { type StoppedInput , stoppedInputs } from "./stopped-inputs.tsx" ; 27import { extensionRenderers , type WebEntry } from "./renderers.ts" ; 28import { headerWidgets } from "./header.ts" ; 29import { extensionView , viewHref , viewRoute } from "./views.ts" ; 30import { openChannel } from "./channel.ts" ; 31import { type ModelInfo , modelKey , type ModelRef , modelSelect } from "./settings.tsx" ; 32import { readWhenVisible } from "./read.ts" ; 33import { transcript } from "./transcript.tsx" ; 34import { scroller } from "./scroller.tsx" ; 35import { branchCommands , describe , type Move } from "./tree.tsx" ; 36import { computed , json , Scope , swap } from "./ui.ts" ; 37 38/** What every conversation view shares: the installed extensions, the available models, and those enabled. */ 39export type Shared = { 40readonly extensions :Signal < readonly ExtensionInfo []>; 41readonly models :Signal < readonly ModelInfo []>; 42readonly enabled :Signal < readonly ModelRef []>; 43/** The machines a conversation can work on: this one, then those settings name. */ 44readonly machines :Signal < readonly string []>; 45readonly projects :Signal < ProjectIndex >; 46/** Fetch the available models again, as after logging in or out of a provider. */ 47readonly refreshModels :() => void ; 48}; 49export type ExtensionInfo = { 50name :string ; 51directory ?:string | null ; 52description :string | null ; 53status :"loaded" | "failed" ; 54/** Why its last load failed. */ 55error :string | null ; 56/** Whether a version of it runs: one that failed to reload still runs the last that loaded. */ 57running :boolean ; 58/** The harness's own, rather than one on disk. */ 59builtin :boolean ; 60alwaysOn :boolean ; 61/** Whether a new conversation starts with it, until one is chosen from before. */ 62defaultEnabled :boolean ; 63web :WebEntry | null ; 64/** Names it went by before, which old history still calls it. */ 65formerly :string []; 66configuration :ConfigurationSchema | null ; 67/** Its global editable prose default, when it registers any. */ 68promptProse :{ default :string } | null ; 69startup ?:ExtensionStartup | null ; 70}; 71export type CommandInfo = { 72name :string ; 73extension :string ; 74description :string ; 75/** Runs here, in the browser, rather than on the server. */ 76run ?:() => Promise < void >; 77}; 78 79const EMPTY :readonly never [] = []; 80const IDLE :LiveState = {}; 81 82function selectedNames ( agent :AgentState ) :string [] { 83const selection = agent . extensions ; 84if ( Array . isArray ( selection )) return selection ; 85// old conversations inherited subagent without storing its name 86return [ ...new Set ([ "subagent" , ...( selection ?. add ?? [])])]. filter ( 87( name ) => ! selection ?. remove ?. includes ( name ), 88); 89} 90 91/** The running extensions this conversation has, selected or always on, with their frontends. */ 92function withRenderers ( installed :readonly ExtensionInfo [], agent :AgentState ) { 93const names = selectedNames ( agent ); 94return installed . flatMap (({ name, running, alwaysOn}) => { 95const renderers = extensionRenderers ( name ); 96return running && ( alwaysOn || names . includes ( name )) && renderers ?[{ name, renderers}] :[]; 97}); 98} 99 100const totalCost = ( usage :UsageState | null ) => 101[ 102 ...Object . values ( usage ?. models ?? {}), 103 ...Object . values ( usage ?. tools ?? {}), 104]. reduce (( sum , entry ) => sum + ( entry . cost ?. total ?? 0 ), 0 ); 105 106/** Compact token counts the way pi-coding-agent's footer does. */ 107const formatTokens = ( count :number ) :string => { 108if ( count < 1000 ) return String ( count ); 109if ( count < 10000 ) return ` ${( count / 1000 ). toFixed ( 1 )} k` ; 110if ( count < 1000000 ) return ` ${ Math . round ( count / 1000 )} k` ; 111if ( count < 10000000 ) return ` ${( count / 1000000 ). toFixed ( 1 )} M` ; 112return ` ${ Math . round ( count / 1000000 )} M` ; 113}; 114 115/** The context a response saw, as pi's calculateContextTokens. */ 116const contextTokensOf = ( usage :Usage ) :number => 117usage . totalTokens || 118usage . input + usage . output + usage . cacheRead + usage . cacheWrite ; 119 120/** Rough chars/4 estimate of one model message, for messages past the last reported usage. */ 121function estimateMessageTokens ( message :Message ) :number { 122const content = ( message as { content :unknown }). content ; 123if ( typeof content === "string" ) return Math . ceil ( content . length / 4 ); 124if ( ! Array . isArray ( content )) return 0 ; 125let chars = 0 ; 126for ( const block of content as unknown []) { 127if ( typeof block === "string" ) chars += block . length ; 128else if ( block !== null && typeof block === "object" ) { 129const record = block as Record < string , unknown >; 130chars += typeof record . text === "string" ?record . text . length :JSON . stringify ( block ). length ; 131} 132} 133return Math . ceil ( chars / 4 ); 134} 135 136/** 137* The latest assistant usage, plus estimated tokens of what followed it: a la 138* pi-coding-agent's estimateProjectedContextTokens. Aborted and errored responses 139* carry no valid usage, and all-zero usage means the provider reported nothing. 140*/ 141function estimateContext ( 142entries :readonly EntryRecord [], 143) :{ tokens :number ; latest ?:Usage ; durationMs ?:number } | null { 144const head = entries . findLast (( entry ) => entry . head !== undefined ); 145let trailing = 0 ; 146for ( let i = entries . length - 1 ; i >= 0 ; i -- ) { 147const entry = entries [ i ]; 148const messages = entry . model ?? []; 149for ( let j = messages . length - 1 ; j >= 0 ; j -- ) { 150const message = messages [ j ]; 151// retained responses measured the context before this head marker 152if (( head === undefined || entry . id > head . id ) && message . role === "assistant" ) { 153const candidate = message . usage ; 154if ( 155message . stopReason !== "aborted" && message . stopReason !== "error" && 156candidate && contextTokensOf ( candidate ) > 0 157) { 158return { 159tokens :contextTokensOf ( candidate ) + trailing , 160latest :candidate , 161durationMs :message . durationMs , 162}; 163} 164} 165trailing += estimateMessageTokens ( message ); 166} 167} 168return head === undefined ?null :{ tokens :trailing }; 169} 170 171/** Cache hit rate of one response: its cache reads over its prompt tokens. */ 172const cacheHitRate = ( usage :Usage ) :number | undefined => { 173const prompt = usage . input + usage . cacheRead + usage . cacheWrite ; 174return prompt > 0 ?( usage . cacheRead / prompt ) * 100 :undefined ; 175}; 176 177type HeaderStatus = { 178cost :number ; 179/** Estimated context tokens, or null before the first response. */ 180tokens :number | null ; 181/** The model's context window, or 0 when it is not known. */ 182window :number ; 183/** Latest response's cache hit rate, when it touched the cache. */ 184hitRate :number | undefined ; 185}; 186 187const contextText = ( status :HeaderStatus ) :string => { 188if ( status . tokens === null ) { 189return status . window > 0 ?`?/ ${ formatTokens ( status . window )} ` :"" ; 190} 191if ( status . window <= 0 ) return formatTokens ( status . tokens ); 192return ` ${ formatTokens ( status . tokens )} / ${ formatTokens ( status . window )} ` ; 193}; 194 195const contextClass = ( status :HeaderStatus ) :string => { 196if ( status . tokens === null || status . window <= 0 ) return "" ; 197const percent = ( status . tokens / status . window ) * 100 ; 198if ( percent > 90 ) return "ctx-high" ; 199if ( percent > 70 ) return "ctx-warn" ; 200return "" ; 201}; 202 203const LEVELS = [ "off" , "minimal" , "low" , "medium" , "high" , "xhigh" , "max" ]; 204 205/** As pi-ai's clampThinkingLevel: the level itself if supported, else the next one up, else the next one down. */ 206function clamp ( levels :readonly string [], level :string ) :string { 207if ( levels . includes ( level )) return level ; 208const at = LEVELS . indexOf ( level ); 209return ( 210LEVELS . slice ( at + 1 ). find (( l ) => levels . includes ( l )) ?? 211LEVELS . slice ( 0 , Math . max ( at , 0 )). findLast (( l ) => levels . includes ( l )) ?? 212levels [ 0 ] ?? 213"off" 214); 215} 216 217/** The thinking levels the chosen model supports; a level it lacks moves to its nearest. */ 218export function thinkingSelect ( 219scope :Scope , 220models :Signal < readonly ModelInfo []>, 221model :Signal < string >, 222current :Signal < string >, 223choose :( level :string ) => void , 224) :HTMLSelectElement { 225const select = < select class = "thinking-level" aria - label = "Thinking level" / > as HTMLSelectElement ; 226const render = () => { 227const known = models . get (). find (( m ) => modelKey ( m ) === model . get ()); 228const levels = known ?. thinkingLevels ?? [ current . get ()]; 229select . replaceChildren ( 230 ...levels . map (( level ) => < option value = { level }>{ level }</ option >), 231); 232select . value = current . get (); 233select . disabled = levels . length === 1 ; 234// not before the model is known: until then, every level looks unsupported 235if ( known && ! levels . includes ( current . get ())) { 236queueMicrotask (() => choose ( clamp ( levels , current . get ()))); 237} 238}; 239for ( const source of [ models , model , current ]) { 240scope . add ( source . subscribe ( render ). unsubscribe ); 241} 242render (); 243select . addEventListener ( "change" , () => choose ( select . value )); 244return select ; 245} 246 247/** The conversation's bar: its agent's model, level and extensions, and what it has cost and holds. */ 248function header ( 249scope :Scope , 250tid :string , 251shared :Shared , 252notify :( message : string, error ?:boolean ) = > void , 253contextOf :( extension : string, agent :AgentState ) = > HeaderContext , 254viewing :Signal < string >, 255) { 256const configure = ( change :object ) = > 257api ( "PATCH" , `/api/conversations/ ${ tid } /agent` , change ). catch (( error ) => 258notify ( error . message , true ) 259); 260// the selects' own choices, which follow the agent 261const model = new Signal ( "" ); 262const thinking = new Signal ( "off" ); 263let agent :AgentState = {}; 264const configurationAgent = new Signal ( agent ); 265let usage :UsageState | null = null ; 266let entries : readonly EntryRecord [ ] = EMPTY ; 267 268const selected = < summary > none </ summary > as HTMLElement ; 269const toggles = < div class = "extension-options" / >; 270const boxes = new Map < string , HTMLInputElement >(); 271const configurations = new Map < 272string , 273{ element : HTMLElement ; alwaysOn : boolean ; commit () : Promise < void > | undefined } 274>(); 275const saves = new Set < Promise < unknown >>(); 276const saving = ( save: Promise < unknown >) => { 277saves . add ( save ); 278void save . catch (() => {}). finally (() => saves . delete ( save )); 279}; 280swap ( scope , toggles , shared . extensions , ( installed , own ) => { 281boxes . clear (); 282configurations . clear (); 283return installed 284. filter ( 285( extension ) => extension . running && ( ! extension . alwaysOn || extension . configuration ), 286) 287. map (( extension ) => { 288const box = ( 289< input 290type = "checkbox" 291checked = { selectedNames ( agent ). includes ( extension . name )} 292_onchange = { function () { 293const current = selectedNames ( agent ). filter ( 294( name ) => name !== extension . name , 295); 296configure ({ 297extensions : this . checked ? [ ...current , extension . name ] : current , 298}); 299}} 300/> 301) as HTMLInputElement ; 302if ( ! extension . alwaysOn ) boxes . set ( extension . name , box ); 303const editor = extension . configuration 304? configurationEditor ( 305own , 306tid , 307extension . name , 308extension . configuration , 309saving , 310liveChoices ( own , extension . name , shared ), 311shared . projects , 312computed < ConfigurationLocation >( own , [ configurationAgent ], () => 313contextOf ( extension . name , configurationAgent . get ())), 314) 315: null ; 316const fields = editor ?. element ; 317if ( editor ) { 318editor . element . hidden = ! extension . alwaysOn && 319! selectedNames ( agent ). includes ( extension . name ); 320configurations . set ( extension . name , { 321...editor , 322alwaysOn : extension . alwaysOn , 323}); 324} 325return ( 326< div class = "extension-choice" > 327{ extension . alwaysOn ? [] : ( 328< label title = { extension . description ?? "" }> 329{ box } 330{ extension . name } 331</ label > 332)} 333{ fields ?? []} 334< / div> 335); 336}); 337}); 338 339const dropdown = < details class = "extensions" >{ selected }{ toggles }< /details> as HTMLDetailsElement; 340const commit = () => { 341for ( const editor of configurations . values ()) { 342void editor . commit () ?. catch (( error ) => notify ( error . message , true )); 343} 344}; 345const outside = ( event : MouseEvent ) => { 346if ( ! dropdown . open ) return ; 347const target = event . target as Node ; 348if ( ! dropdown . contains ( target )) { 349dropdown . open = false ; 350commit (); 351} else if ( selected . contains ( target )) commit (); 352}; 353document . addEventListener ( "click" , outside , true ); 354scope . add (() => document . removeEventListener ( "click" , outside , true )); 355 356const cost = document . createTextNode ( "" ); 357const widgets = headerWidgets (); 358scope . add ( widgets . dispose ); 359const views = < span class = "view-links" / > as HTMLElement ; 360const drawWidgets = () = > { 361const available = withRenderers ( shared . extensions . get (), agent ); 362widgets . update ( 363available . flatMap (({ name , renderers }) => 364renderers . header ? [{ name , create : renderers . header }] : [] 365), 366( extension ) => contextOf ( extension , agent ), 367); 368const open = viewing . get (). split ( "/" )[ 0 ]; 369views . replaceChildren ( 370... available . flatMap (({ name , renderers : { view } }) => 371view 372? [ 373< a 374href = { name === open ? `#/ ${ tid } ` : viewHref ( tid , name )} 375title = { name === open ? "return to conversation" : `open ${ view . label } ` } 376aria-current = { name === open ? "page" : "false" } 377> 378{ view . label } 379</ a >, 380] 381: [] 382), 383); 384}; 385scope . add ( viewing . subscribe ( drawWidgets ). unsubscribe ); 386scope . add ( shared . extensions . subscribe ( drawWidgets ). unsubscribe ); 387drawWidgets (); 388 389const context = < span /> as HTMLElement ; 390const hit = document . createTextNode ( "" ); 391const rate = document . createTextNode ( "" ); 392const totals = ( 393< span class = "usage" > 394{ cost } { context } 395{ hit } 396{ rate } 397< / span> 398) as HTMLElement ; 399const drawTotals = () => { 400const window = shared . models . get (). find (( m ) => modelKey ( m ) === model . get ()) 401?. contextWindow ?? 0 ; 402const estimate = estimateContext ( entries ); 403const latest = estimate ?. latest ; 404const touched = latest !== undefined && ( latest . cacheRead > 0 || latest . cacheWrite > 0 ); 405const status : HeaderStatus = { 406cost : totalCost ( usage ), 407tokens : estimate ?. tokens ?? null , 408window , 409hitRate : latest !== undefined && touched ? cacheHitRate ( latest ) : undefined , 410}; 411const durationMs = estimate ?. durationMs ?? 0 ; 412const perSecond = durationMs > 0 ? ( estimate ?. latest ?. output ?? 0 ) / ( durationMs / 1000 ) : NaN ; 413cost . data = `$ ${ status . cost . toFixed ( 4 )} ` ; 414context . className = contextClass ( status ); 415context . textContent = contextText ( status ); 416hit . data = status . hitRate === undefined ? "" : ` CH ${ status . hitRate . toFixed ( 1 )} %` ; 417rate . data = Number . isFinite ( perSecond ) && perSecond > 0 ? ` ${ perSecond . toFixed ( 1 )} tok/s` : "" ; 418}; 419scope . add ( shared . models . subscribe ( drawTotals ). unsubscribe ); 420scope . add ( model . subscribe ( drawTotals ). unsubscribe ); 421drawTotals (); 422 423return { 424element : ( 425< header class = "bar conversation-header" > 426{ modelSelect ( scope , shared . models , shared . enabled , model , ( chosen ) => 427configure ({ model : chosen }))} 428{ thinkingSelect ( scope , shared . models , model , thinking , ( level ) => 429configure ({ thinkingLevel : level }))} 430{ dropdown } 431< span class = "trailing" > 432{ views } 433{ widgets . element } 434{ totals } 435</ span > 436< / header > 437), 438async settle() { 439await Promise . all ([ ... saves ]); 440} , 441update(next: { 442agent : AgentState ; 443usage : UsageState | null ; 444entries : readonly EntryRecord []; 445} ) { 446if ( next . agent !== agent ) { 447agent = next . agent ; 448configurationAgent . set ( agent ); 449const names = selectedNames ( agent ); 450selected . textContent = names . join ( ", " ) || "none" ; 451for ( const [ name , box ] of boxes ) box . checked = names . includes ( name ); 452for ( const [ name , { element , alwaysOn }] of configurations ) { 453element . hidden = ! alwaysOn && ! names . includes ( name ); 454} 455// together, so neither select sees the other's old value 456effect (() => { 457model . set ( agent . model ? modelKey ( agent . model ) : "" ); 458thinking . set ( agent . thinkingLevel ?? "off" ); 459}); 460drawWidgets (); 461} 462if (next.usage !== usage || next.entries !== entries) { 463usage = next . usage ; 464entries = next . entries ; 465drawTotals (); 466} 467}, 468}; 469} 470 471/** A notice above the composer, which goes after a while. */ 472export function notifier () { 473const element = < div class = "notice" hidden /> as HTMLDivElement; 474let timer : ReturnType < typeof setTimeout > | undefined ; 475const notify = ( message : string , error = false ) => { 476element . textContent = message ; 477element . classList . toggle ( "error" , error ); 478element . hidden = false ; 479clearTimeout ( timer ); 480timer = setTimeout (() => ( element . hidden = true ), error ? 12_000 : 6_000 ); 481}; 482return { element , notify }; 483} 484 485/** The conversation's toolbar, for the app's bar, and the transcript and composer, for its pane. */ 486export function conversation( 487scope: Scope, 488tid: string, 489shared: Shared, 490/** The view open over it, as ` < extension > ` or ` < extension > / < route > `, or `""`. */ 491viewing: Signal < string > = new Signal(""), 492): Element[] { 493const { element : notice , notify } = notifier (); 494const busy = new Signal ( false ); 495const shellEnabled = new Signal ( false ); 496let shellState : ShellState = {}; 497const commands = new Signal < readonly CommandInfo []>( EMPTY ); 498let aborting = false ; 499const { element : input , setText , restore } = composer ( 500scope , 501tid , 502busy , 503commands , 504async ( text , followUp , images ) => { 505// busy configuration waits for a boundary; new inputs must be able to queue before it 506if ( ! busy . get ()) await top . settle (); 507const command = text . match ( / ^\/(\S+)\s*([\s\S]*)$ / ); 508if ( shellEnabled . get () && text . startsWith ( "!" )) { 509if ( images . length ) throw new Error ( "Shell commands cannot include images" ); 510const excludeFromContext = text . startsWith ( "!!" ); 511await api ( "POST" , `/api/conversations/ ${ tid } /shell` , { 512command : text . slice ( excludeFromContext ? 2 : 1 ). trim (), 513excludeFromContext , 514}); 515} else if ( command ) { 516const { result } = await api <{ result : JsonValue }>( 517"POST" , 518`/api/conversations/ ${ tid } /commands/ ${ command [ 1 ]} ` , 519{ args : command [ 2 ] }, 520); 521notify ( 522result === null 523? `/ ${ command [ 1 ]} done` 524: typeof result === "string" 525? result 526: json ( result ), 527); 528} else { 529// saved diff edits report on leaving the view; an idle message sends after them instead 530if ( ! busy . get ()) await view . flush ?. (); 531const whenBusy = busy . get () ? followUp ? "followUp" : "steer" : undefined ; 532await api ( "POST" , `/api/conversations/ ${ tid } /messages` , { 533content : userContent ( text , images ), 534... ( whenBusy ? { whenBusy } : {}), 535}); 536} 537}, 538notify , 539async () => { 540if ( aborting ) return ; 541aborting = true ; 542try { 543const { inputs = [] } = await api <{ inputs ?: StoppedInput [] }>( 544"POST" , 545`/api/conversations/ ${ tid } /abort` , 546); 547await stopped . recover ( inputs ); 548} finally { 549aborting = false ; 550} 551}, 552( prefix ) => 553api < string []>( 554"GET" , 555`/api/conversations/ ${ tid } /references? ${ new URLSearchParams ({ prefix })} ` , 556), 557shellEnabled , 558); 559// a move along the tree shows where it went for as long as the conversation stays there 560let moved : Move | null = null ; 561const arrivals = new Set <{ move : Move ; arrive : () => void }>(); 562const point = < span class = "point" / > as HTMLElement ; 563const undo = < button type = "button" > undo < /button> as HTMLButtonElement; 564const continuation = ( 565< div class = "continuation" role = "status" hidden > 566{ point } 567{ undo } 568< / div> 569) as HTMLElement ; 570const arrived = () = > { 571for ( const arrival of arrivals ) { 572if ( ! arrival . move . landed ( latestEntry )) continue ; 573arrivals . delete ( arrival ); 574arrival . arrive (); 575} 576if ( moved !== null && latestEntry !== moved . at ) moved = null ; 577continuation . hidden = moved === null ; 578} ; 579undo.addEventListener("click", async () = > { 580const move = moved ; 581if ( move === null || move . previous === null || undo . disabled ) return ; 582undo . disabled = true ; 583try { 584await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId : move . previous }); 585const textarea = input . querySelector ( "textarea" ) ! ; 586if ( move . how === "edit" && textarea . value === move . entry . text ) setText ( "" ); 587else textarea . focus (); 588} catch ( error ) { 589undo . disabled = false ; 590notify ( error instanceof Error ? error . message : String ( error ), true ); 591} 592}); 593const local : CommandInfo [] = [ 594... branchCommands ({ tid } , (place) = > go ( place , setText ), async ( move ) => { 595await new Promise < void >(( arrive ) => { 596if ( move . landed ( latestEntry )) arrive (); 597else arrivals . add ({ move , arrive }); 598}); 599if ( move . how === "regenerate" ) { 600return notify ( "regenerating response" ); 601} 602if ( move . at === move . previous ) return ; 603moved = move ; 604const [ label , text ] = describe ( move . entry ); 605continuation . dataset . kind = move . entry . kind ; 606point . replaceChildren ( 607< span class = "verb" >{ move . how === "edit" ? "editing" : "continuing after" }</ span >, 608< span class = "label" >{ label }< / span>, 609< span class = "text" >{ text }< / span>, 610); 611undo . hidden = move . previous === null ; 612undo . disabled = false ; 613continuation . hidden = false ; 614}), 615{ 616name : " archive " , 617extension : " berry " , 618description : "archive this conversation" , 619run : async () => { 620await api ( "PATCH" , `/api/conversations/ ${ tid } ` , { archived : true } ); 621notify ( 622"this conversation was moved to the archive. you can use /restore to restore it to the main conversation list" , 623); 624}, 625}, 626]; 627commands . set ( local ); 628// the extensions' commands, asked for again whenever the conversation's extensions change 629let names : string | null = null ; 630let commandRequest = 0 ; 631const fetchCommands = async () => { 632const request = ++ commandRequest ; 633commands . set ( local ); 634try { 635const list = await api < CommandInfo []>( "GET" , `/api/conversations/ ${ tid } /commands` ); 636if ( request !== commandRequest ) return ; 637commands . set ([ 638... local , 639... list . filter (( c ) => ! local . some (( l ) => l . name === c . name )), 640]); 641} catch { 642// keep local commands when the target cannot be reached 643} 644}; 645 646const command = async ( name : string , args = "" ) => 647( 648await api <{ result : JsonValue | null }>( 649"POST" , 650`/api/conversations/ ${ tid } /commands/ ${ encodeURIComponent ( name )} ` , 651{ args }, 652) 653). result ; 654/** What an extension's frontend works with: its own handlers, and the user's commands. */ 655const contextOf = ( extension : string , agent : AgentState ) : HeaderContext => { 656const path = ( name : string ) => 657`/api/conversations/ ${ tid } /extensions/ ${ encodeURIComponent ( extension )} / ${ 658encodeURIComponent ( name ) 659} ` ; 660return { 661tid , 662model : agent . model ?? null , 663request : async ( name , args , options ) => 664( 665await api <{ result : JsonValue }>( 666"POST" , 667path ( name ), 668args === undefined ? {} : { args }, 669options ?. signal , 670) 671). result , 672connect : ( name , args ) => openChannel ( path ( name ), args ), 673command , 674}; 675}; 676const top = header ( scope , tid , shared , notify , contextOf , viewing ); 677const view = extensionView (); 678scope . add ( view . dispose ); 679let agent : AgentState = {}; 680let latestEntry : number | null = null ; 681const drawView = () = > { 682if ( latest === null ) return ; 683const [ name , ... route ] = viewing . get (). split ( "/" ); 684const create = withRenderers ( shared . extensions . get (), agent ) 685. find (( extension ) => extension . name === name )?. renderers . view ?. create ?? null ; 686view . update ( name ? { name , create } : null , { 687... contextOf ( name , agent ), 688route : viewRoute ( route . join ( "/" )), 689href : ( next ) => viewHref ( tid , name , next ), 690go : ( next ) => void ( location . hash = viewHref ( tid , name , next )), 691latest : latestEntry , 692}); 693}; 694scope . add ( viewing . subscribe ( drawView ). unsubscribe ); 695scope . add ( shared . extensions . subscribe ( drawView ). unsubscribe ); 696const history = transcript (); 697scope . add ( history . dispose ); 698const markRead = readWhenVisible ( 699scope , 700( entryId ) => api ( "POST" , `/api/conversations/ ${ tid } /read` , { entryId }), 701); 702const queued = inbox ( scope , tid ); 703const stopped = stoppedInputs ( scope , tid , restore , notify ); 704const activity = document . createTextNode ( "" ); 705const working = ( 706< div class = "working" role = "status" aria - live = "polite" hidden > 707< span class = "working-spinner" aria - hidden = "true" /> 708{ activity } 709< / div> 710) as HTMLElement ; 711 712// revisions are drawn a frame at a time: those arriving meanwhile, or while the page is hidden, draw as one 713let latest : ConversationView | null = null ; 714let frame = 0 ; 715const draw = () => { 716frame = 0 ; 717const { entries , docs } = latest ! ; 718const live = ( docs [ "pi.live" ] ?? IDLE ) as LiveState ; 719agent = ( docs [ "pi.agent" ] ?? {}) as AgentState ; 720latestEntry = entries . at ( - 1 )?. id ?? null ; 721arrived (); 722top . update ({ 723agent , 724usage : ( docs [ "pi.usage" ] ?? null ) as UsageState | null , 725entries , 726}); 727history . update ( entries , live , shellState . run ); 728markRead ( latest ! ); 729busy . set ( live . run !== undefined || shellState . run !== undefined ); 730const statuses = []; 731if ( busy . get ()) statuses . push ( "working…" ); 732if ( live . compactions ?. length ) statuses . push ( "compacting context…" ); 733const text = statuses . join ( " · " ); 734if ( activity . data !== text ) activity . data = text ; 735working . hidden = statuses . length === 0 ; 736shellEnabled . set ( selectedNames ( agent ). includes ( "code" )); 737const binding = entries . findLast (( entry ) => { 738if ( entry . kind !== "berry.event" ) return false ; 739const { ext , event } = entry . data as { ext : string ; event ?: { type ?: string } }; 740return ( ext === "environment" && event ?. type === "bind" ) || 741([ "code" , "coding" ]. includes ( ext ) && event ?. type === "cd" ); 742}); 743const selection = json ([ selectedNames ( agent ), binding ?. id ?? null ]); 744if ( selection !== names ) { 745names = selection ; 746void fetchCommands (); 747} 748drawView (); 749const items = ( docs [ "pi.inbox" ] as InboxState | undefined )?. items ?? EMPTY ; 750queued . update ( items ); 751}; 752scope . add ( 753watch < ConversationView >( `conversation: ${ tid } ` , ( view ) => { 754latest = view ; 755frame ||= requestAnimationFrame ( draw ); 756}), 757); 758scope . add ( 759connected . subscribe (( up ) => { 760if ( up && latest ) frame ||= requestAnimationFrame ( draw ); 761}). unsubscribe , 762); 763scope . add (() => cancelAnimationFrame ( frame )); 764scope . add ( watch < ShellState >( `shell: ${ tid } ` , ( state ) => { 765shellState = state ; 766if ( latest ) frame ||= requestAnimationFrame ( draw ); 767})); 768 769return [ 770top . element , 771< div class = "pane conversation" > 772{ scroller ( scope , history . element , history . last , history . grow )} 773{ queued . element } 774{ stopped . element } 775{ continuation } 776{ notice } 777{ working } 778{ input } 779< /div>, 780view . element , 781]; 782} 783 784/** 785* A branch not yet taken: `tid` as of its entry `at`, which sending a message continues from, or with `fork` starts a 786* new conversation from. 787*/ 788export function draft ( 789scope : Scope , 790tid : string , 791at : number , 792fork : boolean , 793) : Element [] { 794const history = transcript (); 795scope . add ( history . dispose ); 796const { element : notice , notify } = notifier (); 797api <{ entries : EntryRecord [] }>( 798"GET" , 799`/api/conversations/ ${ tid } /context?at= ${ at } ` , 800) 801. then (( context ) => history . update ( context . entries , IDLE )) 802. catch (( error ) => notify ( error . message , true )); 803 804const commands = new Signal < readonly CommandInfo []>( EMPTY ); 805const { element : input , setText } = composer ( 806scope , 807` ${ tid } ${ fork ? "/fork" : "" } @ ${ at } ` , 808new Signal ( false ), 809commands , 810async ( text , _followUp , images ) => { 811const command = text . match ( / ^\/(\S+) / )?.[ 1 ]; 812if ( command ) { 813throw new Error ( 814`/ ${ command } needs a conversation: send a message to start this one` , 815); 816} 817if ( ! fork ) { 818await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId : at }); 819await api ( "POST" , `/api/conversations/ ${ tid } /messages` , { 820content : userContent ( text , images ), 821}); 822return go ({ tid }, setText ); 823} 824const forked = await api <{ tid : string }>( 825"POST" , 826`/api/conversations/ ${ tid } /fork` , 827{ 828entryId : at , 829}, 830); 831await api ( "POST" , `/api/conversations/ ${ forked . tid } /messages` , { 832content : userContent ( text , images ), 833}); 834go ({ tid : forked . tid }, setText ); 835}, 836notify , 837undefined , 838( prefix ) => 839api < string []>( 840"GET" , 841`/api/conversations/ ${ tid } /references? ${ new URLSearchParams ({ prefix , at : String ( at ) })} ` , 842), 843); 844commands . set ( 845branchCommands ({ tid , at , fork }, ( place ) => go ( place , setText )), 846); 847 848return [ 849< header class = "bar draft-header" > 850< span >{ fork ? "new conversation from" : "new branch of" }</ span > 851< a href = { `#/ ${ tid } ` } class = "tid" > 852{ tid } 853< / a> 854< span class = "spacer" / > 855< span class = "note" > draft conversation turn : send a message < /span> 856</ header >, 857< div class = "pane conversation" > 858{ scroller ( scope , history . element , history . last , history . grow )} 859{ notice } 860{ input } 861< /div>, 862]; 863}