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,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||||
@@ -25,11 +25,7 @@ import {
|
|||||||
type MessageInputRef,
|
type MessageInputRef,
|
||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
import { CommandAutocomplete } from "./command-autocomplete";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
|
||||||
useAgentCommandsQuery,
|
|
||||||
type DraftCommandConfig,
|
|
||||||
} from "@/hooks/use-agent-commands-query";
|
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
@@ -37,6 +33,8 @@ import { useVoiceOptional } from "@/contexts/voice-context";
|
|||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||||
|
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -117,61 +115,20 @@ export function AgentInputArea({
|
|||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
|
||||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
||||||
|
const messageInputRef = useRef<MessageInputRef>(null);
|
||||||
|
|
||||||
// Command autocomplete logic
|
const autocomplete = useAgentAutocomplete({
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
userInput,
|
||||||
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
setUserInput,
|
||||||
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({
|
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: canLoadCommands,
|
draftConfig: commandDraftConfig,
|
||||||
draftConfig: commandQueryDraftConfig,
|
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
|
// Clear send error when user edits the input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sendError && userInput) {
|
if (sendError && userInput) {
|
||||||
@@ -185,7 +142,6 @@ export function AgentInputArea({
|
|||||||
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
|
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
|
||||||
>(null);
|
>(null);
|
||||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||||
const messageInputRef = useRef<MessageInputRef>(null);
|
|
||||||
|
|
||||||
// Expose addImages function to parent for drag-and-drop support
|
// Expose addImages function to parent for drag-and-drop support
|
||||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||||
@@ -592,17 +548,9 @@ export function AgentInputArea({
|
|||||||
queueMessage(payload.text, payload.images);
|
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;
|
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(
|
const handleCommandKeyPress = useCallback(
|
||||||
(event: { key: string; preventDefault: () => void }) => {
|
(event: { key: string; preventDefault: () => void }) => {
|
||||||
if (
|
if (
|
||||||
@@ -617,53 +565,15 @@ export function AgentInputArea({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showCommandAutocomplete || filteredCommands.length === 0) {
|
return autocomplete.onKeyPress(event);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
showCommandAutocomplete,
|
autocomplete,
|
||||||
filteredCommands,
|
|
||||||
commandSelectedIndex,
|
|
||||||
handleCommandSelect,
|
|
||||||
hasSendableContent,
|
hasSendableContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
setUserInput,
|
handleCancelAgent,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -775,14 +685,15 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command autocomplete dropdown */}
|
{/* Command autocomplete dropdown */}
|
||||||
{showCommandAutocomplete && canLoadCommands && (
|
{autocomplete.isVisible && (
|
||||||
<CommandAutocomplete
|
<Autocomplete
|
||||||
serverId={serverId}
|
options={autocomplete.options}
|
||||||
agentId={agentId}
|
selectedIndex={autocomplete.selectedIndex}
|
||||||
filter={commandFilter}
|
isLoading={autocomplete.isLoading}
|
||||||
selectedIndex={commandSelectedIndex}
|
errorMessage={autocomplete.errorMessage}
|
||||||
onSelect={handleCommandSelect}
|
loadingText="Loading commands..."
|
||||||
draftConfig={commandQueryDraftConfig}
|
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