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:
@@ -5,7 +5,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
type MessageInputRef,
|
||||
} from "./message-input";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import { CommandAutocomplete } from "./command-autocomplete";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type DraftCommandConfig,
|
||||
} from "@/hooks/use-agent-commands-query";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
@@ -37,6 +33,8 @@ import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -117,61 +115,20 @@ export function AgentInputArea({
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
|
||||
// Command autocomplete logic
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||
const normalizedCommandDraftConfig = useMemo(() => {
|
||||
if (!commandDraftConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cwd = commandDraftConfig.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeId = commandDraftConfig.modeId?.trim() ?? "";
|
||||
const model = commandDraftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? "";
|
||||
return {
|
||||
provider: commandDraftConfig.provider,
|
||||
cwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}, [commandDraftConfig]);
|
||||
|
||||
// Prefetch commands for real agents and for draft screens when context is available.
|
||||
const isRealAgent = agentId && !agentId.startsWith("__");
|
||||
const commandQueryDraftConfig = isRealAgent
|
||||
? undefined
|
||||
: normalizedCommandDraftConfig;
|
||||
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
|
||||
const { commands } = useAgentCommandsQuery({
|
||||
const autocomplete = useAgentAutocomplete({
|
||||
userInput,
|
||||
setUserInput,
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: canLoadCommands,
|
||||
draftConfig: commandQueryDraftConfig,
|
||||
draftConfig: commandDraftConfig,
|
||||
onAutocompleteApplied: () => {
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
// Filter commands for keyboard navigation
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!showCommandAutocomplete) return [];
|
||||
const filterLower = commandFilter.toLowerCase();
|
||||
return commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
}, [commands, commandFilter, showCommandAutocomplete]);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setCommandSelectedIndex(0);
|
||||
}, [commandFilter]);
|
||||
|
||||
// Clear send error when user edits the input
|
||||
useEffect(() => {
|
||||
if (sendError && userInput) {
|
||||
@@ -185,7 +142,6 @@ export function AgentInputArea({
|
||||
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
|
||||
>(null);
|
||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
|
||||
// Expose addImages function to parent for drag-and-drop support
|
||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||
@@ -592,17 +548,9 @@ export function AgentInputArea({
|
||||
queueMessage(payload.text, payload.images);
|
||||
}, []);
|
||||
|
||||
// Handle command selection from autocomplete
|
||||
const handleCommandSelect = useCallback(
|
||||
(cmd: { name: string; description: string; argumentHint: string }) => {
|
||||
setUserInput(`/${cmd.name} `);
|
||||
messageInputRef.current?.focus();
|
||||
},
|
||||
[setUserInput]
|
||||
);
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete
|
||||
// Handle keyboard navigation for command autocomplete and stop action.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: { key: string; preventDefault: () => void }) => {
|
||||
if (
|
||||
@@ -617,53 +565,15 @@ export function AgentInputArea({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!showCommandAutocomplete || filteredCommands.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setCommandSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredCommands.length - 1
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setCommandSelectedIndex((prev) =>
|
||||
prev < filteredCommands.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selected = filteredCommands[commandSelectedIndex];
|
||||
if (selected) {
|
||||
handleCommandSelect(selected);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setUserInput("");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return autocomplete.onKeyPress(event);
|
||||
},
|
||||
[
|
||||
showCommandAutocomplete,
|
||||
filteredCommands,
|
||||
commandSelectedIndex,
|
||||
handleCommandSelect,
|
||||
autocomplete,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
setUserInput,
|
||||
handleCancelAgent,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -775,14 +685,15 @@ export function AgentInputArea({
|
||||
)}
|
||||
|
||||
{/* Command autocomplete dropdown */}
|
||||
{showCommandAutocomplete && canLoadCommands && (
|
||||
<CommandAutocomplete
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
filter={commandFilter}
|
||||
selectedIndex={commandSelectedIndex}
|
||||
onSelect={handleCommandSelect}
|
||||
draftConfig={commandQueryDraftConfig}
|
||||
{autocomplete.isVisible && (
|
||||
<Autocomplete
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText="Loading commands..."
|
||||
emptyText="No commands found"
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type DraftCommandConfig,
|
||||
} from "@/hooks/use-agent-commands-query";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { Theme } from "@/styles/theme";
|
||||
|
||||
interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
}
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
filter: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (command: AgentSlashCommand) => void;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
serverId,
|
||||
agentId,
|
||||
filter,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
draftConfig,
|
||||
}: CommandAutocompleteProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: true,
|
||||
draftConfig,
|
||||
});
|
||||
|
||||
// Filter commands based on input after /
|
||||
const filterLower = filter.toLowerCase();
|
||||
const filteredCommands = commands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(filterLower)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.loadingItem}>
|
||||
<Text style={styles.loadingText}>Loading commands...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>Error: {error?.message ?? "Failed to load"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredCommands.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>No commands found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always">
|
||||
{filteredCommands.map((cmd, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={cmd.name}
|
||||
onPress={() => onSelect(cmd)}
|
||||
style={[
|
||||
styles.commandItem,
|
||||
isSelected && {
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.commandHeader}>
|
||||
<Text style={styles.commandName}>/{cmd.name}</Text>
|
||||
{cmd.argumentHint && (
|
||||
<Text style={styles.commandArgs}>{cmd.argumentHint}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.commandDescription} numberOfLines={1}>
|
||||
{cmd.description}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) {
|
||||
const filterLower = filter.toLowerCase();
|
||||
return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
maxHeight: 200,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
commandItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
commandHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
commandName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandArgs: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
loadingItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as any) as Record<string, any>;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getAutocompleteFallbackIndex,
|
||||
getAutocompleteScrollOffset,
|
||||
orderAutocompleteOptions,
|
||||
} from "./autocomplete-utils";
|
||||
|
||||
const OPTIONS = ["alpha", "beta", "gamma"];
|
||||
|
||||
describe("orderAutocompleteOptions", () => {
|
||||
it("keeps first logical option closest to the input by default", () => {
|
||||
expect(orderAutocompleteOptions(OPTIONS)).toEqual([
|
||||
"gamma",
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps normal top-down order when below-input is selected", () => {
|
||||
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutocompleteFallbackIndex", () => {
|
||||
it("picks the option nearest the input by default", () => {
|
||||
expect(getAutocompleteFallbackIndex(3)).toBe(2);
|
||||
expect(getAutocompleteFallbackIndex(0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("picks top item when below-input ordering is used", () => {
|
||||
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutocompleteScrollOffset", () => {
|
||||
it("scrolls up when the active item is above the viewport", () => {
|
||||
expect(
|
||||
getAutocompleteScrollOffset({
|
||||
currentOffset: 120,
|
||||
viewportHeight: 80,
|
||||
itemTop: 90,
|
||||
itemHeight: 20,
|
||||
})
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it("scrolls down when the active item is below the viewport", () => {
|
||||
expect(
|
||||
getAutocompleteScrollOffset({
|
||||
currentOffset: 0,
|
||||
viewportHeight: 100,
|
||||
itemTop: 150,
|
||||
itemHeight: 24,
|
||||
})
|
||||
).toBe(74);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
||||
|
||||
export type AutocompleteOptionsPosition = "above-input" | "below-input";
|
||||
|
||||
export function orderAutocompleteOptions<T>(
|
||||
options: readonly T[],
|
||||
position: AutocompleteOptionsPosition = "above-input"
|
||||
): T[] {
|
||||
if (position === "below-input") {
|
||||
return [...options];
|
||||
}
|
||||
return [...options].reverse();
|
||||
}
|
||||
|
||||
export function getAutocompleteFallbackIndex(
|
||||
itemCount: number,
|
||||
position: AutocompleteOptionsPosition = "above-input"
|
||||
): number {
|
||||
if (itemCount <= 0) {
|
||||
return -1;
|
||||
}
|
||||
return position === "above-input" ? itemCount - 1 : 0;
|
||||
}
|
||||
|
||||
export function getAutocompleteNextIndex(args: {
|
||||
currentIndex: number;
|
||||
itemCount: number;
|
||||
key: "ArrowDown" | "ArrowUp";
|
||||
}): number {
|
||||
return getNextActiveIndex(args);
|
||||
}
|
||||
|
||||
export function getAutocompleteScrollOffset(args: {
|
||||
currentOffset: number;
|
||||
viewportHeight: number;
|
||||
itemTop: number;
|
||||
itemHeight: number;
|
||||
}): number {
|
||||
if (args.viewportHeight <= 0) {
|
||||
return args.currentOffset;
|
||||
}
|
||||
|
||||
const itemBottom = args.itemTop + args.itemHeight;
|
||||
const viewportTop = args.currentOffset;
|
||||
const viewportBottom = args.currentOffset + args.viewportHeight;
|
||||
|
||||
if (args.itemTop < viewportTop) {
|
||||
return Math.max(0, args.itemTop);
|
||||
}
|
||||
|
||||
if (itemBottom > viewportBottom) {
|
||||
return Math.max(0, itemBottom - args.viewportHeight);
|
||||
}
|
||||
|
||||
return args.currentOffset;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AutocompleteProps {
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
isLoading?: boolean;
|
||||
errorMessage?: string;
|
||||
loadingText?: string;
|
||||
emptyText?: string;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
errorMessage,
|
||||
loadingText = "Loading...",
|
||||
emptyText = "No results found",
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
const scrollOffsetRef = useRef(0);
|
||||
|
||||
const ensureActiveItemVisible = useCallback(() => {
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = getAutocompleteScrollOffset({
|
||||
currentOffset: scrollOffsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
itemHeight: layout.height,
|
||||
});
|
||||
|
||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = nextOffset;
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rowLayoutsRef.current.clear();
|
||||
scrollOffsetRef.current = 0;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
pinToBottom();
|
||||
}, [options, pinToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [ensureActiveItemVisible, options.length]);
|
||||
|
||||
const handleScrollViewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible]
|
||||
);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
rowLayoutsRef.current.set(index, {
|
||||
top: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{loadingText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<View style={styles.emptyItem}>
|
||||
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { maxHeight }]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={(event) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onLayout={(event) => handleRowLayout(index, event)}
|
||||
onPress={() => onSelect(option)}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.item,
|
||||
(hovered || pressed || isSelected) && styles.itemActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.itemMain}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemLabel}>{option.label}</Text>
|
||||
{option.detail ? (
|
||||
<Text style={styles.itemDetail}>{option.detail}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{option.description ? (
|
||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||
{option.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
itemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
itemLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
itemDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
itemDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyItem: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as any) as Record<string, any>;
|
||||
@@ -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