import { useCallback, useEffect, useMemo, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { useAgentCommandsQuery, type AgentSlashCommand, type DraftCommandConfig, } from "./use-agent-commands-query"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; import { applyFileMentionReplacement, findActiveFileMention, type FileMentionRange, } from "@/utils/file-mention-autocomplete"; interface UseAgentAutocompleteInput { userInput: string; cursorIndex: number; setUserInput: (nextValue: string) => void; serverId: string; agentId: string; draftConfig?: DraftCommandConfig; onAutocompleteApplied?: () => void; onClientSlashCommand?: (command: ClientSlashCommand) => void; canExecuteClientSlashCommand?: boolean; } type AgentAutocompleteOption = | (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand }) | (AutocompleteOption & { type: "provider_command" }) | (AutocompleteOption & { type: "workspace_entry"; entryPath: string; mention: FileMentionRange; }); interface AgentAutocompleteResult { isVisible: boolean; options: AutocompleteOption[]; selectedIndex: number; isLoading: boolean; errorMessage?: string; loadingText: string; emptyText: string; onSelectOption: (option: AutocompleteOption) => void; onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; } interface DirectorySuggestionEntry { path: string; kind: "file" | "directory"; } type AvailableCommand = | { source: "client"; command: ClientSlashCommand } | { source: "provider"; command: AgentSlashCommand }; function normalizeDraftCommandConfig( draftConfig?: DraftCommandConfig, ): DraftCommandConfig | undefined { if (!draftConfig) { return undefined; } const cwd = draftConfig.cwd.trim(); if (!cwd) { return undefined; } const modeId = draftConfig.modeId?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const featureValues = draftConfig.featureValues; return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), ...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}), }; } function mapDirectorySuggestionsToEntries(payload: { entries?: Array<{ path: string; kind: string }>; directories?: string[]; }): DirectorySuggestionEntry[] { if (Array.isArray(payload.entries) && payload.entries.length > 0) { return payload.entries.flatMap((entry) => { if ( !entry || typeof entry.path !== "string" || (entry.kind !== "file" && entry.kind !== "directory") ) { return []; } return [{ path: entry.path, kind: entry.kind }]; }); } return (payload.directories ?? []).map((path) => ({ path, kind: "directory" as const, })); } function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption { const command = entry.command; const base = { id: command.name, label: `/${command.name}`, detail: command.argumentHint || undefined, description: command.description, kind: "command" as const, }; if (entry.source === "client") { return { ...base, type: "client_command", command: entry.command, }; } return { ...base, type: "provider_command", }; } type AutocompleteMode = "command" | "file" | null; function resolveAutocompleteMode(args: { showFileAutocomplete: boolean; showCommandAutocomplete: boolean; }): AutocompleteMode { if (args.showFileAutocomplete) { return "file"; } if (args.showCommandAutocomplete) { return "command"; } return null; } function resolveAutocompleteIsVisible(args: { mode: AutocompleteMode; canLoadCommands: boolean; serverId: string; autocompleteCwd: string; }): boolean { if (args.mode === "command") { return args.canLoadCommands; } if (args.mode === "file") { return Boolean(args.serverId) && args.autocompleteCwd.length > 0; } return false; } function resolveAutocompleteIsLoading(args: { mode: AutocompleteMode; isCommandsLoading: boolean; fileSuggestionsIsPending: boolean; fileSuggestionsIsLoading: boolean; optionsLength: number; }): boolean { if (args.mode === "command") { return args.isCommandsLoading; } if (args.mode === "file") { return ( args.fileSuggestionsIsPending || (args.fileSuggestionsIsLoading && args.optionsLength === 0) ); } return false; } function resolveAutocompleteErrorMessage(args: { mode: AutocompleteMode; isCommandError: boolean; commandError: Error | null; fileSuggestionsError: unknown; }): string | undefined { if (args.mode === "command") { return args.isCommandError ? (args.commandError?.message ?? "Failed to load") : undefined; } if (args.mode === "file") { return args.fileSuggestionsError instanceof Error ? args.fileSuggestionsError.message : undefined; } return undefined; } export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { const { userInput, cursorIndex, setUserInput, serverId, agentId, draftConfig, onAutocompleteApplied, onClientSlashCommand, canExecuteClientSlashCommand, } = input; const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ""; const activeFileMention = useMemo( () => findActiveFileMention({ text: userInput, cursorIndex, }), [cursorIndex, userInput], ); const showFileAutocomplete = activeFileMention !== null; const fileFilterQuery = activeFileMention?.query ?? ""; const [debouncedFileFilterQuery, setDebouncedFileFilterQuery] = useState(fileFilterQuery); useEffect(() => { const timer = setTimeout(() => setDebouncedFileFilterQuery(fileFilterQuery), 180); return () => clearTimeout(timer); }, [fileFilterQuery]); const normalizedDraftConfig = useMemo( () => normalizeDraftCommandConfig(draftConfig), [draftConfig], ); const isDraftContext = normalizedDraftConfig !== undefined; const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined; const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext); const agentCwd = useSessionStore( (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "", ); const autocompleteCwd = useMemo(() => { if (isDraftContext) { return queryDraftConfig?.cwd ?? ""; } return agentCwd.trim(); }, [agentCwd, isDraftContext, queryDraftConfig]); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const mode = resolveAutocompleteMode({ showFileAutocomplete, showCommandAutocomplete }); const isVisible = resolveAutocompleteIsVisible({ mode, canLoadCommands, serverId, autocompleteCwd, }); const { commands, isLoading: isCommandsLoading, isError, error, } = useAgentCommandsQuery({ serverId, agentId, enabled: mode === "command" && canLoadCommands, draftConfig: queryDraftConfig, }); const fileSuggestionsQuery = useQuery({ queryKey: [ "directorySuggestions", serverId, autocompleteCwd, debouncedFileFilterQuery, true, true, ], queryFn: async (): Promise => { if (!client) { throw new Error("Daemon client unavailable"); } const response = await client.getDirectorySuggestions({ cwd: autocompleteCwd, query: debouncedFileFilterQuery, limit: 50, includeFiles: true, includeDirectories: true, }); if (response.error) { throw new Error(response.error); } return mapDirectorySuggestionsToEntries(response); }, enabled: mode === "file" && Boolean(serverId) && autocompleteCwd.length > 0 && Boolean(client) && isConnected, retry: false, staleTime: 15_000, placeholderData: keepPreviousData, }); const options = useMemo(() => { if (!isVisible) { return []; } if (mode === "command") { const filterLower = commandFilterQuery.toLowerCase(); const providerCommands = commands.map( (command): AvailableCommand => ({ source: "provider", command }), ); const availableCommands: AvailableCommand[] = isDraftContext ? providerCommands : [ ...CLIENT_SLASH_COMMANDS.map( (command): AvailableCommand => ({ source: "client", command }), ), ...providerCommands, ]; const matches = availableCommands.filter((entry) => entry.command.name.toLowerCase().includes(filterLower), ); const orderedMatches = orderAutocompleteOptions(matches); return orderedMatches.map(mapCommandToOption); } if (mode === "file" && activeFileMention) { const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []); return orderedEntries.map((entry) => ({ type: "workspace_entry" as const, id: `${entry.kind}:${entry.path}`, label: entry.path, kind: entry.kind, entryPath: entry.path, mention: activeFileMention, })); } return []; }, [ activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isDraftContext, isVisible, mode, ]); const onSelectOption = useCallback( (option: AutocompleteOption) => { const selected = option as AgentAutocompleteOption; if ( selected.type === "client_command" && selected.command.execution === "immediate" && canExecuteClientSlashCommand && onClientSlashCommand ) { onClientSlashCommand(selected.command); return; } if (selected.type === "client_command" || selected.type === "provider_command") { setUserInput(`/${selected.id} `); onAutocompleteApplied?.(); return; } const nextInput = applyFileMentionReplacement({ text: userInput, mention: selected.mention, relativePath: selected.entryPath, }); setUserInput(nextInput); onAutocompleteApplied?.(); }, [ canExecuteClientSlashCommand, onAutocompleteApplied, onClientSlashCommand, setUserInput, userInput, ], ); const { selectedIndex, onKeyPress } = useAutocomplete({ isVisible, options, query: mode === "command" ? commandFilterQuery : fileFilterQuery, onSelectOption, onEscape: mode === "command" ? () => setUserInput("") : undefined, }); const isLoading = resolveAutocompleteIsLoading({ mode, isCommandsLoading, fileSuggestionsIsPending: fileSuggestionsQuery.isPending, fileSuggestionsIsLoading: fileSuggestionsQuery.isLoading, optionsLength: options.length, }); const errorMessage = resolveAutocompleteErrorMessage({ mode, isCommandError: isError, commandError: error, fileSuggestionsError: fileSuggestionsQuery.error, }); const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands..."; const emptyText = mode === "file" ? "No files or directories found" : "No commands found"; return { isVisible, options, selectedIndex, isLoading, errorMessage, loadingText, emptyText, onSelectOption, onKeyPress, }; }