Files
llmx/codex-cli/tests/terminal-chat-input-file-tag-suggestions.test.tsx
moppywhip bc4e6db749 feat: @mention files in codex (#701)
Solves #700

## State of the World Before

Prior to this PR, when users wanted to share file contents with Codex,
they had two options:
- Manually copy and paste file contents into the chat
- Wait for the assistant to use the shell tool to view the file

The second approach required the assistant to:
1. Recognize the need to view a file
2. Execute a shell tool call
3. Wait for the tool call to complete
4. Process the file contents

This consumed extra tokens and reduced user control over which files
were shared with the model.

## State of the World After

With this PR, users can now:
- Reference files directly in their chat input using the `@path` syntax
- Have file contents automatically expanded into XML blocks before being
sent to the LLM

For example, users can type `@src/utils/config.js` in their message, and
the file contents will be included in context. Within the terminal chat
history, these file blocks will be collapsed back to `@path` format in
the UI for clean presentation.

Tag File suggestions:
<img width="857" alt="file-suggestions"
src="https://github.com/user-attachments/assets/397669dc-ad83-492d-b5f0-164fab2ff4ba"
/>

Tagging files in action:
<img width="858" alt="tagging-files"
src="https://github.com/user-attachments/assets/0de9d559-7b7f-4916-aeff-87ae9b16550a"
/>

Demo video of file tagging:
[![Demo video of file
tagging](https://img.youtube.com/vi/vL4LqtBnqt8/0.jpg)](https://www.youtube.com/watch?v=vL4LqtBnqt8)

## Implementation Details

This PR consists of 2 main components:

1. **File Tag Utilities**:
- New `file-tag-utils.ts` utility module that handles both expansion and
collapsing of file tags
- `expandFileTags()` identifies `@path` tokens and replaces them with
XML blocks containing file contents
- `collapseXmlBlocks()` reverses the process, converting XML blocks back
to `@path` format for UI display
- Tokens are only expanded if they point to valid files (directories are
ignored)
   - Expansion happens just before sending input to the model

2. **Terminal Chat Integration**:
- Leveraged the existing file system completion system for tabbing to
support the `@path` syntax
   - Added `updateFsSuggestions` helper to manage filesystem suggestions
- Added `replaceFileSystemSuggestion` to replace input with filesystem
suggestions
- Applied `collapseXmlBlocks` in the chat response rendering so that
tagged files are shown as simple `@path` tags

The PR also includes test coverage for both the UI and the file tag
utilities.

## Next Steps

Some ideas I'd like to implement if this feature gets merged:

- Line selection: `@path[50:80]` to grab specific sections of files
- Method selection: `@path#methodName` to grab just one function/class
- Visual improvements: highlight file tags in the UI to make them more
noticeable
2025-04-30 16:19:55 -07:00

207 lines
6.3 KiB
TypeScript

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<void>,
) {
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<void>,
) {
// 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<typeof TerminalChatInput> = {
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(
<TerminalChatInput {...baseProps} />,
);
// 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(
<TerminalChatInput {...baseProps} />,
);
// 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(
<TerminalChatInput {...baseProps} />,
);
// 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(
<TerminalChatInput {...baseProps} />,
);
// 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(
<TerminalChatInput {...baseProps} />,
);
// 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();
});
});