char-slop/ai-dots
ai dotfiles
git clone https://git.t4t.associates/char-slop/ai-dots
38d12cd
main
1import type { ExtensionContext , Theme } from "@earendil-works/pi-coding-agent" ; 2import { 3type Component , 4CURSOR_MARKER , 5decodeKittyPrintable , 6type Focusable , 7type KeybindingsManager , 8visibleWidth , 9wrapTextWithAnsi , 10} from "@earendil-works/pi-tui" ; 11 12function decodePrintableKey ( data :string ) :string | undefined { 13const kittyPrintable = decodeKittyPrintable ( data ); 14if ( kittyPrintable !== undefined ) return kittyPrintable ; 15 16const match = data . match ( / ^\x1b\[27;(\d+);(\d+)~$ / ); 17if ( ! match ) return undefined ; 18 19const modifier = Number . parseInt ( match [ 1 ], 10 ) - 1 ; 20const codepoint = Number . parseInt ( match [ 2 ], 10 ); 21const lockMask = 64 | 128 ; 22if ((( modifier & ~ lockMask ) & ~ 1 ) !== 0 || codepoint < 32 ) return undefined ; 23 24try { 25return String . fromCodePoint ( codepoint ); 26} catch { 27return undefined ; 28} 29} 30 31type UICustomCarrier = { 32ui :Pick < ExtensionContext [ "ui" ], "custom" >; 33}; 34 35export interface CompactConfirmOptions { 36/** Optional abort signal. If aborted before resolution, resolves to false. */ 37signal ?:AbortSignal ; 38} 39 40/** 41* Show a single-line yes/no prompt. 42* 43* The user must type the literal word "yes" or "no" (case-insensitive, 44* whitespace-trimmed) and press Enter. Esc or Ctrl-C cancels and resolves 45* `false` — deny is always the safe default. 46*/ 47export async function compactConfirm ( 48ctx :UICustomCarrier , 49heading :string , 50summary ?:string , 51opts :CompactConfirmOptions = {}, 52) :Promise < boolean > { 53if ( opts . signal ?. aborted ) return false ; 54 55return ctx . ui . custom < boolean >( 56( _tui , theme , keybindings , done ) => 57new CompactConfirmComponent ( 58heading , 59summary , 60theme , 61keybindings , 62opts . signal , 63done , 64), 65); 66} 67 68const VALID_RESPONSES = new Set ([ "yes" , "no" ]); 69 70class CompactConfirmComponent implements Component , Focusable { 71focused = true ; 72 73private heading :string ; 74private summary :string | undefined ; 75private theme :Theme ; 76private keybindings :KeybindingsManager ; 77private done :( result :boolean ) => void ; 78private buffer = "" ; 79private settled = false ; 80private cachedWidth ?:number ; 81private cachedLines ?:string []; 82private cachedBuffer ?:string ; 83private abortCleanup ?:() => void ; 84 85constructor ( 86heading :string , 87summary :string | undefined , 88theme :Theme , 89keybindings :KeybindingsManager , 90signal :AbortSignal | undefined , 91done :( result :boolean ) => void , 92) { 93this . heading = heading ; 94this . summary = summary ; 95this . theme = theme ; 96this . keybindings = keybindings ; 97this . done = done ; 98 99if ( signal ) { 100const onAbort = () => this . resolve ( false ); 101signal . addEventListener ( "abort" , onAbort , { once :true }); 102this . abortCleanup = () => signal . removeEventListener ( "abort" , onAbort ); 103} 104} 105 106handleInput ( data :string ) :void { 107if ( this . settled ) return ; 108 109// Bracketed paste: strip the markers and treat the content as a single 110// typed chunk, with newlines/tabs removed so a pasted "yes\n" cannot 111// auto-submit. 112if ( data . startsWith ( "\x1b[200~" )) { 113const end = data . indexOf ( "\x1b[201~" ); 114if ( end !== - 1 ) { 115const pasted = data . slice ( "\x1b[200~" . length , end ). replace ( / [\r\n\t] / g , "" ); 116this . appendPrintable ( pasted ); 117this . invalidate (); 118return ; 119} 120} 121 122if ( this . keybindings . matches ( data , "tui.select.cancel" )) { 123return this . resolve ( false ); 124} 125 126if ( this . keybindings . matches ( data , "tui.input.submit" ) || data === "\n" || data === "\r" ) { 127const value = this . buffer . trim (). toLowerCase (); 128if ( value === "yes" ) return this . resolve ( true ); 129if ( value === "no" ) return this . resolve ( false ); 130// Otherwise ignore — force the user to type a recognised response. 131return ; 132} 133 134if ( this . keybindings . matches ( data , "tui.editor.deleteCharBackward" )) { 135if ( this . buffer . length > 0 ) { 136this . buffer = this . buffer . slice ( 0 , - 1 ); 137this . invalidate (); 138} 139return ; 140} 141 142if ( this . keybindings . matches ( data , "tui.editor.deleteWordBackward" )) { 143if ( this . buffer . length > 0 ) { 144this . buffer = "" ; 145this . invalidate (); 146} 147return ; 148} 149 150const printable = decodePrintableKey ( data ) ?? ( this . isPrintable ( data ) ?data :undefined ); 151if ( printable !== undefined ) { 152this . appendPrintable ( printable ); 153this . invalidate (); 154} 155} 156 157invalidate () :void { 158this . cachedLines = undefined ; 159this . cachedWidth = undefined ; 160this . cachedBuffer = undefined ; 161} 162 163render ( width :number ) :string [] { 164if ( 165this . cachedLines && 166this . cachedWidth === width && 167this . cachedBuffer === this . buffer 168) { 169return this . cachedLines ; 170} 171 172const t = this . theme ; 173const value = this . buffer . trim (). toLowerCase (); 174const isValid = VALID_RESPONSES . has ( value ); 175 176const headingPart = t . fg ( "muted" , this . heading ); 177const summaryPart = this . summary ?` ${ t . bold ( t . fg ( "accent" , this . summary ))} ` :"" ; 178const hintLabel = isValid ?"" :' ("yes" or "no")' ; 179const hintPart = t . fg ( "dim" , ` ${ hintLabel } : ` ); 180const bufferPart = isValid ?t . fg ( "accent" , this . buffer ) :this . buffer ; 181const cursorPart = this . focused ?` ${ CURSOR_MARKER } \x1b[7m \x1b[27m` :"" ; 182 183const line = ` ${ headingPart } ${ summaryPart } ${ hintPart } ${ bufferPart } ${ cursorPart } ` ; 184 185const padX = 1 ; 186const contentWidth = Math . max ( 1 , width - padX * 2 ); 187const margin = " " . repeat ( padX ); 188const wrapped = wrapTextWithAnsi ( line , contentWidth ). map (( row ) => { 189const padded = margin + row + margin ; 190const pad = Math . max ( 0 , width - visibleWidth ( padded )); 191return padded + " " . repeat ( pad ); 192}); 193 194const lines = wrapped . length > 0 ?wrapped :[ " " . repeat ( width )]; 195 196this . cachedLines = lines ; 197this . cachedWidth = width ; 198this . cachedBuffer = this . buffer ; 199return lines ; 200} 201 202dispose () :void { 203this . abortCleanup ?.(); 204} 205 206private appendPrintable ( text :string ) :void { 207// Don't let the buffer grow without bound — five chars is enough for 208// "yes"/"no" with a typo or two of headroom. 209const room = Math . max ( 0 , 16 - this . buffer . length ); 210if ( room === 0 ) return ; 211this . buffer += text . slice ( 0 , room ); 212} 213 214private isPrintable ( data :string ) :boolean { 215if ( data . length === 0 ) return false ; 216for ( const ch of data ) { 217const code = ch . charCodeAt ( 0 ); 218if ( code < 32 || code === 0x7f || ( code >= 0x80 && code <= 0x9f )) { 219return false ; 220} 221} 222return true ; 223} 224 225private resolve ( value :boolean ) :void { 226if ( this . settled ) return ; 227this . settled = true ; 228this . done ( value ); 229} 230}