/ utils / suggestions / commandSuggestions.ts
commandSuggestions.ts
  1  import Fuse from 'fuse.js'
  2  import {
  3    type Command,
  4    formatDescriptionWithSource,
  5    getCommand,
  6    getCommandName,
  7  } from '../../commands.js'
  8  import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js'
  9  import { getSkillUsageScore } from './skillUsageTracking.js'
 10  
 11  // Treat these characters as word separators for command search
 12  const SEPARATORS = /[:_-]/g
 13  
 14  type 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.
 25  let fuseCache: {
 26    commands: Command[]
 27    fuse: Fuse<CommandSearchItem>
 28  } | null = null
 29  
 30  function 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   */
 86  function 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   */
 99  export 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   */
114  export 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   */
164  export 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   */
200  export 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   */
208  export 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   */
221  export 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   */
233  function 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   */
250  function 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   */
265  function 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   */
292  export 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   */
503  export 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
542  function 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   */
552  export 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  }