feat: tab completions for file paths (#279)

Made a PR as was requested in the #113
This commit is contained in:
Aiden Cline
2025-04-21 00:34:27 -05:00
committed by GitHub
parent f6b12aa994
commit ee7ce5b601
8 changed files with 370 additions and 42 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ result
.vscode/
.idea/
.history/
.zed/
*.swp
*~

View File

@@ -155,6 +155,8 @@ export interface MultilineTextEditorHandle {
isCursorAtLastRow(): boolean;
/** Full text contents */
getText(): string;
/** Move the cursor to the end of the text */
moveCursorToEnd(): void;
}
const MultilineTextEditorInner = (
@@ -372,6 +374,16 @@ const MultilineTextEditorInner = (
return row === lineCount - 1;
},
getText: () => buffer.current.getText(),
moveCursorToEnd: () => {
buffer.current.move("home");
const lines = buffer.current.getText().split("\n");
for (let i = 0; i < lines.length - 1; i++) {
buffer.current.move("down");
}
buffer.current.move("end");
// Force a re-render
setVersion((v) => v + 1);
},
}),
[],
);

View File

@@ -0,0 +1,64 @@
import { Box, Text } from "ink";
import React, { useMemo } from "react";
type TextCompletionProps = {
/**
* Array of text completion options to display in the list
*/
completions: Array<string>;
/**
* Maximum number of completion items to show at once in the view
*/
displayLimit: number;
/**
* Index of the currently selected completion in the completions array
*/
selectedCompletion: number;
};
function TerminalChatCompletions({
completions,
selectedCompletion,
displayLimit,
}: TextCompletionProps): JSX.Element {
const visibleItems = useMemo(() => {
// Try to keep selection centered in view
let startIndex = Math.max(
0,
selectedCompletion - Math.floor(displayLimit / 2),
);
// Fix window position when at the end of the list
if (completions.length - startIndex < displayLimit) {
startIndex = Math.max(0, completions.length - displayLimit);
}
const endIndex = Math.min(completions.length, startIndex + displayLimit);
return completions.slice(startIndex, endIndex).map((completion, index) => ({
completion,
originalIndex: index + startIndex,
}));
}, [completions, selectedCompletion, displayLimit]);
return (
<Box flexDirection="column">
{visibleItems.map(({ completion, originalIndex }) => (
<Text
key={completion}
dimColor={originalIndex !== selectedCompletion}
underline={originalIndex === selectedCompletion}
backgroundColor={
originalIndex === selectedCompletion ? "blackBright" : undefined
}
>
{completion}
</Text>
))}
</Box>
);
}
export default TerminalChatCompletions;

View File

@@ -8,8 +8,10 @@ import type {
import MultilineTextEditor from "./multiline-editor";
import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
import TextCompletions from "./terminal-chat-completions.js";
import { log } from "../../utils/agent/log.js";
import { loadConfig } from "../../utils/config.js";
import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js";
import { createInputItem } from "../../utils/input-utils.js";
import { setSessionId } from "../../utils/session.js";
import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands";
@@ -90,6 +92,8 @@ export default function TerminalChatInput({
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [draftInput, setDraftInput] = useState<string>("");
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
const [fsSuggestions, setFsSuggestions] = useState<Array<string>>([]);
const [selectedCompletion, setSelectedCompletion] = useState<number>(-1);
// Multiline text editor key to force remount after submission
const [editorKey, setEditorKey] = useState(0);
// Imperative handle from the multiline editor so we can query caret position
@@ -196,6 +200,44 @@ export default function TerminalChatInput({
}
}
if (!confirmationPrompt && !loading) {
if (fsSuggestions.length > 0) {
if (_key.upArrow) {
setSelectedCompletion((prev) =>
prev <= 0 ? fsSuggestions.length - 1 : prev - 1,
);
return;
}
if (_key.downArrow) {
setSelectedCompletion((prev) =>
prev >= fsSuggestions.length - 1 ? 0 : prev + 1,
);
return;
}
if (_key.tab && selectedCompletion >= 0) {
const words = input.trim().split(/\s+/);
const selected = fsSuggestions[selectedCompletion];
if (words.length > 0 && selected) {
words[words.length - 1] = selected;
const newText = words.join(" ");
setInput(newText);
// Force remount of the editor with the new text
setEditorKey((k) => k + 1);
// We need to move the cursor to the end after editor remounts
setTimeout(() => {
editorRef.current?.moveCursorToEnd?.();
}, 0);
setFsSuggestions([]);
setSelectedCompletion(-1);
}
return;
}
}
if (_key.upArrow) {
// Only recall history when the caret was *already* on the very first
// row *before* this key-press.
@@ -241,6 +283,19 @@ export default function TerminalChatInput({
}
// Otherwise let it propagate
}
if (_key.tab) {
const words = input.split(/\s+/);
const mostRecentWord = words[words.length - 1];
if (mostRecentWord === undefined || mostRecentWord === "") {
return;
}
const completions = getFileSystemSuggestions(mostRecentWord);
setFsSuggestions(completions);
if (completions.length > 0) {
setSelectedCompletion(0);
}
}
}
// Update the cached cursor position *after* we've potentially handled
@@ -533,6 +588,8 @@ export default function TerminalChatInput({
setDraftInput("");
setSelectedSuggestion(0);
setInput("");
setFsSuggestions([]);
setSelectedCompletion(-1);
},
[
setInput,
@@ -578,7 +635,7 @@ export default function TerminalChatInput({
thinkingSeconds={thinkingSeconds}
/>
) : (
<Box>
<Box paddingX={1}>
<MultilineTextEditor
ref={editorRef}
onChange={(txt: string) => {
@@ -587,6 +644,20 @@ export default function TerminalChatInput({
setHistoryIndex(null);
}
setInput(txt);
// Clear tab completions if a space is typed
if (txt.endsWith(" ")) {
setFsSuggestions([]);
setSelectedCompletion(-1);
} else if (fsSuggestions.length > 0) {
// Update file suggestions as user types
const words = txt.trim().split(/\s+/);
const mostRecentWord =
words.length > 0 ? words[words.length - 1] : "";
if (mostRecentWord !== undefined) {
setFsSuggestions(getFileSystemSuggestions(mostRecentWord));
}
}
}}
key={editorKey}
initialText={input}
@@ -623,47 +694,51 @@ export default function TerminalChatInput({
</Box>
)}
<Box paddingX={2} marginBottom={1}>
<Text dimColor>
{isNew && !input ? (
<>
try:{" "}
{suggestions.map((m, key) => (
<Fragment key={key}>
{key !== 0 ? " | " : ""}
<Text
backgroundColor={
key + 1 === selectedSuggestion ? "blackBright" : ""
}
>
{m}
</Text>
</Fragment>
))}
</>
) : (
<>
send q or ctrl+c to exit | send "/clear" to reset | send "/help"
for commands | press enter to send | shift+enter for new line
{contextLeftPercent > 25 && (
<>
{" — "}
<Text color={contextLeftPercent > 40 ? "green" : "yellow"}>
{Math.round(contextLeftPercent)}% context left
</Text>
</>
)}
{contextLeftPercent <= 25 && (
<>
{" — "}
<Text color="red">
{Math.round(contextLeftPercent)}% context left send
"/compact" to condense context
</Text>
</>
)}
</>
)}
</Text>
{isNew && !input ? (
<Text dimColor>
try:{" "}
{suggestions.map((m, key) => (
<Fragment key={key}>
{key !== 0 ? " | " : ""}
<Text
backgroundColor={
key + 1 === selectedSuggestion ? "blackBright" : ""
}
>
{m}
</Text>
</Fragment>
))}
</Text>
) : fsSuggestions.length > 0 ? (
<TextCompletions
completions={fsSuggestions}
selectedCompletion={selectedCompletion}
displayLimit={5}
/>
) : (
<Text dimColor>
send q or ctrl+c to exit | send "/clear" to reset | send "/help" for
commands | press enter to send | shift+enter for new line
{contextLeftPercent > 25 && (
<>
{" — "}
<Text color={contextLeftPercent > 40 ? "green" : "yellow"}>
{Math.round(contextLeftPercent)}% context left
</Text>
</>
)}
{contextLeftPercent <= 25 && (
<>
{" — "}
<Text color="red">
{Math.round(contextLeftPercent)}% context left send
"/compact" to condense context
</Text>
</>
)}
</Text>
)}
</Box>
</Box>
);

View File

@@ -44,6 +44,11 @@ export type TextInputProps = {
* Function to call when `Enter` is pressed, where first argument is a value of the input.
*/
readonly onSubmit?: (value: string) => void;
/**
* Explicitly set the cursor position to the end of the text
*/
readonly cursorToEnd?: boolean;
};
function findPrevWordJump(prompt: string, cursorOffset: number) {
@@ -90,12 +95,22 @@ function TextInput({
showCursor = true,
onChange,
onSubmit,
cursorToEnd = false,
}: TextInputProps) {
const [state, setState] = useState({
cursorOffset: (originalValue || "").length,
cursorWidth: 0,
});
useEffect(() => {
if (cursorToEnd) {
setState((prev) => ({
...prev,
cursorOffset: (originalValue || "").length,
}));
}
}, [cursorToEnd, originalValue, focus]);
const { cursorOffset, cursorWidth } = state;
useEffect(() => {

View File

@@ -0,0 +1,42 @@
import fs from "fs";
import os from "os";
import path from "path";
export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
if (!pathPrefix) {
return [];
}
try {
const sep = path.sep;
const hasTilde = pathPrefix === "~" || pathPrefix.startsWith("~" + sep);
const expanded = hasTilde
? path.join(os.homedir(), pathPrefix.slice(1))
: pathPrefix;
const normalized = path.normalize(expanded);
const isDir = pathPrefix.endsWith(path.sep);
const base = path.basename(normalized);
const dir =
normalized === "." && !pathPrefix.startsWith("." + sep) && !hasTilde
? process.cwd()
: path.dirname(normalized);
const readDir = isDir ? path.join(dir, base) : dir;
return fs
.readdirSync(readDir)
.filter((item) => isDir || item.startsWith(base))
.map((item) => {
const fullPath = path.join(readDir, item);
const isDirectory = fs.statSync(fullPath).isDirectory();
if (isDirectory) {
return path.join(fullPath, sep);
}
return fullPath;
});
} catch {
return [];
}
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { getFileSystemSuggestions } from "../src/utils/file-system-suggestions";
vi.mock("fs");
vi.mock("os");
describe("getFileSystemSuggestions", () => {
const mockFs = fs as unknown as {
readdirSync: ReturnType<typeof vi.fn>;
statSync: ReturnType<typeof vi.fn>;
};
const mockOs = os as unknown as {
homedir: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty array for empty prefix", () => {
expect(getFileSystemSuggestions("")).toEqual([]);
});
it("expands ~ to home directory", () => {
mockOs.homedir = vi.fn(() => "/home/testuser");
mockFs.readdirSync = vi.fn(() => ["file1.txt", "docs"]);
mockFs.statSync = vi.fn((p) => ({
isDirectory: () => path.basename(p) === "docs",
}));
const result = getFileSystemSuggestions("~/");
expect(mockFs.readdirSync).toHaveBeenCalledWith("/home/testuser");
expect(result).toEqual([
path.join("/home/testuser", "file1.txt"),
path.join("/home/testuser", "docs" + path.sep),
]);
});
it("filters by prefix if not a directory", () => {
mockFs.readdirSync = vi.fn(() => ["abc.txt", "abd.txt", "xyz.txt"]);
mockFs.statSync = vi.fn((p) => ({
isDirectory: () => p.includes("abd"),
}));
const result = getFileSystemSuggestions("a");
expect(result).toEqual(["abc.txt", "abd.txt/"]);
});
it("handles errors gracefully", () => {
mockFs.readdirSync = vi.fn(() => {
throw new Error("failed");
});
const result = getFileSystemSuggestions("some/path");
expect(result).toEqual([]);
});
it("normalizes relative path", () => {
mockFs.readdirSync = vi.fn(() => ["foo", "bar"]);
mockFs.statSync = vi.fn((_p) => ({
isDirectory: () => true,
}));
const result = getFileSystemSuggestions("./");
expect(result).toContain("foo/");
expect(result).toContain("bar/");
});
});

View File

@@ -0,0 +1,46 @@
import React from "react";
import { describe, it, expect } from "vitest";
import type { ComponentProps } from "react";
import { renderTui } from "./ui-test-helpers.js";
import TerminalChatCompletions from "../src/components/chat/terminal-chat-completions.js";
describe("TerminalChatCompletions", () => {
const baseProps: ComponentProps<typeof TerminalChatCompletions> = {
completions: ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"],
displayLimit: 3,
selectedCompletion: 0,
};
it("renders visible completions within displayLimit", async () => {
const { lastFrameStripped } = renderTui(
<TerminalChatCompletions {...baseProps} />,
);
const frame = lastFrameStripped();
expect(frame).toContain("Option 1");
expect(frame).toContain("Option 2");
expect(frame).toContain("Option 3");
expect(frame).not.toContain("Option 4");
});
it("centers the selected completion in the visible list", async () => {
const { lastFrameStripped } = renderTui(
<TerminalChatCompletions {...baseProps} selectedCompletion={2} />,
);
const frame = lastFrameStripped();
expect(frame).toContain("Option 2");
expect(frame).toContain("Option 3");
expect(frame).toContain("Option 4");
expect(frame).not.toContain("Option 1");
});
it("adjusts when selectedCompletion is near the end", async () => {
const { lastFrameStripped } = renderTui(
<TerminalChatCompletions {...baseProps} selectedCompletion={4} />,
);
const frame = lastFrameStripped();
expect(frame).toContain("Option 3");
expect(frame).toContain("Option 4");
expect(frame).toContain("Option 5");
expect(frame).not.toContain("Option 2");
});
});