source dump of claude code
at main 567 lines 19 kB view raw
1import Fuse from 'fuse.js' 2import { 3 type Command, 4 formatDescriptionWithSource, 5 getCommand, 6 getCommandName, 7} from '../../commands.js' 8import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' 9import { getSkillUsageScore } from './skillUsageTracking.js' 10 11// Treat these characters as word separators for command search 12const SEPARATORS = /[:_-]/g 13 14type CommandSearchItem = { 15 descriptionKey: string[] 16 partKey: string[] | undefined 17 commandName: string 18 command: Command 19 aliasKey: string[] | undefined 20} 21 22// Cache the Fuse index keyed by the commands array identity. The commands 23// array is stable (memoized in REPL.tsx), so we only rebuild when it changes 24// rather than on every keystroke. 25let fuseCache: { 26 commands: Command[] 27 fuse: Fuse<CommandSearchItem> 28} | null = null 29 30function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> { 31 if (fuseCache?.commands === commands) { 32 return fuseCache.fuse 33 } 34 35 const commandData: CommandSearchItem[] = commands 36 .filter(cmd => !cmd.isHidden) 37 .map(cmd => { 38 const commandName = getCommandName(cmd) 39 const parts = commandName.split(SEPARATORS).filter(Boolean) 40 41 return { 42 descriptionKey: (cmd.description ?? '') 43 .split(' ') 44 .map(word => cleanWord(word)) 45 .filter(Boolean), 46 partKey: parts.length > 1 ? parts : undefined, 47 commandName, 48 command: cmd, 49 aliasKey: cmd.aliases, 50 } 51 }) 52 53 const fuse = new Fuse(commandData, { 54 includeScore: true, 55 threshold: 0.3, // relatively strict matching 56 location: 0, // prefer matches at the beginning of strings 57 distance: 100, // increased to allow matching in descriptions 58 keys: [ 59 { 60 name: 'commandName', 61 weight: 3, // Highest priority for command names 62 }, 63 { 64 name: 'partKey', 65 weight: 2, // Next highest priority for command parts 66 }, 67 { 68 name: 'aliasKey', 69 weight: 2, // Same high priority for aliases 70 }, 71 { 72 name: 'descriptionKey', 73 weight: 0.5, // Lower priority for descriptions 74 }, 75 ], 76 }) 77 78 fuseCache = { commands, fuse } 79 return fuse 80} 81 82/** 83 * Type guard to check if a suggestion's metadata is a Command. 84 * Commands have a name string and a type property. 85 */ 86function isCommandMetadata(metadata: unknown): metadata is Command { 87 return ( 88 typeof metadata === 'object' && 89 metadata !== null && 90 'name' in metadata && 91 typeof (metadata as { name: unknown }).name === 'string' && 92 'type' in metadata 93 ) 94} 95 96/** 97 * Represents a slash command found mid-input (not at the start) 98 */ 99export type MidInputSlashCommand = { 100 token: string // e.g., "/com" 101 startPos: number // Position of "/" 102 partialCommand: string // e.g., "com" 103} 104 105/** 106 * Finds a slash command token that appears mid-input (not at position 0). 107 * A mid-input slash command is a "/" preceded by whitespace, where the cursor 108 * is at or after the "/". 109 * 110 * @param input The full input string 111 * @param cursorOffset The current cursor position 112 * @returns The mid-input slash command info, or null if not found 113 */ 114export function findMidInputSlashCommand( 115 input: string, 116 cursorOffset: number, 117): MidInputSlashCommand | null { 118 // If input starts with "/", this is start-of-input case (handled elsewhere) 119 if (input.startsWith('/')) { 120 return null 121 } 122 123 // Look backwards from cursor to find a "/" preceded by whitespace 124 const beforeCursor = input.slice(0, cursorOffset) 125 126 // Find the last "/" in the text before cursor 127 // Pattern: whitespace followed by "/" then optional alphanumeric/dash characters. 128 // Lookbehind (?<=\s) is avoided — it defeats YARR JIT in JSC, and the 129 // interpreter scans O(n) even with the $ anchor. Capture the whitespace 130 // instead and offset match.index by 1. 131 const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/) 132 if (!match || match.index === undefined) { 133 return null 134 } 135 136 // Get the full token (may extend past cursor) 137 const slashPos = match.index + 1 138 const textAfterSlash = input.slice(slashPos + 1) 139 140 // Extract the command portion (until whitespace or end) 141 const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/) 142 const fullCommand = commandMatch ? commandMatch[0] : '' 143 144 // If cursor is past the command (after a space), don't show ghost text 145 if (cursorOffset > slashPos + 1 + fullCommand.length) { 146 return null 147 } 148 149 return { 150 token: '/' + fullCommand, 151 startPos: slashPos, 152 partialCommand: fullCommand, 153 } 154} 155 156/** 157 * Finds the best matching command for a partial command string. 158 * Delegates to generateCommandSuggestions and filters to prefix matches. 159 * 160 * @param partialCommand The partial command typed by the user (without "/") 161 * @param commands Available commands 162 * @returns The completion suffix (e.g., "mit" for partial "com" matching "commit"), or null 163 */ 164export function getBestCommandMatch( 165 partialCommand: string, 166 commands: Command[], 167): { suffix: string; fullCommand: string } | null { 168 if (!partialCommand) { 169 return null 170 } 171 172 // Use existing suggestion logic 173 const suggestions = generateCommandSuggestions('/' + partialCommand, commands) 174 if (suggestions.length === 0) { 175 return null 176 } 177 178 // Find first suggestion that is a prefix match (for inline completion) 179 const query = partialCommand.toLowerCase() 180 for (const suggestion of suggestions) { 181 if (!isCommandMetadata(suggestion.metadata)) { 182 continue 183 } 184 const name = getCommandName(suggestion.metadata) 185 if (name.toLowerCase().startsWith(query)) { 186 const suffix = name.slice(partialCommand.length) 187 // Only return if there's something to complete 188 if (suffix) { 189 return { suffix, fullCommand: name } 190 } 191 } 192 } 193 194 return null 195} 196 197/** 198 * Checks if input is a command (starts with slash) 199 */ 200export function isCommandInput(input: string): boolean { 201 return input.startsWith('/') 202} 203 204/** 205 * Checks if a command input has arguments 206 * A command with just a trailing space is considered to have no arguments 207 */ 208export function hasCommandArgs(input: string): boolean { 209 if (!isCommandInput(input)) return false 210 211 if (!input.includes(' ')) return false 212 213 if (input.endsWith(' ')) return false 214 215 return true 216} 217 218/** 219 * Formats a command with proper notation 220 */ 221export function formatCommand(command: string): string { 222 return `/${command} ` 223} 224 225/** 226 * Generates a deterministic unique ID for a command suggestion. 227 * Commands with the same name from different sources get unique IDs. 228 * 229 * Only prompt commands can have duplicates (from user settings, project 230 * settings, plugins, etc). Built-in commands (local, local-jsx) are 231 * defined once in code and can't have duplicates. 232 */ 233function getCommandId(cmd: Command): string { 234 const commandName = getCommandName(cmd) 235 if (cmd.type === 'prompt') { 236 // For plugin commands, include the repository to disambiguate 237 if (cmd.source === 'plugin' && cmd.pluginInfo?.repository) { 238 return `${commandName}:${cmd.source}:${cmd.pluginInfo.repository}` 239 } 240 return `${commandName}:${cmd.source}` 241 } 242 // Built-in commands include type as fallback for future-proofing 243 return `${commandName}:${cmd.type}` 244} 245 246/** 247 * Checks if a query matches any of the command's aliases. 248 * Returns the matched alias if found, otherwise undefined. 249 */ 250function findMatchedAlias( 251 query: string, 252 aliases?: string[], 253): string | undefined { 254 if (!aliases || aliases.length === 0 || query === '') { 255 return undefined 256 } 257 // Check if query is a prefix of any alias (case-insensitive) 258 return aliases.find(alias => alias.toLowerCase().startsWith(query)) 259} 260 261/** 262 * Creates a suggestion item from a command. 263 * Only shows the matched alias in parentheses if the user typed an alias. 264 */ 265function createCommandSuggestionItem( 266 cmd: Command, 267 matchedAlias?: string, 268): SuggestionItem { 269 const commandName = getCommandName(cmd) 270 // Only show the alias if the user typed it 271 const aliasText = matchedAlias ? ` (${matchedAlias})` : '' 272 273 const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow' 274 const fullDescription = 275 (isWorkflow ? cmd.description : formatDescriptionWithSource(cmd)) + 276 (cmd.type === 'prompt' && cmd.argNames?.length 277 ? ` (arguments: ${cmd.argNames.join(', ')})` 278 : '') 279 280 return { 281 id: getCommandId(cmd), 282 displayText: `/${commandName}${aliasText}`, 283 tag: isWorkflow ? 'workflow' : undefined, 284 description: fullDescription, 285 metadata: cmd, 286 } 287} 288 289/** 290 * Generate command suggestions based on input 291 */ 292export function generateCommandSuggestions( 293 input: string, 294 commands: Command[], 295): SuggestionItem[] { 296 // Only process command input 297 if (!isCommandInput(input)) { 298 return [] 299 } 300 301 // If there are arguments, don't show suggestions 302 if (hasCommandArgs(input)) { 303 return [] 304 } 305 306 const query = input.slice(1).toLowerCase().trim() 307 308 // When just typing '/' without additional text 309 if (query === '') { 310 const visibleCommands = commands.filter(cmd => !cmd.isHidden) 311 312 // Find recently used skills (only prompt commands have usage tracking) 313 const recentlyUsed: Command[] = [] 314 const commandsWithScores = visibleCommands 315 .filter(cmd => cmd.type === 'prompt') 316 .map(cmd => ({ 317 cmd, 318 score: getSkillUsageScore(getCommandName(cmd)), 319 })) 320 .filter(item => item.score > 0) 321 .sort((a, b) => b.score - a.score) 322 323 // Take top 5 recently used skills 324 for (const item of commandsWithScores.slice(0, 5)) { 325 recentlyUsed.push(item.cmd) 326 } 327 328 // Create a set of recently used command IDs to avoid duplicates 329 const recentlyUsedIds = new Set(recentlyUsed.map(cmd => getCommandId(cmd))) 330 331 // Categorize remaining commands (excluding recently used) 332 const builtinCommands: Command[] = [] 333 const userCommands: Command[] = [] 334 const projectCommands: Command[] = [] 335 const policyCommands: Command[] = [] 336 const otherCommands: Command[] = [] 337 338 visibleCommands.forEach(cmd => { 339 // Skip if already in recently used 340 if (recentlyUsedIds.has(getCommandId(cmd))) { 341 return 342 } 343 344 if (cmd.type === 'local' || cmd.type === 'local-jsx') { 345 builtinCommands.push(cmd) 346 } else if ( 347 cmd.type === 'prompt' && 348 (cmd.source === 'userSettings' || cmd.source === 'localSettings') 349 ) { 350 userCommands.push(cmd) 351 } else if (cmd.type === 'prompt' && cmd.source === 'projectSettings') { 352 projectCommands.push(cmd) 353 } else if (cmd.type === 'prompt' && cmd.source === 'policySettings') { 354 policyCommands.push(cmd) 355 } else { 356 otherCommands.push(cmd) 357 } 358 }) 359 360 // Sort each category alphabetically 361 const sortAlphabetically = (a: Command, b: Command) => 362 getCommandName(a).localeCompare(getCommandName(b)) 363 364 builtinCommands.sort(sortAlphabetically) 365 userCommands.sort(sortAlphabetically) 366 projectCommands.sort(sortAlphabetically) 367 policyCommands.sort(sortAlphabetically) 368 otherCommands.sort(sortAlphabetically) 369 370 // Combine with built-in commands prioritized after recently used, 371 // so they remain visible even when many skills are installed 372 return [ 373 ...recentlyUsed, 374 ...builtinCommands, 375 ...userCommands, 376 ...projectCommands, 377 ...policyCommands, 378 ...otherCommands, 379 ].map(cmd => createCommandSuggestionItem(cmd)) 380 } 381 382 // The Fuse index filters isHidden at build time and is keyed on the 383 // (memoized) commands array identity, so a command that is hidden when Fuse 384 // first builds stays invisible to Fuse for the whole session. If the user 385 // types the exact name of a currently-hidden command, prepend it to the 386 // Fuse results so exact-name always wins over weak description fuzzy 387 // matches — but only when no visible command shares the name (that would 388 // be the user's explicit override and should win). Prepend rather than 389 // early-return so visible prefix siblings (e.g. /voice-memo) still appear 390 // below, and getBestCommandMatch can still find a non-empty suffix. 391 let hiddenExact = commands.find( 392 cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query, 393 ) 394 if ( 395 hiddenExact && 396 commands.some( 397 cmd => !cmd.isHidden && getCommandName(cmd).toLowerCase() === query, 398 ) 399 ) { 400 hiddenExact = undefined 401 } 402 403 const fuse = getCommandFuse(commands) 404 const searchResults = fuse.search(query) 405 406 // Sort results prioritizing exact/prefix command name matches over fuzzy description matches 407 // Priority order: 408 // 1. Exact name match (highest) 409 // 2. Exact alias match 410 // 3. Prefix name match 411 // 4. Prefix alias match 412 // 5. Fuzzy match (lowest) 413 // Precompute per-item values once to avoid O(n log n) recomputation in comparator 414 const withMeta = searchResults.map(r => { 415 const name = r.item.commandName.toLowerCase() 416 const aliases = r.item.aliasKey?.map(alias => alias.toLowerCase()) ?? [] 417 const usage = 418 r.item.command.type === 'prompt' 419 ? getSkillUsageScore(getCommandName(r.item.command)) 420 : 0 421 return { r, name, aliases, usage } 422 }) 423 424 const sortedResults = withMeta.sort((a, b) => { 425 const aName = a.name 426 const bName = b.name 427 const aAliases = a.aliases 428 const bAliases = b.aliases 429 430 // Check for exact name match (highest priority) 431 const aExactName = aName === query 432 const bExactName = bName === query 433 if (aExactName && !bExactName) return -1 434 if (bExactName && !aExactName) return 1 435 436 // Check for exact alias match 437 const aExactAlias = aAliases.some(alias => alias === query) 438 const bExactAlias = bAliases.some(alias => alias === query) 439 if (aExactAlias && !bExactAlias) return -1 440 if (bExactAlias && !aExactAlias) return 1 441 442 // Check for prefix name match 443 const aPrefixName = aName.startsWith(query) 444 const bPrefixName = bName.startsWith(query) 445 if (aPrefixName && !bPrefixName) return -1 446 if (bPrefixName && !aPrefixName) return 1 447 // Among prefix name matches, prefer the shorter name (closer to exact) 448 if (aPrefixName && bPrefixName && aName.length !== bName.length) { 449 return aName.length - bName.length 450 } 451 452 // Check for prefix alias match 453 const aPrefixAlias = aAliases.find(alias => alias.startsWith(query)) 454 const bPrefixAlias = bAliases.find(alias => alias.startsWith(query)) 455 if (aPrefixAlias && !bPrefixAlias) return -1 456 if (bPrefixAlias && !aPrefixAlias) return 1 457 // Among prefix alias matches, prefer the shorter alias 458 if ( 459 aPrefixAlias && 460 bPrefixAlias && 461 aPrefixAlias.length !== bPrefixAlias.length 462 ) { 463 return aPrefixAlias.length - bPrefixAlias.length 464 } 465 466 // For similar match types, use Fuse score with usage as tiebreaker 467 const scoreDiff = (a.r.score ?? 0) - (b.r.score ?? 0) 468 if (Math.abs(scoreDiff) > 0.1) { 469 return scoreDiff 470 } 471 // For similar Fuse scores, prefer more frequently used skills 472 return b.usage - a.usage 473 }) 474 475 // Map search results to suggestion items 476 // Note: We intentionally don't deduplicate here because commands with the same name 477 // from different sources (e.g., projectSettings vs userSettings) may have different 478 // implementations and should both be available to the user 479 const fuseSuggestions = sortedResults.map(result => { 480 const cmd = result.r.item.command 481 // Only show alias in parentheses if the user typed an alias 482 const matchedAlias = findMatchedAlias(query, cmd.aliases) 483 return createCommandSuggestionItem(cmd, matchedAlias) 484 }) 485 // Skip the prepend if hiddenExact is already in fuseSuggestions — this 486 // happens when isHidden flips false→true mid-session (OAuth expiry, 487 // GrowthBook kill-switch) and the stale Fuse index still holds the 488 // command. Fuse already sorts exact-name matches first, so no reorder 489 // is needed; we just don't want a duplicate id (duplicate React keys, 490 // both rows rendering as selected). 491 if (hiddenExact) { 492 const hiddenId = getCommandId(hiddenExact) 493 if (!fuseSuggestions.some(s => s.id === hiddenId)) { 494 return [createCommandSuggestionItem(hiddenExact), ...fuseSuggestions] 495 } 496 } 497 return fuseSuggestions 498} 499 500/** 501 * Apply selected command to input 502 */ 503export function applyCommandSuggestion( 504 suggestion: string | SuggestionItem, 505 shouldExecute: boolean, 506 commands: Command[], 507 onInputChange: (value: string) => void, 508 setCursorOffset: (offset: number) => void, 509 onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void, 510): void { 511 // Extract command name and object from string or SuggestionItem metadata 512 let commandName: string 513 let commandObj: Command | undefined 514 if (typeof suggestion === 'string') { 515 commandName = suggestion 516 commandObj = shouldExecute ? getCommand(commandName, commands) : undefined 517 } else { 518 if (!isCommandMetadata(suggestion.metadata)) { 519 return // Invalid suggestion, nothing to apply 520 } 521 commandName = getCommandName(suggestion.metadata) 522 commandObj = suggestion.metadata 523 } 524 525 // Format the command input with trailing space 526 const newInput = formatCommand(commandName) 527 onInputChange(newInput) 528 setCursorOffset(newInput.length) 529 530 // Execute command if requested and it takes no arguments 531 if (shouldExecute && commandObj) { 532 if ( 533 commandObj.type !== 'prompt' || 534 (commandObj.argNames ?? []).length === 0 535 ) { 536 onSubmit(newInput, /* isSubmittingSlashCommand */ true) 537 } 538 } 539} 540 541// Helper function at bottom of file per CLAUDE.md 542function cleanWord(word: string) { 543 return word.toLowerCase().replace(/[^a-z0-9]/g, '') 544} 545 546/** 547 * Find all /command patterns in text for highlighting. 548 * Returns array of {start, end} positions. 549 * Requires whitespace or start-of-string before the slash to avoid 550 * matching paths like /usr/bin. 551 */ 552export function findSlashCommandPositions( 553 text: string, 554): Array<{ start: number; end: number }> { 555 const positions: Array<{ start: number; end: number }> = [] 556 // Match /command patterns preceded by whitespace or start-of-string 557 const regex = /(^|[\s])(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g 558 let match: RegExpExecArray | null = null 559 while ((match = regex.exec(text)) !== null) { 560 const precedingChar = match[1] ?? '' 561 const commandName = match[2] ?? '' 562 // Start position is after the whitespace (if any) 563 const start = match.index + precedingChar.length 564 positions.push({ start, end: start + commandName.length }) 565 } 566 return positions 567}