Feat add autocomplete (#18)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,3 +148,4 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Letta Code local settings
|
# Letta Code local settings
|
||||||
.letta/settings.local.json
|
.letta/settings.local.json
|
||||||
|
.idea
|
||||||
|
|||||||
246
src/cli/components/FileAutocomplete.tsx
Normal file
246
src/cli/components/FileAutocomplete.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { searchFiles } from "../helpers/fileSearch";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
interface FileMatch {
|
||||||
|
path: string;
|
||||||
|
type: "file" | "dir" | "url";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileAutocompleteProps {
|
||||||
|
currentInput: string;
|
||||||
|
cursorPosition?: number;
|
||||||
|
onSelect?: (path: string) => void;
|
||||||
|
onActiveChange?: (isActive: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileAutocomplete({
|
||||||
|
currentInput,
|
||||||
|
cursorPosition = currentInput.length,
|
||||||
|
onSelect,
|
||||||
|
onActiveChange,
|
||||||
|
}: FileAutocompleteProps) {
|
||||||
|
const [matches, setMatches] = useState<FileMatch[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [lastValidQuery, setLastValidQuery] = useState<string>("");
|
||||||
|
|
||||||
|
// Extract the text after the "@" symbol where the cursor is positioned
|
||||||
|
const extractSearchQuery = useCallback(
|
||||||
|
(
|
||||||
|
input: string,
|
||||||
|
cursor: number,
|
||||||
|
): { query: string; hasSpaceAfter: boolean; atIndex: number } | null => {
|
||||||
|
// Find all @ positions
|
||||||
|
const atPositions: number[] = [];
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
if (input[i] === "@") {
|
||||||
|
// Only count @ at start or after space
|
||||||
|
if (i === 0 || input[i - 1] === " ") {
|
||||||
|
atPositions.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atPositions.length === 0) return null;
|
||||||
|
|
||||||
|
// Find which @ the cursor is in
|
||||||
|
let atIndex = -1;
|
||||||
|
for (const pos of atPositions) {
|
||||||
|
// Find the end of this @reference (next space or end of string)
|
||||||
|
const afterAt = input.slice(pos + 1);
|
||||||
|
const spaceIndex = afterAt.indexOf(" ");
|
||||||
|
const endPos = spaceIndex === -1 ? input.length : pos + 1 + spaceIndex;
|
||||||
|
|
||||||
|
// Check if cursor is within this @reference
|
||||||
|
if (cursor >= pos && cursor <= endPos) {
|
||||||
|
atIndex = pos;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cursor is not in any @reference, don't show autocomplete
|
||||||
|
if (atIndex === -1) return null;
|
||||||
|
|
||||||
|
// Get text after "@" until next space or end
|
||||||
|
const afterAt = input.slice(atIndex + 1);
|
||||||
|
const spaceIndex = afterAt.indexOf(" ");
|
||||||
|
const query = spaceIndex === -1 ? afterAt : afterAt.slice(0, spaceIndex);
|
||||||
|
const hasSpaceAfter = spaceIndex !== -1;
|
||||||
|
|
||||||
|
return { query, hasSpaceAfter, atIndex };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (!matches.length || isLoading) return;
|
||||||
|
|
||||||
|
const maxIndex = Math.min(matches.length, 10) - 1;
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : maxIndex));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) => (prev < maxIndex ? prev + 1 : 0));
|
||||||
|
} else if ((key.tab || key.return) && onSelect) {
|
||||||
|
// Insert selected file path on Tab or Enter
|
||||||
|
const selected = matches[selectedIndex];
|
||||||
|
if (selected) {
|
||||||
|
onSelect(selected.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const result = extractSearchQuery(currentInput, cursorPosition);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
setMatches([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query, hasSpaceAfter } = result;
|
||||||
|
|
||||||
|
// If there's text after the space, user has moved on - hide autocomplete
|
||||||
|
// But keep it open if there's just a trailing space (allows editing the path)
|
||||||
|
if (hasSpaceAfter && query.length > 0) {
|
||||||
|
const atIndex = currentInput.lastIndexOf("@");
|
||||||
|
const afterSpace = currentInput.slice(atIndex + 1 + query.length + 1);
|
||||||
|
|
||||||
|
// Always hide if there's more non-whitespace content after, or another @
|
||||||
|
if (afterSpace.trim().length > 0 || afterSpace.includes("@")) {
|
||||||
|
setMatches([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just a trailing space - check if this query had valid matches when selected
|
||||||
|
// Use lastValidQuery to remember what was successfully selected
|
||||||
|
if (query === lastValidQuery && lastValidQuery.length > 0) {
|
||||||
|
// Show the selected file (non-interactive)
|
||||||
|
if (matches[0]?.path !== query) {
|
||||||
|
setMatches([{ path: query, type: "file" }]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
onActiveChange?.(false); // Don't block Enter key
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid selection was made, hide
|
||||||
|
setMatches([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If query is empty (just typed "@"), show current directory contents
|
||||||
|
if (query.length === 0) {
|
||||||
|
setIsLoading(true);
|
||||||
|
onActiveChange?.(true);
|
||||||
|
searchFiles("", false) // Don't do deep search for empty query
|
||||||
|
.then((results) => {
|
||||||
|
setMatches(results);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setIsLoading(false);
|
||||||
|
onActiveChange?.(results.length > 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMatches([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setIsLoading(false);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a URL pattern
|
||||||
|
if (query.startsWith("http://") || query.startsWith("https://")) {
|
||||||
|
setMatches([{ path: query, type: "url" }]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
onActiveChange?.(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for matching files (deep search through subdirectories)
|
||||||
|
setIsLoading(true);
|
||||||
|
onActiveChange?.(true);
|
||||||
|
searchFiles(query, true) // Enable deep search
|
||||||
|
.then((results) => {
|
||||||
|
setMatches(results);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setIsLoading(false);
|
||||||
|
onActiveChange?.(results.length > 0);
|
||||||
|
// Remember this query had valid matches
|
||||||
|
if (results.length > 0) {
|
||||||
|
setLastValidQuery(query);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setMatches([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setIsLoading(false);
|
||||||
|
onActiveChange?.(false);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentInput,
|
||||||
|
cursorPosition,
|
||||||
|
onActiveChange,
|
||||||
|
extractSearchQuery,
|
||||||
|
lastValidQuery,
|
||||||
|
matches[0]?.path,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Don't show if no "@" in input
|
||||||
|
if (!currentInput.includes("@")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show if no matches and not loading
|
||||||
|
if (matches.length === 0 && !isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={colors.command.border}
|
||||||
|
paddingX={1}
|
||||||
|
marginBottom={1}
|
||||||
|
>
|
||||||
|
<Text dimColor>
|
||||||
|
File/URL autocomplete (↑↓ to navigate, Tab/Enter to select):
|
||||||
|
</Text>
|
||||||
|
{isLoading ? (
|
||||||
|
<Text dimColor>Searching...</Text>
|
||||||
|
) : (
|
||||||
|
matches.slice(0, 10).map((item, idx) => (
|
||||||
|
<Box key={item.path} flexDirection="row" gap={1}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
idx === selectedIndex
|
||||||
|
? colors.status.success
|
||||||
|
: item.type === "dir"
|
||||||
|
? colors.status.processing
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
bold={idx === selectedIndex}
|
||||||
|
>
|
||||||
|
{idx === selectedIndex ? "▶ " : " "}
|
||||||
|
{item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"}
|
||||||
|
</Text>
|
||||||
|
<Text bold={idx === selectedIndex}>{item.path}</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{matches.length > 10 && (
|
||||||
|
<Text dimColor>... and {matches.length - 10} more</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/cli/components/InputAssist.tsx
Normal file
42
src/cli/components/InputAssist.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CommandPreview } from "./CommandPreview";
|
||||||
|
import { FileAutocomplete } from "./FileAutocomplete";
|
||||||
|
|
||||||
|
interface InputAssistProps {
|
||||||
|
currentInput: string;
|
||||||
|
cursorPosition: number;
|
||||||
|
onFileSelect: (path: string) => void;
|
||||||
|
onAutocompleteActiveChange: (isActive: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows contextual assistance below the input:
|
||||||
|
* - File autocomplete when "@" is detected
|
||||||
|
* - Command preview when "/" is detected
|
||||||
|
* - Nothing otherwise
|
||||||
|
*/
|
||||||
|
export function InputAssist({
|
||||||
|
currentInput,
|
||||||
|
cursorPosition,
|
||||||
|
onFileSelect,
|
||||||
|
onAutocompleteActiveChange,
|
||||||
|
}: InputAssistProps) {
|
||||||
|
// Show file autocomplete when @ is present
|
||||||
|
if (currentInput.includes("@")) {
|
||||||
|
return (
|
||||||
|
<FileAutocomplete
|
||||||
|
currentInput={currentInput}
|
||||||
|
cursorPosition={cursorPosition}
|
||||||
|
onSelect={onFileSelect}
|
||||||
|
onActiveChange={onAutocompleteActiveChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show command preview when input starts with /
|
||||||
|
if (currentInput.startsWith("/")) {
|
||||||
|
return <CommandPreview currentInput={currentInput} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No assistance needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { PermissionMode } from "../../permissions/mode";
|
import type { PermissionMode } from "../../permissions/mode";
|
||||||
import { permissionMode } from "../../permissions/mode";
|
import { permissionMode } from "../../permissions/mode";
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
import { CommandPreview } from "./CommandPreview";
|
|
||||||
import { colors } from "./colors";
|
import { colors } from "./colors";
|
||||||
|
import { InputAssist } from "./InputAssist";
|
||||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||||
import { ShimmerText } from "./ShimmerText";
|
import { ShimmerText } from "./ShimmerText";
|
||||||
|
|
||||||
@@ -51,6 +51,9 @@ export function Input({
|
|||||||
const [currentMode, setCurrentMode] = useState<PermissionMode>(
|
const [currentMode, setCurrentMode] = useState<PermissionMode>(
|
||||||
externalMode || permissionMode.getMode(),
|
externalMode || permissionMode.getMode(),
|
||||||
);
|
);
|
||||||
|
const [isAutocompleteActive, setIsAutocompleteActive] = useState(false);
|
||||||
|
const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);
|
||||||
|
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);
|
||||||
|
|
||||||
// Sync with external mode changes (from plan approval dialog)
|
// Sync with external mode changes (from plan approval dialog)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,6 +175,11 @@ export function Input({
|
|||||||
}, [streaming, thinkingMessage, visible]);
|
}, [streaming, thinkingMessage, visible]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
// Don't submit if autocomplete is active with matches
|
||||||
|
if (isAutocompleteActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (streaming || commandRunning) {
|
if (streaming || commandRunning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,6 +192,38 @@ export function Input({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle file selection from autocomplete
|
||||||
|
const handleFileSelect = (selectedPath: string) => {
|
||||||
|
// Find the last "@" and replace everything after it with the selected path
|
||||||
|
const atIndex = value.lastIndexOf("@");
|
||||||
|
if (atIndex === -1) return;
|
||||||
|
|
||||||
|
const beforeAt = value.slice(0, atIndex);
|
||||||
|
const afterAt = value.slice(atIndex + 1);
|
||||||
|
const spaceIndex = afterAt.indexOf(" ");
|
||||||
|
|
||||||
|
let newValue: string;
|
||||||
|
let newCursorPos: number;
|
||||||
|
|
||||||
|
// Replace the query part with the selected path
|
||||||
|
if (spaceIndex === -1) {
|
||||||
|
// No space after @query, replace to end
|
||||||
|
newValue = `${beforeAt}@${selectedPath} `;
|
||||||
|
newCursorPos = newValue.length;
|
||||||
|
} else {
|
||||||
|
// Space exists, replace only the query part
|
||||||
|
const afterQuery = afterAt.slice(spaceIndex);
|
||||||
|
newValue = `${beforeAt}@${selectedPath}${afterQuery}`;
|
||||||
|
newCursorPos = beforeAt.length + selectedPath.length + 1; // After the path
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
setCursorPos(newCursorPos);
|
||||||
|
|
||||||
|
// Reset cursor position after a short delay so it only applies once
|
||||||
|
setTimeout(() => setCursorPos(undefined), 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Get display name and color for permission mode
|
// Get display name and color for permission mode
|
||||||
const getModeInfo = () => {
|
const getModeInfo = () => {
|
||||||
switch (currentMode) {
|
switch (currentMode) {
|
||||||
@@ -254,6 +294,8 @@ export function Input({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
cursorPosition={cursorPos}
|
||||||
|
onCursorMove={setCurrentCursorPosition}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -261,28 +303,31 @@ export function Input({
|
|||||||
{/* Bottom horizontal divider */}
|
{/* Bottom horizontal divider */}
|
||||||
<Text dimColor>{horizontalLine}</Text>
|
<Text dimColor>{horizontalLine}</Text>
|
||||||
|
|
||||||
{value.startsWith("/") ? (
|
<InputAssist
|
||||||
<CommandPreview currentInput={value} />
|
currentInput={value}
|
||||||
) : (
|
cursorPosition={currentCursorPosition}
|
||||||
<Box justifyContent="space-between" marginBottom={1}>
|
onFileSelect={handleFileSelect}
|
||||||
{ctrlCPressed ? (
|
onAutocompleteActiveChange={setIsAutocompleteActive}
|
||||||
<Text dimColor>Press CTRL-C again to exit</Text>
|
/>
|
||||||
) : escapePressed ? (
|
|
||||||
<Text dimColor>Press Esc again to clear</Text>
|
<Box justifyContent="space-between" marginBottom={1}>
|
||||||
) : modeInfo ? (
|
{ctrlCPressed ? (
|
||||||
<Text>
|
<Text dimColor>Press CTRL-C again to exit</Text>
|
||||||
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
|
) : escapePressed ? (
|
||||||
<Text color={modeInfo.color} dimColor>
|
<Text dimColor>Press Esc again to clear</Text>
|
||||||
{" "}
|
) : modeInfo ? (
|
||||||
(shift+tab to cycle)
|
<Text>
|
||||||
</Text>
|
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
|
||||||
|
<Text color={modeInfo.color} dimColor>
|
||||||
|
{" "}
|
||||||
|
(shift+tab to cycle)
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
</Text>
|
||||||
<Text dimColor>Press / for commands</Text>
|
) : (
|
||||||
)}
|
<Text dimColor>Press / for commands or @ for files</Text>
|
||||||
<Text dimColor>https://discord.gg/letta</Text>
|
)}
|
||||||
</Box>
|
<Text dimColor>https://discord.gg/letta</Text>
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface PasteAwareTextInputProps {
|
|||||||
onSubmit?: (value: string) => void;
|
onSubmit?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
|
cursorPosition?: number;
|
||||||
|
onCursorMove?: (position: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLines(text: string): number {
|
function countLines(text: string): number {
|
||||||
@@ -32,6 +34,8 @@ export function PasteAwareTextInput({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
placeholder,
|
placeholder,
|
||||||
focus = true,
|
focus = true,
|
||||||
|
cursorPosition,
|
||||||
|
onCursorMove,
|
||||||
}: PasteAwareTextInputProps) {
|
}: PasteAwareTextInputProps) {
|
||||||
const [displayValue, setDisplayValue] = useState(value);
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
const [actualValue, setActualValue] = useState(value);
|
const [actualValue, setActualValue] = useState(value);
|
||||||
@@ -41,6 +45,20 @@ export function PasteAwareTextInput({
|
|||||||
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
const [nudgeCursorOffset, setNudgeCursorOffset] = useState<
|
||||||
number | undefined
|
number | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// Apply cursor position from parent
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof cursorPosition === "number") {
|
||||||
|
setNudgeCursorOffset(cursorPosition);
|
||||||
|
caretOffsetRef.current = cursorPosition;
|
||||||
|
}
|
||||||
|
}, [cursorPosition]);
|
||||||
|
|
||||||
|
// Notify parent of cursor position changes
|
||||||
|
// Default assumption: cursor is at the end when typing
|
||||||
|
useEffect(() => {
|
||||||
|
onCursorMove?.(displayValue.length);
|
||||||
|
}, [displayValue, onCursorMove]);
|
||||||
const TextInputAny = RawTextInput as unknown as React.ComponentType<{
|
const TextInputAny = RawTextInput as unknown as React.ComponentType<{
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -218,7 +236,7 @@ export function PasteAwareTextInput({
|
|||||||
const resolved = resolvePlaceholders(newValue);
|
const resolved = resolvePlaceholders(newValue);
|
||||||
setActualValue(resolved);
|
setActualValue(resolved);
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
// Default caret behavior on typing/appends: move to end
|
// Default: cursor moves to end (most common case)
|
||||||
caretOffsetRef.current = newValue.length;
|
caretOffsetRef.current = newValue.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -201,11 +201,13 @@ export function onChunk(
|
|||||||
// TODO remove once SDK v1 has proper typing for in-stream errors
|
// TODO remove once SDK v1 has proper typing for in-stream errors
|
||||||
// Check for streaming error objects (not typed in SDK but emitted by backend)
|
// Check for streaming error objects (not typed in SDK but emitted by backend)
|
||||||
// These are emitted when LLM errors occur during streaming (rate limits, timeouts, etc.)
|
// These are emitted when LLM errors occur during streaming (rate limits, timeouts, etc.)
|
||||||
const chunkAny = chunk as any;
|
const chunkWithError = chunk as typeof chunk & {
|
||||||
if (chunkAny.error && !chunk.messageType) {
|
error?: { message?: string; detail?: string };
|
||||||
|
};
|
||||||
|
if (chunkWithError.error && !chunk.messageType) {
|
||||||
const errorId = `err-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
const errorId = `err-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const errorMsg = chunkAny.error.message || "An error occurred";
|
const errorMsg = chunkWithError.error.message || "An error occurred";
|
||||||
const errorDetail = chunkAny.error.detail || "";
|
const errorDetail = chunkWithError.error.detail || "";
|
||||||
const fullErrorText = errorDetail
|
const fullErrorText = errorDetail
|
||||||
? `${errorMsg}: ${errorDetail}`
|
? `${errorMsg}: ${errorDetail}`
|
||||||
: errorMsg;
|
: errorMsg;
|
||||||
|
|||||||
176
src/cli/helpers/fileSearch.ts
Normal file
176
src/cli/helpers/fileSearch.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { readdirSync, statSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
interface FileMatch {
|
||||||
|
path: string;
|
||||||
|
type: "file" | "dir" | "url";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively search a directory for files matching a pattern
|
||||||
|
*/
|
||||||
|
function searchDirectoryRecursive(
|
||||||
|
dir: string,
|
||||||
|
pattern: string,
|
||||||
|
maxDepth: number = 3,
|
||||||
|
currentDepth: number = 0,
|
||||||
|
maxResults: number = 100,
|
||||||
|
results: FileMatch[] = [],
|
||||||
|
): FileMatch[] {
|
||||||
|
if (currentDepth > maxDepth || results.length >= maxResults) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Skip hidden files and common ignore patterns
|
||||||
|
if (
|
||||||
|
entry.startsWith(".") ||
|
||||||
|
entry === "node_modules" ||
|
||||||
|
entry === "dist" ||
|
||||||
|
entry === "build"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullPath = join(dir, entry);
|
||||||
|
const stats = statSync(fullPath);
|
||||||
|
|
||||||
|
// Check if entry matches the pattern
|
||||||
|
const matches =
|
||||||
|
pattern.length === 0 ||
|
||||||
|
entry.toLowerCase().includes(pattern.toLowerCase());
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
const relativePath = fullPath.startsWith(process.cwd())
|
||||||
|
? fullPath.slice(process.cwd().length + 1)
|
||||||
|
: fullPath;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
path: relativePath,
|
||||||
|
type: stats.isDirectory() ? "dir" : "file",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= maxResults) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively search subdirectories
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
searchDirectoryRecursive(
|
||||||
|
fullPath,
|
||||||
|
pattern,
|
||||||
|
maxDepth,
|
||||||
|
currentDepth + 1,
|
||||||
|
maxResults,
|
||||||
|
results,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't read directory, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for files and directories matching the query
|
||||||
|
* @param query - The search query (partial file path)
|
||||||
|
* @param deep - Whether to search recursively through subdirectories
|
||||||
|
* @returns Array of matching files and directories
|
||||||
|
*/
|
||||||
|
export async function searchFiles(
|
||||||
|
query: string,
|
||||||
|
deep: boolean = false,
|
||||||
|
): Promise<FileMatch[]> {
|
||||||
|
const results: FileMatch[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine the directory to search in
|
||||||
|
let searchDir = process.cwd();
|
||||||
|
let searchPattern = query;
|
||||||
|
|
||||||
|
// Handle relative paths like "./src" or "../test"
|
||||||
|
if (query.includes("/")) {
|
||||||
|
const lastSlashIndex = query.lastIndexOf("/");
|
||||||
|
const dirPart = query.slice(0, lastSlashIndex);
|
||||||
|
searchPattern = query.slice(lastSlashIndex + 1);
|
||||||
|
|
||||||
|
// Resolve the directory path
|
||||||
|
try {
|
||||||
|
searchDir = resolve(process.cwd(), dirPart);
|
||||||
|
} catch {
|
||||||
|
// If path doesn't exist, return empty results
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deep) {
|
||||||
|
// Deep search: recursively search subdirectories
|
||||||
|
const deepResults = searchDirectoryRecursive(
|
||||||
|
searchDir,
|
||||||
|
searchPattern,
|
||||||
|
3, // Max depth of 3 levels
|
||||||
|
0,
|
||||||
|
100, // Max 100 results
|
||||||
|
);
|
||||||
|
results.push(...deepResults);
|
||||||
|
} else {
|
||||||
|
// Shallow search: only current directory
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(searchDir);
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist or can't be read
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter entries matching the search pattern
|
||||||
|
// If pattern is empty, show all entries (for when user just types "@")
|
||||||
|
const matchingEntries =
|
||||||
|
searchPattern.length === 0
|
||||||
|
? entries
|
||||||
|
: entries.filter((entry) =>
|
||||||
|
entry.toLowerCase().includes(searchPattern.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get stats for each matching entry
|
||||||
|
for (const entry of matchingEntries.slice(0, 50)) {
|
||||||
|
// Limit to 50 results
|
||||||
|
try {
|
||||||
|
const fullPath = join(searchDir, entry);
|
||||||
|
const stats = statSync(fullPath);
|
||||||
|
|
||||||
|
// Make path relative to cwd if possible
|
||||||
|
const relativePath = fullPath.startsWith(process.cwd())
|
||||||
|
? fullPath.slice(process.cwd().length + 1)
|
||||||
|
: fullPath;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
path: relativePath,
|
||||||
|
type: stats.isDirectory() ? "dir" : "file",
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then files, alphabetically within each group
|
||||||
|
results.sort((a, b) => {
|
||||||
|
if (a.type === "dir" && b.type !== "dir") return -1;
|
||||||
|
if (a.type !== "dir" && b.type === "dir") return 1;
|
||||||
|
return a.path.localeCompare(b.path);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Return empty array on any error
|
||||||
|
console.error("File search error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -147,7 +147,14 @@ export async function handleHeadlessCommand(argv: string[]) {
|
|||||||
|
|
||||||
// Track approval requests
|
// Track approval requests
|
||||||
if (chunk.messageType === "approval_request_message") {
|
if (chunk.messageType === "approval_request_message") {
|
||||||
const toolCall = (chunk as any).toolCall;
|
const chunkWithToolCall = chunk as typeof chunk & {
|
||||||
|
toolCall?: {
|
||||||
|
toolCallId?: string;
|
||||||
|
name?: string;
|
||||||
|
arguments?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const toolCall = chunkWithToolCall.toolCall;
|
||||||
if (toolCall?.toolCallId && toolCall?.name) {
|
if (toolCall?.toolCallId && toolCall?.name) {
|
||||||
approval = {
|
approval = {
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
|
|||||||
146
src/tests/fileSearch.test.ts
Normal file
146
src/tests/fileSearch.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { searchFiles } from "../cli/helpers/fileSearch";
|
||||||
|
|
||||||
|
const TEST_DIR = join(process.cwd(), ".test-filesearch");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create test directory structure
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
mkdirSync(join(TEST_DIR, "src"), { recursive: true });
|
||||||
|
mkdirSync(join(TEST_DIR, "src/components"), { recursive: true });
|
||||||
|
mkdirSync(join(TEST_DIR, "tests"), { recursive: true });
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
writeFileSync(join(TEST_DIR, "README.md"), "# Test");
|
||||||
|
writeFileSync(join(TEST_DIR, "package.json"), "{}");
|
||||||
|
writeFileSync(join(TEST_DIR, "src/index.ts"), "console.log('test')");
|
||||||
|
writeFileSync(join(TEST_DIR, "src/App.tsx"), "export default App");
|
||||||
|
writeFileSync(join(TEST_DIR, "src/components/Button.tsx"), "export Button");
|
||||||
|
writeFileSync(join(TEST_DIR, "tests/app.test.ts"), "test()");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test directory
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles finds files in current directory (shallow)", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("", false);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results.some((r) => r.path === "README.md")).toBe(true);
|
||||||
|
expect(results.some((r) => r.path === "package.json")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles filters by pattern (shallow)", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("README", false);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(results[0]?.path).toBe("README.md");
|
||||||
|
expect(results[0]?.type).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles finds files recursively (deep)", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("App", true);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles finds files in subdirectories (deep)", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("Button", true);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
// Use platform-agnostic path check
|
||||||
|
expect(results[0]?.path).toContain("components");
|
||||||
|
expect(results[0]?.path).toContain("Button.tsx");
|
||||||
|
expect(results[0]?.type).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles identifies directories correctly", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("", false);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
const srcDir = results.find((r) => r.path === "src");
|
||||||
|
expect(srcDir).toBeDefined();
|
||||||
|
expect(srcDir?.type).toBe("dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles returns empty array for non-existent pattern", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("nonexistent12345", true);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles case-insensitive matching", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("readme", false);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(results[0]?.path).toBe("README.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles skips node_modules (deep)", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
// Create node_modules directory
|
||||||
|
mkdirSync(join(TEST_DIR, "node_modules/pkg"), { recursive: true });
|
||||||
|
writeFileSync(join(TEST_DIR, "node_modules/pkg/index.js"), "module");
|
||||||
|
|
||||||
|
const results = await searchFiles("index", true);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
// Should find index.ts but not node_modules/pkg/index.js
|
||||||
|
expect(results.some((r) => r.path.includes("node_modules"))).toBe(false);
|
||||||
|
expect(results.some((r) => r.path.includes("index.ts"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchFiles handles relative path queries", async () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(TEST_DIR);
|
||||||
|
|
||||||
|
const results = await searchFiles("src/A", false);
|
||||||
|
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// Check that at least one result contains App.tsx
|
||||||
|
expect(results.some((r) => r.path.includes("App.tsx"))).toBe(true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user