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
This commit is contained in:
moppywhip
2025-04-30 19:19:55 -04:00
committed by GitHub
parent bd82101859
commit bc4e6db749
10 changed files with 771 additions and 62 deletions

View File

@@ -2,7 +2,24 @@ import fs from "fs";
import os from "os";
import path from "path";
export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
/**
* 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<FileSystemSuggestion> {
if (!pathPrefix) {
return [];
}
@@ -31,10 +48,10 @@ export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
.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 [];

View File

@@ -0,0 +1,62 @@
import fs from "fs";
import path from "path";
/**
* Replaces @path tokens in the input string with <path>file contents</path> XML blocks for LLM context.
* Only replaces if the path points to a file; directories are ignored.
*/
export async function expandFileTags(raw: string): Promise<string> {
const re = /@([\w./~-]+)/g;
let out = raw;
type MatchInfo = { index: number; length: number; path: string };
const matches: Array<MatchInfo> = [];
for (const m of raw.matchAll(re) as IterableIterator<RegExpMatchArray>) {
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</${rel}>`;
out = out.slice(0, index) + xml + out.slice(index + length);
}
} catch {
// If path invalid, leave token as is
}
}
return out;
}
/**
* Collapses <path>content</path> 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
}
},
);
}