char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
e21152dc9
char/aliceai-t5-moe
1import { PROMPT_TRIGGER_PREFIX } from '$lib/constants' ; 2import { ChatFormCommandAction , KeyboardKey } from '$lib/enums' ; 3import type { ChatFormCommand } from '$lib/types' ; 4import { getChatCommands } from '$lib/utils' ; 5import { 6type CommandDismissSnapshot , 7findCommandToken , 8findMentionToken , 9type MentionDismissSnapshot , 10takeCommandDismissSnapshot , 11takeMentionDismissSnapshot 12} from '$lib/utils' ; 13 14/** Dependencies injected as getters so the hook stays free of store circular imports. */ 15export interface UseChatFormPickersOptions { 16getValue :() => string ; 17/** Also fires the form's onChange. */ 18setValue :( value :string ) => void ; 19/** Undefined when unmounted. */ 20getCaretOffset :() => number | undefined ; 21setCaretOffset :( offset :number ) => void ; 22focusInput :() => void ; 23/** Gates `/model`. */ 24getShowModelSelector :() => boolean ; 25/** Gates `/prompt`. */ 26hasPrompts :() => boolean ; 27/** Gates `/cwd`. */ 28hasCwdTools :() => boolean ; 29getCwd :() => string | null ; 30/** Mention search fallback scope. */ 31getServerHome :() => string | null ; 32openModelSelector :() => void ; 33/** Delegate a keydown to the mounted pickers component, if any. */ 34getPickersRef :() => { handleKeydown ( event :KeyboardEvent ) :boolean } | undefined ; 35} 36 37/** 38* Chat-form picker state and the `/`+`@` routing that drives them. 39* Owns open/query state, dismiss snapshots and slash-command dispatch; 40* textarea/caret/attachment handling stays in the chat form. 41*/ 42export function useChatFormPickers ( opts :UseChatFormPickersOptions ) { 43let isCommandPickerOpen = $state ( false ); 44let commandQuery = $state ( '' ); 45let isPromptPickerOpen = $state ( false ); 46let promptSearchQuery = $state ( '' ); 47let isMentionPickerOpen = $state ( false ); 48let mentionQuery = $state ( '' ); 49let isWorkingDirectoryPickerOpen = $state ( false ); 50let workingDirectoryQuery = $state ( '' ); 51// Last dismissed `@`-mention token; while intact, the picker does not 52// reopen, so an escaped `@<query>` stays literal until edited. 53let mentionDismissedSnapshot :MentionDismissSnapshot | null = null ; 54// Same dismissal contract for the `/`-command token. 55let commandDismissedSnapshot :CommandDismissSnapshot | null = null ; 56 57// Fall back to the server home so the picker still finds matches 58// before a cwd is set. 59const mentionScopePath = $derived ( opts . getCwd () ?? opts . getServerHome () ?? null ); 60const availableCommands = $derived ( 61getChatCommands ({ 62hasCwdTools :opts . hasCwdTools , 63hasPrompts :opts . hasPrompts , 64showModelSelector :opts . getShowModelSelector () 65}) 66); 67 68// Dispatch a slash command picked from the list: consume the token and 69// open the target picker, seeding its search with `args`. Runs only on 70// explicit selection (Enter/click), so the buffer is never cleared 71// mid-typing. 72function dispatchCommand ( command :ChatFormCommand , args :string ) { 73isCommandPickerOpen = false ; 74commandQuery = '' ; 75 76switch ( command . action ) { 77case ChatFormCommandAction . PROMPT : 78isWorkingDirectoryPickerOpen = false ; 79opts . setValue ( '' ); 80isPromptPickerOpen = true ; 81promptSearchQuery = args . trim (); 82 83break ; 84case ChatFormCommandAction . CWD :{ 85// Keep `/cwd <args>` in the input so the search field and the 86// token stay two-way bound; normalize partial tokens (`/cw foo`). 87const trimmed = args . trim (); 88const newValue = `/cwd ${ trimmed } ` ; 89 90if ( opts . getValue () !== newValue ) { 91opts . setValue ( newValue ); 92queueMicrotask (() => opts . setCaretOffset ( newValue . length )); 93} 94 95workingDirectoryQuery = trimmed ; 96isWorkingDirectoryPickerOpen = true ; 97 98break ; 99} 100case ChatFormCommandAction . MODEL : 101isWorkingDirectoryPickerOpen = false ; 102opts . setValue ( '' ); 103opts . openModelSelector (); 104 105break ; 106} 107} 108 109function handleInput () { 110const value = opts . getValue (); 111const cursor = opts . getCaretOffset () ?? value . length ; 112 113if ( value . startsWith ( PROMPT_TRIGGER_PREFIX )) { 114isMentionPickerOpen = false ; 115mentionQuery = '' ; 116isPromptPickerOpen = false ; 117promptSearchQuery = '' ; 118 119const token = findCommandToken ( value ); 120 121if ( ! token ) { 122isCommandPickerOpen = false ; 123commandQuery = '' ; 124 125return ; 126} 127 128// While the `/cwd` picker is open the token doubles as its search 129// field: keep the two in sync instead of re-dispatching. 130if ( isWorkingDirectoryPickerOpen ) { 131isCommandPickerOpen = false ; 132commandQuery = '' ; 133 134if ( token . name === 'cwd' ) { 135workingDirectoryQuery = token . args . trim (); 136} else { 137isWorkingDirectoryPickerOpen = false ; 138workingDirectoryQuery = '' ; 139} 140 141return ; 142} 143 144// Dismissed token stays literal until it changes. 145const isDismissedSticky = 146commandDismissedSnapshot !== null && 147commandDismissedSnapshot . name === token . name && 148commandDismissedSnapshot . args === token . args ; 149 150if ( isDismissedSticky ) { 151isCommandPickerOpen = false ; 152commandQuery = '' ; 153 154return ; 155} 156 157// Commands dispatch only on explicit selection (Enter/click), 158// never mid-typing: `/model is broken` is prose until the user 159// picks the command from the list. 160if ( availableCommands . length > 0 ) { 161isCommandPickerOpen = true ; 162commandQuery = token . name ; 163} else { 164isCommandPickerOpen = false ; 165commandQuery = '' ; 166} 167 168return ; 169} 170 171isCommandPickerOpen = false ; 172commandQuery = '' ; 173 174if ( commandDismissedSnapshot !== null ) { 175commandDismissedSnapshot = null ; 176} 177 178if ( isWorkingDirectoryPickerOpen ) { 179isWorkingDirectoryPickerOpen = false ; 180} 181 182const token = findMentionToken ( value , cursor ); 183 184if ( token ) { 185// Dismissed token stays literal: don't reopen until it changes. 186const isDismissedSticky = 187mentionDismissedSnapshot !== null && 188mentionDismissedSnapshot . start === token . start && 189mentionDismissedSnapshot . query === token . query ; 190 191if ( ! isDismissedSticky ) { 192// Only search once a char follows `@`; a bare `@` is a no-op 193// (otherwise the picker flashes an empty hint on re-type). 194if ( token . query . length > 0 ) { 195mentionDismissedSnapshot = null ; 196isMentionPickerOpen = true ; 197mentionQuery = token . query ; 198isPromptPickerOpen = false ; 199promptSearchQuery = '' ; 200 201return ; 202} 203} 204} 205 206isPromptPickerOpen = false ; 207promptSearchQuery = '' ; 208isMentionPickerOpen = false ; 209mentionQuery = '' ; 210 211// Token gone or changed: reset the snapshot so a fresh `@` reopens. 212if ( mentionDismissedSnapshot !== null && ! token ) { 213mentionDismissedSnapshot = null ; 214} 215} 216 217function handleKeydown ( event :KeyboardEvent ) :boolean { 218if ( opts . getPickersRef ()?. handleKeydown ( event )) { 219return true ; 220} 221 222if ( event . key === KeyboardKey . ESCAPE && isPromptPickerOpen ) { 223isPromptPickerOpen = false ; 224promptSearchQuery = '' ; 225 226return true ; 227} 228 229return false ; 230} 231 232function handleCommandSelect ( command :ChatFormCommand ) { 233// Dispatch on the live token so typed args seed the target picker. 234const token = findCommandToken ( opts . getValue ()); 235 236dispatchCommand ( command , token ?. args ?? '' ); 237} 238 239// Picker dismissed: snapshot the live token so it stays literal until 240// deleted or retyped. 241function handleCommandPickerClose () { 242if ( isCommandPickerOpen ) { 243commandDismissedSnapshot = takeCommandDismissSnapshot ( opts . getValue ()); 244} 245 246isCommandPickerOpen = false ; 247commandQuery = '' ; 248 249// Target picker manages its own focus: don't yank it back to the input. 250if ( ! isPromptPickerOpen && ! isMentionPickerOpen && ! isWorkingDirectoryPickerOpen ) { 251opts . focusInput (); 252} 253} 254 255// Same dismissal snapshot for the mention token. 256function handleMentionPickerClose () { 257if ( isMentionPickerOpen ) { 258const cursor = opts . getCaretOffset () ?? opts . getValue (). length ; 259 260mentionDismissedSnapshot = takeMentionDismissSnapshot ( opts . getValue (), cursor ); 261} 262 263isMentionPickerOpen = false ; 264mentionQuery = '' ; 265opts . focusInput (); 266} 267 268function handlePromptPickerClose () { 269isPromptPickerOpen = false ; 270promptSearchQuery = '' ; 271opts . focusInput (); 272} 273 274function handleWorkingDirectoryOpen () { 275workingDirectoryQuery = opts . getCwd () ?? '' ; 276isWorkingDirectoryPickerOpen = true ; 277} 278 279function handleWorkingDirectoryClose () { 280isWorkingDirectoryPickerOpen = false ; 281workingDirectoryQuery = '' ; 282opts . focusInput (); 283} 284 285// Two-way bind the text after `/cwd ` and the picker search input; the 286// reverse direction is handled by handleInput. 287$effect (() => { 288if ( ! isWorkingDirectoryPickerOpen ) return ; 289 290const value = opts . getValue (); 291const token = findCommandToken ( value ); 292 293if ( ! token || token . name !== 'cwd' ) return ; 294 295const newValue = `/cwd ${ workingDirectoryQuery } ` ; 296 297if ( newValue === value ) return ; 298 299opts . setValue ( newValue ); 300queueMicrotask (() => opts . setCaretOffset ( newValue . length )); 301}); 302 303return { 304get availableCommands () { 305return availableCommands ; 306}, 307closePromptPicker () { 308isPromptPickerOpen = false ; 309promptSearchQuery = '' ; 310}, 311get commandQuery () { 312return commandQuery ; 313}, 314set commandQuery ( v :string ) { 315commandQuery = v ; 316}, 317 dispatchCommand, 318 handleCommandPickerClose, 319 handleCommandSelect, 320 handleInput, 321// True when a picker consumed the event, so the form skips submit. 322 handleKeydown, 323 handleMentionPickerClose, 324 handlePromptPickerClose, 325 handleWorkingDirectoryClose, 326 handleWorkingDirectoryOpen, 327get isCommandPickerOpen () { 328return isCommandPickerOpen ; 329}, 330set isCommandPickerOpen ( v :boolean ) { 331isCommandPickerOpen = v ; 332}, 333get isMentionPickerOpen () { 334return isMentionPickerOpen ; 335}, 336set isMentionPickerOpen ( v :boolean ) { 337isMentionPickerOpen = v ; 338}, 339get isPromptPickerOpen () { 340return isPromptPickerOpen ; 341}, 342set isPromptPickerOpen ( v :boolean ) { 343isPromptPickerOpen = v ; 344}, 345get isWorkingDirectoryPickerOpen () { 346return isWorkingDirectoryPickerOpen ; 347}, 348set isWorkingDirectoryPickerOpen ( v :boolean ) { 349isWorkingDirectoryPickerOpen = v ; 350}, 351get mentionQuery () { 352return mentionQuery ; 353}, 354set mentionQuery ( v :string ) { 355mentionQuery = v ; 356}, 357get mentionScopePath () { 358return mentionScopePath ; 359}, 360openPromptPicker () { 361isPromptPickerOpen = true ; 362}, 363get promptSearchQuery () { 364return promptSearchQuery ; 365}, 366set promptSearchQuery ( v :string ) { 367promptSearchQuery = v ; 368}, 369get workingDirectoryQuery () { 370return workingDirectoryQuery ; 371}, 372set workingDirectoryQuery ( v :string ) { 373workingDirectoryQuery = v ; 374} 375}; 376} 377 378export type UseChatFormPickersReturn = ReturnType < typeof useChatFormPickers >;