init claude-code
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js'
|
||||
import {
|
||||
type ParseEntry,
|
||||
quote,
|
||||
tryParseShellCommand,
|
||||
} from '../bash/shellQuote.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getShellType } from '../localInstaller.js'
|
||||
import * as Shell from '../Shell.js'
|
||||
|
||||
// Constants
|
||||
const MAX_SHELL_COMPLETIONS = 15
|
||||
const SHELL_COMPLETION_TIMEOUT_MS = 1000
|
||||
const COMMAND_OPERATORS = ['|', '||', '&&', ';'] as const
|
||||
|
||||
export type ShellCompletionType = 'command' | 'variable' | 'file'
|
||||
|
||||
type InputContext = {
|
||||
prefix: string
|
||||
completionType: ShellCompletionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parsed token is a command operator (|, ||, &&, ;)
|
||||
*/
|
||||
function isCommandOperator(token: ParseEntry): boolean {
|
||||
return (
|
||||
typeof token === 'object' &&
|
||||
token !== null &&
|
||||
'op' in token &&
|
||||
(COMMAND_OPERATORS as readonly string[]).includes(token.op as string)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine completion type based solely on prefix characteristics
|
||||
*/
|
||||
function getCompletionTypeFromPrefix(prefix: string): ShellCompletionType {
|
||||
if (prefix.startsWith('$')) {
|
||||
return 'variable'
|
||||
}
|
||||
if (
|
||||
prefix.includes('/') ||
|
||||
prefix.startsWith('~') ||
|
||||
prefix.startsWith('.')
|
||||
) {
|
||||
return 'file'
|
||||
}
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last string token and its index in parsed tokens
|
||||
*/
|
||||
function findLastStringToken(
|
||||
tokens: ParseEntry[],
|
||||
): { token: string; index: number } | null {
|
||||
const i = tokens.findLastIndex(t => typeof t === 'string')
|
||||
return i !== -1 ? { token: tokens[i] as string, index: i } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in a context that expects a new command
|
||||
* (at start of input or after a command operator)
|
||||
*/
|
||||
function isNewCommandContext(
|
||||
tokens: ParseEntry[],
|
||||
currentTokenIndex: number,
|
||||
): boolean {
|
||||
if (currentTokenIndex === 0) {
|
||||
return true
|
||||
}
|
||||
const prevToken = tokens[currentTokenIndex - 1]
|
||||
return prevToken !== undefined && isCommandOperator(prevToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input to extract completion context
|
||||
*/
|
||||
function parseInputContext(input: string, cursorOffset: number): InputContext {
|
||||
const beforeCursor = input.slice(0, cursorOffset)
|
||||
|
||||
// Check if it's a variable prefix, before expanding with shell-quote
|
||||
const varMatch = beforeCursor.match(/\$[a-zA-Z_][a-zA-Z0-9_]*$/)
|
||||
if (varMatch) {
|
||||
return { prefix: varMatch[0], completionType: 'variable' }
|
||||
}
|
||||
|
||||
// Parse with shell-quote
|
||||
const parseResult = tryParseShellCommand(beforeCursor)
|
||||
if (!parseResult.success) {
|
||||
// Fallback to simple parsing
|
||||
const tokens = beforeCursor.split(/\s+/)
|
||||
const prefix = tokens[tokens.length - 1] || ''
|
||||
const isFirstToken = tokens.length === 1 && !beforeCursor.includes(' ')
|
||||
const completionType = isFirstToken
|
||||
? 'command'
|
||||
: getCompletionTypeFromPrefix(prefix)
|
||||
return { prefix, completionType }
|
||||
}
|
||||
|
||||
// Extract current token
|
||||
const lastToken = findLastStringToken(parseResult.tokens)
|
||||
if (!lastToken) {
|
||||
// No string token found - check if after operator
|
||||
const lastParsedToken = parseResult.tokens[parseResult.tokens.length - 1]
|
||||
const completionType =
|
||||
lastParsedToken && isCommandOperator(lastParsedToken)
|
||||
? 'command'
|
||||
: 'command' // Default to command at start
|
||||
return { prefix: '', completionType }
|
||||
}
|
||||
|
||||
// If there's a trailing space, the user is starting a new argument
|
||||
if (beforeCursor.endsWith(' ')) {
|
||||
// After first token (command) with space = file argument expected
|
||||
return { prefix: '', completionType: 'file' }
|
||||
}
|
||||
|
||||
// Determine completion type from context
|
||||
const baseType = getCompletionTypeFromPrefix(lastToken.token)
|
||||
|
||||
// If it's clearly a file or variable based on prefix, use that type
|
||||
if (baseType === 'variable' || baseType === 'file') {
|
||||
return { prefix: lastToken.token, completionType: baseType }
|
||||
}
|
||||
|
||||
// For command-like tokens, check context: are we starting a new command?
|
||||
const completionType = isNewCommandContext(
|
||||
parseResult.tokens,
|
||||
lastToken.index,
|
||||
)
|
||||
? 'command'
|
||||
: 'file' // Not after operator = file argument
|
||||
|
||||
return { prefix: lastToken.token, completionType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate bash completion command using compgen
|
||||
*/
|
||||
function getBashCompletionCommand(
|
||||
prefix: string,
|
||||
completionType: ShellCompletionType,
|
||||
): string {
|
||||
if (completionType === 'variable') {
|
||||
// Variable completion - remove $ prefix
|
||||
const varName = prefix.slice(1)
|
||||
return `compgen -v ${quote([varName])} 2>/dev/null`
|
||||
} else if (completionType === 'file') {
|
||||
// File completion with trailing slash for directories and trailing space for files
|
||||
// Use 'while read' to prevent command injection from filenames containing newlines
|
||||
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`
|
||||
} else {
|
||||
// Command completion
|
||||
return `compgen -c ${quote([prefix])} 2>/dev/null`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate zsh completion command using native zsh commands
|
||||
*/
|
||||
function getZshCompletionCommand(
|
||||
prefix: string,
|
||||
completionType: ShellCompletionType,
|
||||
): string {
|
||||
if (completionType === 'variable') {
|
||||
// Variable completion - use zsh pattern matching for safe filtering
|
||||
const varName = prefix.slice(1)
|
||||
return `print -rl -- \${(k)parameters[(I)${quote([varName])}*]} 2>/dev/null`
|
||||
} else if (completionType === 'file') {
|
||||
// File completion with trailing slash for directories and trailing space for files
|
||||
// Note: zsh glob expansion is safe from command injection (unlike bash for-in loops)
|
||||
return `for f in ${quote([prefix])}*(N[1,${MAX_SHELL_COMPLETIONS}]); do [[ -d "$f" ]] && echo "$f/" || echo "$f "; done`
|
||||
} else {
|
||||
// Command completion - use zsh pattern matching for safe filtering
|
||||
return `print -rl -- \${(k)commands[(I)${quote([prefix])}*]} 2>/dev/null`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completions for the given shell type
|
||||
*/
|
||||
async function getCompletionsForShell(
|
||||
shellType: 'bash' | 'zsh',
|
||||
prefix: string,
|
||||
completionType: ShellCompletionType,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<SuggestionItem[]> {
|
||||
let command: string
|
||||
|
||||
if (shellType === 'bash') {
|
||||
command = getBashCompletionCommand(prefix, completionType)
|
||||
} else if (shellType === 'zsh') {
|
||||
command = getZshCompletionCommand(prefix, completionType)
|
||||
} else {
|
||||
// Unsupported shell type
|
||||
return []
|
||||
}
|
||||
|
||||
const shellCommand = await Shell.exec(command, abortSignal, 'bash', {
|
||||
timeout: SHELL_COMPLETION_TIMEOUT_MS,
|
||||
})
|
||||
const result = await shellCommand.result
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.filter((line: string) => line.trim())
|
||||
.slice(0, MAX_SHELL_COMPLETIONS)
|
||||
.map((text: string) => ({
|
||||
id: text,
|
||||
displayText: text,
|
||||
description: undefined,
|
||||
metadata: { completionType },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell completions for the given input
|
||||
* Supports bash and zsh shells (matches Shell.ts execution support)
|
||||
*/
|
||||
export async function getShellCompletions(
|
||||
input: string,
|
||||
cursorOffset: number,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<SuggestionItem[]> {
|
||||
const shellType = getShellType()
|
||||
|
||||
// Only support bash/zsh (matches Shell.ts execution support)
|
||||
if (shellType !== 'bash' && shellType !== 'zsh') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const { prefix, completionType } = parseInputContext(input, cursorOffset)
|
||||
|
||||
if (!prefix) {
|
||||
return []
|
||||
}
|
||||
|
||||
const completions = await getCompletionsForShell(
|
||||
shellType,
|
||||
prefix,
|
||||
completionType,
|
||||
abortSignal,
|
||||
)
|
||||
|
||||
// Add inputSnapshot to all suggestions so we can detect when input changes
|
||||
return completions.map(suggestion => ({
|
||||
...suggestion,
|
||||
metadata: {
|
||||
...(suggestion.metadata as { completionType: ShellCompletionType }),
|
||||
inputSnapshot: input,
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
logForDebugging(`Shell completion failed: ${error}`)
|
||||
return [] // Silent fail
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user