Improve desktop command autocomplete to match combobox behavior (#53)
* feat(app): align command autocomplete with desktop combobox * refactor(app): extract shared autocomplete UI and command hook * refactor(app): rename autocomplete hooks to generic naming
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
|
||||
interface UseAgentAutocompleteInput {
|
||||
userInput: string;
|
||||
setUserInput: (nextValue: string) => void;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
onAutocompleteApplied?: () => void;
|
||||
}
|
||||
|
||||
interface AgentAutocompleteResult {
|
||||
isVisible: boolean;
|
||||
options: AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
onSelectOption: (option: AutocompleteOption) => void;
|
||||
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
}
|
||||
|
||||
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() ?? "";
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentAutocomplete(
|
||||
input: UseAgentAutocompleteInput
|
||||
): AgentAutocompleteResult {
|
||||
const {
|
||||
userInput,
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
draftConfig,
|
||||
onAutocompleteApplied,
|
||||
} = input;
|
||||
|
||||
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const filterQuery = showAutocomplete ? userInput.slice(1) : "";
|
||||
|
||||
const normalizedDraftConfig = useMemo(
|
||||
() => normalizeDraftCommandConfig(draftConfig),
|
||||
[draftConfig]
|
||||
);
|
||||
|
||||
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__");
|
||||
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig;
|
||||
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig);
|
||||
const isVisible = showAutocomplete && canLoadOptions;
|
||||
|
||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: canLoadOptions,
|
||||
draftConfig: queryDraftConfig,
|
||||
});
|
||||
|
||||
const options = useMemo<AutocompleteOption[]>(() => {
|
||||
if (!isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filterLower = filterQuery.toLowerCase();
|
||||
const matches = commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((cmd) => ({
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
detail: cmd.argumentHint || undefined,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}, [commands, filterQuery, isVisible]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
setUserInput(`/${option.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
},
|
||||
[onAutocompleteApplied, setUserInput]
|
||||
);
|
||||
|
||||
const { selectedIndex, onKeyPress } = useAutocomplete({
|
||||
isVisible,
|
||||
options,
|
||||
query: filterQuery,
|
||||
onSelectOption,
|
||||
onEscape: () => setUserInput(""),
|
||||
});
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
options,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined,
|
||||
onSelectOption,
|
||||
onKeyPress,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getAutocompleteFallbackIndex,
|
||||
getAutocompleteNextIndex,
|
||||
type AutocompleteOptionsPosition,
|
||||
} from "@/components/ui/autocomplete-utils";
|
||||
|
||||
interface UseAutocompleteInput<TOption> {
|
||||
isVisible: boolean;
|
||||
options: readonly TOption[];
|
||||
query: string;
|
||||
onSelectOption: (option: TOption) => void;
|
||||
onEscape?: () => void;
|
||||
optionsPosition?: AutocompleteOptionsPosition;
|
||||
}
|
||||
|
||||
interface UseAutocompleteResult {
|
||||
selectedIndex: number;
|
||||
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
}
|
||||
|
||||
export function useAutocomplete<TOption>(
|
||||
input: UseAutocompleteInput<TOption>
|
||||
): UseAutocompleteResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const previousQueryRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.isVisible) {
|
||||
previousQueryRef.current = input.query;
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryChanged = previousQueryRef.current !== input.query;
|
||||
previousQueryRef.current = input.query;
|
||||
|
||||
setSelectedIndex((current) => {
|
||||
if (input.options.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition
|
||||
);
|
||||
|
||||
if (queryChanged) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
if (current < 0 || current >= input.options.length) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
|
||||
|
||||
const onKeyPress = useCallback(
|
||||
(event: { key: string; preventDefault: () => void }) => {
|
||||
if (!input.isVisible || input.options.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowUp",
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
getAutocompleteNextIndex({
|
||||
currentIndex: current,
|
||||
itemCount: input.options.length,
|
||||
key: "ArrowDown",
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const fallbackIndex = getAutocompleteFallbackIndex(
|
||||
input.options.length,
|
||||
input.optionsPosition
|
||||
);
|
||||
const resolvedIndex =
|
||||
selectedIndex >= 0 && selectedIndex < input.options.length
|
||||
? selectedIndex
|
||||
: fallbackIndex;
|
||||
const selectedOption = input.options[resolvedIndex];
|
||||
if (selectedOption) {
|
||||
input.onSelectOption(selectedOption);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && input.onEscape) {
|
||||
event.preventDefault();
|
||||
input.onEscape();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[input, selectedIndex]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
onKeyPress,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user