diff --git a/.gitignore b/.gitignore index d5f0dceb..72326607 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ result .vscode/ .idea/ .history/ +.zed/ *.swp *~ diff --git a/codex-cli/src/components/chat/multiline-editor.tsx b/codex-cli/src/components/chat/multiline-editor.tsx index bb4878c6..a91eceea 100644 --- a/codex-cli/src/components/chat/multiline-editor.tsx +++ b/codex-cli/src/components/chat/multiline-editor.tsx @@ -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); + }, }), [], ); diff --git a/codex-cli/src/components/chat/terminal-chat-completions.tsx b/codex-cli/src/components/chat/terminal-chat-completions.tsx new file mode 100644 index 00000000..eb7e47f8 --- /dev/null +++ b/codex-cli/src/components/chat/terminal-chat-completions.tsx @@ -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; + + /** + * 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 ( + + {visibleItems.map(({ completion, originalIndex }) => ( + + {completion} + + ))} + + ); +} + +export default TerminalChatCompletions; diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 5f77f904..ddb9c7a1 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -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(null); const [draftInput, setDraftInput] = useState(""); const [skipNextSubmit, setSkipNextSubmit] = useState(false); + const [fsSuggestions, setFsSuggestions] = useState>([]); + const [selectedCompletion, setSelectedCompletion] = useState(-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} /> ) : ( - + { @@ -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({ )} - - {isNew && !input ? ( - <> - try:{" "} - {suggestions.map((m, key) => ( - - {key !== 0 ? " | " : ""} - - {m} - - - ))} - - ) : ( - <> - 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 && ( - <> - {" — "} - 40 ? "green" : "yellow"}> - {Math.round(contextLeftPercent)}% context left - - - )} - {contextLeftPercent <= 25 && ( - <> - {" — "} - - {Math.round(contextLeftPercent)}% context left — send - "/compact" to condense context - - - )} - - )} - + {isNew && !input ? ( + + try:{" "} + {suggestions.map((m, key) => ( + + {key !== 0 ? " | " : ""} + + {m} + + + ))} + + ) : fsSuggestions.length > 0 ? ( + + ) : ( + + 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 && ( + <> + {" — "} + 40 ? "green" : "yellow"}> + {Math.round(contextLeftPercent)}% context left + + + )} + {contextLeftPercent <= 25 && ( + <> + {" — "} + + {Math.round(contextLeftPercent)}% context left — send + "/compact" to condense context + + + )} + + )} ); diff --git a/codex-cli/src/components/vendor/ink-text-input.tsx b/codex-cli/src/components/vendor/ink-text-input.tsx index 40b0a1d4..9c015be9 100644 --- a/codex-cli/src/components/vendor/ink-text-input.tsx +++ b/codex-cli/src/components/vendor/ink-text-input.tsx @@ -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(() => { diff --git a/codex-cli/src/utils/file-system-suggestions.ts b/codex-cli/src/utils/file-system-suggestions.ts new file mode 100644 index 00000000..13350c9a --- /dev/null +++ b/codex-cli/src/utils/file-system-suggestions.ts @@ -0,0 +1,42 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +export function getFileSystemSuggestions(pathPrefix: string): Array { + 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 []; + } +} diff --git a/codex-cli/tests/file-system-suggestions.test.ts b/codex-cli/tests/file-system-suggestions.test.ts new file mode 100644 index 00000000..2477306c --- /dev/null +++ b/codex-cli/tests/file-system-suggestions.test.ts @@ -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; + statSync: ReturnType; + }; + + const mockOs = os as unknown as { + homedir: ReturnType; + }; + + 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/"); + }); +}); diff --git a/codex-cli/tests/terminal-chat-completions.test.tsx b/codex-cli/tests/terminal-chat-completions.test.tsx new file mode 100644 index 00000000..b3b0b145 --- /dev/null +++ b/codex-cli/tests/terminal-chat-completions.test.tsx @@ -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 = { + 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( + , + ); + 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( + , + ); + 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( + , + ); + const frame = lastFrameStripped(); + expect(frame).toContain("Option 3"); + expect(frame).toContain("Option 4"); + expect(frame).toContain("Option 5"); + expect(frame).not.toContain("Option 2"); + }); +});