mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
删除未使用的文件(BuiltinStatusLine.tsx、4 个重复的 .ts stub)、 移除约 55 个文件中未使用的 React 导入、 清理约 50 处未使用的导入/变量/参数。 净减少 ~296 行代码,precheck 4077 测试全部通过。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1583 lines
62 KiB
TypeScript
1583 lines
62 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNotifications } from 'src/context/notifications.js';
|
||
import { Text } from '@anthropic/ink';
|
||
import { logEvent } from 'src/services/analytics/index.js';
|
||
import { useDebounceCallback } from 'usehooks-ts';
|
||
import { type Command, getCommandName } from '../commands.js';
|
||
import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js';
|
||
import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js';
|
||
import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js';
|
||
import { KeyboardEvent, useInput } from '@anthropic/ink';
|
||
// backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown>
|
||
import { useOptionalKeybindingContext, useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js';
|
||
import { useKeybindings } from '../keybindings/useKeybinding.js';
|
||
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
|
||
import { useAppState, useAppStateStore } from '../state/AppState.js';
|
||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||
import type { InlineGhostText, PromptInputMode } from '../types/textInputTypes.js';
|
||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
||
import { generateProgressiveArgumentHint, parseArguments } from '../utils/argumentSubstitution.js';
|
||
import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js';
|
||
import { formatLogMetadata } from '../utils/format.js';
|
||
import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js';
|
||
import {
|
||
applyCommandSuggestion,
|
||
findMidInputSlashCommand,
|
||
generateCommandSuggestions,
|
||
getBestCommandMatch,
|
||
isCommandInput,
|
||
} from '../utils/suggestions/commandSuggestions.js';
|
||
import {
|
||
getDirectoryCompletions,
|
||
getPathCompletions,
|
||
isPathLikeToken,
|
||
} from '../utils/suggestions/directoryCompletion.js';
|
||
import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js';
|
||
import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js';
|
||
import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js';
|
||
import {
|
||
applyFileSuggestion,
|
||
findLongestCommonPrefix,
|
||
onIndexBuildComplete,
|
||
startBackgroundCacheRefresh,
|
||
} from './fileSuggestions.js';
|
||
import { generateUnifiedSuggestions } from './unifiedSuggestions.js';
|
||
|
||
// Unicode-aware character class for file path tokens:
|
||
// \p{L} = letters (CJK, Latin, Cyrillic, etc.)
|
||
// \p{N} = numbers (incl. fullwidth)
|
||
// \p{M} = combining marks (macOS NFD accents, Devanagari vowel signs)
|
||
const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u;
|
||
const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u;
|
||
const TOKEN_WITH_AT_RE = /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u;
|
||
const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u;
|
||
const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u;
|
||
const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/;
|
||
|
||
// Type guard for path completion metadata
|
||
function isPathMetadata(metadata: unknown): metadata is { type: 'directory' | 'file' } {
|
||
return (
|
||
typeof metadata === 'object' &&
|
||
metadata !== null &&
|
||
'type' in metadata &&
|
||
(metadata.type === 'directory' || metadata.type === 'file')
|
||
);
|
||
}
|
||
|
||
// Helper to determine selectedSuggestion when updating suggestions
|
||
function getPreservedSelection(
|
||
prevSuggestions: SuggestionItem[],
|
||
prevSelection: number,
|
||
newSuggestions: SuggestionItem[],
|
||
): number {
|
||
// No new suggestions
|
||
if (newSuggestions.length === 0) {
|
||
return -1;
|
||
}
|
||
|
||
// No previous selection
|
||
if (prevSelection < 0) {
|
||
return 0;
|
||
}
|
||
|
||
// Get the previously selected item
|
||
const prevSelectedItem = prevSuggestions[prevSelection];
|
||
if (!prevSelectedItem) {
|
||
return 0;
|
||
}
|
||
|
||
// Try to find the same item in the new list by ID
|
||
const newIndex = newSuggestions.findIndex(item => item.id === prevSelectedItem.id);
|
||
|
||
// Return the new index if found, otherwise default to 0
|
||
return newIndex >= 0 ? newIndex : 0;
|
||
}
|
||
|
||
function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string {
|
||
const metadata = suggestion.metadata as { sessionId: string } | undefined;
|
||
return metadata?.sessionId ? `/resume ${metadata.sessionId}` : `/resume ${suggestion.displayText}`;
|
||
}
|
||
|
||
type Props = {
|
||
onInputChange: (value: string) => void;
|
||
onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void;
|
||
setCursorOffset: (offset: number) => void;
|
||
input: string;
|
||
cursorOffset: number;
|
||
commands: Command[];
|
||
mode: string;
|
||
agents: AgentDefinition[];
|
||
setSuggestionsState: (
|
||
f: (previousSuggestionsState: {
|
||
suggestions: SuggestionItem[];
|
||
selectedSuggestion: number;
|
||
commandArgumentHint?: string;
|
||
}) => {
|
||
suggestions: SuggestionItem[];
|
||
selectedSuggestion: number;
|
||
commandArgumentHint?: string;
|
||
},
|
||
) => void;
|
||
suggestionsState: {
|
||
suggestions: SuggestionItem[];
|
||
selectedSuggestion: number;
|
||
commandArgumentHint?: string;
|
||
};
|
||
suppressSuggestions?: boolean;
|
||
markAccepted: () => void;
|
||
onModeChange?: (mode: PromptInputMode) => void;
|
||
};
|
||
|
||
type UseTypeaheadResult = {
|
||
suggestions: SuggestionItem[];
|
||
selectedSuggestion: number;
|
||
suggestionType: SuggestionType;
|
||
maxColumnWidth?: number;
|
||
commandArgumentHint?: string;
|
||
inlineGhostText?: InlineGhostText;
|
||
handleKeyDown: (e: KeyboardEvent) => void;
|
||
};
|
||
|
||
/**
|
||
* Extract search token from a completion token by removing @ prefix and quotes
|
||
* @param completionToken The completion token
|
||
* @returns The search token with @ and quotes removed
|
||
*/
|
||
export function extractSearchToken(completionToken: { token: string; isQuoted?: boolean }): string {
|
||
if (completionToken.isQuoted) {
|
||
// Remove @" prefix and optional closing "
|
||
return completionToken.token.slice(2).replace(/"$/, '');
|
||
} else if (completionToken.token.startsWith('@')) {
|
||
return completionToken.token.substring(1);
|
||
} else {
|
||
return completionToken.token;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format a replacement value with proper @ prefix and quotes based on context
|
||
* @param options Configuration for formatting
|
||
* @param options.displayText The text to display
|
||
* @param options.mode The current mode (bash or prompt)
|
||
* @param options.hasAtPrefix Whether the original token has @ prefix
|
||
* @param options.needsQuotes Whether the text needs quotes (contains spaces)
|
||
* @param options.isQuoted Whether the original token was already quoted (user typed @"...)
|
||
* @param options.isComplete Whether this is a complete suggestion (adds trailing space)
|
||
* @returns The formatted replacement value
|
||
*/
|
||
export function formatReplacementValue(options: {
|
||
displayText: string;
|
||
mode: string;
|
||
hasAtPrefix: boolean;
|
||
needsQuotes: boolean;
|
||
isQuoted?: boolean;
|
||
isComplete: boolean;
|
||
}): string {
|
||
const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = options;
|
||
const space = isComplete ? ' ' : '';
|
||
|
||
if (isQuoted || needsQuotes) {
|
||
// Use quoted format
|
||
return mode === 'bash' ? `"${displayText}"${space}` : `@"${displayText}"${space}`;
|
||
} else if (hasAtPrefix) {
|
||
return mode === 'bash' ? `${displayText}${space}` : `@${displayText}${space}`;
|
||
} else {
|
||
return displayText;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply a shell completion suggestion by replacing the current word
|
||
*/
|
||
export function applyShellSuggestion(
|
||
suggestion: SuggestionItem,
|
||
input: string,
|
||
cursorOffset: number,
|
||
onInputChange: (value: string) => void,
|
||
setCursorOffset: (offset: number) => void,
|
||
completionType: ShellCompletionType | undefined,
|
||
): void {
|
||
const beforeCursor = input.slice(0, cursorOffset);
|
||
const lastSpaceIndex = beforeCursor.lastIndexOf(' ');
|
||
const wordStart = lastSpaceIndex + 1;
|
||
|
||
// Prepare the replacement text based on completion type
|
||
let replacementText: string;
|
||
if (completionType === 'variable') {
|
||
replacementText = '$' + suggestion.displayText + ' ';
|
||
} else if (completionType === 'command') {
|
||
replacementText = suggestion.displayText + ' ';
|
||
} else {
|
||
replacementText = suggestion.displayText;
|
||
}
|
||
|
||
const newInput = input.slice(0, wordStart) + replacementText + input.slice(cursorOffset);
|
||
|
||
onInputChange(newInput);
|
||
setCursorOffset(wordStart + replacementText.length);
|
||
}
|
||
|
||
const DM_MEMBER_RE = /(^|\s)@[\w-]*$/;
|
||
|
||
function applyTriggerSuggestion(
|
||
suggestion: SuggestionItem,
|
||
input: string,
|
||
cursorOffset: number,
|
||
triggerRe: RegExp,
|
||
onInputChange: (value: string) => void,
|
||
setCursorOffset: (offset: number) => void,
|
||
): void {
|
||
const m = input.slice(0, cursorOffset).match(triggerRe);
|
||
if (!m || m.index === undefined) return;
|
||
const prefixStart = m.index + (m[1]?.length ?? 0);
|
||
const before = input.slice(0, prefixStart);
|
||
const newInput = before + suggestion.displayText + ' ' + input.slice(cursorOffset);
|
||
onInputChange(newInput);
|
||
setCursorOffset(before.length + suggestion.displayText.length + 1);
|
||
}
|
||
|
||
let currentShellCompletionAbortController: AbortController | null = null;
|
||
|
||
/**
|
||
* Generate bash shell completion suggestions
|
||
*/
|
||
async function generateBashSuggestions(input: string, cursorOffset: number): Promise<SuggestionItem[]> {
|
||
try {
|
||
if (currentShellCompletionAbortController) {
|
||
currentShellCompletionAbortController.abort();
|
||
}
|
||
|
||
currentShellCompletionAbortController = new AbortController();
|
||
const suggestions = await getShellCompletions(input, cursorOffset, currentShellCompletionAbortController.signal);
|
||
|
||
return suggestions;
|
||
} catch {
|
||
// Silent failure - don't break UX
|
||
logEvent('tengu_shell_completion_failed', {});
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply a directory/path completion suggestion to the input
|
||
* Always adds @ prefix since we're replacing the entire token (including any existing @)
|
||
*
|
||
* @param input The current input text
|
||
* @param suggestionId The ID of the suggestion to apply
|
||
* @param tokenStartPos The start position of the token being replaced
|
||
* @param tokenLength The length of the token being replaced
|
||
* @param isDirectory Whether the suggestion is a directory (adds / suffix) or file (adds space)
|
||
* @returns Object with the new input text and cursor position
|
||
*/
|
||
export function applyDirectorySuggestion(
|
||
input: string,
|
||
suggestionId: string,
|
||
tokenStartPos: number,
|
||
tokenLength: number,
|
||
isDirectory: boolean,
|
||
): { newInput: string; cursorPos: number } {
|
||
const suffix = isDirectory ? '/' : ' ';
|
||
const before = input.slice(0, tokenStartPos);
|
||
const after = input.slice(tokenStartPos + tokenLength);
|
||
// Always add @ prefix - if token already has it, we're replacing
|
||
// the whole token (including @) with @suggestion.id
|
||
const replacement = '@' + suggestionId + suffix;
|
||
const newInput = before + replacement + after;
|
||
|
||
return {
|
||
newInput,
|
||
cursorPos: before.length + replacement.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Extract a completable token at the cursor position
|
||
* @param text The input text
|
||
* @param cursorPos The cursor position
|
||
* @param includeAtSymbol Whether to consider @ symbol as part of the token
|
||
* @returns The completable token and its start position, or null if not found
|
||
*/
|
||
export function extractCompletionToken(
|
||
text: string,
|
||
cursorPos: number,
|
||
includeAtSymbol = false,
|
||
): { token: string; startPos: number; isQuoted?: boolean } | null {
|
||
// Empty input check
|
||
if (!text) return null;
|
||
|
||
// Get text up to cursor
|
||
const textBeforeCursor = text.substring(0, cursorPos);
|
||
|
||
// Check for quoted @ mention first (e.g., @"my file with spaces")
|
||
if (includeAtSymbol) {
|
||
const quotedAtRegex = /@"([^"]*)"?$/;
|
||
const quotedMatch = textBeforeCursor.match(quotedAtRegex);
|
||
if (quotedMatch && quotedMatch.index !== undefined) {
|
||
// Include any remaining quoted content after cursor until closing quote or end
|
||
const textAfterCursor = text.substring(cursorPos);
|
||
const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/);
|
||
const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : '';
|
||
|
||
return {
|
||
token: quotedMatch[0] + quotedSuffix,
|
||
startPos: quotedMatch.index,
|
||
isQuoted: true,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan
|
||
if (includeAtSymbol) {
|
||
const atIdx = textBeforeCursor.lastIndexOf('@');
|
||
if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) {
|
||
const fromAt = textBeforeCursor.substring(atIdx);
|
||
const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE);
|
||
if (atHeadMatch && atHeadMatch[0].length === fromAt.length) {
|
||
const textAfterCursor = text.substring(cursorPos);
|
||
const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE);
|
||
const tokenSuffix = afterMatch ? afterMatch[0] : '';
|
||
return {
|
||
token: atHeadMatch[0] + tokenSuffix,
|
||
startPos: atIdx,
|
||
isQuoted: false,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Non-@ token or cursor outside @ token — use $ anchor on (short) tail
|
||
const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE;
|
||
const match = textBeforeCursor.match(tokenRegex);
|
||
if (!match || match.index === undefined) {
|
||
return null;
|
||
}
|
||
|
||
// Check if cursor is in the MIDDLE of a token (more word characters after cursor)
|
||
// If so, extend the token to include all characters until whitespace or end of string
|
||
const textAfterCursor = text.substring(cursorPos);
|
||
const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE);
|
||
const tokenSuffix = afterMatch ? afterMatch[0] : '';
|
||
|
||
return {
|
||
token: match[0] + tokenSuffix,
|
||
startPos: match.index,
|
||
isQuoted: false,
|
||
};
|
||
}
|
||
|
||
function extractCommandNameAndArgs(value: string): {
|
||
commandName: string;
|
||
args: string;
|
||
} | null {
|
||
if (isCommandInput(value)) {
|
||
const spaceIndex = value.indexOf(' ');
|
||
if (spaceIndex === -1)
|
||
return {
|
||
commandName: value.slice(1),
|
||
args: '',
|
||
};
|
||
return {
|
||
commandName: value.slice(1, spaceIndex),
|
||
args: value.slice(spaceIndex + 1),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function hasCommandWithArguments(isAtEndWithWhitespace: boolean, value: string) {
|
||
// If value.endsWith(' ') but the user is not at the end, then the user has
|
||
// potentially gone back to the command in an effort to edit the command name
|
||
// (but preserve the arguments).
|
||
return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ');
|
||
}
|
||
|
||
/**
|
||
* Hook for handling typeahead functionality for both commands and file paths
|
||
*/
|
||
export function useTypeahead({
|
||
commands,
|
||
onInputChange,
|
||
onSubmit,
|
||
setCursorOffset,
|
||
input,
|
||
cursorOffset,
|
||
mode,
|
||
agents,
|
||
setSuggestionsState,
|
||
suggestionsState: { suggestions, selectedSuggestion, commandArgumentHint },
|
||
suppressSuggestions = false,
|
||
markAccepted,
|
||
onModeChange,
|
||
}: Props): UseTypeaheadResult {
|
||
const { addNotification } = useNotifications();
|
||
const thinkingToggleShortcut = useShortcutDisplay('chat:thinkingToggle', 'Chat', 'alt+t');
|
||
const [suggestionType, setSuggestionType] = useState<SuggestionType>('none');
|
||
|
||
// Compute max column width from ALL commands once (not filtered results)
|
||
// This prevents layout shift when filtering
|
||
const allCommandsMaxWidth = useMemo(() => {
|
||
const visibleCommands = commands.filter(cmd => !cmd.isHidden);
|
||
if (visibleCommands.length === 0) return undefined;
|
||
const maxLen = Math.max(
|
||
...visibleCommands.map(cmd => {
|
||
const name = getCommandName(cmd);
|
||
return name ? name.length : 0;
|
||
}),
|
||
);
|
||
return maxLen + 6; // +1 for "/" prefix, +5 for padding
|
||
}, [commands]);
|
||
|
||
const [maxColumnWidth, setMaxColumnWidth] = useState<number | undefined>(undefined);
|
||
const mcpResources = useAppState(s => s.mcp.resources);
|
||
const store = useAppStateStore();
|
||
const promptSuggestion = useAppState(s => s.promptSuggestion);
|
||
// PromptInput hides suggestion ghost text in teammate view — mirror that
|
||
// gate here so Tab/rightArrow can't accept what isn't displayed.
|
||
const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId);
|
||
|
||
// Access keybinding context to check for pending chord sequences
|
||
const keybindingContext = useOptionalKeybindingContext();
|
||
|
||
// State for inline ghost text (bash history completion - async)
|
||
const [inlineGhostText, setInlineGhostText] = useState<InlineGhostText | undefined>(undefined);
|
||
|
||
// Synchronous ghost text for prompt mode mid-input slash commands.
|
||
// Computed during render via useMemo to eliminate the one-frame flicker
|
||
// that occurs when using useState + useEffect (effect runs after render).
|
||
const syncPromptGhostText = useMemo((): InlineGhostText | undefined => {
|
||
if (mode !== 'prompt' || suppressSuggestions) return undefined;
|
||
const midInputCommand = findMidInputSlashCommand(input, cursorOffset);
|
||
if (!midInputCommand) return undefined;
|
||
const match = getBestCommandMatch(midInputCommand.partialCommand, commands);
|
||
if (!match) return undefined;
|
||
return {
|
||
text: match.suffix,
|
||
fullCommand: match.fullCommand,
|
||
insertPosition: midInputCommand.startPos + 1 + midInputCommand.partialCommand.length,
|
||
};
|
||
}, [input, cursorOffset, mode, commands, suppressSuggestions]);
|
||
|
||
// Merged ghost text: prompt mode uses synchronous useMemo, bash mode uses async useState
|
||
const effectiveGhostText = suppressSuggestions
|
||
? undefined
|
||
: mode === 'prompt'
|
||
? syncPromptGhostText
|
||
: inlineGhostText;
|
||
|
||
// Use a ref for cursorOffset to avoid re-triggering suggestions on cursor movement alone
|
||
// We only want to re-fetch suggestions when the actual search token changes
|
||
const cursorOffsetRef = useRef(cursorOffset);
|
||
cursorOffsetRef.current = cursorOffset;
|
||
|
||
// Track the latest search token to discard stale results from slow async operations
|
||
const latestSearchTokenRef = useRef<string | null>(null);
|
||
// Track previous input to detect actual text changes vs. callback recreations
|
||
const prevInputRef = useRef('');
|
||
// Track the latest path token to discard stale results from path completion
|
||
const latestPathTokenRef = useRef('');
|
||
// Track the latest bash input to discard stale results from history completion
|
||
const latestBashInputRef = useRef('');
|
||
// Track the latest slack channel token to discard stale results from MCP
|
||
const latestSlackTokenRef = useRef('');
|
||
// Track suggestions via ref to avoid updateSuggestions being recreated on selection changes
|
||
const suggestionsRef = useRef(suggestions);
|
||
suggestionsRef.current = suggestions;
|
||
// Track the input value when suggestions were manually dismissed to prevent re-triggering
|
||
const dismissedForInputRef = useRef<string | null>(null);
|
||
|
||
// Clear all suggestions
|
||
const clearSuggestions = useCallback(() => {
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: [],
|
||
selectedSuggestion: -1,
|
||
}));
|
||
setSuggestionType('none');
|
||
setMaxColumnWidth(undefined);
|
||
setInlineGhostText(undefined);
|
||
}, [setSuggestionsState]);
|
||
|
||
// Expensive async operation to fetch file/resource suggestions
|
||
const fetchFileSuggestions = useCallback(
|
||
async (searchToken: string, isAtSymbol = false): Promise<void> => {
|
||
latestSearchTokenRef.current = searchToken;
|
||
const combinedItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol);
|
||
// Discard stale results if a newer query was initiated while waiting
|
||
if (latestSearchTokenRef.current !== searchToken) {
|
||
return;
|
||
}
|
||
if (combinedItems.length === 0) {
|
||
// Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: [],
|
||
selectedSuggestion: -1,
|
||
}));
|
||
setSuggestionType('none');
|
||
setMaxColumnWidth(undefined);
|
||
return;
|
||
}
|
||
setSuggestionsState(prev => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: combinedItems,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, combinedItems),
|
||
}));
|
||
setSuggestionType(combinedItems.length > 0 ? 'file' : 'none');
|
||
setMaxColumnWidth(undefined); // No fixed width for file suggestions
|
||
},
|
||
[mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents],
|
||
);
|
||
|
||
// Pre-warm the file index on mount so the first @-mention doesn't block.
|
||
// The build runs in background with ~4ms event-loop yields, so it doesn't
|
||
// delay first render — it just races the user's first @ keystroke.
|
||
//
|
||
// If the user types before the build finishes, they get partial results
|
||
// from the ready chunks; when the build completes, re-fire the last
|
||
// search so partial upgrades to full. Clears the token ref so the same
|
||
// query isn't discarded as stale.
|
||
//
|
||
// Skipped under NODE_ENV=test: REPL-mounting tests would spawn git ls-files
|
||
// against the real CI workspace (270k+ files on Windows runners), and the
|
||
// background build outlives the test — its setImmediate chain leaks into
|
||
// subsequent tests in the shard. The subscriber still registers so
|
||
// fileSuggestions tests that trigger a refresh directly work correctly.
|
||
useEffect(() => {
|
||
if (process.env.NODE_ENV !== 'test') {
|
||
startBackgroundCacheRefresh();
|
||
}
|
||
return onIndexBuildComplete(() => {
|
||
const token = latestSearchTokenRef.current;
|
||
if (token !== null) {
|
||
latestSearchTokenRef.current = null;
|
||
void fetchFileSuggestions(token, token === '');
|
||
}
|
||
});
|
||
}, [fetchFileSuggestions]);
|
||
|
||
// Debounce the file fetch operation. 50ms sits just above macOS default
|
||
// key-repeat (~33ms) so held-delete/backspace coalesces into one search
|
||
// instead of stuttering on each repeated key. The search itself is ~8–15ms
|
||
// on a 270k-file index.
|
||
const debouncedFetchFileSuggestions = useDebounceCallback(fetchFileSuggestions, 50);
|
||
|
||
const fetchSlackChannels = useCallback(
|
||
async (partial: string): Promise<void> => {
|
||
latestSlackTokenRef.current = partial;
|
||
const channels = await getSlackChannelSuggestions(store.getState().mcp.clients, partial);
|
||
if (latestSlackTokenRef.current !== partial) return;
|
||
setSuggestionsState(prev => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: channels,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, channels),
|
||
}));
|
||
setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none');
|
||
setMaxColumnWidth(undefined);
|
||
},
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref
|
||
[setSuggestionsState],
|
||
);
|
||
|
||
// First keystroke after # needs the MCP round-trip; subsequent keystrokes
|
||
// that share the same first-word segment hit the cache synchronously.
|
||
const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150);
|
||
|
||
// Handle immediate suggestion logic (cheap operations)
|
||
const updateSuggestions = useCallback(
|
||
async (value: string, inputCursorOffset?: number): Promise<void> => {
|
||
// Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset)
|
||
const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current;
|
||
if (suppressSuggestions) {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
|
||
// Check for mid-input slash command (e.g., "help me /com")
|
||
// Only in prompt mode, not when input starts with "/" (handled separately)
|
||
// Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo.
|
||
// We only need to clear dropdown suggestions here when ghost text is active.
|
||
if (mode === 'prompt') {
|
||
const midInputCommand = findMidInputSlashCommand(value, effectiveCursorOffset);
|
||
if (midInputCommand) {
|
||
const match = getBestCommandMatch(midInputCommand.partialCommand, commands);
|
||
if (match) {
|
||
// Clear dropdown suggestions when showing ghost text
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: [],
|
||
selectedSuggestion: -1,
|
||
}));
|
||
setSuggestionType('none');
|
||
setMaxColumnWidth(undefined);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bash mode: check for history-based ghost text completion
|
||
if (mode === 'bash' && value.trim()) {
|
||
latestBashInputRef.current = value;
|
||
const historyMatch = await getShellHistoryCompletion(value);
|
||
// Discard stale results if input changed while waiting
|
||
if (latestBashInputRef.current !== value) {
|
||
return;
|
||
}
|
||
if (historyMatch) {
|
||
setInlineGhostText({
|
||
text: historyMatch.suffix,
|
||
fullCommand: historyMatch.fullCommand,
|
||
insertPosition: value.length,
|
||
});
|
||
// Clear dropdown suggestions when showing ghost text
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: [],
|
||
selectedSuggestion: -1,
|
||
}));
|
||
setSuggestionType('none');
|
||
setMaxColumnWidth(undefined);
|
||
return;
|
||
} else {
|
||
// No history match, clear ghost text
|
||
setInlineGhostText(undefined);
|
||
}
|
||
}
|
||
|
||
// Check for @ to trigger team member / named subagent suggestions
|
||
// Must check before @ file symbol to prevent conflict
|
||
// Skip in bash mode - @ has no special meaning in shell commands
|
||
const atMatch = mode !== 'bash' ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) : null;
|
||
if (atMatch) {
|
||
const partialName = (atMatch[2] ?? '').toLowerCase();
|
||
// Imperative read — reading at call-time fixes staleness for
|
||
// teammates/subagents added mid-session.
|
||
const state = store.getState();
|
||
const members: SuggestionItem[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
if (isAgentSwarmsEnabled() && state.teamContext) {
|
||
for (const t of Object.values(state.teamContext.teammates ?? {})) {
|
||
if (t.name === TEAM_LEAD_NAME) continue;
|
||
if (!t.name.toLowerCase().startsWith(partialName)) continue;
|
||
seen.add(t.name);
|
||
members.push({
|
||
id: `dm-${t.name}`,
|
||
displayText: `@${t.name}`,
|
||
description: 'send message',
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const [name, agentId] of state.agentNameRegistry) {
|
||
if (seen.has(name)) continue;
|
||
if (!name.toLowerCase().startsWith(partialName)) continue;
|
||
const status = state.tasks[agentId]?.status;
|
||
members.push({
|
||
id: `dm-${name}`,
|
||
displayText: `@${name}`,
|
||
description: status ? `send message · ${status}` : 'send message',
|
||
});
|
||
}
|
||
|
||
if (members.length > 0) {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
setSuggestionsState(prev => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: members,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, members),
|
||
}));
|
||
setSuggestionType('agent');
|
||
setMaxColumnWidth(undefined);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Check for # to trigger Slack channel suggestions (requires Slack MCP server)
|
||
if (mode === 'prompt') {
|
||
const hashMatch = value.substring(0, effectiveCursorOffset).match(HASH_CHANNEL_RE);
|
||
if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) {
|
||
debouncedFetchSlackChannels(hashMatch[2]!);
|
||
return;
|
||
} else if (suggestionType === 'slack-channel') {
|
||
debouncedFetchSlackChannels.cancel();
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
|
||
// Check for @ symbol to trigger file suggestions (including quoted paths)
|
||
// Includes colon for MCP resources (e.g., server:resource/path)
|
||
const hasAtSymbol = value.substring(0, effectiveCursorOffset).match(HAS_AT_SYMBOL_RE);
|
||
|
||
// First, check for slash command suggestions (higher priority than @ symbol)
|
||
// Only show slash command selector if cursor is not on the "/" character itself
|
||
// Also don't show if cursor is at end of line with whitespace before it
|
||
// Don't show slash commands in bash mode
|
||
const isAtEndWithWhitespace =
|
||
effectiveCursorOffset === value.length &&
|
||
effectiveCursorOffset > 0 &&
|
||
value.length > 0 &&
|
||
value[effectiveCursorOffset - 1] === ' ';
|
||
|
||
// Handle directory completion for commands
|
||
if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0) {
|
||
const parsedCommand = extractCommandNameAndArgs(value);
|
||
|
||
if (parsedCommand && parsedCommand.commandName === 'add-dir' && parsedCommand.args) {
|
||
const { args } = parsedCommand;
|
||
|
||
// Clear suggestions if args end with whitespace (user is done with path)
|
||
if (args.match(/\s+$/)) {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
|
||
const dirSuggestions = await getDirectoryCompletions(args);
|
||
if (dirSuggestions.length > 0) {
|
||
setSuggestionsState(prev => ({
|
||
suggestions: dirSuggestions,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, dirSuggestions),
|
||
commandArgumentHint: undefined,
|
||
}));
|
||
setSuggestionType('directory');
|
||
return;
|
||
}
|
||
|
||
// No suggestions found - clear and return
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
|
||
// Handle custom title completion for /resume command
|
||
if (
|
||
parsedCommand &&
|
||
parsedCommand.commandName === 'resume' &&
|
||
parsedCommand.args !== undefined &&
|
||
value.includes(' ')
|
||
) {
|
||
const { args } = parsedCommand;
|
||
|
||
// Get custom title suggestions using partial match
|
||
const matches = await searchSessionsByCustomTitle(args, {
|
||
limit: 10,
|
||
});
|
||
|
||
const suggestions = matches.map(log => {
|
||
const sessionId = getSessionIdFromLog(log);
|
||
return {
|
||
id: `resume-title-${sessionId}`,
|
||
displayText: log.customTitle!,
|
||
description: formatLogMetadata(log),
|
||
metadata: { sessionId },
|
||
};
|
||
});
|
||
|
||
if (suggestions.length > 0) {
|
||
setSuggestionsState(prev => ({
|
||
suggestions,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestions),
|
||
commandArgumentHint: undefined,
|
||
}));
|
||
setSuggestionType('custom-title');
|
||
return;
|
||
}
|
||
|
||
// No suggestions found - clear and return
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Determine whether to display the argument hint and command suggestions.
|
||
// Only consider text up to the cursor — when the cursor is mid-input (e.g.,
|
||
// user typed "/com" before existing text), text after the cursor shouldn't
|
||
// affect command matching or argument detection.
|
||
const commandInput = value.substring(0, effectiveCursorOffset);
|
||
if (
|
||
mode === 'prompt' &&
|
||
isCommandInput(commandInput) &&
|
||
effectiveCursorOffset > 0 &&
|
||
!hasCommandWithArguments(isAtEndWithWhitespace, commandInput)
|
||
) {
|
||
let commandArgumentHint: string | undefined;
|
||
if (commandInput.length > 1) {
|
||
// We have a partial or complete command without arguments
|
||
// Check if it matches a command exactly and has an argument hint
|
||
|
||
// Extract command name: everything after / until the first space (or end)
|
||
const spaceIndex = commandInput.indexOf(' ');
|
||
const commandName = spaceIndex === -1 ? commandInput.slice(1) : commandInput.slice(1, spaceIndex);
|
||
|
||
// Check if there are real arguments (non-whitespace after the command)
|
||
const hasRealArguments = spaceIndex !== -1 && commandInput.slice(spaceIndex + 1).trim().length > 0;
|
||
|
||
// Check if input is exactly "command + single space" (ready for arguments)
|
||
const hasExactlyOneTrailingSpace = spaceIndex !== -1 && commandInput.length === spaceIndex + 1;
|
||
|
||
// If input has a space after the command, don't show suggestions
|
||
// This prevents Enter from selecting a different command after Tab completion
|
||
if (spaceIndex !== -1) {
|
||
const exactMatch = commands.find(cmd => {
|
||
const cmdName = getCommandName(cmd);
|
||
return cmdName && cmdName === commandName;
|
||
});
|
||
if (exactMatch || hasRealArguments) {
|
||
// Priority 1: Static argumentHint (only on first trailing space for backwards compat)
|
||
if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) {
|
||
commandArgumentHint = exactMatch.argumentHint;
|
||
}
|
||
// Priority 2: Progressive hint from argNames (show when trailing space)
|
||
else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && commandInput.endsWith(' ')) {
|
||
const argsText = commandInput.slice(spaceIndex + 1);
|
||
const typedArgs = parseArguments(argsText);
|
||
commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs);
|
||
}
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint,
|
||
suggestions: [],
|
||
selectedSuggestion: -1,
|
||
}));
|
||
setSuggestionType('none');
|
||
setMaxColumnWidth(undefined);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Note: argument hint is only shown when there's exactly one trailing space
|
||
// (set above when hasExactlyOneTrailingSpace is true)
|
||
}
|
||
|
||
const commandItems = generateCommandSuggestions(commandInput, commands);
|
||
setSuggestionsState(() => ({
|
||
commandArgumentHint,
|
||
suggestions: commandItems,
|
||
selectedSuggestion: commandItems.length > 0 ? 0 : -1,
|
||
}));
|
||
setSuggestionType(commandItems.length > 0 ? 'command' : 'none');
|
||
|
||
// Use stable width from all commands (prevents layout shift when filtering)
|
||
if (commandItems.length > 0) {
|
||
setMaxColumnWidth(allCommandsMaxWidth);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (suggestionType === 'command') {
|
||
// If we had command suggestions but the input no longer starts with '/'
|
||
// we need to clear the suggestions. However, we should not return
|
||
// because there may be relevant @ symbol and file suggestions.
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
} else if (isCommandInput(commandInput) && hasCommandWithArguments(isAtEndWithWhitespace, commandInput)) {
|
||
// If we have a command with arguments (no trailing space), clear any stale hint
|
||
// This prevents the hint from flashing when transitioning between states
|
||
setSuggestionsState(prev => (prev.commandArgumentHint ? { ...prev, commandArgumentHint: undefined } : prev));
|
||
}
|
||
|
||
if (suggestionType === 'custom-title') {
|
||
// If we had custom-title suggestions but the input is no longer /resume
|
||
// we need to clear the suggestions.
|
||
clearSuggestions();
|
||
}
|
||
|
||
if (suggestionType === 'agent' && suggestionsRef.current.some((s: SuggestionItem) => s.id?.startsWith('dm-'))) {
|
||
// If we had team member suggestions but the input no longer has @
|
||
// we need to clear the suggestions.
|
||
const hasAt = value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/);
|
||
if (!hasAt) {
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
|
||
// Check for @ symbol to trigger file and MCP resource suggestions
|
||
// Skip @ autocomplete in bash mode - @ has no special meaning in shell commands
|
||
if (hasAtSymbol && mode !== 'bash') {
|
||
// Get the @ token (including the @ symbol)
|
||
const completionToken = extractCompletionToken(value, effectiveCursorOffset, true);
|
||
if (completionToken && completionToken.token.startsWith('@')) {
|
||
const searchToken = extractSearchToken(completionToken);
|
||
|
||
// If the token after @ is path-like, use path completion instead of fuzzy search
|
||
// This handles cases like @~/path, @./path, @/path for directory traversal
|
||
if (isPathLikeToken(searchToken)) {
|
||
latestPathTokenRef.current = searchToken;
|
||
const pathSuggestions = await getPathCompletions(searchToken, {
|
||
maxResults: 10,
|
||
});
|
||
// Discard stale results if a newer query was initiated while waiting
|
||
if (latestPathTokenRef.current !== searchToken) {
|
||
return;
|
||
}
|
||
if (pathSuggestions.length > 0) {
|
||
setSuggestionsState(prev => ({
|
||
suggestions: pathSuggestions,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, pathSuggestions),
|
||
commandArgumentHint: undefined,
|
||
}));
|
||
setSuggestionType('directory');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Skip if we already fetched for this exact token (prevents loop from
|
||
// suggestions dependency causing updateSuggestions to be recreated)
|
||
if (latestSearchTokenRef.current === searchToken) {
|
||
return;
|
||
}
|
||
void debouncedFetchFileSuggestions(searchToken, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If we have active file suggestions or the input changed, check for file suggestions
|
||
if (suggestionType === 'file') {
|
||
const completionToken = extractCompletionToken(value, effectiveCursorOffset, true);
|
||
if (completionToken) {
|
||
const searchToken = extractSearchToken(completionToken);
|
||
// Skip if we already fetched for this exact token
|
||
if (latestSearchTokenRef.current === searchToken) {
|
||
return;
|
||
}
|
||
void debouncedFetchFileSuggestions(searchToken, false);
|
||
} else {
|
||
// If we had file suggestions but now there's no completion token
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
|
||
// Clear shell suggestions if not in bash mode OR if input has changed
|
||
if (suggestionType === 'shell') {
|
||
const inputSnapshot = (suggestionsRef.current[0]?.metadata as { inputSnapshot?: string })?.inputSnapshot;
|
||
|
||
if (mode !== 'bash' || value !== inputSnapshot) {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
},
|
||
[
|
||
suggestionType,
|
||
commands,
|
||
setSuggestionsState,
|
||
clearSuggestions,
|
||
debouncedFetchFileSuggestions,
|
||
debouncedFetchSlackChannels,
|
||
mode,
|
||
suppressSuggestions,
|
||
// Note: using suggestionsRef instead of suggestions to avoid recreating
|
||
// this callback when only selectedSuggestion changes (not the suggestions list)
|
||
allCommandsMaxWidth,
|
||
],
|
||
);
|
||
|
||
// Update suggestions when input changes
|
||
// Note: We intentionally don't depend on cursorOffset here - cursor movement alone
|
||
// shouldn't re-trigger suggestions. The cursorOffsetRef is used to get the current
|
||
// position when needed without causing re-renders.
|
||
useEffect(() => {
|
||
// If suggestions were dismissed for this exact input, don't re-trigger
|
||
if (dismissedForInputRef.current === input) {
|
||
return;
|
||
}
|
||
// When the actual input text changes (not just updateSuggestions being recreated),
|
||
// reset the search token ref so the same query can be re-fetched.
|
||
// This fixes: type @readme.md, clear, retype @readme.md → no suggestions.
|
||
if (prevInputRef.current !== input) {
|
||
prevInputRef.current = input;
|
||
latestSearchTokenRef.current = null;
|
||
}
|
||
// Clear the dismissed state when input changes
|
||
dismissedForInputRef.current = null;
|
||
void updateSuggestions(input);
|
||
}, [input, updateSuggestions]);
|
||
|
||
// Handle tab key press - complete suggestions or trigger file suggestions
|
||
const handleTab = useCallback(async () => {
|
||
// If we have inline ghost text, apply it
|
||
if (effectiveGhostText) {
|
||
// Check for bash mode history completion first
|
||
if (mode === 'bash') {
|
||
// Replace the input with the full command from history
|
||
onInputChange(effectiveGhostText.fullCommand);
|
||
setCursorOffset(effectiveGhostText.fullCommand.length);
|
||
setInlineGhostText(undefined);
|
||
return;
|
||
}
|
||
|
||
// Find the mid-input command to get its position (for prompt mode)
|
||
const midInputCommand = findMidInputSlashCommand(input, cursorOffset);
|
||
if (midInputCommand) {
|
||
// Replace the partial command with the full command + space
|
||
const before = input.slice(0, midInputCommand.startPos);
|
||
const after = input.slice(midInputCommand.startPos + midInputCommand.token.length);
|
||
const newInput = before + '/' + effectiveGhostText.fullCommand + ' ' + after;
|
||
const newCursorOffset = midInputCommand.startPos + 1 + effectiveGhostText.fullCommand.length + 1;
|
||
|
||
onInputChange(newInput);
|
||
setCursorOffset(newCursorOffset);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If we have active suggestions, select one
|
||
if (suggestions.length > 0) {
|
||
// Cancel any pending debounced fetches to prevent flicker when accepting
|
||
debouncedFetchFileSuggestions.cancel();
|
||
debouncedFetchSlackChannels.cancel();
|
||
|
||
const index = selectedSuggestion === -1 ? 0 : selectedSuggestion;
|
||
const suggestion = suggestions[index];
|
||
|
||
if (suggestionType === 'command' && index < suggestions.length) {
|
||
if (suggestion) {
|
||
// Splice the completed command at the cursor position, preserving
|
||
// any text after the cursor (e.g., user typed "/com" before existing text).
|
||
const metadata = suggestion.metadata;
|
||
if (metadata && typeof metadata === 'object' && 'name' in metadata && 'type' in metadata) {
|
||
const commandName = getCommandName(metadata as Command);
|
||
const replacement = `/${commandName} `;
|
||
onInputChange(replacement + input.slice(cursorOffset));
|
||
setCursorOffset(replacement.length);
|
||
}
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'custom-title' && suggestions.length > 0) {
|
||
// Apply custom title to /resume command with sessionId
|
||
if (suggestion) {
|
||
const newInput = buildResumeInputFromSuggestion(suggestion);
|
||
onInputChange(newInput);
|
||
setCursorOffset(newInput.length);
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'directory' && suggestions.length > 0) {
|
||
const suggestion = suggestions[index];
|
||
if (suggestion) {
|
||
// Check if this is a command context (e.g., /add-dir) or general path completion
|
||
const isInCommandContext = isCommandInput(input);
|
||
|
||
let newInput: string;
|
||
if (isInCommandContext) {
|
||
// Command context: replace just the argument portion
|
||
const spaceIndex = input.indexOf(' ');
|
||
const commandPart = input.slice(0, spaceIndex + 1); // Include the space
|
||
const cmdSuffix =
|
||
isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory' ? '/' : ' ';
|
||
newInput = commandPart + suggestion.id + cmdSuffix;
|
||
|
||
onInputChange(newInput);
|
||
setCursorOffset(newInput.length);
|
||
|
||
if (isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory') {
|
||
// For directories, fetch new suggestions for the updated path
|
||
setSuggestionsState(prev => ({
|
||
...prev,
|
||
commandArgumentHint: undefined,
|
||
}));
|
||
void updateSuggestions(newInput, newInput.length);
|
||
} else {
|
||
clearSuggestions();
|
||
}
|
||
} else {
|
||
// General path completion: replace the path token in input with @-prefixed path
|
||
// Try to get token with @ prefix first to check if already prefixed
|
||
const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true);
|
||
const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false);
|
||
|
||
if (completionToken) {
|
||
const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory';
|
||
const result = applyDirectorySuggestion(
|
||
input,
|
||
suggestion.id,
|
||
completionToken.startPos,
|
||
completionToken.token.length,
|
||
isDir,
|
||
);
|
||
newInput = result.newInput;
|
||
|
||
onInputChange(newInput);
|
||
setCursorOffset(result.cursorPos);
|
||
|
||
if (isDir) {
|
||
// For directories, fetch new suggestions for the updated path
|
||
setSuggestionsState(prev => ({
|
||
...prev,
|
||
commandArgumentHint: undefined,
|
||
}));
|
||
void updateSuggestions(newInput, result.cursorPos);
|
||
} else {
|
||
// For files, clear suggestions
|
||
clearSuggestions();
|
||
}
|
||
} else {
|
||
// No completion token found (e.g., cursor after space) - just clear suggestions
|
||
// without modifying input to avoid data loss
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
}
|
||
} else if (suggestionType === 'shell' && suggestions.length > 0) {
|
||
const suggestion = suggestions[index];
|
||
if (suggestion) {
|
||
const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined;
|
||
applyShellSuggestion(
|
||
suggestion,
|
||
input,
|
||
cursorOffset,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
metadata?.completionType,
|
||
);
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'agent' && suggestions.length > 0 && suggestions[index]?.id?.startsWith('dm-')) {
|
||
const suggestion = suggestions[index];
|
||
if (suggestion) {
|
||
applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset);
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'slack-channel' && suggestions.length > 0) {
|
||
const suggestion = suggestions[index];
|
||
if (suggestion) {
|
||
applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset);
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'file' && suggestions.length > 0) {
|
||
const completionToken = extractCompletionToken(input, cursorOffset, true);
|
||
if (!completionToken) {
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
|
||
// Check if all suggestions share a common prefix longer than the current input
|
||
const commonPrefix = findLongestCommonPrefix(suggestions);
|
||
|
||
// Determine if token starts with @ to preserve it during replacement
|
||
const hasAtPrefix = completionToken.token.startsWith('@');
|
||
// The effective token length excludes the @ and quotes if present
|
||
let effectiveTokenLength: number;
|
||
if (completionToken.isQuoted) {
|
||
// Remove @" prefix and optional closing " to get effective length
|
||
effectiveTokenLength = completionToken.token.slice(2).replace(/"$/, '').length;
|
||
} else if (hasAtPrefix) {
|
||
effectiveTokenLength = completionToken.token.length - 1;
|
||
} else {
|
||
effectiveTokenLength = completionToken.token.length;
|
||
}
|
||
|
||
// If there's a common prefix longer than what the user has typed,
|
||
// replace the current input with the common prefix
|
||
if (commonPrefix.length > effectiveTokenLength) {
|
||
const replacementValue = formatReplacementValue({
|
||
displayText: commonPrefix,
|
||
mode,
|
||
hasAtPrefix,
|
||
needsQuotes: false, // common prefix doesn't need quotes unless already quoted
|
||
isQuoted: completionToken.isQuoted,
|
||
isComplete: false, // partial completion
|
||
});
|
||
|
||
applyFileSuggestion(
|
||
replacementValue,
|
||
input,
|
||
completionToken.token,
|
||
completionToken.startPos,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
);
|
||
// Don't clear suggestions so user can continue typing or select a specific option
|
||
// Instead, update for the new prefix
|
||
void updateSuggestions(input.replace(completionToken.token, replacementValue), cursorOffset);
|
||
} else if (index < suggestions.length) {
|
||
// Otherwise, apply the selected suggestion
|
||
const suggestion = suggestions[index];
|
||
if (suggestion) {
|
||
const needsQuotes = suggestion.displayText.includes(' ');
|
||
const replacementValue = formatReplacementValue({
|
||
displayText: suggestion.displayText,
|
||
mode,
|
||
hasAtPrefix,
|
||
needsQuotes,
|
||
isQuoted: completionToken.isQuoted,
|
||
isComplete: true, // complete suggestion
|
||
});
|
||
|
||
applyFileSuggestion(
|
||
replacementValue,
|
||
input,
|
||
completionToken.token,
|
||
completionToken.startPos,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
);
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
}
|
||
} else if (input.trim() !== '') {
|
||
let suggestionType: SuggestionType;
|
||
let suggestionItems: SuggestionItem[];
|
||
|
||
if (mode === 'bash') {
|
||
suggestionType = 'shell';
|
||
// This should be very fast, taking <10ms
|
||
const bashSuggestions = await generateBashSuggestions(input, cursorOffset);
|
||
if (bashSuggestions.length === 1) {
|
||
// If single suggestion, apply it immediately
|
||
const suggestion = bashSuggestions[0];
|
||
if (suggestion) {
|
||
const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined;
|
||
applyShellSuggestion(
|
||
suggestion,
|
||
input,
|
||
cursorOffset,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
metadata?.completionType,
|
||
);
|
||
}
|
||
suggestionItems = [];
|
||
} else {
|
||
suggestionItems = bashSuggestions;
|
||
}
|
||
} else {
|
||
suggestionType = 'file';
|
||
// If no suggestions, fetch file and MCP resource suggestions
|
||
const completionInfo = extractCompletionToken(input, cursorOffset, true);
|
||
if (completionInfo) {
|
||
// If token starts with @, search without the @ prefix
|
||
const isAtSymbol = completionInfo.token.startsWith('@');
|
||
const searchToken = isAtSymbol ? completionInfo.token.substring(1) : completionInfo.token;
|
||
|
||
suggestionItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol);
|
||
} else {
|
||
suggestionItems = [];
|
||
}
|
||
}
|
||
|
||
if (suggestionItems.length > 0) {
|
||
// Multiple suggestions or not bash mode: show list
|
||
setSuggestionsState(prev => ({
|
||
commandArgumentHint: undefined,
|
||
suggestions: suggestionItems,
|
||
selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestionItems),
|
||
}));
|
||
setSuggestionType(suggestionType);
|
||
setMaxColumnWidth(undefined);
|
||
}
|
||
}
|
||
}, [
|
||
suggestions,
|
||
selectedSuggestion,
|
||
input,
|
||
suggestionType,
|
||
commands,
|
||
mode,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
onSubmit,
|
||
clearSuggestions,
|
||
cursorOffset,
|
||
updateSuggestions,
|
||
mcpResources,
|
||
setSuggestionsState,
|
||
agents,
|
||
debouncedFetchFileSuggestions,
|
||
debouncedFetchSlackChannels,
|
||
effectiveGhostText,
|
||
]);
|
||
|
||
// Handle enter key press - apply and execute suggestions
|
||
const handleEnter = useCallback(() => {
|
||
if (selectedSuggestion < 0 || suggestions.length === 0) return;
|
||
|
||
const suggestion = suggestions[selectedSuggestion];
|
||
|
||
if (suggestionType === 'command' && selectedSuggestion < suggestions.length) {
|
||
if (suggestion) {
|
||
applyCommandSuggestion(
|
||
suggestion,
|
||
true, // execute on return
|
||
commands,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
onSubmit,
|
||
);
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'custom-title' && selectedSuggestion < suggestions.length) {
|
||
// Apply custom title and execute /resume command with sessionId
|
||
if (suggestion) {
|
||
const newInput = buildResumeInputFromSuggestion(suggestion);
|
||
onInputChange(newInput);
|
||
setCursorOffset(newInput.length);
|
||
onSubmit(newInput, /* isSubmittingSlashCommand */ true);
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'shell' && selectedSuggestion < suggestions.length) {
|
||
const suggestion = suggestions[selectedSuggestion];
|
||
if (suggestion) {
|
||
const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined;
|
||
applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType);
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
} else if (
|
||
suggestionType === 'agent' &&
|
||
selectedSuggestion < suggestions.length &&
|
||
suggestion?.id?.startsWith('dm-')
|
||
) {
|
||
applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset);
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
} else if (suggestionType === 'slack-channel' && selectedSuggestion < suggestions.length) {
|
||
if (suggestion) {
|
||
applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset);
|
||
debouncedFetchSlackChannels.cancel();
|
||
clearSuggestions();
|
||
}
|
||
} else if (suggestionType === 'file' && selectedSuggestion < suggestions.length) {
|
||
// Extract completion token directly when needed
|
||
const completionInfo = extractCompletionToken(input, cursorOffset, true);
|
||
if (completionInfo) {
|
||
if (suggestion) {
|
||
const hasAtPrefix = completionInfo.token.startsWith('@');
|
||
const needsQuotes = suggestion.displayText.includes(' ');
|
||
const replacementValue = formatReplacementValue({
|
||
displayText: suggestion.displayText,
|
||
mode,
|
||
hasAtPrefix,
|
||
needsQuotes,
|
||
isQuoted: completionInfo.isQuoted,
|
||
isComplete: true, // complete suggestion
|
||
});
|
||
|
||
applyFileSuggestion(
|
||
replacementValue,
|
||
input,
|
||
completionInfo.token,
|
||
completionInfo.startPos,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
);
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
} else if (suggestionType === 'directory' && selectedSuggestion < suggestions.length) {
|
||
if (suggestion) {
|
||
// In command context (e.g., /add-dir), Enter submits the command
|
||
// rather than applying the directory suggestion. Just clear
|
||
// suggestions and let the submit handler process the current input.
|
||
if (isCommandInput(input)) {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
return;
|
||
}
|
||
|
||
// General path completion: replace the path token
|
||
const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true);
|
||
const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false);
|
||
|
||
if (completionToken) {
|
||
const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory';
|
||
const result = applyDirectorySuggestion(
|
||
input,
|
||
suggestion.id,
|
||
completionToken.startPos,
|
||
completionToken.token.length,
|
||
isDir,
|
||
);
|
||
onInputChange(result.newInput);
|
||
setCursorOffset(result.cursorPos);
|
||
}
|
||
// If no completion token found (e.g., cursor after space), don't modify input
|
||
// to avoid data loss - just clear suggestions
|
||
|
||
debouncedFetchFileSuggestions.cancel();
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
}, [
|
||
suggestions,
|
||
selectedSuggestion,
|
||
suggestionType,
|
||
commands,
|
||
input,
|
||
cursorOffset,
|
||
mode,
|
||
onInputChange,
|
||
setCursorOffset,
|
||
onSubmit,
|
||
clearSuggestions,
|
||
debouncedFetchFileSuggestions,
|
||
debouncedFetchSlackChannels,
|
||
]);
|
||
|
||
// Handler for autocomplete:accept - accepts current suggestion via Tab or Right Arrow
|
||
const handleAutocompleteAccept = useCallback(() => {
|
||
void handleTab();
|
||
}, [handleTab]);
|
||
|
||
// Handler for autocomplete:dismiss - clears suggestions and prevents re-triggering
|
||
const handleAutocompleteDismiss = useCallback(() => {
|
||
debouncedFetchFileSuggestions.cancel();
|
||
debouncedFetchSlackChannels.cancel();
|
||
clearSuggestions();
|
||
// Remember the input when dismissed to prevent immediate re-triggering
|
||
dismissedForInputRef.current = input;
|
||
}, [debouncedFetchFileSuggestions, debouncedFetchSlackChannels, clearSuggestions, input]);
|
||
|
||
// Handler for autocomplete:previous - selects previous suggestion
|
||
const handleAutocompletePrevious = useCallback(() => {
|
||
setSuggestionsState(prev => ({
|
||
...prev,
|
||
selectedSuggestion: prev.selectedSuggestion <= 0 ? suggestions.length - 1 : prev.selectedSuggestion - 1,
|
||
}));
|
||
}, [suggestions.length, setSuggestionsState]);
|
||
|
||
// Handler for autocomplete:next - selects next suggestion
|
||
const handleAutocompleteNext = useCallback(() => {
|
||
setSuggestionsState(prev => ({
|
||
...prev,
|
||
selectedSuggestion: prev.selectedSuggestion >= suggestions.length - 1 ? 0 : prev.selectedSuggestion + 1,
|
||
}));
|
||
}, [suggestions.length, setSuggestionsState]);
|
||
|
||
// Autocomplete context keybindings - only active when suggestions are visible
|
||
const autocompleteHandlers = useMemo(
|
||
() => ({
|
||
'autocomplete:accept': handleAutocompleteAccept,
|
||
'autocomplete:dismiss': handleAutocompleteDismiss,
|
||
'autocomplete:previous': handleAutocompletePrevious,
|
||
'autocomplete:next': handleAutocompleteNext,
|
||
}),
|
||
[handleAutocompleteAccept, handleAutocompleteDismiss, handleAutocompletePrevious, handleAutocompleteNext],
|
||
);
|
||
|
||
// Register autocomplete as an overlay so CancelRequestHandler defers ESC handling
|
||
// This ensures ESC dismisses autocomplete before canceling running tasks
|
||
const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText;
|
||
const isModalOverlayActive = useIsModalOverlayActive();
|
||
useRegisterOverlay('autocomplete', isAutocompleteActive);
|
||
// Register Autocomplete context so it appears in activeContexts for other handlers.
|
||
// This allows Chat's resolver to see Autocomplete and defer to its bindings for up/down.
|
||
useRegisterKeybindingContext('Autocomplete', isAutocompleteActive);
|
||
|
||
// Disable autocomplete keybindings when a modal overlay (e.g., DiffDialog) is active,
|
||
// so escape reaches the overlay's handler instead of dismissing autocomplete
|
||
useKeybindings(autocompleteHandlers, {
|
||
context: 'Autocomplete',
|
||
isActive: isAutocompleteActive && !isModalOverlayActive,
|
||
});
|
||
|
||
function acceptSuggestionText(text: string): void {
|
||
const detectedMode = getModeFromInput(text);
|
||
if (detectedMode !== 'prompt' && onModeChange) {
|
||
onModeChange(detectedMode);
|
||
const stripped = getValueFromInput(text);
|
||
onInputChange(stripped);
|
||
setCursorOffset(stripped.length);
|
||
} else {
|
||
onInputChange(text);
|
||
setCursorOffset(text.length);
|
||
}
|
||
}
|
||
|
||
// Handle keyboard input for behaviors not covered by keybindings
|
||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||
// Handle right arrow to accept prompt suggestion ghost text
|
||
if (e.key === 'right' && !isViewingTeammate) {
|
||
const suggestionText = promptSuggestion.text;
|
||
const suggestionShownAt = promptSuggestion.shownAt;
|
||
if (suggestionText && suggestionShownAt > 0 && input === '') {
|
||
markAccepted();
|
||
acceptSuggestionText(suggestionText);
|
||
e.stopImmediatePropagation();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Handle Tab key fallback behaviors when no autocomplete suggestions
|
||
// Don't handle tab if shift is pressed (used for mode cycle)
|
||
if (e.key === 'tab' && !e.shift) {
|
||
// Skip if autocomplete is handling this (suggestions or ghost text exist)
|
||
if (suggestions.length > 0 || effectiveGhostText) {
|
||
return;
|
||
}
|
||
// Accept prompt suggestion if it exists in AppState
|
||
const suggestionText = promptSuggestion.text;
|
||
const suggestionShownAt = promptSuggestion.shownAt;
|
||
if (suggestionText && suggestionShownAt > 0 && input === '' && !isViewingTeammate) {
|
||
e.preventDefault();
|
||
markAccepted();
|
||
acceptSuggestionText(suggestionText);
|
||
return;
|
||
}
|
||
// Remind user about thinking toggle shortcut if empty input
|
||
if (input.trim() === '') {
|
||
e.preventDefault();
|
||
addNotification({
|
||
key: 'thinking-toggle-hint',
|
||
jsx: <Text dimColor>Use {thinkingToggleShortcut} to toggle thinking</Text>,
|
||
priority: 'immediate',
|
||
timeoutMs: 3000,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Only continue with navigation if we have suggestions
|
||
if (suggestions.length === 0) return;
|
||
|
||
// Handle Ctrl-N/P for navigation (arrows handled by keybindings)
|
||
// Skip if we're in the middle of a chord sequence to allow chords like ctrl+f n
|
||
const hasPendingChord = keybindingContext?.pendingChord != null;
|
||
if (e.ctrl && e.key === 'n' && !hasPendingChord) {
|
||
e.preventDefault();
|
||
handleAutocompleteNext();
|
||
return;
|
||
}
|
||
|
||
if (e.ctrl && e.key === 'p' && !hasPendingChord) {
|
||
e.preventDefault();
|
||
handleAutocompletePrevious();
|
||
return;
|
||
}
|
||
|
||
// Handle selection and execution via return/enter
|
||
// Shift+Enter and Meta+Enter insert newlines (handled by useTextInput),
|
||
// so don't accept the suggestion for those.
|
||
if (e.key === 'return' && !e.shift && !e.meta) {
|
||
e.preventDefault();
|
||
handleEnter();
|
||
}
|
||
};
|
||
|
||
// Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to
|
||
// <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
|
||
// KeyboardEvent until the consumer is migrated (separate PR).
|
||
// TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown.
|
||
useInput((_input, _key, event) => {
|
||
const kbEvent = new KeyboardEvent(event.keypress);
|
||
handleKeyDown(kbEvent);
|
||
if (kbEvent.didStopImmediatePropagation()) {
|
||
event.stopImmediatePropagation();
|
||
}
|
||
});
|
||
|
||
return {
|
||
suggestions,
|
||
selectedSuggestion,
|
||
suggestionType,
|
||
maxColumnWidth,
|
||
commandArgumentHint,
|
||
inlineGhostText: effectiveGhostText,
|
||
handleKeyDown,
|
||
};
|
||
}
|