feat(app): add @ workspace file autocomplete in agent chat

This commit is contained in:
Mohamed Boudra
2026-02-18 13:25:55 +07:00
parent b1955cf74d
commit d30bb84741
16 changed files with 7993 additions and 7680 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+83 -67
View File
@@ -1,25 +1,27 @@
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";
import { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { File, Folder } from 'lucide-react-native'
import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
export interface AutocompleteOption {
id: string;
label: string;
detail?: string;
description?: string;
id: string
label: string
detail?: string
description?: string
kind?: 'command' | 'file' | 'directory'
}
interface AutocompleteProps {
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
maxHeight?: number;
options: readonly AutocompleteOption[]
selectedIndex: number
onSelect: (option: AutocompleteOption) => void
isLoading?: boolean
errorMessage?: string
loadingText?: string
emptyText?: string
maxHeight?: number
}
export function Autocomplete({
@@ -28,23 +30,24 @@ export function Autocomplete({
onSelect,
isLoading = false,
errorMessage,
loadingText = "Loading...",
emptyText = "No results found",
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 { theme } = useUnistyles()
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;
return
}
const layout = rowLayoutsRef.current.get(selectedIndex);
const layout = rowLayoutsRef.current.get(selectedIndex)
if (!layout) {
return;
return
}
const nextOffset = getAutocompleteScrollOffset({
@@ -52,60 +55,60 @@ export function Autocomplete({
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
});
})
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return;
return
}
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex])
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
scrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
scrollRef.current?.scrollToEnd({ animated: false })
})
}, [])
useEffect(() => {
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0
}, [options])
useEffect(() => {
if (options.length === 0) {
return;
return
}
pinToBottom();
}, [options, pinToBottom]);
pinToBottom()
}, [options, pinToBottom])
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible);
const raf = requestAnimationFrame(ensureActiveItemVisible)
return () => {
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
cancelAnimationFrame(raf)
}
}, [ensureActiveItemVisible, options.length])
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
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()
},
[ensureActiveItemVisible]
);
)
if (isLoading) {
return (
@@ -114,7 +117,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
</View>
);
)
}
if (errorMessage) {
@@ -124,7 +127,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
);
)
}
if (options.length === 0) {
@@ -134,7 +137,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
</View>
);
)
}
return (
@@ -144,7 +147,7 @@ export function Autocomplete({
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
}}
scrollEventThrottle={16}
style={styles.scrollView}
@@ -152,7 +155,7 @@ export function Autocomplete({
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const isSelected = index === selectedIndex
return (
<Pressable
key={option.id}
@@ -163,12 +166,19 @@ export function Autocomplete({
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{option.kind === 'directory' || option.kind === 'file' ? (
<View style={styles.itemLeading}>
{option.kind === 'directory' ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<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}
{option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
</View>
{option.description ? (
<Text style={styles.itemDescription} numberOfLines={1}>
@@ -177,11 +187,11 @@ export function Autocomplete({
) : null}
</View>
</Pressable>
);
)
})}
</ScrollView>
</View>
);
)
}
const styles = StyleSheet.create(((theme: Theme) => ({
@@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
overflow: 'hidden',
},
scrollView: {
flexGrow: 0,
@@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: theme.spacing[1],
},
itemActive: {
backgroundColor: theme.colors.surface1,
},
@@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
minWidth: 0,
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
itemLabel: {
@@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;
})) as any) as Record<string, any>
@@ -1,42 +1,44 @@
export type ComboboxOptionKind = 'directory' | 'file'
export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
kind?: "directory";
id: string
label: string
description?: string
kind?: ComboboxOptionKind
}
export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
customValuePrefix: string;
customValueDescription?: string;
customValueKind?: "directory";
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
customValuePrefix: string
customValueDescription?: string
customValueKind?: ComboboxOptionKind
}
export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[];
searchQuery: string;
searchable: boolean;
allowCustomValue: boolean;
options: ComboboxOptionModel[]
searchQuery: string
searchable: boolean
allowCustomValue: boolean
}): boolean {
const sanitizedSearchValue = input.searchQuery.trim();
const sanitizedSearchValue = input.searchQuery.trim()
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false;
return false
}
return !input.options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
)
}
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const filteredOptions = normalizedSearch
? input.options.filter(
(opt) =>
@@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions(
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
)
: input.options;
: input.options
const sanitizedSearchValue = input.searchQuery.trim();
const sanitizedSearchValue = input.searchQuery.trim()
const showCustomOption = shouldShowCustomComboboxOption({
options: input.options,
searchQuery: input.searchQuery,
searchable: input.searchable,
allowCustomValue: input.allowCustomValue,
});
})
const visibleOptions: ComboboxOptionModel[] = [];
const visibleOptions: ComboboxOptionModel[] = []
if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim();
const trimmedPrefix = input.customValuePrefix.trim()
const customLabel =
trimmedPrefix.length > 0
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
visibleOptions.push({
id: sanitizedSearchValue,
label: customLabel,
description: input.customValueDescription,
kind: input.customValueKind,
});
})
}
visibleOptions.push(...filteredOptions);
return visibleOptions;
visibleOptions.push(...filteredOptions)
return visibleOptions
}
export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[],
optionsPosition: "below-search" | "above-search"
optionsPosition: 'below-search' | 'above-search'
): ComboboxOptionModel[] {
if (optionsPosition !== "above-search") {
return visibleOptions;
if (optionsPosition !== 'above-search') {
return visibleOptions
}
return [...visibleOptions].reverse();
return [...visibleOptions].reverse()
}
export function getComboboxFallbackIndex(
itemCount: number,
optionsPosition: "below-search" | "above-search"
optionsPosition: 'below-search' | 'above-search'
): number {
if (itemCount <= 0) {
return -1;
return -1
}
return optionsPosition === "above-search" ? itemCount - 1 : 0;
return optionsPosition === 'above-search' ? itemCount - 1 : 0
}
+271 -274
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from 'react'
import {
View,
Text,
@@ -10,87 +10,88 @@ import {
Platform,
StatusBar,
useWindowDimensions,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
} from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Folder, Search } from "lucide-react-native";
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
import { getNextActiveIndex } from "./combobox-keyboard";
} from '@gorhom/bottom-sheet'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Check, File, Folder, Search } from 'lucide-react-native'
import {
flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from '@floating-ui/react-native'
import { getNextActiveIndex } from './combobox-keyboard'
import {
buildVisibleComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
} from './combobox-options'
import type { ComboboxOptionModel } from './combobox-options'
const IS_WEB = Platform.OS === "web";
const IS_WEB = Platform.OS === 'web'
export type ComboboxOption = ComboboxOptionModel;
export type ComboboxOption = ComboboxOptionModel
export interface ComboboxProps {
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
customValueKind?: "directory";
optionsPosition?: "below-search" | "above-search";
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
options: ComboboxOption[]
value: string
onSelect: (id: string) => void
onSearchQueryChange?: (query: string) => void
searchable?: boolean
placeholder?: string
searchPlaceholder?: string
emptyText?: string
allowCustomValue?: boolean
customValuePrefix?: string
customValueDescription?: string
customValueKind?: 'directory' | 'file'
optionsPosition?: 'below-search' | 'above-search'
title?: string
open?: boolean
onOpenChange?: (open: boolean) => void
desktopPlacement?: 'top-start' | 'bottom-start'
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity.
*/
desktopPreventInitialFlash?: boolean;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
desktopPreventInitialFlash?: boolean
anchorRef: React.RefObject<View | null>
children?: ReactNode
}
function toNumericStyleValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (typeof value === 'string') {
const parsed = Number.parseFloat(value)
if (Number.isFinite(parsed)) {
return parsed;
return parsed
}
}
return null;
return null
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
}
interface SearchInputProps {
placeholder: string;
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
placeholder: string
value: string
onChangeText: (text: string) => void
onSubmitEditing?: () => void
autoFocus?: boolean
}
function SearchInput({
@@ -100,18 +101,18 @@ function SearchInput({
onSubmitEditing,
autoFocus = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
inputRef.current?.focus()
}, 50)
return () => clearTimeout(timer)
}
}, [autoFocus]);
}, [autoFocus])
return (
<View style={styles.searchInputContainer}>
@@ -119,7 +120,7 @@ function SearchInput({
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -129,17 +130,17 @@ function SearchInput({
onSubmitEditing={onSubmitEditing}
/>
</View>
);
)
}
export interface ComboboxItemProps {
label: string;
description?: string;
kind?: "directory";
selected?: boolean;
active?: boolean;
onPress: () => void;
testID?: string;
label: string
description?: string
kind?: 'directory' | 'file'
selected?: boolean
active?: boolean
onPress: () => void
testID?: string
}
export function ComboboxItem({
@@ -151,7 +152,7 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
const { theme } = useUnistyles()
return (
<Pressable
testID={testID}
@@ -163,15 +164,23 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === "directory" ? (
{kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} />
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null}
</View>
{selected ? (
@@ -180,11 +189,15 @@ export function ComboboxItem({
</View>
) : null}
</Pressable>
);
)
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
return (
<Text testID="combobox-empty-text" style={styles.emptyText}>
{children}
</Text>
)
}
export function Combobox({
@@ -193,202 +206,196 @@ export function Combobox({
onSelect,
onSearchQueryChange,
searchable = true,
placeholder = "Search...",
placeholder = 'Search...',
searchPlaceholder,
emptyText = "No options match your search.",
emptyText = 'No options match your search.',
allowCustomValue = false,
customValuePrefix = "Use",
customValuePrefix = 'Use',
customValueDescription,
customValueKind,
optionsPosition = "below-search",
title = "Select",
optionsPosition = 'below-search',
title = 'Select',
open,
onOpenChange,
desktopPlacement = "top-start",
desktopPlacement = 'top-start',
desktopPreventInitialFlash = true,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
const { height: windowHeight } = useWindowDimensions();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
)
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = isControlled ? open : internalOpen;
const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
setInternalOpen(nextOpen)
}
onOpenChange?.(nextOpen);
onOpenChange?.(nextOpen)
},
[isControlled, onOpenChange]
);
)
const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery);
onSearchQueryChange?.(nextQuery);
setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery)
},
[onSearchQueryChange]
);
)
const handleClose = useCallback(() => {
setOpen(false);
setSearchQueryWithCallback("");
}, [setOpen, setSearchQueryWithCallback]);
setOpen(false)
setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback])
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback("");
setSearchQueryWithCallback('')
}
}, [isOpen, setSearchQueryWithCallback]);
}, [isOpen, setSearchQueryWithCallback])
const collisionPadding = useMemo(() => {
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const basePadding = 16
if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding)
}, [])
const middleware = useMemo(
() => [
floatingOffset(Platform.OS === "web" ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight };
if (!prev) return next;
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
const next = { width: availableWidth, height: availableHeight }
if (!prev) return next
if (prev.width === next.width && prev.height === next.height) return prev
return next
})
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
const next = rects.reference.width
if (prev === next) return prev
return next
})
},
}),
],
[collisionPadding, isDesktopAboveSearch]
);
)
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
});
})
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
setReferenceWidth(null);
return;
setAvailableSize(null)
setReferenceWidth(null)
return
}
const raf = requestAnimationFrame(() => void update());
return () => cancelAnimationFrame(raf);
}, [desktopPlacement, isMobile, update, isOpen]);
const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen])
useEffect(() => {
if (!isOpen || isMobile) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
setReferenceAtOrigin(false)
setReferenceTop(null)
return
}
const referenceEl = anchorRef.current;
const referenceEl = anchorRef.current
if (!referenceEl) {
setReferenceAtOrigin(false);
setReferenceTop(null);
return;
setReferenceAtOrigin(false)
setReferenceTop(null)
return
}
const measure = () => {
referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
setReferenceTop((prev) => (prev === y ? prev : y));
});
};
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y))
})
}
measure();
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
measure()
const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
const floatingTop = toNumericStyleValue(floatingStyles.top);
const floatingLeft = toNumericStyleValue(floatingStyles.left);
const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left)
const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding)
: null;
: null
const hasResolvedDesktopPosition =
referenceWidth !== null &&
floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent =
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
const shouldUseDesktopFade = !desktopPreventInitialFlash
const desktopPositionStyle = isDesktopAboveSearch
? {
left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0,
}
: floatingStyles;
: floatingStyles
useEffect(() => {
if (!isMobile) return;
if (!isMobile) return
if (isOpen) {
bottomSheetRef.current?.present();
bottomSheetRef.current?.present()
} else {
bottomSheetRef.current?.dismiss();
bottomSheetRef.current?.dismiss()
}
}, [isOpen, isMobile]);
}, [isOpen, isMobile])
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
handleClose()
}
},
[handleClose]
);
)
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
);
)
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim()
const showCustomOption = useMemo(
() =>
shouldShowCustomComboboxOption({
@@ -398,7 +405,7 @@ export function Combobox({
allowCustomValue,
}),
[allowCustomValue, options, searchQuery, searchable]
);
)
const visibleOptions = useMemo(
() =>
@@ -420,139 +427,132 @@ export function Combobox({
searchQuery,
searchable,
]
);
)
const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions]
);
)
const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== "above-search") {
return;
if (isMobile || effectiveOptionsPosition !== 'above-search') {
return
}
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
});
}, [effectiveOptionsPosition, isMobile]);
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
})
}, [effectiveOptionsPosition, isMobile])
const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) {
return;
return
}
pinDesktopOptionsToBottom();
}, [isOpen, pinDesktopOptionsToBottom]);
pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom])
useEffect(() => {
if (!isOpen) {
return;
return
}
pinDesktopOptionsToBottom();
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
useLayoutEffect(() => {
if (!isOpen || isMobile) {
return;
return
}
void update();
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
useEffect(() => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1);
return;
setActiveIndex(-1)
return
}
const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length,
effectiveOptionsPosition
);
)
if (normalizedSearch) {
setActiveIndex(fallbackIndex);
return;
setActiveIndex(fallbackIndex)
return
}
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
}, [
effectiveOptionsPosition,
isMobile,
isOpen,
normalizedSearch,
value,
orderedVisibleOptions,
]);
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
handleClose();
onSelect(id)
handleClose()
},
[handleClose, onSelect]
);
)
const handleSubmitSearch = useCallback(() => {
if (showCustomOption) {
handleSelect(sanitizedSearchValue);
handleSelect(sanitizedSearchValue)
}
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
}, [handleSelect, sanitizedSearchValue, showCustomOption])
const handleDesktopKey = useCallback(
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
if (!isOpen) return;
if (!IS_WEB && isMobile) return;
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return
if (!IS_WEB && isMobile) return
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault()
setActiveIndex((currentIndex) =>
getNextActiveIndex({
currentIndex,
itemCount: orderedVisibleOptions.length,
key,
})
);
return;
)
return
}
if (key === "Enter") {
if (orderedVisibleOptions.length === 0) return;
event?.preventDefault();
if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return
event?.preventDefault()
const index =
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
handleSelect(orderedVisibleOptions[index]!.id);
return;
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id)
return
}
if (key === "Escape") {
event?.preventDefault();
handleClose();
if (key === 'Escape') {
event?.preventDefault()
handleClose()
}
},
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
);
)
useEffect(() => {
if (!IS_WEB || !isOpen) return;
if (!IS_WEB || !isOpen) return
const handler = (event: KeyboardEvent) => {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
const key = event.key
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return
}
handleDesktopKey(key, event);
};
handleDesktopKey(key, event)
}
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
window.addEventListener('keydown', handler, true)
return () => {
window.removeEventListener("keydown", handler, true);
};
}, [handleDesktopKey, isOpen]);
window.removeEventListener('keydown', handler, true)
}
}, [handleDesktopKey, isOpen])
const searchInput = (
<SearchInput
@@ -562,7 +562,7 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
);
)
const optionsList = (
<>
@@ -582,17 +582,17 @@ export function Combobox({
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
)}
</>
);
)
const defaultContent = (
<>
{effectiveOptionsPosition === "above-search" ? optionsList : null}
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null}
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
</>
);
)
const content = children ?? defaultContent;
const content = children ?? defaultContent
if (isMobile) {
return (
@@ -620,18 +620,13 @@ export function Combobox({
{content}
</BottomSheetScrollView>
</BottomSheetModal>
);
)
}
if (!isOpen) return <></>;
if (!isOpen) return <></>
return (
<Modal
transparent
animationType="none"
visible={isOpen}
onRequestClose={handleClose}
>
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
@@ -641,13 +636,15 @@ export function Combobox({
style={[
styles.desktopContainer,
{
position: "absolute",
position: 'absolute',
minWidth: referenceWidth ?? 200,
maxWidth: 400,
},
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
typeof availableSize?.height === 'number'
? { maxHeight: Math.min(availableSize.height, 400) }
: null,
]}
ref={refs.setFloating}
collapsable={false}
@@ -664,7 +661,7 @@ export function Combobox({
</ScrollView>
) : (
<>
{effectiveOptionsPosition === "above-search" ? (
{effectiveOptionsPosition === 'above-search' ? (
<ScrollView
ref={desktopOptionsScrollRef}
contentContainerStyle={[
@@ -680,7 +677,7 @@ export function Combobox({
</ScrollView>
) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? (
{effectiveOptionsPosition === 'below-search' ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -695,13 +692,13 @@ export function Combobox({
</Animated.View>
</View>
</Modal>
);
)
}
const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
@@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
comboboxItem: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
@@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
marginLeft: "auto",
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
},
comboboxItemContent: {
flex: 1,
@@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({
},
comboboxItemLeadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
@@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
@@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
textAlign: 'left',
},
comboboxScrollContent: {
paddingBottom: theme.spacing[8],
@@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
desktopBackdrop: {
position: "absolute",
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
@@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
shadowColor: "#000",
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
overflow: 'hidden',
},
desktopScroll: {
flexShrink: 1,
@@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",
justifyContent: 'flex-end',
},
}));
}))
+227 -62
View File
@@ -1,125 +1,290 @@
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";
import { useCallback, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
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'
import { useSessionStore } from '@/stores/session-store'
import {
applyFileMentionReplacement,
findActiveFileMention,
type FileMentionRange,
} from '@/utils/file-mention-autocomplete'
interface UseAgentAutocompleteInput {
userInput: string;
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
onAutocompleteApplied?: () => void;
userInput: string
cursorIndex: number
setUserInput: (nextValue: string) => void
serverId: string
agentId: string
draftConfig?: DraftCommandConfig
onAutocompleteApplied?: () => void
}
type AgentAutocompleteOption =
| (AutocompleteOption & { type: 'command' })
| (AutocompleteOption & {
type: 'workspace_entry'
entryPath: string
mention: FileMentionRange
})
interface AgentAutocompleteResult {
isVisible: boolean;
options: AutocompleteOption[];
selectedIndex: number;
isLoading: boolean;
errorMessage?: string;
onSelectOption: (option: AutocompleteOption) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
isVisible: boolean
options: AutocompleteOption[]
selectedIndex: number
isLoading: boolean
errorMessage?: string
loadingText: string
emptyText: string
onSelectOption: (option: AutocompleteOption) => void
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
}
interface DirectorySuggestionEntry {
path: string
kind: 'file' | 'directory'
}
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig
): DraftCommandConfig | undefined {
if (!draftConfig) {
return undefined;
return undefined
}
const cwd = draftConfig.cwd.trim();
const cwd = draftConfig.cwd.trim()
if (!cwd) {
return undefined;
return undefined
}
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
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 {
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,
}))
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const {
userInput,
cursorIndex,
setUserInput,
serverId,
agentId,
draftConfig,
onAutocompleteApplied,
} = input;
} = input
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const filterQuery = showAutocomplete ? userInput.slice(1) : "";
const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
const activeFileMention = useMemo(
() =>
findActiveFileMention({
text: userInput,
cursorIndex,
}),
[cursorIndex, userInput]
)
const showFileAutocomplete = activeFileMention !== null
const fileFilterQuery = activeFileMention?.query ?? ''
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 isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
)
const autocompleteCwd = useMemo(() => {
if (isRealAgent) {
return agentCwd.trim()
}
return queryDraftConfig?.cwd ?? ''
}, [agentCwd, isRealAgent, queryDraftConfig])
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
)
const mode: 'command' | 'file' | null = showFileAutocomplete
? 'file'
: showCommandAutocomplete
? 'command'
: null
const isVisible =
mode === 'command'
? canLoadCommands
: mode === 'file'
? Boolean(serverId) && autocompleteCwd.length > 0
: false
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
serverId,
agentId,
enabled: canLoadOptions,
enabled: mode === 'command' && canLoadCommands,
draftConfig: queryDraftConfig,
});
})
const options = useMemo<AutocompleteOption[]>(() => {
const fileSuggestionsQuery = useQuery({
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error('Daemon client unavailable')
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
query: fileFilterQuery,
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,
})
const options = useMemo<AgentAutocompleteOption[]>(() => {
if (!isVisible) {
return [];
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]);
if (mode === 'command') {
const filterLower = commandFilterQuery.toLowerCase()
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
const orderedMatches = orderAutocompleteOptions(matches)
return orderedMatches.map((cmd) => ({
type: 'command' as const,
id: cmd.name,
label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined,
description: cmd.description,
kind: 'command',
}))
}
if (mode === 'file' && activeFileMention) {
return (fileSuggestionsQuery.data ?? []).map((entry) => ({
type: 'workspace_entry' as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
detail: entry.kind === 'directory' ? 'Directory' : 'File',
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}))
}
return []
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
setUserInput(`/${option.id} `);
onAutocompleteApplied?.();
const selected = option as AgentAutocompleteOption
if (selected.type === 'command') {
setUserInput(`/${selected.id} `)
onAutocompleteApplied?.()
return
}
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
relativePath: selected.entryPath,
})
setUserInput(nextInput)
onAutocompleteApplied?.()
},
[onAutocompleteApplied, setUserInput]
);
[onAutocompleteApplied, setUserInput, userInput]
)
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: filterQuery,
query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
onSelectOption,
onEscape: () => setUserInput(""),
});
onEscape: mode === 'command' ? () => setUserInput('') : undefined,
})
const isLoading =
mode === 'command'
? isCommandsLoading
: mode === 'file'
? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading
: false
const errorMessage =
mode === 'command'
? isError
? (error?.message ?? 'Failed to load')
: undefined
: mode === 'file'
? fileSuggestionsQuery.error instanceof Error
? fileSuggestionsQuery.error.message
: undefined
: undefined
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
return {
isVisible,
options,
selectedIndex,
isLoading,
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined,
errorMessage,
loadingText,
emptyText,
onSelectOption,
onKeyPress,
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
describe('findActiveFileMention', () => {
it('detects mentions at the start of input', () => {
const mention = findActiveFileMention({
text: '@src/components',
cursorIndex: '@src/components'.length,
})
expect(mention).toEqual({
start: 0,
end: '@src/components'.length,
query: 'src/components',
})
})
it('detects mentions in the middle of input using cursor position', () => {
const text = 'read "@src/com" before merging'
const cursorIndex = text.indexOf('"') + 9
const mention = findActiveFileMention({
text,
cursorIndex,
})
expect(mention).toEqual({
start: text.indexOf('@'),
end: cursorIndex,
query: 'src/com',
})
})
it('returns null when cursor is outside the mention token', () => {
const text = 'please review @src/components now'
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
})
expect(mention).toBeNull()
})
})
describe('applyFileMentionReplacement', () => {
it('replaces only the active @query segment with a quoted relative path', () => {
const text = 'open @src/com next'
const next = applyFileMentionReplacement({
text,
mention: { start: 5, end: 13, query: 'src/com' },
relativePath: 'src/components/chat.tsx',
})
expect(next).toBe('open "src/components/chat.tsx" next')
})
it('escapes double quotes in replacement path', () => {
const text = '@foo'
const next = applyFileMentionReplacement({
text,
mention: { start: 0, end: 4, query: 'foo' },
relativePath: 'src/"quoted".ts',
})
expect(next).toBe('"src/\\"quoted\\".ts"')
})
})
@@ -0,0 +1,48 @@
export interface FileMentionRange {
start: number
end: number
query: string
}
interface FindActiveFileMentionInput {
text: string
cursorIndex: number
}
interface ApplyFileMentionReplacementInput {
text: string
mention: FileMentionRange
relativePath: string
}
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
const beforeCursor = input.text.slice(0, clampedCursor)
for (
let atIndex = beforeCursor.lastIndexOf('@');
atIndex >= 0;
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
) {
const query = beforeCursor.slice(atIndex + 1)
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue
}
return {
start: atIndex,
end: clampedCursor,
query,
}
}
return null
}
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
const safePath = input.relativePath.replace(/"/g, '\\"')
const before = input.text.slice(0, input.mention.start)
const after = input.text.slice(input.mention.end)
return `${before}"${safePath}"${after}`
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from 'vitest'
import {
AgentStreamMessageSchema,
@@ -6,172 +6,176 @@ import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
} from './messages.js'
describe("shared messages stream parsing", () => {
it("parses representative fetch_agent_timeline_response payload", () => {
describe('shared messages stream parsing', () => {
it('parses representative fetch_agent_timeline_response payload', () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
type: 'fetch_agent_timeline_response',
payload: {
requestId: "req-1",
agentId: "agent_live",
direction: "tail",
projection: "projected",
epoch: "epoch-1",
requestId: 'req-1',
agentId: 'agent_live',
direction: 'tail',
projection: 'projected',
epoch: 'epoch-1',
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
startCursor: { epoch: 'epoch-1', seq: 1 },
endCursor: { epoch: 'epoch-1', seq: 2 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
provider: 'codex',
item: { type: 'assistant_message', text: 'hello' },
timestamp: '2026-02-08T20:10:00.000Z',
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
collapsed: ['assistant_merge'],
},
],
error: null,
},
});
})
expect(parsed.payload.entries).toHaveLength(1);
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
expect(parsed.payload.entries).toHaveLength(1)
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
})
it("parses representative agent_stream tool_call event", () => {
it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
type: 'agent_stream',
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
agentId: 'agent_live',
timestamp: '2026-02-08T20:10:00.000Z',
event: {
type: "timeline",
provider: "claude",
type: 'timeline',
provider: 'claude',
item: {
type: "tool_call",
callId: "call_live",
name: "shell",
status: "running",
type: 'tool_call',
callId: 'call_live',
name: 'shell',
status: 'running',
detail: {
type: "shell",
command: "ls",
type: 'shell',
command: 'ls',
},
error: null,
},
},
},
});
})
expect(parsed.payload.event.type).toBe("timeline");
if (parsed.payload.event.type === "timeline") {
expect(parsed.payload.event.item.type).toBe("tool_call");
if (parsed.payload.event.item.type === "tool_call") {
expect(parsed.payload.event.item.status).toBe("running");
expect(parsed.payload.event.type).toBe('timeline')
if (parsed.payload.event.type === 'timeline') {
expect(parsed.payload.event.item.type).toBe('tool_call')
if (parsed.payload.event.item.type === 'tool_call') {
expect(parsed.payload.event.item.status).toBe('running')
}
}
});
})
it("rejects removed initialize_agent_request inbound payload", () => {
it('rejects removed initialize_agent_request inbound payload', () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
agentId: "agent-legacy",
requestId: "req-legacy-1",
});
expect(parsed.success).toBe(false);
});
type: 'initialize_agent_request',
agentId: 'agent-legacy',
requestId: 'req-legacy-1',
})
expect(parsed.success).toBe(false)
})
it("rejects removed initialize_agent_request outbound payload", () => {
it('rejects removed initialize_agent_request outbound payload', () => {
const parsed = SessionOutboundMessageSchema.safeParse({
type: "initialize_agent_request",
type: 'initialize_agent_request',
payload: {
requestId: "req-legacy-1",
agentId: "agent-legacy",
agentStatus: "running",
requestId: 'req-legacy-1',
agentId: 'agent-legacy',
agentStatus: 'running',
timelineSize: 12,
},
});
expect(parsed.success).toBe(false);
});
})
expect(parsed.success).toBe(false)
})
it("parses directory suggestions request and response payloads", () => {
it('parses directory suggestions request and response payloads', () => {
const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request",
query: "proj",
type: 'directory_suggestions_request',
query: 'proj',
cwd: '/tmp/project',
includeFiles: true,
includeDirectories: true,
limit: 20,
requestId: "req-dir-1",
});
expect(requestParsed.success).toBe(true);
requestId: 'req-dir-1',
})
expect(requestParsed.success).toBe(true)
const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response",
type: 'directory_suggestions_response',
payload: {
directories: ["/Users/test/projects/paseo"],
directories: ['/Users/test/projects/paseo'],
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
error: null,
requestId: "req-dir-1",
requestId: 'req-dir-1',
},
});
expect(responseParsed.success).toBe(true);
});
})
expect(responseParsed.success).toBe(true)
})
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
const fixture = {
type: "agent_stream_snapshot",
type: 'agent_stream_snapshot',
payload: {
agentId: "agent-legacy",
agentId: 'agent-legacy',
events: [],
},
};
}
const wrapped = WSOutboundMessageSchema.safeParse({
type: "session",
type: 'session',
message: fixture,
});
expect(wrapped.success).toBe(false);
});
})
expect(wrapped.success).toBe(false)
})
it("rejects removed legacy git diff request messages", () => {
it('rejects removed legacy git diff request messages', () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request",
agentId: "agent-1",
requestId: "req-1",
});
expect(gitDiffParsed.success).toBe(false);
type: 'git_diff_request',
agentId: 'agent-1',
requestId: 'req-1',
})
expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request",
agentId: "agent-1",
requestId: "req-2",
});
expect(highlightedParsed.success).toBe(false);
});
type: 'highlighted_diff_request',
agentId: 'agent-1',
requestId: 'req-2',
})
expect(highlightedParsed.success).toBe(false)
})
it("rejects removed legacy git diff response messages", () => {
it('rejects removed legacy git diff response messages', () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response",
type: 'git_diff_response',
payload: {
agentId: "agent-1",
diff: "",
agentId: 'agent-1',
diff: '',
error: null,
requestId: "req-1",
requestId: 'req-1',
},
});
expect(gitDiffParsed.success).toBe(false);
})
expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response",
type: 'highlighted_diff_response',
payload: {
agentId: "agent-1",
agentId: 'agent-1',
files: [],
error: null,
requestId: "req-2",
requestId: 'req-2',
},
});
expect(highlightedParsed.success).toBe(false);
});
});
})
expect(highlightedParsed.success).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,165 +1,237 @@
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
describe('searchHomeDirectories', () => {
let tempRoot: string
let homeDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
rmSync(tempRoot, { recursive: true, force: true })
})
it("returns an empty list for blank queries", async () => {
it('returns an empty list for blank queries', async () => {
await expect(
searchHomeDirectories({
homeDir,
query: " ",
query: ' ',
limit: 10,
})
).resolves.toEqual([]);
});
).resolves.toEqual([])
})
it("returns only existing directories", async () => {
it('returns only existing directories', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "proj",
query: 'proj',
limit: 10,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
})
it("supports home-relative path query syntax", async () => {
it('supports home-relative path query syntax', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~/projects/pa",
query: '~/projects/pa',
limit: 10,
});
})
expect(results).toEqual([
path.join(homeDir, "projects", "paseo"),
]);
});
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
})
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(
homeDir,
"something",
"faro",
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
mkdirSync(exactSegmentPath, { recursive: true })
mkdirSync(prefixSegmentPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "faro",
query: 'faro',
limit: 30,
});
})
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex)
})
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "arofo",
query: 'arofo',
limit: 30,
});
})
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex)
})
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~",
query: '~',
limit: 20,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
expect(results).not.toContain(path.join(homeDir, ".hidden"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, '.hidden'))
})
it("does not return hidden directories during tree search", async () => {
it('does not return hidden directories during tree search', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "cache",
query: 'cache',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
});
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
})
it("does not return paths that escape home through symlinks", async () => {
it('does not return paths that escape home through symlinks', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "outside",
query: 'outside',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
})
it("respects the result limit", async () => {
it('respects the result limit', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "p",
query: 'p',
limit: 1,
});
})
expect(results).toHaveLength(1);
});
});
expect(results).toHaveLength(1)
})
})
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
it('returns relative file and directory suggestions for workspace queries', async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
it('filters entries by kind', async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
it('supports path-style queries and does not escape cwd through symlinks', async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff