char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
871f159
main
1import type { Channel , ChannelMessage , View , ViewContext } from "@berry/api/web.ts" ; 2import type { Ghostty , Terminal } from "./ghostty.ts" ; 3import { Grid } from "./grid.ts" ; 4import { COLORS } from "./palette.ts" ; 5import { type ClientMessage , OUTPUT , type ServerMessage , SNAPSHOT } from "./protocol.ts" ; 6 7const FALLBACK = { width :8 , height :18 }; 8 9const css = ([ r , g , b ] :readonly number []) => `rgb( ${ r } ${ g } ${ b } )` ; 10 11const zoomKey = ( key :KeyboardEvent ) => 12( key . ctrlKey || key . metaKey ) && 13[ "Equal" , "Minus" , "Digit0" , "NumpadAdd" , "NumpadSubtract" , "Numpad0" ]. includes ( key . code ); 14 15/** A conversation's shell, drawn from the terminal state the server sends and the output that follows it. */ 16export function terminalView ( load :() => Promise < Ghostty >) :View { 17const rows = < div class = "terminal-rows" / > as HTMLElement ; 18const canvas = < canvas class = "terminal-grid" aria - hidden = "true" / > as HTMLCanvasElement ; 19const grid = new Grid ( rows , canvas ); 20const browser = document . defaultView ! ; 21const probe = < span class = "terminal-probe" aria - hidden = "true" >{ "M" . repeat ( 32 )} 22< / span > as HTMLElement ; 23const input = ( 24< textarea 25class = "terminal-input" 26aria - label = "terminal input" 27autocapitalize = "off" 28autocomplete = "off" 29spellcheck = { false } 30/ > 31) as HTMLTextAreaElement ; 32const screen = ( 33< div class = "terminal-screen" tabIndex = { - 1 } role = "region" aria - label = "terminal" > 34{ canvas} 35{ rows} 36{ probe} 37{ input} 38< / div> 39) as HTMLElement ; 40const status = < p class = "terminal-status-text" / > as HTMLElement ; 41const restart = ( 42< button type = "button" hidden _onclick = {() => send ({ t :"start" , ...size })}> 43start a new shell 44</ button > 45) as HTMLButtonElement ; 46const bar = < div class = "terminal-status" hidden >{ status}{ restart}< / div> as HTMLElement; 47const element = < div class = "terminal-view" >{ screen}{ bar}< / div> as HTMLElement; 48// the defaults the shell is told, so what it draws in them and what it draws explicitly agree 49element . style . backgroundColor = css ( COLORS . background ); 50screen . style . color = css ( COLORS . foreground ); 51 52let context :ViewContext | null = null ; 53let ghostty : Ghostty | null = null ; 54let terminal : Terminal | null = null ; 55let channel : Channel | null = null ; 56let disposed = false ; 57// only opening the view starts a shell by itself; after an exit, or a restart of the server, starting is asked for 58let autostart = true ; 59let retry = 0 ; 60let frame = 0 ; 61let cell = FALLBACK ; 62let size = { cols : 80 , rows : 24 }; 63 64const send = ( message : ClientMessage | Uint8Array ) => channel ?. send ( message ); 65 66function tell ( text : string | null , offerStart = false ) { 67bar . hidden = text === null ; 68status . textContent = text ?? "" ; 69restart . hidden = ! offerStart ; 70} 71 72function draw () { 73frame = 0 ; 74// a freed terminal's memory may hold anything by now 75if ( ! terminal || disposed ) return ; 76grid . update ( terminal . frame ()); 77} 78const redraw = () => void ( disposed || ( frame ||= requestAnimationFrame ( draw ))); 79 80function measure () { 81const box = probe . getBoundingClientRect (); 82cell = box . width > 0 ? { width : box . width / 32 , height : box . height } : FALLBACK ; 83} 84 85/** Fit the session to this view, which is the size it was opened, or last resized, at. */ 86function fit () { 87if ( disposed ) return ; 88measure (); 89const box = screen . getBoundingClientRect (); 90grid . fit ({ 91width : box . width || size . cols * cell . width , 92height : box . height || size . rows * cell . height , 93ratio : browser . devicePixelRatio || 1 , 94cell , 95}); 96const next = box . width > 0 97? { 98cols : Math . max ( 2 , Math . floor ( box . width / cell . width )), 99rows : Math . max ( 1 , Math . floor ( box . height / cell . height )), 100} 101: size ; 102if ( next . cols === size . cols && next . rows === size . rows ) return ; 103size = next ; 104terminal ?. resize ( size . cols , size . rows ); 105send ({ t : "resize" , ... size }); 106redraw (); 107} 108const resizing = typeof ResizeObserver === "undefined" ? null : new ResizeObserver ( fit ); 109resizing ?. observe ( screen ); 110// the probe changes size once the font loads, which changes how many cells fit 111resizing ?. observe ( probe ); 112let resolution : MediaQueryList | undefined ; 113let ratioFrame = 0 ; 114function watchRatio () { 115resolution ?. removeEventListener ( "change" , ratioChanged ); 116resolution = browser . matchMedia ( `(resolution: ${ browser . devicePixelRatio || 1 } dppx)` ); 117resolution . addEventListener ( "change" , ratioChanged ); 118} 119function ratioChanged () { 120ratioFrame ||= requestAnimationFrame (() => { 121ratioFrame = 0 ; 122if ( disposed ) return ; 123watchRatio (); 124fit (); 125}); 126} 127watchRatio (); 128browser . addEventListener ( "resize" , fit ); 129document . fonts ?. ready . then ( fit ); 130element . addEventListener ( "focusin" , () => grid . focus ( true )); 131element . addEventListener ( "focusout" , ( event ) => { 132grid . focus ( element . contains (( event as FocusEvent ). relatedTarget as Node | null )); 133}); 134 135function hear ( message : ChannelMessage ) { 136retry = 0 ; 137if ( message instanceof Uint8Array ) { 138if ( message [ 0 ] === SNAPSHOT ) { 139terminal ?. free (); 140terminal = ghostty ! . restore ( message . subarray ( 1 )); 141tell ( null ); 142element . classList . remove ( "exited" ); 143// the session takes this view's size, unless it already has it 144terminal . resize ( size . cols , size . rows ); 145send ({ t : "resize" , ... size }); 146} else if ( message [ 0 ] === OUTPUT ) terminal ?. write ( message . subarray ( 1 )); 147redraw (); 148return ; 149} 150const said = message as ServerMessage ; 151if ( said . t === "none" ) { 152if ( autostart ) send ({ t : "start" , ... size }); 153else tell ( "no shell is running here" , true ); 154} else if ( said . t === "exit" ) { 155element . classList . add ( "exited" ); 156tell ( `the shell exited ${ said . code === null ? "" : ` with ${ said . code } ` } ` , true ); 157} else if ( said . t === "size" ) { 158terminal ?. resize ( said . cols , said . rows ); 159redraw (); 160} else if ( said . t === "error" ) tell ( said . message , true ); 161autostart = false ; 162} 163 164async function connect () { 165if ( disposed || ! context ) return ; 166try { 167ghostty ??= await load (); 168const opened = channel = context . connect ( "session" ); 169for await ( const message of opened . messages ) { 170if ( disposed || channel !== opened ) return ; 171hear ( message ); 172} 173if ( disposed || channel !== opened ) return ; 174tell ( "disconnected: reconnecting…" ); 175} catch ( error ) { 176if ( disposed ) return ; 177tell ( ` ${ error instanceof Error ? error . message : String ( error )} : reconnecting…` ); 178} 179autostart = false ; 180setTimeout ( connect , Math . min ( 10_000 , 500 * 2 ** retry ++ )); 181} 182 183const selected = () = > { 184const selection = getSelection (); 185return selection !== null && ! selection . isCollapsed && screen . contains ( selection . anchorNode ); 186}; 187// firefox inserts line breaks between positioned glyphs in a native copy. 188screen . addEventListener ( "copy" , ( event ) => { 189const selection = getSelection (); 190const clipboard = ( event as ClipboardEvent ). clipboardData ; 191if ( 192! selection || selection . isCollapsed || ! screen . contains ( selection . anchorNode ) || ! clipboard 193) return ; 194clipboard . setData ( "text/plain" , selection . toString ()); 195event . preventDefault (); 196}); 197 198element . addEventListener ( "keydown" , ( event ) => { 199const key = event as KeyboardEvent ; 200if ( ! terminal || key . isComposing || key . key === "Process" || key . key === "Dead" ) return ; 201const command = key . ctrlKey || key . metaKey ; 202if ( zoomKey ( key )) return ; 203// the browser copies and pastes these; everything else is the shell's 204if ( command && key . code === "KeyC" && selected ()) return ; 205if (( command && key . code === "KeyV" ) || ( key . shiftKey && key . code === "Insert" )) return ; 206const bytes = terminal . key ( key ); 207if ( bytes === null ) return ; 208key . preventDefault (); 209send ( bytes ); 210terminal . scroll ( "bottom" ); 211redraw (); 212if ( document . activeElement !== input ) input . focus ({ preventScroll : true }); 213}); 214element . addEventListener ( "keyup" , ( event ) => { 215if ( zoomKey ( event as KeyboardEvent )) return ; 216const bytes = terminal ?. key ( event as KeyboardEvent , "release" ); 217if ( bytes ) send ( bytes ); 218}); 219// what reaches the textarea without a key press of its own: an input method's composition, or a mobile keyboard 220input . addEventListener ( "input" , ( event ) => { 221if ( ! input . value || ( event as InputEvent ). isComposing ) return ; 222send ( new TextEncoder (). encode ( input . value )); 223input . value = "" ; 224}); 225input . addEventListener ( "compositionend" , () => { 226if ( input . value ) send ( new TextEncoder (). encode ( input . value )); 227input . value = "" ; 228}); 229element . addEventListener ( "paste" , ( event ) => { 230const text = ( event as ClipboardEvent ). clipboardData ?. getData ( "text/plain" ); 231if ( ! terminal || ! text ) return ; 232event . preventDefault (); 233send ( terminal . paste ( text )); 234}); 235let scrolled = 0 ; 236screen . addEventListener ( "wheel" , ( event ) => { 237if (( event as WheelEvent ). ctrlKey ) return ; 238if ( ! terminal ) return ; 239event . preventDefault (); 240scrolled += ( event as WheelEvent ). deltaY / cell . height ; 241const rows = Math . trunc ( scrolled ); 242if ( rows === 0 ) return ; 243scrolled -= rows ; 244terminal . scroll ( rows ); 245redraw (); 246}, { passive : false }); 247screen . addEventListener ( "mouseup" , () => { 248if ( ! selected ()) input . focus ({ preventScroll : true }); 249}); 250 251return { 252element , 253update ( next ) { 254const first = context === null ; 255context = next ; 256if ( ! first ) return ; 257queueMicrotask (() => { 258fit (); 259input . focus ({ preventScroll : true }); 260}); 261void connect (); 262}, 263dispose () { 264disposed = true ; 265resizing ?. disconnect (); 266browser . removeEventListener ( "resize" , fit ); 267resolution ?. removeEventListener ( "change" , ratioChanged ); 268cancelAnimationFrame ( frame ); 269cancelAnimationFrame ( ratioFrame ); 270channel ?. close (); 271terminal ?. free (); 272terminal = null ; 273}, 274}; 275}