char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
bc405c3
main
1import { Signal } from "@char/aftercare" ; 2import type { ImageContent , UserMessage } from "@earendil-works/pi-ai" ; 3import { decodeBase64 , encodeBase64 } from "@std/encoding/base64" ; 4import { type DraftImage , imageDraft } from "./image-drafts.ts" ; 5import type { CommandInfo } from "./conversation.tsx" ; 6import type { Place } from "./tree.tsx" ; 7import { computed , keyed , Scope , swap } from "./ui.ts" ; 8 9type Completion = { value :string ; label :string ; description :string ; directory ?:boolean }; 10type Query = { kind :"command" ; prefix :string } | { 11kind :"reference" ; 12prefix :string ; 13start :number ; 14end :number ; 15}; 16 17/** The reference containing the caret, including its unfinished suffix. */ 18export function referenceAt ( text :string , start :number , end = start ) :Query | null { 19if ( start !== end ) return null ; 20const match = text . slice ( 0 , start ). match ( / (?:^|\s|[([{])@([^\s]*)$ / u ); 21if ( ! match ) return null ; 22const prefix = match [ 1 ]; 23return { 24kind :"reference" , 25 prefix, 26start :start - prefix . length , 27end :start + ( text . slice ( start ). match ( / ^[^\s]* / u )?.[ 0 ]. length ?? 0 ), 28}; 29} 30 31const EMPTY :readonly never [] = []; 32const drafts = new Map < string , string >(); 33let nextSendMenu = 0 ; 34 35function savedText ( route :string ) :string { 36if ( drafts . has ( route )) return drafts . get ( route ) ! ; 37try { 38return localStorage . getItem ( `berry.draft. ${ route } ` ) ?? "" ; 39} catch { 40return "" ; 41} 42} 43 44function saveText ( route :string , text :string ) { 45try { 46if ( text === "" ) localStorage . removeItem ( `berry.draft. ${ route } ` ); 47else localStorage . setItem ( `berry.draft. ${ route } ` , text ); 48drafts . delete ( route ); 49} catch { 50// navigation still preserves drafts when browser storage is unavailable 51drafts . set ( route , text ); 52} 53} 54 55/** Text for the composer of the view a choice in the tree opens. */ 56let handoff :{ route :string ; text :string } | null = null ; 57 58/** Show `place`, and its text in its composer. */ 59export function go ( place :Place , setText :( text :string ) => void ) { 60const draft = place . at === undefined ?"" :` ${ place . fork ? "/fork" : "" } @ ${ place . at } ` ; 61const route = `#/ ${ place . tid } ${ draft } ` ; 62if ( route === location . hash ) { 63if ( place . text !== undefined ) setText ( place . text ); 64return ; 65} 66handoff = place . text === undefined ?null :{ route, text :place . text }; 67location . hash = route ; 68} 69 70/** 71* `send` takes whatever is not a command that runs in the browser; a failure keeps the text. `abort`, where there is 72* something to abort, stops the conversation while it is busy. 73*/ 74export function composer ( 75scope :Scope , 76route :string , 77busy :Signal < boolean >, 78commands :Signal < readonly CommandInfo []>, 79send :( text :string , followUp :boolean , images :readonly ImageContent []) => Promise < void >, 80notify :( message :string , error ?:boolean ) => void , 81abort ?:() => Promise < unknown >, 82completeReferences ?:( prefix :string ) => Promise < readonly string []>, 83shellEnabled = new Signal ( false ), 84) :{ 85element :Element ; 86setText ( text :string ) :void ; 87restore ( content :UserMessage [ "content" ]) :Promise < boolean >; 88} { 89const input = ( 90< textarea 91rows = { 1 } 92placeholder = "message or /command" 93/> 94) as HTMLTextAreaElement ; 95// what submit would run as a shell command rather than send as a message 96const inShellMode = () => shellEnabled . get () && input . value . trimStart (). startsWith ( "!" ); 97const syncShellMode = () => input . classList . toggle ( "shell" , inShellMode ()); 98scope . add ( 99shellEnabled . subscribeImmediate (( enabled ) => { 100input . placeholder = enabled ?"message, /command, or !shell" :"message or /command" ; 101syncShellMode (); 102}). unsubscribe , 103); 104const draft = imageDraft ( route ); 105let pasting = 0 ; 106const attachments = < div class = "attachments" / > as HTMLElement ; 107const previews = keyed < DraftImage , string >( attachments , ( image ) => image . id , ( image ) => { 108const url = URL . createObjectURL ( image . blob ); 109const status = < span class = "attachment-status" role = "status" / > as HTMLElement ; 110const node = ( 111< div class = "attachment" > 112< img src = { url } alt = { image . name } /> 113< span class = "attachment-name" >{ image . name}< / span> 114{ status } 115< button 116type = "button" 117class = "danger" 118aria - label = { `Remove ${image . name } `} 119title = "Remove image" 120_onclick = {() => draft . remove ([ image . id ]). catch (( error ) => notify ( error . message , true ))} 121> 122× 123< / button> 124</ div > 125) as HTMLElement ; 126const update = ( next :DraftImage ) => { 127node . dataset . status = next . status ; 128status . textContent = next . status === "loading" ?"saving…" :next . error ?? "" ; 129status . hidden = status . textContent === "" ; 130}; 131update ( image ); 132return { node , update , dispose :() = > URL . revokeObjectURL ( url ) }; 133}); 134scope . add ( 135draft . items . subscribeImmediate (( items ) => { 136attachments . hidden = items . length === 0 ; 137previews . sync ( items ); 138}). unsubscribe , 139); 140scope . add ( previews . dispose ); 141const draftError = < div class = "attachment-error" role = "alert" / > as HTMLElement ; 142scope . add ( 143draft . error . subscribeImmediate (( error ) => { 144draftError . hidden = error === null ; 145draftError . textContent = error ; 146}). unsubscribe , 147); 148let query :Query | null = null ; 149let revision = 0 ; 150let timer :ReturnType < typeof setTimeout > | undefined ; 151const matches = new Signal < readonly Completion []>( EMPTY ); 152const sendOptionsOpen = new Signal ( false ); 153const selected = new Signal ( 0 ); 154const dismiss = () => { 155query = null ; 156revision ++ ; 157clearTimeout ( timer ); 158matches . set ( EMPTY ); 159}; 160const refreshMatches = () => { 161revision ++ ; 162clearTimeout ( timer ); 163if ( query ?. kind === "command" ) { 164selected . set ( 0 ); 165matches . set ( 166commands . get (). filter (( c ) => c . name . startsWith ( query ! . prefix )). map (( c ) => ({ 167value :`/ ${ c . name } ` , 168label :`/ ${ c . name } ` , 169description :c . description , 170})), 171); 172} else if ( query ?. kind === "reference" && completeReferences ) { 173const current= revision ; 174const prefix = query . prefix ; 175timer = setTimeout (() => { 176void completeReferences ( prefix ). then (( paths ) => { 177if ( current !== revision ) return ; 178selected . set ( 0 ); 179matches . set ( paths . map (( path ) => ({ 180value :path , 181label :`@ ${ path } ` , 182description :"" , 183directory :path . endsWith ( "/" ), 184}))); 185}). catch (() => {}); 186}, 100 ); 187} else { 188matches . set ( EMPTY ); 189} 190}; 191const updateQuery = () => { 192const previousKind = query ?. kind ; 193syncShellMode (); 194query = inShellMode () 195 ?null 196 :input . value . match ( / ^\/(\S*)$ / ) 197 ?{ kind :"command" , prefix :input . value . slice ( 1 ) } 198 :referenceAt ( input . value , input . selectionStart , input . selectionEnd ); 199if ( query ?. kind !== previousKind ) matches . set ( EMPTY ); 200refreshMatches (); 201}; 202scope . add ( dismiss ); 203scope . add ( commands . subscribe ( refreshMatches ). unsubscribe ); 204 205const resize = () = > { 206const frame= input . parentElement ! ; 207// intermediate measurements must not expand the transcript and clamp its scroll position 208frame . style . minHeight = ` ${ frame . getBoundingClientRect (). height } px` ; 209frame . classList . remove ( "stacked" ); 210input . style . height = "auto" ; 211input . style . overflowY = "hidden" ; 212// only soft wrapping should move the controls below the text 213const wrappedHeight = input . scrollHeight ; 214input . style . whiteSpace = "pre" ; 215const wraps = wrappedHeight > input . scrollHeight ; 216input . style . whiteSpace = "" ; 217frame . classList . toggle ( "stacked" , wraps ); 218// allow for subpixel rounding in the native textarea 219input . style . height = ` ${ input . scrollHeight + 1 } px` ; 220input . style . overflowY = "" ; 221frame . style . minHeight = "" ; 222}; 223const complete = ( item :Completion ) => { 224if ( ! query ) return ; 225const start = query . kind === "reference" ?query . start :0 ; 226const end = query . kind === "reference" ?query . end :input . value . length ; 227const suffix = query . kind === "reference" && ! item . directory && end === input . value . length 228 ?" " 229 :"" ; 230const replacement = item . value + suffix ; 231input . value = input . value . slice ( 0 , start ) + replacement + input . value . slice ( end ); 232input . setSelectionRange ( start + replacement . length , start + replacement . length ); 233saveText ( route , input . value ); 234resize (); 235input . focus (); 236if ( item . directory ) updateQuery (); 237else dismiss (); 238}; 239 240const setText = ( text :string ) = > { 241input . value = text ; 242saveText ( route , text ); 243syncShellMode (); 244dismiss (); 245// once it is laid out, which a new view is not yet 246requestAnimationFrame (() => { 247resize (); 248input . focus (); 249}); 250}; 251const saved = savedText ( route ); 252if ( saved !== "" ) setText ( saved ); 253if ( handoff ?. route === `#/ ${ route } ` ) { 254setText ( handoff . text ); 255 handoff= null ; 256} 257 258async function submit ( followUp :boolean ) { 259sendOptionsOpen . set ( false ); 260const original = input . value ; 261const text = original . trim (); 262if ( 263draft . submitting || 264( text === "" && ! draft . loading . get () && draft . items . get (). length === 0 && ! pasting ) 265) return ; 266draft . submitting = true ; 267try { 268if ( draft . loading . get ()) await draft . ready ; 269if ( pasting ) throw new Error ( "Wait for pasted images to finish saving" ); 270const captured = draft . items . get (); 271if ( text === "" && captured . length === 0 ) return ; 272if ( captured . some (( image ) => image . status !== "ready" )) { 273throw new Error ( "Wait for images to finish saving, or remove images with errors" ); 274} 275if ( captured . length > 0 && draft . error . get ()) throw new Error ( draft . error . get () ! ); 276const name = text . match ( / ^\/(\S+) / )?.[ 1 ]; 277if ( name && captured . length > 0 ) throw new Error ( "Slash commands cannot include images" ); 278if ( shellEnabled . get () && text . startsWith ( "!" ) && captured . length > 0 ) { 279throw new Error ( "Shell commands cannot include images" ); 280} 281const images :ImageContent [] = captured . length === 0 ?[] :await Promise . all ( 282captured . map ( async ( image ) => ({ 283type :"image" as const , 284mimeType :image . blob . type , 285data :encodeBase64 ( await image . blob . arrayBuffer ()), 286})), 287); 288const local = commands . get (). find (( command ) => command . run && command . name === name ); 289if ( local ) await local . run ! (); 290else await send ( text , followUp , images ); 291if ( captured . length > 0 ) await draft . remove ( captured . map (( image ) => image . id )); 292if ( input . value === original ) { 293input . value = "" ; 294if ( savedText ( route ) === original ) saveText ( route , "" ); 295dismiss (); 296syncShellMode (); 297resize (); 298} 299} catch ( error ) { 300notify ( error instanceof Error ?error . message :String ( error ), true ); 301} finally { 302draft . submitting = false ; 303} 304} 305 306input . addEventListener ( "paste" , ( event ) => { 307const clipboard = event . clipboardData ; 308if ( ! clipboard ) return ; 309const files = [ ...clipboard . items ]. flatMap (( item ) => { 310const file = item . kind === "file" && item . type . startsWith ( "image/" ) ?item . getAsFile () :null ; 311return file ?[ file ] :[]; 312}); 313if ( files . length === 0 ) return ; 314event . preventDefault (); 315const text = clipboard . getData ( "text/plain" ); 316if ( text ) { 317input . setRangeText ( text , input . selectionStart , input . selectionEnd , "end" ); 318saveText ( route , input . value ); 319resize (); 320updateQuery (); 321} 322pasting ++ ; 323void draft . add ( files ). catch (( error ) => notify ( error . message , true )). finally (() => pasting -- ); 324}); 325input . addEventListener ( "input" , () => { 326saveText ( route , input . value ); 327resize (); 328updateQuery (); 329}); 330input . addEventListener ( "click" , updateQuery ); 331input . addEventListener ( "keyup" , ( event ) => { 332if ([ "ArrowLeft" , "ArrowRight" , "Home" , "End" ]. includes ( event . key )) updateQuery (); 333}); 334input . addEventListener ( "keydown" , ( event ) => { 335if ( event . isComposing ) return ; 336const list = matches . get (); 337if ( event . key === "Escape" && query ) { 338event . preventDefault (); 339dismiss (); 340} else if ([ "ArrowDown" , "ArrowUp" ]. includes ( event . key ) && list . length > 0 ) { 341event . preventDefault (); 342selected . set ( 343( selected . get () + ( event . key === "ArrowDown" ?1 :- 1 ) + list . length ) % list . length , 344); 345suggestions . querySelectorAll ( "button" )[ selected . get ()]?. scrollIntoView ({ block :"nearest" }); 346} else if ( 347list . length > 0 && ( event . key === "Tab" || 348( event . key === "Enter" && query ?. kind === "reference" && ! event . shiftKey && ! event . altKey )) 349) { 350event . preventDefault (); 351complete ( list [ selected . get ()]); 352} else if ( 353event . key === "Enter" && ! event . shiftKey && ! event . isComposing && 354// an on-screen keyboard has no shift+enter for a new line; its buttons send instead 355! matchMedia ( "(pointer: coarse)" ). matches 356) { 357event . preventDefault (); 358void submit ( event . altKey ); 359} 360}); 361 362const suggestions = < div class = "suggestions" hidden = {matches. derive (( m ) => m . length === 0 )} / >; 363swap ( 364scope , 365suggestions , 366matches , 367( list , scope ) => 368list . map (( c , index ) => ( 369< button 370type = "button" 371class = { computed ( scope , [ selected ], () => selected . get () === index ? "selected" : "" )} 372_onmousedown = {( event ) => event . preventDefault ()} 373_onclick = {() => complete ( c )} 374> 375< span class = " command " title = { c . label }>{ c . label }</ span > 376< span class = " description ">{ c . description }</ span > 377</ button > 378)), 379); 380const sendMenuId = ` composer - send - $ { ++ nextSendMenu }`; 381const element = ( 382< div class = "composer" > 383{suggestions} 384{attachments} 385{draftError} 386< div class = "composer-input" > 387{input} 388< div class = "composer-controls" > 389< div 390class = "actions" 391// keep the keyboard up 392_onmousedown = {( event ) => event . preventDefault ()} 393> 394< button 395type = "button" 396class = " primary send " 397aria-label = "Send message" 398title = "Send message" 399hidden = { busy } 400_onclick = {() => void submit ( false )} 401> 402send 403</ button > 404< div class = " send-options " hidden = { computed ( scope , [ busy ], () => ! busy . get ())}> 405< button type = "button" class = " primary " _onclick = {() => void submit ( false )}> 406steer 407</ button > 408< button 409type = "button" 410class = " send-more " 411aria-label = "Other send options" 412title = "Other send options" 413aria-expanded = { sendOptionsOpen . derive ( String )} 414aria-controls = { sendMenuId } 415_onclick = {() => sendOptionsOpen . set ( ! sendOptionsOpen . get ())} 416> 417▾ 418</ button > 419< div 420id = { sendMenuId } 421class = " send-menu " 422role = "group" 423aria-label = "Other send options" 424hidden = { sendOptionsOpen . derive (( open ) => ! open )} 425> 426< button 427type = "button" 428title = "enqueue message for after response" 429_onclick = {() => { 430void submit ( true ); 431input . focus ({ preventScroll : true }); 432}} 433> 434follow up 435</ button > 436</ div > 437</ div > 438</ div > 439{ abort 440? ( 441< button 442type = "button" 443class = "abort" 444aria - label = "Abort response" 445title = "Abort response" 446hidden = { busy . derive (( b ) => ! b )} 447_onmousedown = {( event ) => event . preventDefault ()} 448_onclick = {() => abort (). catch (( error ) => notify ( error . message , true ))} 449/ > 450) 451: "" } 452</ div > 453< / div > 454< div class = "hints" > 455< span hidden = { busy }> 456< kbd > enter </ kbd > send 457</ span > 458< span hidden = {busy. derive (( b ) => ! b )}> 459< kbd > enter </ kbd > steer 460< / span> 461< span hidden = { busy . derive (( b ) => ! b )}> 462< kbd > alt </ kbd > 463< kbd > enter </ kbd > follow up 464</ span > 465< span > 466< kbd > shift </ kbd > 467< kbd > enter < / kbd> new line 468</ span > 469< span > 470< kbd > / </ kbd > commands · < kbd > @ </ kbd > files 471< span hidden = { shellEnabled . derive (( enabled ) => ! enabled )}> 472{ " · " } 473< kbd > ! </ kbd > shell · < kbd > !! </ kbd > shell (silent) 474</ span > 475</ span > 476< span > 477< kbd > tab < / kbd> complete 478</ span > 479< / div> 480</ div > 481); 482const sendOptions = element . querySelector ( ".send-options" ) ! ; 483const more = sendOptions . querySelector < HTMLButtonElement >( ".send-more" ) ! ; 484const dismissSendOptions = ( event : Event ) => { 485if ( ! sendOptions . contains ( event . target as Node )) sendOptionsOpen . set ( false ); 486}; 487const escapeSendOptions = ( event : KeyboardEvent ) => { 488if ( event . key !== "Escape" || ! sendOptionsOpen . get ()) return ; 489event . preventDefault (); 490event . stopPropagation (); 491const keyboard = sendOptions . contains ( document . activeElement ); 492sendOptionsOpen . set ( false ); 493( keyboard ? more : input ). focus ({ preventScroll : true }); 494}; 495document . addEventListener ( "pointerdown" , dismissSendOptions ); 496document . addEventListener ( "focusin" , dismissSendOptions ); 497document . addEventListener ( "keydown" , escapeSendOptions , true ); 498const viewport = input . ownerDocument . defaultView ! ; 499viewport . addEventListener ( "resize" , resize ); 500scope . add (() => { 501document . removeEventListener ( "pointerdown" , dismissSendOptions ); 502document . removeEventListener ( "focusin" , dismissSendOptions ); 503document . removeEventListener ( "keydown" , escapeSendOptions , true ); 504viewport . removeEventListener ( "resize" , resize ); 505}); 506scope . add ( 507busy . subscribe (() => { 508sendOptionsOpen . set ( false ); 509requestAnimationFrame ( resize ); 510}). unsubscribe , 511); 512return { 513element , 514setText , 515async restore ( content ) { 516await draft . ready ; 517if ( input . value !== "" || draft . items . get (). length || draft . submitting || pasting ) { 518return false ; 519} 520const blocks = typeof content === "string" ? [] : content ; 521const files = blocks . flatMap (( block , index ) => 522block . type === "image" 523? [ 524new File ([ decodeBase64 ( block . data )], `Restored image ${ index + 1 } ` , { 525type : block . mimeType , 526}), 527] 528: [] 529); 530const saving = files . length ? draft . add ( files ) : Promise . resolve (); 531setText ( 532typeof content === "string" 533? content 534: blocks . flatMap (( block ) => block . type === "text" ? [ block . text ] : []). join ( "\n" ), 535); 536await saving ; 537return true ; 538}, 539}; 540}