/ utils / suggestions / shellHistoryCompletion.ts
shellHistoryCompletion.ts
  1  import { getHistory } from '../../history.js'
  2  import { logForDebugging } from '../debug.js'
  3  
  4  /**
  5   * Result of shell history completion lookup
  6   */
  7  export type ShellHistoryMatch = {
  8    /** The full command from history */
  9    fullCommand: string
 10    /** The suffix to display as ghost text (the part after user's input) */
 11    suffix: string
 12  }
 13  
 14  // Cache for shell history commands to avoid repeated async reads
 15  // History only changes when user submits a command, so a long TTL is fine
 16  let shellHistoryCache: string[] | null = null
 17  let shellHistoryCacheTimestamp = 0
 18  const CACHE_TTL_MS = 60000 // 60 seconds - history won't change while typing
 19  
 20  /**
 21   * Get shell commands from history, with caching
 22   */
 23  async function getShellHistoryCommands(): Promise<string[]> {
 24    const now = Date.now()
 25  
 26    // Return cached result if still fresh
 27    if (shellHistoryCache && now - shellHistoryCacheTimestamp < CACHE_TTL_MS) {
 28      return shellHistoryCache
 29    }
 30  
 31    const commands: string[] = []
 32    const seen = new Set<string>()
 33  
 34    try {
 35      // Read history entries and filter for bash commands
 36      for await (const entry of getHistory()) {
 37        if (entry.display && entry.display.startsWith('!')) {
 38          // Remove the '!' prefix to get the actual command
 39          const command = entry.display.slice(1).trim()
 40          if (command && !seen.has(command)) {
 41            seen.add(command)
 42            commands.push(command)
 43          }
 44        }
 45        // Limit to 50 most recent unique commands
 46        if (commands.length >= 50) {
 47          break
 48        }
 49      }
 50    } catch (error) {
 51      logForDebugging(`Failed to read shell history: ${error}`)
 52    }
 53  
 54    shellHistoryCache = commands
 55    shellHistoryCacheTimestamp = now
 56    return commands
 57  }
 58  
 59  /**
 60   * Clear the shell history cache (useful when history is updated)
 61   */
 62  export function clearShellHistoryCache(): void {
 63    shellHistoryCache = null
 64    shellHistoryCacheTimestamp = 0
 65  }
 66  
 67  /**
 68   * Add a command to the front of the shell history cache without
 69   * flushing the entire cache.  If the command already exists in the
 70   * cache it is moved to the front (deduped).  When the cache hasn't
 71   * been populated yet this is a no-op – the next lookup will read
 72   * the full history which already includes the new command.
 73   */
 74  export function prependToShellHistoryCache(command: string): void {
 75    if (!shellHistoryCache) {
 76      return
 77    }
 78    const idx = shellHistoryCache.indexOf(command)
 79    if (idx !== -1) {
 80      shellHistoryCache.splice(idx, 1)
 81    }
 82    shellHistoryCache.unshift(command)
 83  }
 84  
 85  /**
 86   * Find the best matching shell command from history for the given input
 87   *
 88   * @param input The current user input (without '!' prefix)
 89   * @returns The best match, or null if no match found
 90   */
 91  export async function getShellHistoryCompletion(
 92    input: string,
 93  ): Promise<ShellHistoryMatch | null> {
 94    // Don't suggest for empty or very short input
 95    if (!input || input.length < 2) {
 96      return null
 97    }
 98  
 99    // Check the trimmed input to make sure there's actual content
100    const trimmedInput = input.trim()
101    if (!trimmedInput) {
102      return null
103    }
104  
105    const commands = await getShellHistoryCommands()
106  
107    // Find the first command that starts with the EXACT input (including spaces)
108    // This ensures "ls " matches "ls -lah" but "ls  " (2 spaces) does not
109    for (const command of commands) {
110      if (command.startsWith(input) && command !== input) {
111        return {
112          fullCommand: command,
113          suffix: command.slice(input.length),
114        }
115      }
116    }
117  
118    return null
119  }