source dump of claude code
at main 259 lines 7.9 kB view raw
1import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js' 2import { 3 type ParseEntry, 4 quote, 5 tryParseShellCommand, 6} from '../bash/shellQuote.js' 7import { logForDebugging } from '../debug.js' 8import { getShellType } from '../localInstaller.js' 9import * as Shell from '../Shell.js' 10 11// Constants 12const MAX_SHELL_COMPLETIONS = 15 13const SHELL_COMPLETION_TIMEOUT_MS = 1000 14const COMMAND_OPERATORS = ['|', '||', '&&', ';'] as const 15 16export type ShellCompletionType = 'command' | 'variable' | 'file' 17 18type InputContext = { 19 prefix: string 20 completionType: ShellCompletionType 21} 22 23/** 24 * Check if a parsed token is a command operator (|, ||, &&, ;) 25 */ 26function isCommandOperator(token: ParseEntry): boolean { 27 return ( 28 typeof token === 'object' && 29 token !== null && 30 'op' in token && 31 (COMMAND_OPERATORS as readonly string[]).includes(token.op as string) 32 ) 33} 34 35/** 36 * Determine completion type based solely on prefix characteristics 37 */ 38function getCompletionTypeFromPrefix(prefix: string): ShellCompletionType { 39 if (prefix.startsWith('$')) { 40 return 'variable' 41 } 42 if ( 43 prefix.includes('/') || 44 prefix.startsWith('~') || 45 prefix.startsWith('.') 46 ) { 47 return 'file' 48 } 49 return 'command' 50} 51 52/** 53 * Find the last string token and its index in parsed tokens 54 */ 55function findLastStringToken( 56 tokens: ParseEntry[], 57): { token: string; index: number } | null { 58 const i = tokens.findLastIndex(t => typeof t === 'string') 59 return i !== -1 ? { token: tokens[i] as string, index: i } : null 60} 61 62/** 63 * Check if we're in a context that expects a new command 64 * (at start of input or after a command operator) 65 */ 66function isNewCommandContext( 67 tokens: ParseEntry[], 68 currentTokenIndex: number, 69): boolean { 70 if (currentTokenIndex === 0) { 71 return true 72 } 73 const prevToken = tokens[currentTokenIndex - 1] 74 return prevToken !== undefined && isCommandOperator(prevToken) 75} 76 77/** 78 * Parse input to extract completion context 79 */ 80function parseInputContext(input: string, cursorOffset: number): InputContext { 81 const beforeCursor = input.slice(0, cursorOffset) 82 83 // Check if it's a variable prefix, before expanding with shell-quote 84 const varMatch = beforeCursor.match(/\$[a-zA-Z_][a-zA-Z0-9_]*$/) 85 if (varMatch) { 86 return { prefix: varMatch[0], completionType: 'variable' } 87 } 88 89 // Parse with shell-quote 90 const parseResult = tryParseShellCommand(beforeCursor) 91 if (!parseResult.success) { 92 // Fallback to simple parsing 93 const tokens = beforeCursor.split(/\s+/) 94 const prefix = tokens[tokens.length - 1] || '' 95 const isFirstToken = tokens.length === 1 && !beforeCursor.includes(' ') 96 const completionType = isFirstToken 97 ? 'command' 98 : getCompletionTypeFromPrefix(prefix) 99 return { prefix, completionType } 100 } 101 102 // Extract current token 103 const lastToken = findLastStringToken(parseResult.tokens) 104 if (!lastToken) { 105 // No string token found - check if after operator 106 const lastParsedToken = parseResult.tokens[parseResult.tokens.length - 1] 107 const completionType = 108 lastParsedToken && isCommandOperator(lastParsedToken) 109 ? 'command' 110 : 'command' // Default to command at start 111 return { prefix: '', completionType } 112 } 113 114 // If there's a trailing space, the user is starting a new argument 115 if (beforeCursor.endsWith(' ')) { 116 // After first token (command) with space = file argument expected 117 return { prefix: '', completionType: 'file' } 118 } 119 120 // Determine completion type from context 121 const baseType = getCompletionTypeFromPrefix(lastToken.token) 122 123 // If it's clearly a file or variable based on prefix, use that type 124 if (baseType === 'variable' || baseType === 'file') { 125 return { prefix: lastToken.token, completionType: baseType } 126 } 127 128 // For command-like tokens, check context: are we starting a new command? 129 const completionType = isNewCommandContext( 130 parseResult.tokens, 131 lastToken.index, 132 ) 133 ? 'command' 134 : 'file' // Not after operator = file argument 135 136 return { prefix: lastToken.token, completionType } 137} 138 139/** 140 * Generate bash completion command using compgen 141 */ 142function getBashCompletionCommand( 143 prefix: string, 144 completionType: ShellCompletionType, 145): string { 146 if (completionType === 'variable') { 147 // Variable completion - remove $ prefix 148 const varName = prefix.slice(1) 149 return `compgen -v ${quote([varName])} 2>/dev/null` 150 } else if (completionType === 'file') { 151 // File completion with trailing slash for directories and trailing space for files 152 // Use 'while read' to prevent command injection from filenames containing newlines 153 return `compgen -f ${quote([prefix])} 2>/dev/null | head -${MAX_SHELL_COMPLETIONS} | while IFS= read -r f; do [ -d "$f" ] && echo "$f/" || echo "$f "; done` 154 } else { 155 // Command completion 156 return `compgen -c ${quote([prefix])} 2>/dev/null` 157 } 158} 159 160/** 161 * Generate zsh completion command using native zsh commands 162 */ 163function getZshCompletionCommand( 164 prefix: string, 165 completionType: ShellCompletionType, 166): string { 167 if (completionType === 'variable') { 168 // Variable completion - use zsh pattern matching for safe filtering 169 const varName = prefix.slice(1) 170 return `print -rl -- \${(k)parameters[(I)${quote([varName])}*]} 2>/dev/null` 171 } else if (completionType === 'file') { 172 // File completion with trailing slash for directories and trailing space for files 173 // Note: zsh glob expansion is safe from command injection (unlike bash for-in loops) 174 return `for f in ${quote([prefix])}*(N[1,${MAX_SHELL_COMPLETIONS}]); do [[ -d "$f" ]] && echo "$f/" || echo "$f "; done` 175 } else { 176 // Command completion - use zsh pattern matching for safe filtering 177 return `print -rl -- \${(k)commands[(I)${quote([prefix])}*]} 2>/dev/null` 178 } 179} 180 181/** 182 * Get completions for the given shell type 183 */ 184async function getCompletionsForShell( 185 shellType: 'bash' | 'zsh', 186 prefix: string, 187 completionType: ShellCompletionType, 188 abortSignal: AbortSignal, 189): Promise<SuggestionItem[]> { 190 let command: string 191 192 if (shellType === 'bash') { 193 command = getBashCompletionCommand(prefix, completionType) 194 } else if (shellType === 'zsh') { 195 command = getZshCompletionCommand(prefix, completionType) 196 } else { 197 // Unsupported shell type 198 return [] 199 } 200 201 const shellCommand = await Shell.exec(command, abortSignal, 'bash', { 202 timeout: SHELL_COMPLETION_TIMEOUT_MS, 203 }) 204 const result = await shellCommand.result 205 return result.stdout 206 .split('\n') 207 .filter((line: string) => line.trim()) 208 .slice(0, MAX_SHELL_COMPLETIONS) 209 .map((text: string) => ({ 210 id: text, 211 displayText: text, 212 description: undefined, 213 metadata: { completionType }, 214 })) 215} 216 217/** 218 * Get shell completions for the given input 219 * Supports bash and zsh shells (matches Shell.ts execution support) 220 */ 221export async function getShellCompletions( 222 input: string, 223 cursorOffset: number, 224 abortSignal: AbortSignal, 225): Promise<SuggestionItem[]> { 226 const shellType = getShellType() 227 228 // Only support bash/zsh (matches Shell.ts execution support) 229 if (shellType !== 'bash' && shellType !== 'zsh') { 230 return [] 231 } 232 233 try { 234 const { prefix, completionType } = parseInputContext(input, cursorOffset) 235 236 if (!prefix) { 237 return [] 238 } 239 240 const completions = await getCompletionsForShell( 241 shellType, 242 prefix, 243 completionType, 244 abortSignal, 245 ) 246 247 // Add inputSnapshot to all suggestions so we can detect when input changes 248 return completions.map(suggestion => ({ 249 ...suggestion, 250 metadata: { 251 ...(suggestion.metadata as { completionType: ShellCompletionType }), 252 inputSnapshot: input, 253 }, 254 })) 255 } catch (error) { 256 logForDebugging(`Shell completion failed: ${error}`) 257 return [] // Silent fail 258 } 259}