diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.ts | 121 |
1 files changed, 114 insertions, 7 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index aacc111d..f4ebfac3 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; import { glob } from 'glob'; @@ -22,6 +22,9 @@ import { Suggestion, } from '../components/SuggestionsDisplay.js'; import { CommandContext, SlashCommand } from '../commands/types.js'; +import { TextBuffer } from '../components/shared/text-buffer.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import { toCodePoints } from '../utils/textUtils.js'; export interface UseCompletionReturn { suggestions: Suggestion[]; @@ -35,12 +38,12 @@ export interface UseCompletionReturn { resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; + handleAutocomplete: (indexToUse: number) => void; } export function useCompletion( - query: string, + buffer: TextBuffer, cwd: string, - isActive: boolean, slashCommands: readonly SlashCommand[], commandContext: CommandContext, config?: Config, @@ -122,13 +125,45 @@ export function useCompletion( }); }, [suggestions.length]); + // Check if cursor is after @ or / without unescaped spaces + const isActive = useMemo(() => { + if (isSlashCommand(buffer.text.trim())) { + return true; + } + + // For other completions like '@', we search backwards from the cursor. + const [row, col] = buffer.cursor; + const currentLine = buffer.lines[row] || ''; + const codePoints = toCodePoints(currentLine); + + for (let i = col - 1; i >= 0; i--) { + const char = codePoints[i]; + + if (char === ' ') { + // Check for unescaped spaces. + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + return false; // Inactive on unescaped space. + } + } else if (char === '@') { + // Active if we find an '@' before any unescaped space. + return true; + } + } + + return false; + }, [buffer.text, buffer.cursor, buffer.lines]); + useEffect(() => { if (!isActive) { resetCompletionState(); return; } - const trimmedQuery = query.trimStart(); + const trimmedQuery = buffer.text.trimStart(); if (trimmedQuery.startsWith('/')) { // Always reset perfect match at the beginning of processing. @@ -275,13 +310,13 @@ export function useCompletion( } // Handle At Command Completion - const atIndex = query.lastIndexOf('@'); + const atIndex = buffer.text.lastIndexOf('@'); if (atIndex === -1) { resetCompletionState(); return; } - const partialPath = query.substring(atIndex + 1); + const partialPath = buffer.text.substring(atIndex + 1); const lastSlashIndex = partialPath.lastIndexOf('/'); const baseDirRelative = lastSlashIndex === -1 @@ -545,7 +580,7 @@ export function useCompletion( clearTimeout(debounceTimeout); }; }, [ - query, + buffer.text, cwd, isActive, resetCompletionState, @@ -554,6 +589,77 @@ export function useCompletion( config, ]); + const handleAutocomplete = useCallback( + (indexToUse: number) => { + if (indexToUse < 0 || indexToUse >= suggestions.length) { + return; + } + const query = buffer.text; + const suggestion = suggestions[indexToUse].value; + + if (query.trimStart().startsWith('/')) { + const hasTrailingSpace = query.endsWith(' '); + const parts = query + .trimStart() + .substring(1) + .split(/\s+/) + .filter(Boolean); + + let isParentPath = false; + // If there's no trailing space, we need to check if the current query + // is already a complete path to a parent command. + if (!hasTrailingSpace) { + let currentLevel: readonly SlashCommand[] | undefined = slashCommands; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const found: SlashCommand | undefined = currentLevel?.find( + (cmd) => cmd.name === part || cmd.altNames?.includes(part), + ); + + if (found) { + if (i === parts.length - 1 && found.subCommands) { + isParentPath = true; + } + currentLevel = found.subCommands as + | readonly SlashCommand[] + | undefined; + } else { + // Path is invalid, so it can't be a parent path. + currentLevel = undefined; + break; + } + } + } + + // Determine the base path of the command. + // - If there's a trailing space, the whole command is the base. + // - If it's a known parent path, the whole command is the base. + // - Otherwise, the base is everything EXCEPT the last partial part. + const basePath = + hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1); + const newValue = `/${[...basePath, suggestion].join(' ')}`; + + buffer.setText(newValue); + } else { + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) return; + const pathPart = query.substring(atIndex + 1); + const lastSlashIndexInPath = pathPart.lastIndexOf('/'); + let autoCompleteStartIndex = atIndex + 1; + if (lastSlashIndexInPath !== -1) { + autoCompleteStartIndex += lastSlashIndexInPath + 1; + } + buffer.replaceRangeByOffset( + autoCompleteStartIndex, + buffer.text.length, + suggestion, + ); + } + resetCompletionState(); + }, + [resetCompletionState, buffer, suggestions, slashCommands], + ); + return { suggestions, activeSuggestionIndex, @@ -566,5 +672,6 @@ export function useCompletion( resetCompletionState, navigateUp, navigateDown, + handleAutocomplete, }; } |
