summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useCompletion.ts
diff options
context:
space:
mode:
authorSandy Tao <[email protected]>2025-07-24 21:41:35 -0700
committerGitHub <[email protected]>2025-07-25 04:41:35 +0000
commit1d7eb0d25078f34b37a0cbd8a6a869d3e61a2602 (patch)
tree189320acd768328061c30d6e7abe5458d015e2c8 /packages/cli/src/ui/hooks/useCompletion.ts
parent273e74c09da89492d16fc076cfbff4d043eafa4c (diff)
[Refactor] Centralizes autocompletion logic within useCompletion (#4740)
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.ts')
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts121
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,
};
}