char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
5147173
main
1import type { 2AssistantMessage , 3ImageContent , 4SystemMessage , 5ToolResultMessage , 6UserMessage , 7} from "@earendil-works/pi-ai" ; 8import { 9getCurrentSystemMessage , 10getToolStateChanges , 11} from "@earendil-works/pi-ai/utils/transcript" ; 12import type { 13AgentState , 14ConversationView , 15EntryRecord , 16LiveState , 17} from "@earendil-works/pi-durable" ; 18import type { JsonValue } from "@berry/api" ; 19import type { ShellExecution } from "@berry/api/shell.ts" ; 20import type { CallStatus , ToolCall , ToolRenderer , ToolView } from "@berry/api/web.ts" ; 21import { code } from "./code.tsx" ; 22import { markdown , markdownView } from "./markdown.ts" ; 23import { toolRenderer } from "./renderers.ts" ; 24import { event } from "./event.tsx" ; 25import { isImageResult , toolResult } from "./tool-result.ts" ; 26import { type Handle , json , keyed , Scope } from "./ui.ts" ; 27import { watch } from "./client.ts" ; 28import { scroller , type Viewport } from "./scroller.tsx" ; 29 30type Block = AssistantMessage [ "content" ][ number ]; 31type ToolCallBlock = Extract < Block , { type :"toolCall" }>; 32type NestedCall = { 33name :string ; 34extension :string ; 35args :JsonValue ; 36status :"running" | "ok" | "error" | "cancelled" ; 37startedAt ?:number ; 38durationMs ?:number ; 39output ?:string ; 40details ?:JsonValue ; 41value ?:JsonValue ; 42error ?:string ; 43}; 44 45const EMPTY :readonly never [] = []; 46const IDLE :LiveState = {}; 47const textOf = ( content :readonly { type :string ; text ?:string }[]) => 48content 49. flatMap (( block ) => block . type === "text" && block . text ?[ block . text ] :[]) 50. join ( "\n" ); 51 52const userText = ({ content} :UserMessage ) => 53typeof content === "string" ?content :textOf ( content ); 54 55/** Give an element its text, hiding it while there is none. */ 56function show ( element :HTMLElement , text :string ) { 57if ( element . textContent === text ) return ; 58element . textContent = text ; 59element . hidden = text === "" ; 60} 61 62/** 63* How a card shows its body until the reader opens it: a `preview`, cut at a height; or `closed`, its header alone, 64* as a script's calls are, whose output tells what they did. 65*/ 66type Rest = "preview" | "closed" ; 67 68/** `250ms`, `12s`, `3m 07s`, `1h 02m`. */ 69function duration ( ms :number ) :string { 70if ( ms < 1000 ) return ` ${ Math . floor ( ms )} ms` ; 71const seconds = Math . floor ( ms / 1000 ); 72const minutes = Math . floor ( seconds / 60 ); 73if ( minutes === 0 ) return ` ${ seconds } s` ; 74if ( minutes < 60 ) { 75return ` ${ minutes } m ${ String ( seconds % 60 ). padStart ( 2 , "0" )} s` ; 76} 77return ` ${ Math . floor ( minutes / 60 )} h ${ String ( minutes % 60 ). padStart ( 2 , "0" )} m` ; 78} 79 80// one observer for every card, rather than one each; every card measures before any lays itself out again, so that 81// the page lays out once rather than once a card 82const measures = new Map < Element , () => (() => void ) | undefined >(); 83const resizes = new ResizeObserver (( entries ) => { 84const layouts = entries . map (({ target}) => measures . get ( target ) ?.()); 85for ( const layout of layouts ) layout ?.(); 86}); 87 88type Card = Handle < ToolCall > & { 89update ( call :ToolCall ) :void ; 90dispose () :void ; 91}; 92 93function cardFrame ( 94heading :Element [], 95content :HTMLElement , 96initial :Rest | "open" = "preview" , 97/** Called whenever it shows its body, which may build it. */ 98reveal = () => {}, 99) { 100const rest = initial === "open" ?"closed" :initial ; 101let view :Rest | "open" = initial ; 102let overflowing = false ; 103 104const header = ( 105< button 106type = "button" 107class = "tool-header" 108_onclick = {() => { 109view = view === "open" ?rest :"open" ; 110layout (); 111}} 112> 113{ heading } 114</ button > 115) as HTMLElement ; 116const blur = ( 117< div class = "tool-blur" aria - hidden = "true" > 118{[ 0 , 1 , 2 , 3 ]. map (() => < span />)} 119</ div > 120) as HTMLElement ; 121const more = ( 122< button 123type = "button" 124class = "tool-more" 125_onclick = {( event ) => { 126view = "open" ; 127layout (); 128if ( event . detail === 0 ) header . focus (); 129}} 130> 131show all 132</ button > 133) as HTMLElement ; 134const less = ( 135< button 136type = "button" 137class = "tool-less" 138_onclick = {() => { 139view = rest ; 140layout (); 141header . focus (); 142}} 143> 144show less 145</ button > 146) as HTMLElement ; 147const preview = ( 148< div class = "tool-preview" > 149{ content } 150{ blur } 151{ more } 152< / div> 153) as HTMLElement ; 154const element = ( 155< div class = "tool" > 156{ header } 157{ preview } 158{ less } 159< / div> 160) as HTMLElement ; 161 162function layout () { 163if ( view !== "closed" ) reveal (); 164element . dataset . view = view ; 165element . dataset . overflowing = String ( overflowing ); 166header . setAttribute ( 167"aria-expanded" , 168String ( view === "open" || ( view === "preview" && ! overflowing )), 169); 170more . hidden = view !== "preview" || ! overflowing ; 171less . hidden = view !== "open" ; 172blur . hidden = more . hidden ; 173content . inert = ! more . hidden ; 174} 175const measure = () => { 176const next = content . offsetHeight > preview . clientHeight + 1 ; 177if ( next === overflowing ) return ; 178return () => { 179overflowing = next ; 180layout (); 181}; 182}; 183 184layout (); 185for ( const observed of [ content , preview ]) { 186measures . set ( observed , measure ); 187resizes . observe ( observed ); 188} 189return { 190element , 191expand () { 192view = "open" ; 193layout (); 194}, 195dispose () { 196for ( const observed of [ content , preview ]) { 197resizes . unobserve ( observed ); 198measures . delete ( observed ); 199} 200}, 201}; 202} 203 204function card ( 205call : ToolCall , 206renderer : ToolRenderer | undefined , 207makeBody : () => ToolView = () => renderer ?. body ?. () ?? defaultBody (), 208initial : Rest | "open" = renderer ?. initial ?? "preview" , 209) : Card { 210let shown : ToolCall | null = null ; 211let ticking : ReturnType < typeof setInterval > | undefined ; 212const summary = < span class = "tool-summary" / > as HTMLElement ; 213const time = < span class = "tool-time" hidden /> as HTMLElement; 214const status = < span class = "tool-status" / > as HTMLElement ; 215const images = < div class = "tool-images" / >; 216const gallery = keyed < ImageContent , number >( 217images , 218( _ , index ) => index , 219( image , index ) => { 220const element = ( 221< img 222class = "image" 223alt = { `${ call . name } image result ${ index + 1 } ` } 224loading = "lazy" 225/ > 226) as HTMLImageElement ; 227const update = ( next : ImageContent ) => { 228element . src = `data: ${ next . mimeType } ;base64, ${ next . data } ` ; 229}; 230update ( image ); 231return { node : element , update }; 232}, 233); 234const content = < div >{ images }</ div > as HTMLElement ; 235// a card resting closed builds its body the first time it opens 236let body : ToolView | null = null ; 237const build = () => { 238if ( body ) return ; 239body = makeBody (); 240content . prepend ( body . element ); 241if ( shown === null ) return ; 242body . update ( shown ); 243gallery . sync ( shown . images ?? EMPTY ); 244}; 245const frame = cardFrame ( 246[< span class = "tool-name" >{ call . name }</ span >, summary , time , status ], 247content , 248initial , 249build , 250); 251// a running call's time ticks; an ended one keeps how long it took, where that is known 252const tick = () => { 253const { status , startedAt , durationMs } = shown ! ; 254const ms = status === "running" && startedAt !== null ? Date . now () - startedAt : durationMs ; 255const timeout = renderer ?. timeout ?. ( shown ! . args ) ?? null ; 256const elapsed = ms === null ? "" : duration ( Math . max ( 0 , ms )); 257show ( time , elapsed && timeout !== null ? ` ${ elapsed } / ${ duration ( timeout * 1000 )} ` : elapsed ); 258}; 259 260function update ( next : ToolCall ) { 261const previous = shown ; 262const keys = Object . keys ( next ) as ( keyof ToolCall )[]; 263if ( previous !== null && keys . every (( key ) => next [ key ] === previous [ key ])) { 264return ; 265} 266shown = next ; 267if ( next . args !== previous ?. args ) { 268summary . textContent = renderer ?. summary ?. ( next . args ) ?? "" ; 269} 270const outcome = next . status === "ok" ? renderer ?. outcome ?. ( next ) ?? null : null ; 271const state = outcome === null ? next . status : "error" ; 272status . textContent = outcome ?? next . status ; 273if ( next . status !== previous ?. status ) { 274if ( next . status === "running" ) ticking ??= setInterval ( tick , 100 ); 275else { 276clearInterval ( ticking ); 277ticking = undefined ; 278} 279} 280if ( state !== frame . element . dataset . status ) { 281frame . element . dataset . status = state ; 282// a failure shows in full, unless the card rests closed: then its header says it failed 283if ( state === "error" && initial === "preview" ) frame . expand (); 284} 285tick (); 286if ( body === null ) return ; 287body . update ( next ); 288gallery . sync ( next . images ?? EMPTY ); 289} 290 291update ( call ); 292return { 293node : frame . element , 294update , 295dispose () { 296clearInterval ( ticking ); 297frame . dispose (); 298gallery . dispose (); 299body ?. dispose ?. (); 300}, 301}; 302} 303 304function defaultBody () : ToolView { 305const sections = [ 306[ 307"args" , 308( c : ToolCall ) => c . args === null || json ( c . args ) === "{}" ? "" : json ( c . args ), 309], 310[ "output" , ( c : ToolCall ) => c . output ?? "" ], 311[ 312"result" , 313( c : ToolCall ) => 314c . result === null || isImageResult ( c . result ) 315? "" 316: typeof c . result === "string" 317? c . result 318: json ( c . result ), 319], 320[ "error" , ( c : ToolCall ) => c . error ?? "" ], 321] as const ; 322const pres = sections . map (([ name , pick ]) => ({ 323pick , 324pre : < pre class = { name } hidden / > as HTMLElement, 325})); 326return { 327element : < div class = "tool-body" >{ pres . map (({ pre }) => pre )}</ div >, 328update ( call ) { 329for ( const { pick , pre } of pres ) show ( pre , pick ( call )); 330}, 331}; 332} 333 334const SUBAGENT : ToolRenderer = { 335initial : "open" , 336summary : ( args ) => ( args as { task ?: string } | null )?. task ?? null , 337body () { 338const link = < a class = "tid" hidden > open subagent < /a> as HTMLAnchorElement; 339const model = < span class = "note" hidden /> as HTMLElement; 340const task = markdownView (); 341const answer = markdownView (); 342const error = < pre class = "error" hidden /> as HTMLElement; 343const fallback = < div >{ task . element }{ answer . element }</ div > as HTMLElement ; 344const content = < div />; 345const scope = new Scope (); 346let history : ReturnType < typeof transcript > | null = null ; 347const pane = scroller ( 348scope , 349content , 350() => history ?. last () ?? null , 351( pane ) => history ?. grow ( pane ), 352); 353pane . classList . add ( "subagent-history" ); 354pane . tabIndex = 0 ; 355pane . setAttribute ( "role" , "region" ); 356pane . setAttribute ( "aria-label" , "Subagent transcript" ); 357pane . hidden = true ; 358let tid : string | null = null ; 359let child : Scope | null = null ; 360let latest : ConversationView | null = null ; 361let agent : AgentState | undefined ; 362let frame = 0 ; 363let current : ToolCall ; 364const release = () => { 365child ?. dispose (); 366child = null ; 367history = null ; 368latest = null ; 369frame = 0 ; 370pane . hidden = true ; 371fallback . hidden = false ; 372content . replaceChildren (); 373}; 374const metadata = () => { 375const args = current . args as { 376model ?: { provider ?: string ; modelId ?: string }; 377thinkingLevel ?: string ; 378} | null ; 379const selected = agent ?. model ?? args ?. model ; 380const level = agent ?. thinkingLevel ?? args ?. thinkingLevel ; 381show ( 382model , 383[ 384selected ?. modelId ? ` ${ selected . provider } / ${ selected . modelId } ` : "" , 385level ? `thinking: ${ level } ` : "" , 386]. filter ( Boolean ). join ( " · " ), 387); 388}; 389return { 390element : ( 391< div class = "tool-body subagent-body" > 392< div class = "subagent-meta" >{ model }{ link }< /div> 393{ fallback } 394{ pane } 395{ error } 396< / div> 397), 398update ( call ) { 399current = call ; 400const args = call . args as { task ?: string ; conversation ?: string } | null ; 401const result = call . result as { conversation ?: string ; answer ?: string } | null ; 402const background = spawned ( call . result , call . details ); 403const next = ( call . details as { tid ?: string } | null )?. tid ?? 404result ?. conversation ?? args ?. conversation ?? null ; 405task . update ( args ?. task ?? "" , call . status !== "streaming" ); 406task . element . hidden = call . status === "ok" ; 407const text = typeof call . result === "string" 408? call . result . replace ( 409/ \n\n\(subagent conversation [^\n]+; pass it as conversation to continue\)$ / , 410"" , 411) 412: result ?. answer ?? "" ; 413answer . update ( text , true ); 414answer . element . hidden = text === "" || call . status === "error" ; 415show ( error , call . error ?? ( call . status === "error" ? text : "" )); 416if ( next !== tid || ( ! background && call . status !== "running" )) { 417release (); 418if ( next !== tid ) agent = undefined ; 419tid = next ; 420} 421link . hidden = tid === null ; 422if ( tid === null ) link . removeAttribute ( "href" ); 423else link . href = `#/ ${ tid } ` ; 424if ( tid !== null && child === null && ( call . status === "running" || background )) { 425child = new Scope (); 426history = transcript (); 427content . replaceChildren ( history . element ); 428child . add ( history . dispose ); 429child . add (() => cancelAnimationFrame ( frame )); 430child . add ( watch < ConversationView >( `conversation: ${ tid } ` , ( view ) => { 431latest = view ; 432agent = ( view . docs [ "pi.agent" ] as AgentState | undefined ) ?? agent ; 433frame ||= requestAnimationFrame (() => { 434frame = 0 ; 435const live = ( latest ! . docs [ "pi.live" ] ?? IDLE ) as LiveState ; 436// a background child that went idle is no longer followed, leaving the link behind 437if ( background && live . run === undefined ) return release (); 438history ! . update ( latest ! . entries , live ); 439pane . hidden = false ; 440fallback . hidden = true ; 441metadata (); 442}); 443})); 444} 445metadata (); 446}, 447dispose () { 448child ?. dispose (); 449scope . dispose (); 450}, 451}; 452}, 453}; 454 455/** Whether a subagent call spawned a background child, which outlives the call. */ 456const spawned = ( result : unknown , details : unknown ) = > 457( result as { status ?: string } | null )?. status === "running" || 458( details as { status ?: string } | null )?. status === "running" ; 459 460const nestedCall = ( record : NestedCall , finished : boolean ) : ToolCall => ({ 461name : record . name , 462args : record . args , 463// a script that ended, however it ended, leaves no foreground call running; a background 464// subagent outlives its script, so its last record still says so 465status : finished && record . status === "running" && 466! ( record . name === "subagent" && spawned ( record . value , record . details )) 467? "cancelled" 468: record . status , 469output : record . output ?? null , 470details : record . details ?? null , 471result : record . value ?? null , 472images : isImageResult ( record . value ) ? [ record . value ] : EMPTY , 473error : record . error ?? null , 474startedAt : record . startedAt ?? null , 475durationMs : record . durationMs ?? null , 476}); 477 478const CODEMODE : ToolRenderer = { 479initial : "open" , 480summary : ( args ) => 481( args as { code ?: string } | null )?. code 482?. split ( "\n" ) 483. find (( l ) => l . trim ()) ?? null , 484body : codemodeBody , 485}; 486 487function codemodeBody () : ToolView { 488const source = < div class = "script" / > as HTMLElement ; 489const nested = < div class = "nested" / >; 490const output = < pre class = "output" hidden /> as HTMLElement; 491const inputCard = cardFrame ([< span class = "tool-name" > input < /span>], source); 492const outputCard = cardFrame ( 493[< span class = "tool-name" > output < / span>], 494< div >{ output }< /div> as HTMLElement, 495); 496for ( const frame of [ inputCard , outputCard ]) { 497frame . element . classList . add ( "codemode-text" ); 498} 499let script : { text : string ; streaming : boolean } | null = null ; 500const calls = keyed <{ extension : string ; call : ToolCall }, number >( 501nested , 502( _ , index ) => index , 503({ extension , call }) => { 504const renderer = call . name === "subagent" && extension === "subagent" 505? SUBAGENT 506: toolRenderer ( call . name , extension ); 507const shown = card ( call , renderer , undefined , renderer === SUBAGENT ? "open" : "closed" ); 508return { 509node : shown . node , 510update : ({ call }) => shown . update ( call ), 511dispose : shown . dispose , 512}; 513}, 514); 515return { 516element : ( 517< div class = "tool-body" > 518{ inputCard . element } 519{ nested } 520{ outputCard . element } 521</ div > 522), 523update ( call ) { 524const text = ( call . args as { code ?: string } | null )?. code ?? "" ; 525inputCard . element . hidden = text === "" ; 526const streaming = call . status === "streaming" ; 527if ( text !== script ?. text || streaming !== script . streaming ) { 528script = { text , streaming }; 529// line numbers match the script's stack traces (`codemode.js:<line>`); highlighted once it is all written 530source . replaceChildren ( 531code ( text , streaming ? {} : { language : "javascript" }), 532); 533} 534const finished = call . status !== "running" && 535call . status !== "streaming" && 536call . status !== "pending" ; 537const records = ( call . details as { calls ?: NestedCall [] } | null )?. calls ?? EMPTY ; 538calls . sync ( 539records . map (( record ) => ({ 540extension : record . extension , 541call : nestedCall ( record , finished ), 542})), 543); 544const result = typeof call . result === "string" ? call . result : ( call . error ?? "" ); 545show ( output , result . replace ( / ^Script (completed|failed)\n? / , "" )); 546outputCard . element . hidden = output . hidden ; 547}, 548dispose () { 549calls . dispose (); 550inputCard . dispose (); 551outputCard . dispose (); 552}, 553}; 554} 555 556function toolCard ( call : ToolCall ) : Card { 557return card ( 558call , 559call . name === "codemode" 560? CODEMODE 561: call . name === "subagent" 562? SUBAGENT 563: toolRenderer ( call . name ), 564); 565} 566 567function shellCall ( run : ShellExecution ) : ToolCall { 568return { 569name : "bash" , 570args : { command : run . command }, 571status : run . status , 572output : ( run . details as { rawOutput ?: string } | null )?. rawOutput ?? null , 573details : run . details , 574result : run . result , 575error : run . error , 576startedAt : run . startedAt , 577durationMs : run . durationMs , 578}; 579} 580 581type Report = { 582tid ?: string ; 583task ?: string ; 584status ?: "completed" | "failed" | "stopped" ; 585answer ?: string ; 586error ?: string ; 587}; 588 589/** A background subagent's report: what it was asked, how it ended, and a link back to it. */ 590function reportCard ( data : Report | null ) : Element { 591const status = data ?. status ?? "completed" ; 592const text = ( status === "failed" ? data ?. error : data ?. answer ) ?? "" ; 593return ( 594< div class = "report" > 595< div class = "subagent-meta" > 596< span class = { status === "failed" ? "error" : "" }> subagent { status }< /span> 597{ data ?. tid ? < a class = "tid" href = { `#/${ data . tid } ` }> open subagent < /a> : []} 598</ div > 599{ data ? . task ? < div class = "report-task" >{ data . task }< / div > : []} 600{ text ? markdown ( text ) : []} 601</ div > 602) as HTMLElement ; 603} 604 605function shellCard ( run : ShellExecution ) : Card { 606const shown = card ( shellCall ( run ), toolRenderer ( "bash" , "code" ), undefined , "open" ); 607const name = ( shown . node as Element ). querySelector ( ".tool-name" ) ! ; 608name . textContent = run . excludeFromContext ? "!! shell" : "! shell" ; 609return shown ; 610} 611 612/** A block of a response, whether the response is still being generated, and whether the block is its newest. */ 613type Shown = { block : Block ; generating : boolean ; last : boolean }; 614 615function contentBlock ( first : Shown , toolCall : ( shown : Shown ) => Handle < Shown >) : Handle < Shown > { 616if ( first . block . type === "toolCall" ) return toolCall ( first ); 617const body = markdownView (); 618if ( first . block . type === "thinking" ) { 619// one element while it streams, so it stays as the reader left it 620const details = ( 621< details class = "thinking" open hidden > 622< summary > thinking </ summary > 623{ body . element } 624</ details > 625) as HTMLElement ; 626const pending = ( 627< div class = "thinking-pending" role = "status" aria - label = "thinking…" hidden > 628{[ ... "thinking..." ]. map (( letter , index ) => ( 629< span 630aria-hidden = "true" 631_also = {( span ) => { 632span . style . animationDelay = `- ${ index * 60 } ms` ; 633}} 634> 635{ letter } 636</ span > 637))} 638</ div > 639) as HTMLElement ; 640const update = ({ block , last } : Shown ) => { 641// empty, as encrypted reasoning is, it shows only while it is being thought 642const text = block . type === "thinking" && ! block . redacted ? block . thinking . trim () : "" ; 643body . update ( text , ! last ); 644details . hidden = text === "" ; 645pending . hidden = text !== "" || ! last ; 646}; 647update ( first ); 648return { 649node : ( 650< div class = "block" > 651{ details } 652{ pending } 653< / div> 654), 655update , 656}; 657} 658const update = ({ block , last } : Shown ) => 659body . update ( block . type === "text" ? block . text : "" , ! last ); 660update ( first ); 661return { node : < div class = "block" >{ body . element }< /div>, update }; 662} 663 664function timestamp ( value : number ) : HTMLTimeElement { 665const time = document . createElement ( "time" ); 666time . className = "message-time" ; 667setTimestamp ( time , value ); 668return time ; 669} 670 671function setTimestamp ( time : HTMLTimeElement , value : number ) { 672const date = new Date ( value ); 673if ( ! Number . isFinite ( date . getTime ())) { 674time . removeAttribute ( "datetime" ); 675time . title = "" ; 676time . textContent = "" ; 677return ; 678} 679const iso = date . toISOString (); 680if ( time . dateTime === iso ) return ; 681time . dateTime = iso ; 682time . title = date . toLocaleString (); 683time . textContent = date . toLocaleTimeString ([], { hour : "numeric" , minute : "2-digit" }); 684} 685 686/** A response; `update` draws its next revision, and whether it is still being generated. */ 687function assistant ( 688message : AssistantMessage , 689generating : boolean , 690toolCall : ( shown : Shown ) => Handle < Shown >, 691) { 692const blocks = < div class = "blocks" / >; 693const time = timestamp ( message . timestamp ); 694const error = < div class = "error" hidden /> as HTMLElement; 695const aborted = ( 696< div class = "note" hidden > 697aborted 698< / div> 699) as HTMLElement ; 700// a block keeps its node while it streams; a tool call keeps its card 701const list = keyed < Shown , string >( 702blocks , 703({ block }, index ) => ` ${ index } : ${ block . type } ` , 704( shown ) => contentBlock ( shown , toolCall ), 705); 706const update = ( message : AssistantMessage , generating : boolean ) => { 707const { content } = message ; 708setTimestamp ( time , message . timestamp ); 709list . sync ( 710content . map (( block , index ) => ({ 711block , 712generating , 713last : generating && index === content . length - 1 , 714})), 715); 716show ( 717error , 718message . stopReason === "error" ? ( message . errorMessage ?? "error" ) : "" , 719); 720aborted . hidden = message . stopReason !== "aborted" ; 721}; 722update ( message , generating ); 723return { 724node : ( 725< div class = "message assistant" > 726{ blocks } 727{ error } 728{ aborted } 729{ time } 730< / div> 731), 732update , 733dispose : list . dispose , 734}; 735} 736 737function user ( message : UserMessage ) : Element { 738const content = typeof message . content === "string" 739? [{ type : "text" as const , text : message . content }] 740: message . content ; 741return ( 742< div class = "message user" > 743{ content . map (( block , index ) => 744block . type === "text" ? < div class = "text" >{ block . text }</ div > : ( 745< img 746class = "image" 747alt = { `Attached image ${ index + 1 } ` } 748loading = "lazy" 749src = { `data:${ block . mimeType } ;base64,${ block . data } ` } 750/ > 751) 752)} 753{ timestamp ( message . timestamp )} 754</ div > 755); 756} 757 758/** A `<details>` whose body is built the first time it opens, since most never are. */ 759function lazyDetails ( summary : Element , body : () => Node [], className ?: string ) : HTMLElement { 760const details = < details >{ summary }</ details > as HTMLDetailsElement ; 761if ( className ) details . className = className ; 762let built = false ; 763details . addEventListener ( "toggle" , () => { 764if ( ! details . open || built ) return ; 765built = true ; 766details . append ( ... body ()); 767}); 768return details ; 769} 770 771type PromptChange = { 772id : number ; 773before : SystemMessage | undefined ; 774messages : SystemMessage []; 775}; 776 777function system () { 778const summary = < summary /> as HTMLElement ; 779const body = < div /> as HTMLElement ; 780let current : PromptChange ; 781let built = false ; 782const renderBody = () = > { 783body . replaceChildren ( ... current . messages . flatMap (( message ) => [ 784... Object . entries ( message . sections ?? {}). flatMap (([ key , value ]) => 785value === null ? [] : [< h4 >{ key }</ h4 >, < pre >{ value }</ pre >] 786), 787... ( message . content . length > 0 788? [ 789< pre > 790{ typeof message . content === "string" ? message . content : textOf ( message . content )} 791</ pre >, 792] 793: []), 794])); 795}; 796const update = ( change : PromptChange ) = > { 797current = change ; 798const { before , messages } = change ; 799const after = getCurrentSystemMessage ( before ? [ before , ... messages ] : messages ); 800const previous = before ?. sections ?? {}; 801const sections = after ?. sections ?? {}; 802const { toolsAdded , toolsRemoved } = getToolStateChanges ( 803before ?. toolsAdded ?? [], 804after ?. toolsAdded ?? [], 805); 806const name = ( key : string , sign = "" ) => < span class = "system-name" >{ sign + key }< /span>; 807const group = ( kind : string , names : Element []) => 808names . length > 0 809? [ 810new Text ( " " ), 811< span class = "system-kind" >{ kind }< / span>, 812< span class = "system-names" >{ names . flatMap (( n ) => [ new Text ( " " ), n ])}</ span >, 813] 814: []; 815summary . replaceChildren ( 816< span class = "system-label" > prompt < / span>, 817< span class = "system-groups" > 818{ group ( 819"sections" , 820[ 821... Object . entries ( sections ). flatMap (([ key , value ]) => 822previous [ key ] === value ? [] : [ name ( key , Object . hasOwn ( previous , key ) ? "" : "+" )] 823), 824... Object . keys ( previous ). filter (( key ) => ! Object . hasOwn ( sections , key )) 825. map (( key ) => name ( key , "-" )), 826] , 827)} 828{ group ( "tools" , [ 829... toolsAdded . map (( tool ) => name ( tool . name , "+" )), 830... toolsRemoved . filter (( tool ) => ! toolsAdded . some (( added ) => added . name === tool . name )) 831. map (( tool ) => name ( tool . name , "-" )), 832])} 833< /span>, 834); 835if ( built ) renderBody (); 836}; 837const node = lazyDetails ( summary , () => { 838built = true ; 839renderBody (); 840return [ body ]; 841}, "system" ); 842return { node , update }; 843} 844 845/** How many entries to draw at once, which is the window a transcript opens with. */ 846const BATCH = 16 ; 847/** How much drawn history to keep above the reader before drawing more, in viewports. */ 848const REACH = 1.5 ; 849 850/** The transcript of a conversation's active entries, then the response being generated; `update` draws a revision. */ 851export function transcript () { 852let results = new Map < string , ToolResultMessage >(); 853/** How each call's card draws itself again from its result and the live state. */ 854const calls = new Map < string , () => void >(); 855const prompts = new Map < number , ReturnType < typeof system >>(); 856const changes = new Map < number , PromptChange >(); 857let lastPrompt : PromptChange | undefined ; 858let promptState : SystemMessage | undefined ; 859let entries : readonly EntryRecord [] = EMPTY ; 860/** The entries that draw: a result shows on its call's card. */ 861let drawn : readonly EntryRecord [] = EMPTY ; 862let live = IDLE ; 863 864/** A call: as the model writes it, then its live slot while the round runs, then its result. */ 865function call ( first : Shown ) : Handle < Shown > { 866let current = first ; 867const id = ( first . block as ToolCallBlock ). id ; 868const state = () : ToolCall => { 869const block = current . block as ToolCallBlock ; 870const result = results . get ( id ) ?? null ; 871const slot : ( NonNullable < LiveState [ "tools" ]>[ number ] & { startedAt ?: number }) | null = 872live . tools ?. find (( slot ) => slot . callId === id ) ?? null ; 873const status : CallStatus = result 874? result . isError ? "error" : "ok" 875: current . generating 876? "streaming" 877: slot ?. status === "running" 878? "running" 879: live . run !== undefined 880? "pending" 881: "cancelled" ; 882const details = ( result ?. details ?? slot ?. details ?? null ) as JsonValue | null ; 883const timing = details as { startedAt ?: number ; durationMs ?: number } | null ; 884return { 885name : block . name , 886args : ( block . arguments ?? null ) as JsonValue | null , 887status , 888output : slot ?. output ?? null , 889details , 890... toolResult ( result ), 891startedAt : slot ?. startedAt ?? timing ?. startedAt ?? null , 892durationMs : result ?. durationMs ?? timing ?. durationMs ?? null , 893}; 894}; 895const view = toolCard ( state ()); 896const refresh = () => view . update ( state ()); 897calls . set ( id , refresh ); 898return { 899node : view . node , 900update ( next ) { 901current = next ; 902refresh (); 903}, 904dispose () { 905calls . delete ( id ); 906view . dispose (); 907}, 908}; 909} 910 911// committed entries never change, so their handles take no updates; only their calls' cards do 912function entry ( record : EntryRecord ) : Handle < EntryRecord > { 913const message = record . model ?.[ 0 ]; 914switch ( record . kind ) { 915case "pi.user" : 916return { node : user ( message as UserMessage ) }; 917case "pi.assistant" : { 918// the response that streamed in keeps its nodes, and how the reader left them, once it commits 919let view = live . generation ?. message === undefined ? partial : null ; 920if ( view ) { 921partial = null ; 922view . update ( message as AssistantMessage , false ); 923} else view = assistant ( message as AssistantMessage , false , call ); 924return { node : view . node , dispose : view . dispose }; 925} 926case "pi.system" : { 927const view = system (); 928view . update ( changes . get ( record . id ) ! ); 929prompts . set ( record . id , view ); 930return { node : view . node , dispose : () => prompts . delete ( record . id ) }; 931} 932case "pi.reset" : 933return { 934node : ( 935< div class = "divider" > 936context reset 937{ message 938? lazyDetails (< summary > handoff </ summary > , () => [ 939markdown ( userText ( message as UserMessage )), 940] ) 941: undefined } 942</ div > 943), 944}; 945case "pi.compaction" : 946return { 947node: lazyDetails (< summary class = "divider" > compacted < / summary>, () => [ 948markdown ( userText ( message as UserMessage )), 949]), 950}; 951case "berry.shell" : { 952const shown = shellCard ( record . data as ShellExecution ); 953return { node : shown . node , dispose : shown . dispose }; 954} 955case "berry.event" : 956return { node : event ( record . data ) ?? document . createComment ( "event" ) }; 957case "berry.subagent-report" : 958return { node : reportCard ( record . data as Report | null ) }; 959default : 960return { 961node : lazyDetails ( 962< summary >{ record . kind }</ summary >, 963() => [< pre >{ json ( record . data ?? null )}</ pre >], 964"system" , 965), 966}; 967} 968} 969 970const list = < div class = "entries" / > as HTMLElement ; 971const rows = keyed < EntryRecord , number >( list , ( record ) => record . id , entry ); 972/** The index in `drawn` of the oldest entry drawn; the ones above it wait, as padding, until the reader reaches them. */ 973let first = 0 ; 974/** What an entry not drawn yet is taken to take, from the ones that are, so the scrollbar shows the history's size. */ 975let mean = 96 ; 976let measured = false ; 977const pad = () = > { 978list . style . paddingTop = first === 0 ? "" : ` ${ Math . round ( first * mean )} px` ; 979}; 980const draw = () = > { 981pad (); 982rows . sync ( drawn . slice ( first )); 983}; 984/** Take the mean height of an entry from the ones drawn, for the ones that are not. */ 985const measure = () = > { 986const count = list . children . length ; 987const content = list . offsetHeight - first * mean ; 988if ( count === 0 || content <= 0 ) return ; 989measured = true ; 990mean = content / count ; 991}; 992/** Run `draw`, changing what stands in for the rest, with the oldest entry drawn staying where the reader sees it. */ 993const holding = ( pane : Viewport , draw : () => void ) => { 994const oldest = list . firstElementChild ; 995const before = oldest ?. getBoundingClientRect (). top ?? 0 ; 996draw (); 997if ( oldest !== null ) pane . scrollTop += oldest . getBoundingClientRect (). top - before ; 998}; 999/** 1000* Draw more of the history as the reader reaches what is drawn, holding what they see in place. Only entries that are 1001* drawn are laid out, so a long history costs what has been read rather than what it holds. 1002*/ 1003const grow = ( pane : Viewport ) => { 1004if ( pane . clientHeight === 0 ) return ; 1005// taken as soon as the history is drawn, so that it is not taken later, while the reader is up in it 1006if ( ! measured ) { 1007holding ( pane , () => { 1008measure (); 1009pad (); 1010}); 1011} 1012if ( first === 0 ) return ; 1013// how far the drawn history starts above the viewport's top, which is negative once the reader is above all of it 1014const reach = pane . scrollTop - first * mean ; 1015// a reader who has gone above what is drawn, as dragging the scrollbar to the top does, wants the entries there, not 1016// a place kept over the gap: whoever is inside the drawn history keeps it, and one over the gap gets drawn to 1017const wanted = reach < 0 ? 0 : pane . clientHeight * REACH ; 1018if ( reach >= wanted ) return ; 1019const batches = Math . ceil (( wanted - reach ) / ( BATCH * mean )); 1020if ( wanted > 0 ) { 1021holding ( pane , () => { 1022first = Math . max ( 0 , first - batches * BATCH ); 1023draw (); 1024}); 1025} else { 1026first = Math . max ( 0 , first - batches * BATCH ); 1027draw (); 1028} 1029}; 1030const pending = < div class = "pending" / >; 1031let partial : ReturnType < typeof assistant > | null = null ; 1032const shellPending = < div />; 1033let shell : { taskId : number ; card : Card } | null = null ; 1034 1035return { 1036element : ( 1037< div class = "transcript" > 1038{list} 1039{pending} 1040{shellPending} 1041< / div> 1042), 1043grow , 1044last () : Element | null { 1045const row = shellPending . lastElementChild ?? pending . lastElementChild ?? 1046list . lastElementChild ; 1047if ( ! row ?. classList . contains ( "assistant" )) return row ; 1048return row . querySelector ( ":scope > .error:not([hidden]), :scope > .note:not([hidden])" ) ?? 1049row . querySelector ( ":scope > .blocks" )?. lastElementChild ?? row ; 1050}, 1051update ( 1052nextEntries : readonly EntryRecord [], 1053nextLive : LiveState , 1054nextShell ?: ShellExecution , 1055) { 1056if ( 1057nextShell && 1058nextEntries . some ( 1059( entry ) => 1060entry . kind === "berry.shell" && 1061( entry . data as ShellExecution ). taskId === nextShell ?. taskId , 1062) 1063) { 1064nextShell = undefined ; 1065} 1066if ( shell ?. taskId !== nextShell ?. taskId ) { 1067shell ?. card . dispose (); 1068shell = nextShell ? { taskId : nextShell . taskId , card : shellCard ( nextShell ) } : null ; 1069shellPending . replaceChildren ( ... ( shell ? [ shell . card . node ] : [])); 1070} 1071if ( shell && nextShell ) shell . card . update ( shellCall ( nextShell )); 1072const moved = nextLive !== live ; 1073live = nextLive ; 1074// the calls whose result this revision changed, or every call when it folded the history again 1075let answered : Set < string > | null = new Set (); 1076if ( nextEntries !== entries ) { 1077// a revision mostly appends, and committed entries never change, so the old ends standing say the rest stands 1078// too; only a revision that changes what came before folds the history again 1079const appended = entries . length > 0 && nextEntries . length >= entries . length && 1080nextEntries [ 0 ] === entries [ 0 ] && nextEntries [ entries . length - 1 ] === entries . at ( - 1 ); 1081const from = appended ? entries . length : 0 ; 1082if ( ! appended ) { 1083results = new Map (); 1084changes . clear (); 1085lastPrompt = undefined ; 1086promptState = undefined ; 1087drawn = EMPTY ; 1088answered = null ; 1089} 1090entries = nextEntries ; 1091const added : EntryRecord [] = []; 1092const changedPrompts = new Set < number >(); 1093for ( const record of entries . slice ( from )) { 1094const messages = ( record . model ?? []). filter (( message ) => message . role === "system" ); 1095if ( record . kind === "pi.system" ) { 1096// reorder patches share a timestamp, but may arrive in separate revisions 1097if ( ! lastPrompt || lastPrompt . messages [ 0 ]?. timestamp !== messages [ 0 ]?. timestamp ) { 1098lastPrompt = { id : record . id , before : promptState , messages : [] }; 1099changes . set ( record . id , lastPrompt ); 1100added . push ( record ); 1101} 1102lastPrompt . messages . push ( ... messages ); 1103changedPrompts . add ( lastPrompt . id ); 1104} else { 1105lastPrompt = undefined ; 1106if ( record . kind !== "pi.tool-result" && record . kind !== "berry.start" ) { 1107added . push ( record ); 1108} 1109} 1110if ( messages . length ) { 1111promptState = getCurrentSystemMessage ( 1112promptState ? [ promptState , ... messages ] : messages , 1113); 1114} 1115for ( const message of record . model ?? []) { 1116if ( message . role !== "toolResult" ) continue ; 1117results . set ( message . toolCallId , message ); 1118answered ?. add ( message . toolCallId ); 1119} 1120} 1121for ( const id of appended ? changedPrompts : prompts . keys ()) { 1122const change = changes . get ( id ); 1123if ( change ) prompts . get ( id )?. update ( change ); 1124} 1125drawn = [ ... drawn , ... added ]; 1126// a history drawn afresh opens at its newest entries, and takes its estimate of the rest again 1127if ( ! appended ) { 1128first = Math . max ( 0 , drawn . length - BATCH ); 1129measured = false ; 1130} 1131draw (); 1132} 1133// a call with its result is done with the live state 1134for ( const [ id , refresh ] of calls ) { 1135if ( answered === null || answered . has ( id ) || ( moved && ! results . has ( id ))) refresh (); 1136} 1137if ( ! moved ) return ; 1138const message = ( live . generation ?. message ?? 1139null ) as AssistantMessage | null ; 1140if ( message === null ) { 1141partial ?. dispose (); 1142partial = null ; 1143pending . replaceChildren (); 1144} else if ( partial === null ) { 1145partial = assistant ( message , true , call ); 1146pending . replaceChildren ( partial . node ); 1147} else partial . update ( message , true ); 1148}, 1149dispose () { 1150rows . dispose (); 1151partial ?. dispose (); 1152shell ?. card . dispose (); 1153}, 1154}; 1155}