mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer. Related to #3940 and #3952.
605 lines
17 KiB
TypeScript
605 lines
17 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 } 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 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;
|
|
draftConfig?: DraftCommandConfig;
|
|
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;
|
|
isLoading: boolean;
|
|
errorMessage?: string;
|
|
loadingText: string;
|
|
emptyText: string;
|
|
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 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, t: TFunction): AgentAutocompleteOption {
|
|
const command = entry.command;
|
|
const base = {
|
|
id: command.name,
|
|
label: `/${command.name}`,
|
|
detail: command.argumentHint || undefined,
|
|
description:
|
|
entry.source === "client" ? t(entry.command.descriptionKey) : entry.command.description,
|
|
kind: "command" as const,
|
|
};
|
|
if (entry.source === "client") {
|
|
return {
|
|
...base,
|
|
type: "client_command",
|
|
command: entry.command,
|
|
};
|
|
}
|
|
if (entry.source === "plugin") {
|
|
return { ...base, type: "plugin_command", command: entry.command };
|
|
}
|
|
return {
|
|
...base,
|
|
type: "provider_command",
|
|
};
|
|
}
|
|
|
|
type AutocompleteMode = "command" | "file" | null;
|
|
|
|
interface BuildAutocompleteOptionsInput {
|
|
isVisible: boolean;
|
|
mode: AutocompleteMode;
|
|
commands: AgentSlashCommand[];
|
|
pluginCommands: readonly PluginClientSlashCommand[];
|
|
isDraftContext: boolean;
|
|
commandFilterQuery: string;
|
|
activeSlashCommand: SlashCommandRange | null;
|
|
activeFileMention: FileMentionRange | null;
|
|
fileSuggestions: DirectorySuggestionEntry[];
|
|
t: TFunction;
|
|
}
|
|
|
|
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
|
if (!input.isVisible) {
|
|
return [];
|
|
}
|
|
|
|
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 orderedMatches = orderAutocompleteOptions(matches);
|
|
return orderedMatches.map((entry) => mapCommandToOption(entry, input.t));
|
|
}
|
|
|
|
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,
|
|
kind: 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;
|
|
}
|
|
|
|
function resolveAutocompleteIsLoading(args: {
|
|
mode: AutocompleteMode;
|
|
isCommandsLoading: boolean;
|
|
fileSuggestionsIsPending: boolean;
|
|
fileSuggestionsIsLoading: boolean;
|
|
optionsLength: number;
|
|
}): boolean {
|
|
if (args.mode === "command") {
|
|
return args.isCommandsLoading && args.optionsLength === 0;
|
|
}
|
|
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;
|
|
t: TFunction;
|
|
}): string | undefined {
|
|
if (args.mode === "command") {
|
|
return args.isCommandError
|
|
? (args.commandError?.message ?? args.t("agentAutocomplete.failedToLoad"))
|
|
: undefined;
|
|
}
|
|
if (args.mode === "file") {
|
|
return args.fileSuggestionsError instanceof Error
|
|
? args.fileSuggestionsError.message
|
|
: undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
|
const { t } = useTranslation();
|
|
const {
|
|
userInput,
|
|
cursorIndex,
|
|
setUserInput,
|
|
serverId,
|
|
agentId,
|
|
draftConfig,
|
|
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 normalizedDraftConfig = useMemo(
|
|
() => normalizeDraftCommandConfig(draftConfig),
|
|
[draftConfig],
|
|
);
|
|
|
|
const isDraftContext = normalizedDraftConfig !== undefined;
|
|
const queryDraftConfig = normalizedDraftConfig;
|
|
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 {
|
|
commands,
|
|
isLoading: isCommandsLoading,
|
|
isError,
|
|
error,
|
|
} = useAgentCommandsQuery({
|
|
serverId,
|
|
agentId,
|
|
enabled: mode === "command" && canLoadCommands,
|
|
draftConfig: queryDraftConfig,
|
|
});
|
|
|
|
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
|
|
|
|
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 options = useMemo<AgentAutocompleteOption[]>(
|
|
() =>
|
|
buildCommandAutocompleteOptions({
|
|
activeFileMention,
|
|
commandFilterQuery,
|
|
commands,
|
|
pluginCommands: pluginClientSlashCommands,
|
|
activeSlashCommand,
|
|
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
|
isDraftContext,
|
|
isVisible,
|
|
mode,
|
|
t,
|
|
}),
|
|
[
|
|
activeFileMention,
|
|
activeSlashCommand,
|
|
commandFilterQuery,
|
|
commands,
|
|
pluginClientSlashCommands,
|
|
fileSuggestionsQuery.data,
|
|
isDraftContext,
|
|
isVisible,
|
|
mode,
|
|
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,
|
|
});
|
|
|
|
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,
|
|
t,
|
|
});
|
|
|
|
const loadingText =
|
|
mode === "file"
|
|
? t("agentAutocomplete.searchingWorkspace")
|
|
: t("agentAutocomplete.loadingCommands");
|
|
const emptyText =
|
|
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
|
|
|
|
return {
|
|
isVisible,
|
|
options,
|
|
selectedIndex,
|
|
isLoading,
|
|
errorMessage,
|
|
loadingText,
|
|
emptyText,
|
|
onSelectOption,
|
|
onKeyPress,
|
|
};
|
|
}
|