diff --git a/codex-cli/src/components/chat/multiline-editor.tsx b/codex-cli/src/components/chat/multiline-editor.tsx index 3b7d277e..6b24bc27 100644 --- a/codex-cli/src/components/chat/multiline-editor.tsx +++ b/codex-cli/src/components/chat/multiline-editor.tsx @@ -137,6 +137,9 @@ export interface MultilineTextEditorProps { // Called when the internal text buffer updates. readonly onChange?: (text: string) => void; + + // Optional initial cursor position (character offset) + readonly initialCursorOffset?: number; } // Expose a minimal imperative API so parent components (e.g. TerminalChatInput) @@ -169,6 +172,7 @@ const MultilineTextEditorInner = ( onSubmit, focus = true, onChange, + initialCursorOffset, }: MultilineTextEditorProps, ref: React.Ref, ): React.ReactElement => { @@ -176,7 +180,7 @@ const MultilineTextEditorInner = ( // Editor State // --------------------------------------------------------------------------- - const buffer = useRef(new TextBuffer(initialText)); + const buffer = useRef(new TextBuffer(initialText, initialCursorOffset)); const [version, setVersion] = useState(0); // Keep track of the current terminal size so that the editor grows/shrinks diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 88a89039..819b8ea3 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -1,5 +1,6 @@ import type { MultilineTextEditorHandle } from "./multiline-editor"; import type { ReviewDecision } from "../../utils/agent/review.js"; +import type { FileSystemSuggestion } from "../../utils/file-system-suggestions.js"; import type { HistoryEntry } from "../../utils/storage/command-history.js"; import type { ResponseInputItem, @@ -11,6 +12,7 @@ import { TerminalChatCommandReview } from "./terminal-chat-command-review.js"; import TextCompletions from "./terminal-chat-completions.js"; import { loadConfig } from "../../utils/config.js"; import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js"; +import { expandFileTags } from "../../utils/file-tag-utils"; import { createInputItem } from "../../utils/input-utils.js"; import { log } from "../../utils/logger/log.js"; import { setSessionId } from "../../utils/session.js"; @@ -92,16 +94,120 @@ export default function TerminalChatInput({ const [historyIndex, setHistoryIndex] = useState(null); const [draftInput, setDraftInput] = useState(""); const [skipNextSubmit, setSkipNextSubmit] = useState(false); - const [fsSuggestions, setFsSuggestions] = useState>([]); + const [fsSuggestions, setFsSuggestions] = useState< + Array + >([]); const [selectedCompletion, setSelectedCompletion] = useState(-1); // Multiline text editor key to force remount after submission - const [editorKey, setEditorKey] = useState(0); + const [editorState, setEditorState] = useState<{ + key: number; + initialCursorOffset?: number; + }>({ key: 0 }); // Imperative handle from the multiline editor so we can query caret position const editorRef = useRef(null); // Track the caret row across keystrokes const prevCursorRow = useRef(null); const prevCursorWasAtLastRow = useRef(false); + // --- Helper for updating input, remounting editor, and moving cursor to end --- + const applyFsSuggestion = useCallback((newInputText: string) => { + setInput(newInputText); + setEditorState((s) => ({ + key: s.key + 1, + initialCursorOffset: newInputText.length, + })); + }, []); + + // --- Helper for updating file system suggestions --- + function updateFsSuggestions( + txt: string, + alwaysUpdateSelection: boolean = false, + ) { + // Clear file system completions if a space is typed + if (txt.endsWith(" ")) { + setFsSuggestions([]); + setSelectedCompletion(-1); + } else { + // Determine the current token (last whitespace-separated word) + const words = txt.trim().split(/\s+/); + const lastWord = words[words.length - 1] ?? ""; + + const shouldUpdateSelection = + lastWord.startsWith("@") || alwaysUpdateSelection; + + // Strip optional leading '@' for the path prefix + let pathPrefix: string; + if (lastWord.startsWith("@")) { + pathPrefix = lastWord.slice(1); + // If only '@' is typed, list everything in the current directory + pathPrefix = pathPrefix.length === 0 ? "./" : pathPrefix; + } else { + pathPrefix = lastWord; + } + + if (shouldUpdateSelection) { + const completions = getFileSystemSuggestions(pathPrefix); + setFsSuggestions(completions); + if (completions.length > 0) { + setSelectedCompletion((prev) => + prev < 0 || prev >= completions.length ? 0 : prev, + ); + } else { + setSelectedCompletion(-1); + } + } else if (fsSuggestions.length > 0) { + // Token cleared → clear menu + setFsSuggestions([]); + setSelectedCompletion(-1); + } + } + } + + /** + * Result of replacing text with a file system suggestion + */ + interface ReplacementResult { + /** The new text with the suggestion applied */ + text: string; + /** The selected suggestion if a replacement was made */ + suggestion: FileSystemSuggestion | null; + /** Whether a replacement was actually made */ + wasReplaced: boolean; + } + + // --- Helper for replacing input with file system suggestion --- + function getFileSystemSuggestion( + txt: string, + requireAtPrefix: boolean = false, + ): ReplacementResult { + if (fsSuggestions.length === 0 || selectedCompletion < 0) { + return { text: txt, suggestion: null, wasReplaced: false }; + } + + const words = txt.trim().split(/\s+/); + const lastWord = words[words.length - 1] ?? ""; + + // Check if @ prefix is required and the last word doesn't have it + if (requireAtPrefix && !lastWord.startsWith("@")) { + return { text: txt, suggestion: null, wasReplaced: false }; + } + + const selected = fsSuggestions[selectedCompletion]; + if (!selected) { + return { text: txt, suggestion: null, wasReplaced: false }; + } + + const replacement = lastWord.startsWith("@") + ? `@${selected.path}` + : selected.path; + words[words.length - 1] = replacement; + return { + text: words.join(" "), + suggestion: selected, + wasReplaced: true, + }; + } + // Load command history on component mount useEffect(() => { async function loadHistory() { @@ -223,21 +329,12 @@ export default function TerminalChatInput({ } 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); + const { text: newText, wasReplaced } = + getFileSystemSuggestion(input); + // Only proceed if the text was actually changed + if (wasReplaced) { + applyFsSuggestion(newText); setFsSuggestions([]); setSelectedCompletion(-1); } @@ -277,7 +374,7 @@ export default function TerminalChatInput({ setInput(history[newIndex]?.command ?? ""); // Re-mount the editor so it picks up the new initialText - setEditorKey((k) => k + 1); + setEditorState((s) => ({ key: s.key + 1 })); return; // handled } @@ -296,28 +393,23 @@ export default function TerminalChatInput({ if (newIndex >= history.length) { setHistoryIndex(null); setInput(draftInput); - setEditorKey((k) => k + 1); + setEditorState((s) => ({ key: s.key + 1 })); } else { setHistoryIndex(newIndex); setInput(history[newIndex]?.command ?? ""); - setEditorKey((k) => k + 1); + setEditorState((s) => ({ key: s.key + 1 })); } return; // handled } // 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); - } + // Defer filesystem suggestion logic to onSubmit if enter key is pressed + if (!_key.return) { + // Pressing tab should trigger the file system suggestions + const shouldUpdateSelection = _key.tab; + const targetInput = _key.delete ? input.slice(0, -1) : input + _input; + updateFsSuggestions(targetInput, shouldUpdateSelection); } } @@ -599,7 +691,10 @@ export default function TerminalChatInput({ ); text = text.trim(); - const inputItem = await createInputItem(text, images); + // Expand @file tokens into XML blocks for the model + const expandedText = await expandFileTags(text); + + const inputItem = await createInputItem(expandedText, images); submitInput([inputItem]); // Get config for history persistence. @@ -673,28 +768,30 @@ 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} + key={editorState.key} + initialCursorOffset={editorState.initialCursorOffset} initialText={input} height={6} focus={active} onSubmit={(txt) => { - onSubmit(txt); - setEditorKey((k) => k + 1); + // If final token is an @path, replace with filesystem suggestion if available + const { + text: replacedText, + suggestion, + wasReplaced, + } = getFileSystemSuggestion(txt, true); + + // If we replaced @path token with a directory, don't submit + if (wasReplaced && suggestion?.isDirectory) { + applyFsSuggestion(replacedText); + // Update suggestions for the new directory + updateFsSuggestions(replacedText, true); + return; + } + + onSubmit(replacedText); + setEditorState((s) => ({ key: s.key + 1 })); setInput(""); setHistoryIndex(null); setDraftInput(""); @@ -741,7 +838,7 @@ export default function TerminalChatInput({ ) : fsSuggestions.length > 0 ? ( suggestion.path)} selectedCompletion={selectedCompletion} displayLimit={5} /> diff --git a/codex-cli/src/components/chat/terminal-chat-response-item.tsx b/codex-cli/src/components/chat/terminal-chat-response-item.tsx index 824619f1..5ca53ac3 100644 --- a/codex-cli/src/components/chat/terminal-chat-response-item.tsx +++ b/codex-cli/src/components/chat/terminal-chat-response-item.tsx @@ -10,6 +10,7 @@ import type { } from "openai/resources/responses/responses"; import { useTerminalSize } from "../../hooks/use-terminal-size"; +import { collapseXmlBlocks } from "../../utils/file-tag-utils"; import { parseToolCall, parseToolCallOutput } from "../../utils/parsers"; import chalk, { type ForegroundColorName } from "chalk"; import { Box, Text } from "ink"; @@ -137,7 +138,7 @@ function TerminalChatResponseMessage({ : c.type === "refusal" ? c.refusal : c.type === "input_text" - ? c.text + ? collapseXmlBlocks(c.text) : c.type === "input_image" ? "" : c.type === "input_file" diff --git a/codex-cli/src/text-buffer.ts b/codex-cli/src/text-buffer.ts index 0bbf84e1..4869b18c 100644 --- a/codex-cli/src/text-buffer.ts +++ b/codex-cli/src/text-buffer.ts @@ -100,11 +100,14 @@ export default class TextBuffer { private clipboard: string | null = null; - constructor(text = "") { + constructor(text = "", initialCursorIdx = 0) { this.lines = text.split("\n"); if (this.lines.length === 0) { this.lines = [""]; } + + // No need to reset cursor on failure - class already default cursor position to 0,0 + this.setCursorIdx(initialCursorIdx); } /* ======================================================================= @@ -122,6 +125,39 @@ export default class TextBuffer { this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow)); } + /** + * Sets the cursor position based on a character offset from the start of the document. + * @param idx The character offset to move to (0-based) + * @returns true if successful, false if the index was invalid + */ + private setCursorIdx(idx: number): boolean { + // Reset preferred column since this is an explicit horizontal movement + this.preferredCol = null; + + let remainingChars = idx; + let row = 0; + + // Count characters line by line until we find the right position + while (row < this.lines.length) { + const lineLength = this.lineLen(row); + // Add 1 for the newline character (except for the last line) + const totalChars = lineLength + (row < this.lines.length - 1 ? 1 : 0); + + if (remainingChars <= lineLength) { + this.cursorRow = row; + this.cursorCol = remainingChars; + return true; + } + + // Move to next line, subtract this line's characters plus newline + remainingChars -= totalChars; + row++; + } + + // If we get here, the index was too large + return false; + } + /* ===================================================================== * History helpers * =================================================================== */ diff --git a/codex-cli/src/utils/file-system-suggestions.ts b/codex-cli/src/utils/file-system-suggestions.ts index 13350c9a..6a7b1ae9 100644 --- a/codex-cli/src/utils/file-system-suggestions.ts +++ b/codex-cli/src/utils/file-system-suggestions.ts @@ -2,7 +2,24 @@ import fs from "fs"; import os from "os"; import path from "path"; -export function getFileSystemSuggestions(pathPrefix: string): Array { +/** + * Represents a file system suggestion with path and directory information + */ +export interface FileSystemSuggestion { + /** The full path of the suggestion */ + path: string; + /** Whether the suggestion is a directory */ + isDirectory: boolean; +} + +/** + * Gets file system suggestions based on a path prefix + * @param pathPrefix The path prefix to search for + * @returns Array of file system suggestions + */ +export function getFileSystemSuggestions( + pathPrefix: string, +): Array { if (!pathPrefix) { return []; } @@ -31,10 +48,10 @@ export function getFileSystemSuggestions(pathPrefix: string): Array { .map((item) => { const fullPath = path.join(readDir, item); const isDirectory = fs.statSync(fullPath).isDirectory(); - if (isDirectory) { - return path.join(fullPath, sep); - } - return fullPath; + return { + path: isDirectory ? path.join(fullPath, sep) : fullPath, + isDirectory, + }; }); } catch { return []; diff --git a/codex-cli/src/utils/file-tag-utils.ts b/codex-cli/src/utils/file-tag-utils.ts new file mode 100644 index 00000000..f57e2fdb --- /dev/null +++ b/codex-cli/src/utils/file-tag-utils.ts @@ -0,0 +1,62 @@ +import fs from "fs"; +import path from "path"; + +/** + * Replaces @path tokens in the input string with file contents XML blocks for LLM context. + * Only replaces if the path points to a file; directories are ignored. + */ +export async function expandFileTags(raw: string): Promise { + const re = /@([\w./~-]+)/g; + let out = raw; + type MatchInfo = { index: number; length: number; path: string }; + const matches: Array = []; + + for (const m of raw.matchAll(re) as IterableIterator) { + const idx = m.index; + const captured = m[1]; + if (idx !== undefined && captured) { + matches.push({ index: idx, length: m[0].length, path: captured }); + } + } + + // Process in reverse to avoid index shifting. + for (let i = matches.length - 1; i >= 0; i--) { + const { index, length, path: p } = matches[i]!; + const resolved = path.resolve(process.cwd(), p); + try { + const st = fs.statSync(resolved); + if (st.isFile()) { + const content = fs.readFileSync(resolved, "utf-8"); + const rel = path.relative(process.cwd(), resolved); + const xml = `<${rel}>\n${content}\n`; + out = out.slice(0, index) + xml + out.slice(index + length); + } + } catch { + // If path invalid, leave token as is + } + } + return out; +} + +/** + * Collapses content XML blocks back to @path format. + * This is the reverse operation of expandFileTags. + * Only collapses blocks where the path points to a valid file; invalid paths remain unchanged. + */ +export function collapseXmlBlocks(text: string): string { + return text.replace( + /<([^\n>]+)>([\s\S]*?)<\/\1>/g, + (match, path1: string) => { + const filePath = path.normalize(path1.trim()); + + try { + // Only convert to @path format if it's a valid file + return fs.statSync(path.resolve(process.cwd(), filePath)).isFile() + ? "@" + filePath + : match; + } catch { + return match; // Keep XML block if path is invalid + } + }, + ); +} diff --git a/codex-cli/tests/file-system-suggestions.test.ts b/codex-cli/tests/file-system-suggestions.test.ts index 2477306c..b75a47cc 100644 --- a/codex-cli/tests/file-system-suggestions.test.ts +++ b/codex-cli/tests/file-system-suggestions.test.ts @@ -36,8 +36,14 @@ describe("getFileSystemSuggestions", () => { expect(mockFs.readdirSync).toHaveBeenCalledWith("/home/testuser"); expect(result).toEqual([ - path.join("/home/testuser", "file1.txt"), - path.join("/home/testuser", "docs" + path.sep), + { + path: path.join("/home/testuser", "file1.txt"), + isDirectory: false, + }, + { + path: path.join("/home/testuser", "docs" + path.sep), + isDirectory: true, + }, ]); }); @@ -48,7 +54,16 @@ describe("getFileSystemSuggestions", () => { })); const result = getFileSystemSuggestions("a"); - expect(result).toEqual(["abc.txt", "abd.txt/"]); + expect(result).toEqual([ + { + path: "abc.txt", + isDirectory: false, + }, + { + path: "abd.txt/", + isDirectory: true, + }, + ]); }); it("handles errors gracefully", () => { @@ -67,7 +82,11 @@ describe("getFileSystemSuggestions", () => { })); const result = getFileSystemSuggestions("./"); - expect(result).toContain("foo/"); - expect(result).toContain("bar/"); + const paths = result.map((item) => item.path); + const allDirectories = result.every((item) => item.isDirectory === true); + + expect(paths).toContain("foo/"); + expect(paths).toContain("bar/"); + expect(allDirectories).toBe(true); }); }); diff --git a/codex-cli/tests/file-tag-utils.test.ts b/codex-cli/tests/file-tag-utils.test.ts new file mode 100644 index 00000000..6833487b --- /dev/null +++ b/codex-cli/tests/file-tag-utils.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { + expandFileTags, + collapseXmlBlocks, +} from "../src/utils/file-tag-utils.js"; + +/** + * Unit-tests for file tag utility functions: + * - expandFileTags(): Replaces tokens like `@relative/path` with XML blocks containing file contents + * - collapseXmlBlocks(): Reverses the expansion, converting XML blocks back to @path format + */ + +describe("expandFileTags", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-test-")); + const originalCwd = process.cwd(); + + beforeAll(() => { + // Run the test from within the temporary directory so that the helper + // generates relative paths that are predictable and isolated. + process.chdir(tmpDir); + }); + + afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("replaces @file token with XML wrapped contents", async () => { + const filename = "hello.txt"; + const fileContent = "Hello, world!"; + fs.writeFileSync(path.join(tmpDir, filename), fileContent); + + const input = `Please read @${filename}`; + const output = await expandFileTags(input); + + expect(output).toContain(`<${filename}>`); + expect(output).toContain(fileContent); + expect(output).toContain(``); + }); + + it("leaves token unchanged when file does not exist", async () => { + const input = "This refers to @nonexistent.file"; + const output = await expandFileTags(input); + expect(output).toEqual(input); + }); + + it("handles multiple @file tokens in one string", async () => { + const fileA = "a.txt"; + const fileB = "b.txt"; + fs.writeFileSync(path.join(tmpDir, fileA), "A content"); + fs.writeFileSync(path.join(tmpDir, fileB), "B content"); + const input = `@${fileA} and @${fileB}`; + const output = await expandFileTags(input); + expect(output).toContain("A content"); + expect(output).toContain("B content"); + expect(output).toContain(`<${fileA}>`); + expect(output).toContain(`<${fileB}>`); + }); + + it("does not replace @dir if it's a directory", async () => { + const dirName = "somedir"; + fs.mkdirSync(path.join(tmpDir, dirName)); + const input = `Check @${dirName}`; + const output = await expandFileTags(input); + expect(output).toContain(`@${dirName}`); + }); + + it("handles @file with special characters in name", async () => { + const fileName = "weird-._~name.txt"; + fs.writeFileSync(path.join(tmpDir, fileName), "special chars"); + const input = `@${fileName}`; + const output = await expandFileTags(input); + expect(output).toContain("special chars"); + expect(output).toContain(`<${fileName}>`); + }); + + it("handles repeated @file tokens", async () => { + const fileName = "repeat.txt"; + fs.writeFileSync(path.join(tmpDir, fileName), "repeat content"); + const input = `@${fileName} @${fileName}`; + const output = await expandFileTags(input); + // Both tags should be replaced + expect(output.match(new RegExp(`<${fileName}>`, "g"))?.length).toBe(2); + }); + + it("handles empty file", async () => { + const fileName = "empty.txt"; + fs.writeFileSync(path.join(tmpDir, fileName), ""); + const input = `@${fileName}`; + const output = await expandFileTags(input); + expect(output).toContain(`<${fileName}>\n\n`); + }); + + it("handles string with no @file tokens", async () => { + const input = "No tags here."; + const output = await expandFileTags(input); + expect(output).toBe(input); + }); +}); + +describe("collapseXmlBlocks", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-collapse-test-")); + const originalCwd = process.cwd(); + + beforeAll(() => { + // Run the test from within the temporary directory so that the helper + // generates relative paths that are predictable and isolated. + process.chdir(tmpDir); + }); + + afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("collapses XML block to @path format for valid file", () => { + // Create a real file + const fileName = "valid-file.txt"; + fs.writeFileSync(path.join(tmpDir, fileName), "file content"); + + const input = `<${fileName}>\nHello, world!\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`@${fileName}`); + }); + + it("does not collapse XML block for unrelated xml block", () => { + const xmlBlockName = "non-file-block"; + const input = `<${xmlBlockName}>\nContent here\n`; + const output = collapseXmlBlocks(input); + // Should remain unchanged + expect(output).toBe(input); + }); + + it("does not collapse XML block for a directory", () => { + // Create a directory + const dirName = "test-dir"; + fs.mkdirSync(path.join(tmpDir, dirName), { recursive: true }); + + const input = `<${dirName}>\nThis is a directory\n`; + const output = collapseXmlBlocks(input); + // Should remain unchanged + expect(output).toBe(input); + }); + + it("collapses multiple valid file XML blocks in one string", () => { + // Create real files + const fileA = "a.txt"; + const fileB = "b.txt"; + fs.writeFileSync(path.join(tmpDir, fileA), "A content"); + fs.writeFileSync(path.join(tmpDir, fileB), "B content"); + + const input = `<${fileA}>\nA content\n and <${fileB}>\nB content\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`@${fileA} and @${fileB}`); + }); + + it("only collapses valid file paths in mixed content", () => { + // Create a real file + const validFile = "valid.txt"; + fs.writeFileSync(path.join(tmpDir, validFile), "valid content"); + const invalidFile = "invalid.txt"; + + const input = `<${validFile}>\nvalid content\n and <${invalidFile}>\ninvalid content\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe( + `@${validFile} and <${invalidFile}>\ninvalid content\n`, + ); + }); + + it("handles paths with subdirectories for valid files", () => { + // Create a nested file + const nestedDir = "nested/path"; + const nestedFile = "nested/path/file.txt"; + fs.mkdirSync(path.join(tmpDir, nestedDir), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, nestedFile), "nested content"); + + const relPath = "nested/path/file.txt"; + const input = `<${relPath}>\nContent here\n`; + const output = collapseXmlBlocks(input); + const expectedPath = path.normalize(relPath); + expect(output).toBe(`@${expectedPath}`); + }); + + it("handles XML blocks with special characters in path for valid files", () => { + // Create a file with special characters + const specialFileName = "weird-._~name.txt"; + fs.writeFileSync(path.join(tmpDir, specialFileName), "special chars"); + + const input = `<${specialFileName}>\nspecial chars\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`@${specialFileName}`); + }); + + it("handles XML blocks with empty content for valid files", () => { + // Create an empty file + const emptyFileName = "empty.txt"; + fs.writeFileSync(path.join(tmpDir, emptyFileName), ""); + + const input = `<${emptyFileName}>\n\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`@${emptyFileName}`); + }); + + it("handles string with no XML blocks", () => { + const input = "No tags here."; + const output = collapseXmlBlocks(input); + expect(output).toBe(input); + }); + + it("handles adjacent XML blocks for valid files", () => { + // Create real files + const adjFile1 = "adj1.txt"; + const adjFile2 = "adj2.txt"; + fs.writeFileSync(path.join(tmpDir, adjFile1), "adj1"); + fs.writeFileSync(path.join(tmpDir, adjFile2), "adj2"); + + const input = `<${adjFile1}>\nadj1\n<${adjFile2}>\nadj2\n`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`@${adjFile1}@${adjFile2}`); + }); + + it("ignores malformed XML blocks", () => { + const input = "content without closing tag"; + const output = collapseXmlBlocks(input); + expect(output).toBe(input); + }); + + it("handles mixed content with valid file XML blocks and regular text", () => { + // Create a real file + const mixedFile = "mixed-file.txt"; + fs.writeFileSync(path.join(tmpDir, mixedFile), "file content"); + + const input = `This is <${mixedFile}>\nfile content\n and some more text.`; + const output = collapseXmlBlocks(input); + expect(output).toBe(`This is @${mixedFile} and some more text.`); + }); +}); diff --git a/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx new file mode 100644 index 00000000..74dbd8c4 --- /dev/null +++ b/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import type { ComponentProps } from "react"; +import { renderTui } from "./ui-test-helpers.js"; +import TerminalChatInput from "../src/components/chat/terminal-chat-input.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Helper function for typing and flushing +async function type( + stdin: NodeJS.WritableStream, + text: string, + flush: () => Promise, +) { + stdin.write(text); + await flush(); +} + +/** + * Helper to reliably trigger file system suggestions in tests. + * + * This function simulates typing '@' followed by Tab to ensure suggestions appear. + * + * In real usage, simply typing '@' does trigger suggestions correctly. + */ +async function typeFileTag( + stdin: NodeJS.WritableStream, + flush: () => Promise, +) { + // Type @ character + stdin.write("@"); + await flush(); + + stdin.write("\t"); + await flush(); +} + +// Mock the file system suggestions utility +vi.mock("../src/utils/file-system-suggestions.js", () => ({ + FileSystemSuggestion: class {}, // Mock the interface + getFileSystemSuggestions: vi.fn((pathPrefix: string) => { + const normalizedPrefix = pathPrefix.startsWith("./") + ? pathPrefix.slice(2) + : pathPrefix; + const allItems = [ + { path: "file1.txt", isDirectory: false }, + { path: "file2.js", isDirectory: false }, + { path: "directory1/", isDirectory: true }, + { path: "directory2/", isDirectory: true }, + ]; + return allItems.filter((item) => item.path.startsWith(normalizedPrefix)); + }), +})); + +// Mock the createInputItem function to avoid filesystem operations +vi.mock("../src/utils/input-utils.js", () => ({ + createInputItem: vi.fn(async (text: string) => ({ + role: "user", + type: "message", + content: [{ type: "input_text", text }], + })), +})); + +describe("TerminalChatInput file tag suggestions", () => { + // Standard props for all tests + const baseProps: ComponentProps = { + isNew: false, + loading: false, + submitInput: vi.fn().mockImplementation(() => {}), + confirmationPrompt: null, + explanation: undefined, + submitConfirmation: vi.fn(), + setLastResponseId: vi.fn(), + setItems: vi.fn(), + contextLeftPercent: 50, + openOverlay: vi.fn(), + openDiffOverlay: vi.fn(), + openModelOverlay: vi.fn(), + openApprovalOverlay: vi.fn(), + openHelpOverlay: vi.fn(), + onCompact: vi.fn(), + interruptAgent: vi.fn(), + active: true, + thinkingSeconds: 0, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows file system suggestions when typing @ alone", async () => { + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type @ and activate suggestions + await typeFileTag(stdin, flush); + + // Check that current directory suggestions are shown + const frame = lastFrameStripped(); + expect(frame).toContain("file1.txt"); + + cleanup(); + }); + + it("completes the selected file system suggestion with Tab", async () => { + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type @ and activate suggestions + await typeFileTag(stdin, flush); + + // Press Tab to select the first suggestion + await type(stdin, "\t", flush); + + // Check that the input has been completed with the selected suggestion + const frameAfterTab = lastFrameStripped(); + expect(frameAfterTab).toContain("@file1.txt"); + // Check that the rest of the suggestions have collapsed + expect(frameAfterTab).not.toContain("file2.txt"); + expect(frameAfterTab).not.toContain("directory2/"); + expect(frameAfterTab).not.toContain("directory1/"); + + cleanup(); + }); + + it("clears file system suggestions when typing a space", async () => { + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type @ and activate suggestions + await typeFileTag(stdin, flush); + + // Check that suggestions are shown + let frame = lastFrameStripped(); + expect(frame).toContain("file1.txt"); + + // Type a space to clear suggestions + await type(stdin, " ", flush); + + // Check that suggestions are cleared + frame = lastFrameStripped(); + expect(frame).not.toContain("file1.txt"); + + cleanup(); + }); + + it("selects and retains directory when pressing Enter on directory suggestion", async () => { + const { stdin, lastFrameStripped, flush, cleanup } = renderTui( + , + ); + + // Type @ and activate suggestions + await typeFileTag(stdin, flush); + + // Navigate to directory suggestion (we need two down keys to get to the first directory) + await type(stdin, "\u001B[B", flush); // Down arrow key - move to file2.js + await type(stdin, "\u001B[B", flush); // Down arrow key - move to directory1/ + + // Check that the directory suggestion is selected + let frame = lastFrameStripped(); + expect(frame).toContain("directory1/"); + + // Press Enter to select the directory + await type(stdin, "\r", flush); + + // Check that the input now contains the directory path + frame = lastFrameStripped(); + expect(frame).toContain("@directory1/"); + + // Check that submitInput was NOT called (since we're only navigating, not submitting) + expect(baseProps.submitInput).not.toHaveBeenCalled(); + + cleanup(); + }); + + it("submits when pressing Enter on file suggestion", async () => { + const { stdin, flush, cleanup } = renderTui( + , + ); + + // Type @ and activate suggestions + await typeFileTag(stdin, flush); + + // Press Enter to select first suggestion (file1.txt) + await type(stdin, "\r", flush); + + // Check that submitInput was called + expect(baseProps.submitInput).toHaveBeenCalled(); + + // Get the arguments passed to submitInput + const submitArgs = (baseProps.submitInput as any).mock.calls[0][0]; + + // Verify the first argument is an array with at least one item + expect(Array.isArray(submitArgs)).toBe(true); + expect(submitArgs.length).toBeGreaterThan(0); + + // Check that the content includes the file path + const content = submitArgs[0].content; + expect(Array.isArray(content)).toBe(true); + expect(content.length).toBeGreaterThan(0); + expect(content[0].text).toContain("@file1.txt"); + + cleanup(); + }); +}); diff --git a/codex-cli/tests/text-buffer.test.ts b/codex-cli/tests/text-buffer.test.ts index e5c532f7..fc29e3e1 100644 --- a/codex-cli/tests/text-buffer.test.ts +++ b/codex-cli/tests/text-buffer.test.ts @@ -136,6 +136,33 @@ describe("TextBuffer – basic editing parity with Rust suite", () => { }); }); + describe("cursor initialization", () => { + it("initializes cursor to (0,0) by default", () => { + const buf = new TextBuffer("hello\nworld"); + expect(buf.getCursor()).toEqual([0, 0]); + }); + + it("sets cursor to valid position within line", () => { + const buf = new TextBuffer("hello", 2); + expect(buf.getCursor()).toEqual([0, 2]); // cursor at 'l' + }); + + it("sets cursor to end of line", () => { + const buf = new TextBuffer("hello", 5); + expect(buf.getCursor()).toEqual([0, 5]); // cursor after 'o' + }); + + it("sets cursor across multiple lines", () => { + const buf = new TextBuffer("hello\nworld", 7); + expect(buf.getCursor()).toEqual([1, 1]); // cursor at 'o' in 'world' + }); + + it("defaults to position 0 for invalid index", () => { + const buf = new TextBuffer("hello", 999); + expect(buf.getCursor()).toEqual([0, 0]); + }); + }); + /* ------------------------------------------------------------------ */ /* Vertical cursor movement – we should preserve the preferred column */ /* ------------------------------------------------------------------ */