mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
* feat(app): show loading and errors in the composer command menu Rows are single lines with a kind icon, the matched part of the name, the argument hint, the description and a source tag. The detail card is gone. The menu used to stay hidden while commands loaded and then show "Error: ..." over a list whose options Enter could still pick, so a failed load plus Enter archived the agent through a hidden /exit. Loading and error are now status rows with no options; the error row names the reason and offers Retry. Loading appears after 300ms so a warm cache paints the results first, as before. An empty query used to select /exit. Provider commands now come first, so the bottom row the input selects is a provider command. useAutocomplete derives the selection during render. When results replace the loading row, the selection is valid in the same render, so the popover stays mounted at its anchor instead of remounting. Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its own server-launched sessions, so the Claude provider no longer lists them. * fix(app): never default the command menu to a Paseo action An arrow-key choice was tied to the query it was made for, so returning to that query brought it back: arrow to /exit on `/`, type `com`, delete it, and Enter archived the agent. A choice now clears whenever the query changes. An empty query still selected /exit when the agent had no provider or plugin commands, because Paseo's actions were the only entries. With nothing else to list, an empty query now lists nothing and waits for a search; typing `/ex` still finds /exit. * fix(app): sort empty-query commands without toSorted Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is not a function" on iOS and Android and the app showed its error screen. Browsers and Node have it, which is why web and unit tests passed. * test(app): cover the stale command choice in Playwright and wait out the loading row on devices The renderHook test mounted React under JSDOM, which docs/testing.md forbids. The Playwright test drives the same sequence in a real browser and fails without the reset in useAutocomplete: Enter chose the stale /exit. The composer-keyboard harnesses treated a visible popover as a ready list. The popover now shows a loading row first, and a cold Claude command list takes several seconds, so both harnesses wait for the rows. * fix(app): size the command menu loading spinner to the row icons Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
774 lines
23 KiB
TypeScript
774 lines
23 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
import type { TFunction } from "i18next";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { AutocompleteOption, AutocompleteStatus } from "@/components/ui/autocomplete";
|
|
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
|
import {
|
|
useAutocompleteCommands,
|
|
type AgentSlashCommand,
|
|
type DraftCommandConfig,
|
|
type DraftCommandTarget,
|
|
} from "@/agent-commands";
|
|
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 { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
|
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
|
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
|
|
import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model";
|
|
import {
|
|
applySlashCommandReplacement,
|
|
filterAndRankCommandAutocompleteEntries,
|
|
filterInlineSkillCommandEntries,
|
|
findActiveSlashCommand,
|
|
type SlashCommandRange,
|
|
} from "@/utils/agent-command-autocomplete";
|
|
import {
|
|
applyFileMentionReplacement,
|
|
findActiveFileMention,
|
|
type FileMentionRange,
|
|
} from "@/utils/file-mention-autocomplete";
|
|
|
|
interface UseAgentAutocompleteInput {
|
|
userInput: string;
|
|
cursorIndex: number;
|
|
setUserInput: (nextValue: string) => void;
|
|
serverId: string;
|
|
agentId: string;
|
|
draft?: DraftCommandTarget;
|
|
onAutocompleteApplied?: () => void;
|
|
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
|
canExecuteClientSlashCommand?: boolean;
|
|
pluginClientSlashCommands?: readonly PluginClientSlashCommand[];
|
|
}
|
|
|
|
interface AgentAutocompleteKeyPressEvent {
|
|
key: string;
|
|
preventDefault: () => void;
|
|
input: AgentAutocompleteInputSnapshot;
|
|
}
|
|
|
|
interface AgentAutocompleteInputSnapshot {
|
|
text: string;
|
|
selection: { start: number; end: number };
|
|
}
|
|
|
|
type AgentAutocompleteOption =
|
|
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
|
|
| (AutocompleteOption & {
|
|
type: "plugin_command";
|
|
command: PluginClientSlashCommand;
|
|
})
|
|
| (AutocompleteOption & { type: "provider_command" })
|
|
| (AutocompleteOption & {
|
|
type: "workspace_entry";
|
|
entryPath: string;
|
|
mention: FileMentionRange;
|
|
});
|
|
|
|
interface AgentAutocompleteResult {
|
|
isVisible: boolean;
|
|
options: AutocompleteOption[];
|
|
selectedIndex: number;
|
|
/** Shown in place of options; set exactly when `options` is empty. */
|
|
status: AutocompleteStatus | null;
|
|
onSelectOption: (option: AutocompleteOption, input?: AgentAutocompleteInputSnapshot) => void;
|
|
onKeyPress: (event: AgentAutocompleteKeyPressEvent) => boolean;
|
|
}
|
|
|
|
interface AgentAutocompleteSnapshot {
|
|
text: string;
|
|
slashCommand: SlashCommandRange | null;
|
|
fileMention: FileMentionRange | null;
|
|
}
|
|
|
|
function resolveAgentAutocompleteSnapshot(input: {
|
|
input?: AgentAutocompleteInputSnapshot;
|
|
userInput: string;
|
|
cursorIndex: number;
|
|
activeSlashCommand: SlashCommandRange | null;
|
|
activeFileMention: FileMentionRange | null;
|
|
}): AgentAutocompleteSnapshot {
|
|
if (!input.input) {
|
|
return {
|
|
text: input.userInput,
|
|
slashCommand: input.activeSlashCommand,
|
|
fileMention: input.activeFileMention,
|
|
};
|
|
}
|
|
|
|
const text = input.input.text;
|
|
const cursorIndex = input.input.selection.start;
|
|
return {
|
|
text,
|
|
slashCommand: findActiveSlashCommand({ text, cursorIndex }),
|
|
fileMention: findActiveFileMention({ text, cursorIndex }),
|
|
};
|
|
}
|
|
|
|
interface DirectorySuggestionEntry {
|
|
path: string;
|
|
kind: "file" | "directory";
|
|
}
|
|
|
|
type AvailableCommand =
|
|
| { source: "client"; command: ClientSlashCommand }
|
|
| { source: "plugin"; command: PluginClientSlashCommand }
|
|
| { source: "provider"; command: AgentSlashCommand };
|
|
|
|
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
|
|
if (draft?.status !== "ready") {
|
|
return undefined;
|
|
}
|
|
|
|
const draftConfig = draft.config;
|
|
const cwd = draftConfig.cwd.trim();
|
|
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,
|
|
}));
|
|
}
|
|
|
|
const CLIENT_COMMAND_ICONS = {
|
|
"archive-agent": "archive",
|
|
"replace-agent-with-draft": "new-draft",
|
|
} as const satisfies Record<ClientSlashCommand["kind"], AutocompleteOption["icon"]>;
|
|
|
|
interface CommandOptionContext {
|
|
query: string;
|
|
pluginNames: ReadonlyMap<string, string>;
|
|
t: TFunction;
|
|
}
|
|
|
|
function mapCommandToOption(
|
|
entry: AvailableCommand,
|
|
context: CommandOptionContext,
|
|
): AgentAutocompleteOption {
|
|
const command = entry.command;
|
|
const base = {
|
|
id: command.name,
|
|
label: `/${command.name}`,
|
|
// The label carries a leading slash the query does not.
|
|
labelMatches: findHighlightRanges(context.query, command.name).map((range) => ({
|
|
start: range.start + 1,
|
|
length: range.length,
|
|
})),
|
|
argumentHint: command.argumentHint || undefined,
|
|
};
|
|
if (entry.source === "client") {
|
|
return {
|
|
...base,
|
|
type: "client_command",
|
|
command: entry.command,
|
|
icon: CLIENT_COMMAND_ICONS[entry.command.kind],
|
|
description: context.t(entry.command.descriptionKey),
|
|
tag: "Paseo",
|
|
};
|
|
}
|
|
if (entry.source === "plugin") {
|
|
return {
|
|
...base,
|
|
type: "plugin_command",
|
|
command: entry.command,
|
|
icon: "plugin",
|
|
description: entry.command.description,
|
|
tag: context.pluginNames.get(entry.command.pluginId) ?? entry.command.pluginId,
|
|
};
|
|
}
|
|
const isSkill = entry.command.kind === "skill";
|
|
return {
|
|
...base,
|
|
type: "provider_command",
|
|
icon: isSkill ? "skill" : "command",
|
|
description: entry.command.description,
|
|
tag: isSkill ? context.t("agentAutocomplete.skillTag") : undefined,
|
|
};
|
|
}
|
|
|
|
type AutocompleteMode = "command" | "file" | null;
|
|
|
|
interface BuildAutocompleteOptionsInput {
|
|
mode: AutocompleteMode;
|
|
commands: AgentSlashCommand[];
|
|
pluginCommands: readonly PluginClientSlashCommand[];
|
|
pluginNames: ReadonlyMap<string, string>;
|
|
isDraftContext: boolean;
|
|
commandFilterQuery: string;
|
|
activeSlashCommand: SlashCommandRange | null;
|
|
activeFileMention: FileMentionRange | null;
|
|
fileSuggestions: DirectorySuggestionEntry[];
|
|
t: TFunction;
|
|
}
|
|
|
|
function buildAutocompleteOptions(input: BuildAutocompleteOptionsInput): AgentAutocompleteOption[] {
|
|
if (input.mode === "command") {
|
|
const providerCommands = input.commands.map((command) => ({
|
|
source: "provider" as const,
|
|
command,
|
|
}));
|
|
const rootCommands: AvailableCommand[] = mergeSlashCommandSources({
|
|
builtIn: CLIENT_SLASH_COMMANDS,
|
|
plugins: input.pluginCommands,
|
|
provider: input.commands,
|
|
onPluginCollision(command, winner) {
|
|
console.warn(
|
|
`[Plugins] Client slash command /${command.name} from ${command.pluginId} ignored; ${winner} command wins`,
|
|
);
|
|
},
|
|
})
|
|
.filter((entry) => !input.isDraftContext || entry.source !== "built-in")
|
|
.map((entry): AvailableCommand => {
|
|
if (entry.source === "built-in") return { source: "client", command: entry.command };
|
|
return entry;
|
|
});
|
|
const availableCommands: AvailableCommand[] =
|
|
input.activeSlashCommand?.position === "inline"
|
|
? filterInlineSkillCommandEntries(providerCommands)
|
|
: rootCommands;
|
|
const matches = filterAndRankCommandAutocompleteEntries(
|
|
availableCommands,
|
|
input.commandFilterQuery,
|
|
);
|
|
const context = {
|
|
query: input.commandFilterQuery,
|
|
pluginNames: input.pluginNames,
|
|
t: input.t,
|
|
};
|
|
return orderAutocompleteOptions(matches).map((entry) => mapCommandToOption(entry, context));
|
|
}
|
|
|
|
const activeFileMention = input.activeFileMention;
|
|
if (input.mode === "file" && activeFileMention) {
|
|
const orderedEntries = orderAutocompleteOptions(input.fileSuggestions);
|
|
return orderedEntries.map((entry) => ({
|
|
type: "workspace_entry" as const,
|
|
id: `${entry.kind}:${entry.path}`,
|
|
label: entry.path,
|
|
icon: entry.kind,
|
|
entryPath: entry.path,
|
|
mention: activeFileMention,
|
|
}));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
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 resolveCanLoadCommands(args: {
|
|
serverId: string;
|
|
agentId: string;
|
|
isDraftContext: boolean;
|
|
}): boolean {
|
|
if (!args.serverId) {
|
|
return false;
|
|
}
|
|
return Boolean(args.agentId) || args.isDraftContext;
|
|
}
|
|
|
|
type CommandsState = "ready" | "loading" | "error";
|
|
|
|
function resolveCommandsState(input: {
|
|
canListCommands: boolean;
|
|
isLoading: boolean;
|
|
isError: boolean;
|
|
}): CommandsState {
|
|
if (!input.canListCommands) return "ready";
|
|
if (input.isError) return "error";
|
|
if (input.isLoading) return "loading";
|
|
return "ready";
|
|
}
|
|
|
|
interface AutocompleteMenu {
|
|
options: AgentAutocompleteOption[];
|
|
status: AutocompleteStatus | null;
|
|
}
|
|
|
|
/**
|
|
* One state for the open menu, so options and a loading or error row cannot both claim it:
|
|
* while a status row shows there are no options for Enter or Tab to pick.
|
|
*/
|
|
function resolveCommandMenu(input: {
|
|
commandsState: CommandsState;
|
|
options: () => AgentAutocompleteOption[];
|
|
providerLabel: string | null;
|
|
error: Error | null;
|
|
onRetry: () => void;
|
|
emptyText: string;
|
|
t: TFunction;
|
|
}): AutocompleteMenu {
|
|
const { t } = input;
|
|
if (input.commandsState === "loading") {
|
|
const message = input.providerLabel
|
|
? t("agentAutocomplete.loadingProviderCommands", { provider: input.providerLabel })
|
|
: t("agentAutocomplete.loadingCommands");
|
|
return { options: [], status: { kind: "loading", message } };
|
|
}
|
|
if (input.commandsState === "error") {
|
|
const title = input.providerLabel
|
|
? t("agentAutocomplete.providerCommandsFailed", { provider: input.providerLabel })
|
|
: t("agentAutocomplete.commandsFailed");
|
|
return {
|
|
options: [],
|
|
status: { kind: "error", title, reason: input.error?.message, onRetry: input.onRetry },
|
|
};
|
|
}
|
|
return withEmptyStatus(input.options(), input.emptyText);
|
|
}
|
|
|
|
function resolveFileMenu(input: {
|
|
options: () => AgentAutocompleteOption[];
|
|
isPending: boolean;
|
|
isLoading: boolean;
|
|
error: unknown;
|
|
t: TFunction;
|
|
}): AutocompleteMenu {
|
|
const options = input.options();
|
|
if (input.isPending || (input.isLoading && options.length === 0)) {
|
|
return {
|
|
options: [],
|
|
status: { kind: "loading", message: input.t("agentAutocomplete.searchingWorkspace") },
|
|
};
|
|
}
|
|
if (input.error instanceof Error) {
|
|
return {
|
|
options: [],
|
|
status: {
|
|
kind: "error",
|
|
title: input.t("agentAutocomplete.filesFailed"),
|
|
reason: input.error.message,
|
|
},
|
|
};
|
|
}
|
|
return withEmptyStatus(options, input.t("agentAutocomplete.noFiles"));
|
|
}
|
|
|
|
function withEmptyStatus(options: AgentAutocompleteOption[], emptyText: string): AutocompleteMenu {
|
|
return options.length > 0
|
|
? { options, status: null }
|
|
: { options, status: { kind: "empty", message: emptyText } };
|
|
}
|
|
|
|
/** How long a command menu waits for commands before it opens on a loading row. */
|
|
const COMMANDS_LOADING_REVEAL_MS = 300;
|
|
|
|
/**
|
|
* Whether the open command menu has waited long enough to show a loading row. Commands that
|
|
* arrive sooner paint the list as the first frame; once the wait is over, loading (a retry)
|
|
* shows at once so the open menu never closes on itself.
|
|
*/
|
|
function useLoadingRevealed(isMenuOpen: boolean): boolean {
|
|
const [revealed, setRevealed] = useState(false);
|
|
useEffect(() => {
|
|
if (!isMenuOpen) {
|
|
setRevealed(false);
|
|
return undefined;
|
|
}
|
|
const timer = setTimeout(() => setRevealed(true), COMMANDS_LOADING_REVEAL_MS);
|
|
return () => clearTimeout(timer);
|
|
}, [isMenuOpen]);
|
|
return revealed;
|
|
}
|
|
|
|
/** The names command rows and status rows show: the provider's label and each plugin's name. */
|
|
function useCommandSourceNames(input: {
|
|
serverId: string;
|
|
agentId: string;
|
|
draftConfig: DraftCommandConfig | undefined;
|
|
cwd: string;
|
|
isCommandMenuOpen: boolean;
|
|
commandsState: CommandsState;
|
|
pluginCommandCount: number;
|
|
}): { providerLabel: string | null; pluginNames: ReadonlyMap<string, string> } {
|
|
const agentProvider = useSessionStore(
|
|
(state) => state.sessions[input.serverId]?.agents?.get(input.agentId)?.provider,
|
|
);
|
|
const provider = input.draftConfig?.provider ?? agentProvider;
|
|
// Only a loading or error row names the provider; only plugin rows need plugin names.
|
|
const { entries: providerEntries } = useProvidersSnapshot(input.serverId, {
|
|
cwd: input.cwd,
|
|
enabled: input.isCommandMenuOpen && input.commandsState !== "ready",
|
|
});
|
|
const pluginList = usePluginList(input.serverId, {
|
|
enabled: input.isCommandMenuOpen && input.pluginCommandCount > 0,
|
|
});
|
|
const pluginNames = useMemo(
|
|
() => new Map((pluginList.data ?? []).map((plugin) => [plugin.id, pluginDisplayName(plugin)])),
|
|
[pluginList.data],
|
|
);
|
|
return {
|
|
providerLabel: provider ? resolveProviderLabel(provider, providerEntries) : null,
|
|
pluginNames,
|
|
};
|
|
}
|
|
|
|
// A draft's agentId is a draft key the daemon does not know, so a draft lists
|
|
// commands by its config only, and not at all until that config is complete.
|
|
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
|
|
const queryDraftConfig = resolveDraftQueryConfig(draft);
|
|
const isDraftContext = draft !== undefined;
|
|
return {
|
|
isDraftContext,
|
|
queryDraftConfig,
|
|
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
|
|
};
|
|
}
|
|
|
|
function resolveCommandsEmptyText(args: {
|
|
draft: DraftCommandTarget | undefined;
|
|
t: TFunction;
|
|
}): string {
|
|
if (args.draft?.status === "needs-project") {
|
|
return args.t("agentAutocomplete.chooseProjectForCommands");
|
|
}
|
|
if (args.draft?.status === "needs-provider") {
|
|
return args.t("agentAutocomplete.chooseModelForCommands");
|
|
}
|
|
return args.t("agentAutocomplete.noCommands");
|
|
}
|
|
|
|
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
|
const { t } = useTranslation();
|
|
const {
|
|
userInput,
|
|
cursorIndex,
|
|
setUserInput,
|
|
serverId,
|
|
agentId,
|
|
draft,
|
|
onAutocompleteApplied,
|
|
onClientSlashCommand,
|
|
canExecuteClientSlashCommand,
|
|
pluginClientSlashCommands = [],
|
|
} = input;
|
|
|
|
const activeSlashCommand = useMemo(
|
|
() =>
|
|
findActiveSlashCommand({
|
|
text: userInput,
|
|
cursorIndex,
|
|
}),
|
|
[cursorIndex, userInput],
|
|
);
|
|
const showCommandAutocomplete = activeSlashCommand !== null;
|
|
const commandFilterQuery = activeSlashCommand?.query ?? "";
|
|
|
|
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 { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
|
|
() => resolveDraftCommandContext(draft),
|
|
[draft],
|
|
);
|
|
const canLoadCommands = resolveCanLoadCommands({ serverId, 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 canShowAutocomplete = resolveAutocompleteIsVisible({
|
|
mode,
|
|
canLoadCommands,
|
|
serverId,
|
|
autocompleteCwd,
|
|
});
|
|
|
|
const commandsQuery = useAutocompleteCommands({
|
|
serverId,
|
|
agentId,
|
|
enabled: mode === "command" && canLoadCommands && canListCommands,
|
|
draftConfig: queryDraftConfig,
|
|
});
|
|
const commandsState = resolveCommandsState({
|
|
canListCommands,
|
|
isLoading: commandsQuery.isLoading,
|
|
isError: commandsQuery.isError,
|
|
});
|
|
const isCommandMenuOpen = mode === "command" && canShowAutocomplete;
|
|
const isLoadingRevealed = useLoadingRevealed(isCommandMenuOpen);
|
|
const isVisible =
|
|
canShowAutocomplete &&
|
|
!(isCommandMenuOpen && commandsState === "loading" && !isLoadingRevealed);
|
|
const { providerLabel, pluginNames } = useCommandSourceNames({
|
|
serverId,
|
|
agentId,
|
|
draftConfig: queryDraftConfig,
|
|
cwd: autocompleteCwd,
|
|
isCommandMenuOpen,
|
|
commandsState,
|
|
pluginCommandCount: pluginClientSlashCommands.length,
|
|
});
|
|
|
|
const fileSuggestionsQuery = useQuery({
|
|
queryKey: [
|
|
"directorySuggestions",
|
|
serverId,
|
|
autocompleteCwd,
|
|
debouncedFileFilterQuery,
|
|
true,
|
|
true,
|
|
],
|
|
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
|
|
if (!client) {
|
|
throw new Error(t("common.errors.daemonClientUnavailable"));
|
|
}
|
|
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 commandsEmptyText = resolveCommandsEmptyText({ draft, t });
|
|
const retryCommandsQuery = commandsQuery.retry;
|
|
const retryCommands = useCallback(() => {
|
|
retryCommandsQuery();
|
|
// Pressing Retry takes focus on web; hand it back so the user keeps typing the command.
|
|
onAutocompleteApplied?.();
|
|
}, [onAutocompleteApplied, retryCommandsQuery]);
|
|
const { options, status } = useMemo<AutocompleteMenu>(() => {
|
|
if (!isVisible) return { options: [], status: null };
|
|
const buildOptions = () =>
|
|
buildAutocompleteOptions({
|
|
activeFileMention,
|
|
commandFilterQuery,
|
|
commands: commandsQuery.commands,
|
|
pluginCommands: pluginClientSlashCommands,
|
|
pluginNames,
|
|
activeSlashCommand,
|
|
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
|
isDraftContext,
|
|
mode,
|
|
t,
|
|
});
|
|
if (mode === "file") {
|
|
return resolveFileMenu({
|
|
options: buildOptions,
|
|
isPending: fileSuggestionsQuery.isPending,
|
|
isLoading: fileSuggestionsQuery.isLoading,
|
|
error: fileSuggestionsQuery.error,
|
|
t,
|
|
});
|
|
}
|
|
return resolveCommandMenu({
|
|
commandsState,
|
|
options: buildOptions,
|
|
providerLabel,
|
|
error: commandsQuery.error,
|
|
onRetry: retryCommands,
|
|
emptyText: commandsEmptyText,
|
|
t,
|
|
});
|
|
}, [
|
|
activeFileMention,
|
|
activeSlashCommand,
|
|
commandFilterQuery,
|
|
commandsEmptyText,
|
|
commandsQuery.commands,
|
|
commandsQuery.error,
|
|
commandsState,
|
|
fileSuggestionsQuery.data,
|
|
fileSuggestionsQuery.error,
|
|
fileSuggestionsQuery.isLoading,
|
|
fileSuggestionsQuery.isPending,
|
|
isDraftContext,
|
|
isVisible,
|
|
mode,
|
|
pluginClientSlashCommands,
|
|
pluginNames,
|
|
providerLabel,
|
|
retryCommands,
|
|
t,
|
|
]);
|
|
|
|
const onSelectOption = useCallback(
|
|
(option: AutocompleteOption, snapshot?: AgentAutocompleteInputSnapshot) => {
|
|
const selected = option as AgentAutocompleteOption;
|
|
const current = resolveAgentAutocompleteSnapshot({
|
|
input: snapshot,
|
|
userInput,
|
|
cursorIndex,
|
|
activeSlashCommand,
|
|
activeFileMention,
|
|
});
|
|
const selectedIsCommand =
|
|
selected.type === "client_command" ||
|
|
selected.type === "plugin_command" ||
|
|
selected.type === "provider_command";
|
|
if (snapshot && selectedIsCommand && !current.slashCommand) return;
|
|
if (
|
|
selected.type === "client_command" &&
|
|
selected.command.execution === "immediate" &&
|
|
canExecuteClientSlashCommand &&
|
|
onClientSlashCommand
|
|
) {
|
|
onClientSlashCommand(selected.command);
|
|
return;
|
|
}
|
|
|
|
if (selectedIsCommand) {
|
|
if (!current.slashCommand) {
|
|
setUserInput(`/${selected.id} `);
|
|
onAutocompleteApplied?.();
|
|
return;
|
|
}
|
|
|
|
const nextInput = applySlashCommandReplacement({
|
|
text: current.text,
|
|
command: current.slashCommand,
|
|
commandName: selected.id,
|
|
});
|
|
setUserInput(nextInput);
|
|
onAutocompleteApplied?.();
|
|
return;
|
|
}
|
|
|
|
if (!current.fileMention) return;
|
|
const nextInput = applyFileMentionReplacement({
|
|
text: current.text,
|
|
mention: current.fileMention,
|
|
relativePath: selected.entryPath,
|
|
});
|
|
setUserInput(nextInput);
|
|
onAutocompleteApplied?.();
|
|
},
|
|
[
|
|
canExecuteClientSlashCommand,
|
|
onAutocompleteApplied,
|
|
onClientSlashCommand,
|
|
setUserInput,
|
|
userInput,
|
|
cursorIndex,
|
|
activeFileMention,
|
|
activeSlashCommand,
|
|
],
|
|
);
|
|
|
|
const selectOptionFromKeyPress = useCallback(
|
|
(option: AutocompleteOption, event?: AgentAutocompleteKeyPressEvent) =>
|
|
onSelectOption(option, event?.input),
|
|
[onSelectOption],
|
|
);
|
|
|
|
const { selectedIndex, onKeyPress } = useAutocomplete({
|
|
isVisible,
|
|
options,
|
|
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
|
|
onSelectOption: selectOptionFromKeyPress,
|
|
onEscape:
|
|
mode === "command" && activeSlashCommand?.position === "start"
|
|
? () => setUserInput("")
|
|
: undefined,
|
|
});
|
|
|
|
return {
|
|
isVisible,
|
|
options,
|
|
selectedIndex,
|
|
status,
|
|
onSelectOption,
|
|
onKeyPress,
|
|
};
|
|
}
|