char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
10b0cd2
main
1import type { Signal } from "@char/aftercare" ; 2import { type ProjectIndex , workspacePath } from "@berry/api/projects.ts" ; 3import { api } from "./client.ts" ; 4import type { Scope } from "./ui.ts" ; 5 6export type FolderOptions = { 7scope :Scope ; 8environment :Signal < string >; 9base :() => string | null ; 10projects ?:Signal < ProjectIndex >; 11}; 12 13type Suggestion = { path :string ; project ?:string ; platform ?:string }; 14 15let nextList = 0 ; 16 17export function folderInput ({ scope, environment, base, projects} :FolderOptions ) { 18const id = `folder-options- ${ ++ nextList } ` ; 19const input = ( 20< input 21id = { ` ${ id } -input` } 22type = "text" 23role = "combobox" 24aria-autocomplete = "list" 25aria-expanded = "false" 26aria-controls = { id } 27autocomplete = "off" 28spellcheck = { false } 29placeholder = "/path/to/my-directory/" 30/> 31) as HTMLInputElement ; 32const list = < div id = { id } role = "listbox" class = "folder-options" / > as HTMLElement ; 33const status = < p class = "folder-status" role = "status" / > as HTMLElement ; 34const menu = < div class = "folder-menu" hidden >{ list }{ status }< / div > as HTMLElement ; 35const element = < div class = "folder-input" >{ input }{ menu }< / div > as HTMLElement; 36let suggestions: Suggestion[] = []; 37// the last answer for this machine, which stands in, narrowed, while the next is fetched 38let found: string[] = []; 39let selected = -1; 40let revision = 0; 41let timer: ReturnType < typeof setTimeout > | undefined; 42let request: AbortController | undefined; 43let focused = false; 44 45const draw = () = > { 46list . replaceChildren ( ...suggestions . map (( suggestion , index ) => ( 47< button 48type = "button" 49role = "option" 50tabIndex = { - 1 } 51id = { ` ${ id } - ${ index } ` } 52aria-selected = { String ( index === selected )} 53title = { suggestion . path } 54_onmousedown = {( event ) => event . preventDefault ()} 55_onclick = {() => choose ( suggestion . path )} 56> 57< span class = "folder-path ">{ suggestion . path }</ span > 58{ suggestion . project ?< span class = "folder-project" >{ suggestion . project }</ span > : []} 59</ button > 60))); 61menu . hidden = suggestions . length === 0 && ! status . textContent ; 62input . setAttribute ( "aria-expanded" , String ( suggestions . length > 0 )); 63if ( selected < 0 ) input . removeAttribute ( "aria-activedescendant" ); 64else input . setAttribute ( "aria-activedescendant" , ` ${ id } - ${ selected } ` ); 65} ; 66const cancel = () = > { 67revision ++ ; 68clearTimeout ( timer ); 69request ?. abort (); 70} ; 71const close = () = > { 72cancel (); 73suggestions = []; 74selected = - 1 ; 75status . textContent = "" ; 76draw (); 77} ; 78const choose = (path: string) = > { 79input . value = path ; 80input . dispatchEvent ( new Event ( "input" , { bubbles : true })); 81close (); 82} ; 83const refresh = () = > { 84cancel (); 85if ( ! focused ) return close (); 86const version = revision ; 87const machine = environment . get (); 88const prefix = input . value ; 89const saved = Object . values ( projects ?. get () ?? {}). flatMap (( project ) => 90project . roots . filter (( root ) => 91root . environment === machine && 92( root . platform === "win32" 93? root . cwd . toLowerCase (). includes ( prefix . toLowerCase ()) 94: root . cwd . includes ( prefix )) 95). map (( root ) => ({ path : root . cwd , project : project . name , platform : root . platform })) 96). slice ( 0 , 10 ); 97const merge = ( paths: string []) => [ 98...saved , 99...paths . filter (( path ) => 100! saved . some (( item ) => 101workspacePath ( item . path , item . platform ) === workspacePath ( path , item . platform ) 102) 103). map (( path ) => ({ path })), 104]; 105suggestions = merge ( found . filter (( path ) => path . startsWith ( prefix ))); 106selected = - 1 ; 107status . textContent = suggestions . length ? "" : "finding folders…" ; 108draw (); 109timer = setTimeout ( async () => { 110request = new AbortController (); 111const query = new URLSearchParams ({ environment : machine , prefix }); 112const cwd = base (); 113if ( cwd ) query . set ( "cwd" , cwd ); 114try { 115const paths = await api < string []>( 116"GET" , 117`/api/folders? ${ query } ` , 118undefined , 119request . signal , 120); 121if ( version !== revision ) return ; 122found = paths ; 123suggestions = merge ( paths ); 124status . textContent = suggestions . length ? "" : "no matching folders" ; 125draw (); 126} catch ( error ) { 127if ( version !== revision ) return ; 128status . textContent = ( error as Error ). message ; 129draw (); 130} 131}, 120 ); 132} ; 133input.addEventListener("focus", () = > { 134focused = true ; 135refresh (); 136} ); 137input.addEventListener("input", refresh); 138input.addEventListener("blur", () = > { 139focused = false ; 140close (); 141} ); 142input.addEventListener("keydown", (event) = > { 143if ( event . key === "Escape" && ! menu . hidden ) { 144event . preventDefault (); 145event . stopImmediatePropagation (); 146close (); 147} else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions . length ) { 148event . preventDefault (); 149selected = selected < 0 150? event . key === "ArrowDown" ? 0 : suggestions . length - 1 151: ( selected + ( event . key === "ArrowDown" ? 1 : - 1 ) + suggestions . length ) % 152suggestions . length ; 153draw (); 154list . children [ selected ]?. scrollIntoView ?. ({ block : "nearest" }); 155} else if (event.key === "Enter" && selected >= 0 ) { 156event . preventDefault (); 157event . stopImmediatePropagation (); 158choose ( suggestions [ selected ]. path ); 159} 160}); 161scope . add ( 162environment . subscribe (() => { 163found = []; 164refresh (); 165} ).unsubscribe, 166); 167if (projects) scope.add(projects.subscribe(refresh).unsubscribe); 168scope.add(close); 169return { input , element } ; 170}