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: [](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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
240
codex-cli/tests/file-tag-utils.test.ts
Normal file
240
codex-cli/tests/file-tag-utils.test.ts
Normal file
@@ -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(`</${filename}>`);
|
||||
});
|
||||
|
||||
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</${fileName}>`);
|
||||
});
|
||||
|
||||
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</${fileName}>`;
|
||||
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</${xmlBlockName}>`;
|
||||
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</${dirName}>`;
|
||||
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</${fileA}> and <${fileB}>\nB content\n</${fileB}>`;
|
||||
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</${validFile}> and <${invalidFile}>\ninvalid content\n</${invalidFile}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(
|
||||
`@${validFile} and <${invalidFile}>\ninvalid content\n</${invalidFile}>`,
|
||||
);
|
||||
});
|
||||
|
||||
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</${relPath}>`;
|
||||
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</${specialFileName}>`;
|
||||
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</${emptyFileName}>`;
|
||||
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</${adjFile1}><${adjFile2}>\nadj2\n</${adjFile2}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${adjFile1}@${adjFile2}`);
|
||||
});
|
||||
|
||||
it("ignores malformed XML blocks", () => {
|
||||
const input = "<incomplete>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</${mixedFile}> and some more text.`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`This is @${mixedFile} and some more text.`);
|
||||
});
|
||||
});
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user