Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Brandt
600a2db82f More efficient file reads (#73)
Allows the client to handle line offsets, rather than loading the entire
file and then slicing it on the agent side.

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-29 13:54:30 +02:00
6 changed files with 35 additions and 151 deletions

View File

@@ -1,5 +1,9 @@
# Changelog
## v0.4.7
- More efficient file reads from the client.
## v0.4.6
- Update to @anthropic-ai/claude-code@v1.0.128

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.4.6",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.4.6",
"version": "0.4.7",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "1.0.128",

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.4.6",
"version": "0.4.7",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",

View File

@@ -102,15 +102,12 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
line: input.offset + 1,
limit: input.linesToRead,
});
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(
content.content,
input.offset ?? 0,
input.linesToRead,
defaults.maxFileSize,
);
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
@@ -120,11 +117,11 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.actualEndLine}. `;
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.linesRead}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.actualEndLine}.`;
readInfo += `Continue with offset=${result.linesRead}.`;
}
readInfo += "</file-read-info>";

View File

@@ -5,66 +5,44 @@ describe("extractLinesWithByteLimit", () => {
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
it("should extract all lines when under byte limit", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 5, 1000);
const result = extractLinesWithByteLimit(simpleContent, 1000);
expect(result.content).toBe(simpleContent);
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should extract partial lines with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 2, 2, 1000);
expect(result.content).toBe("Line 3\nLine 4");
expect(result.actualEndLine).toBe(3);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
it("should limit output when exceeding byte limit", () => {
// Create content that will exceed byte limit
const longLine = "x".repeat(100);
const manyLines = Array(10).fill(longLine).join("\n");
const result = extractLinesWithByteLimit(manyLines, 0, 10, 250);
const result = extractLinesWithByteLimit(manyLines, 250);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBe(1); // Should only get 2 lines
expect(result.linesRead).toBe(2);
});
it("should handle offset beyond file length", () => {
const result = extractLinesWithByteLimit(simpleContent, 10, 5, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(10);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(0);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 0, 10, 1000);
const result = extractLinesWithByteLimit("", 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0); // Empty string splits to [""] and we process that one empty line
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1); // We read the one empty line
});
it("should handle single line file", () => {
const singleLine = "This is a single line without newline";
const result = extractLinesWithByteLimit(singleLine, 0, 10, 1000);
const result = extractLinesWithByteLimit(singleLine, 1000);
expect(result.content).toBe(singleLine);
expect(result.actualEndLine).toBe(0);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1);
});
it("should correctly count bytes with multi-byte characters", () => {
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
const result = extractLinesWithByteLimit(unicodeContent, 0, 10, 1000);
const result = extractLinesWithByteLimit(unicodeContent, 1000);
expect(result.content).toBe(unicodeContent);
expect(result.linesRead).toBe(4);
@@ -77,63 +55,25 @@ describe("extractLinesWithByteLimit", () => {
const line3 = "c".repeat(40);
const content = `${line1}\n${line2}\n${line3}`;
const result = extractLinesWithByteLimit(content, 0, 3, 85);
const result = extractLinesWithByteLimit(content, 85);
expect(result.content).toBe(`${line1}\n${line2}`);
expect(result.actualEndLine).toBe(1);
expect(result.content).toBe(`${line1}\n${line2}\n`);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should read exactly to limit when possible", () => {
const exactContent = "12345\n67890\n12345"; // 17 bytes total
const result = extractLinesWithByteLimit(exactContent, 0, 3, 17);
const result = extractLinesWithByteLimit(exactContent, 17);
expect(result.content).toBe(exactContent);
expect(result.actualEndLine).toBe(2);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle reading from middle to end", () => {
const result = extractLinesWithByteLimit(simpleContent, 3, 100, 1000);
expect(result.content).toBe("Line 4\nLine 5");
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
it("should handle large content with typical parameters", () => {
const longContent = Array(2000).fill("Line content here").join("\n");
const result = extractLinesWithByteLimit(longContent, 0, 1000, 50000);
expect(result.actualEndLine).toBeLessThanOrEqual(1000); // Should be limited by line count
});
it("should provide correct data for partial read at start", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 2, 1000);
expect(result.actualEndLine).toBe(1);
expect(result.linesRead).toBe(2);
});
it("should provide correct data for partial read with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 1, 2, 1000);
expect(result.actualEndLine).toBe(2);
expect(result.linesRead).toBe(2);
});
it("should not add newline after last line", () => {
const result = extractLinesWithByteLimit("Line 1\nLine 2\nLine 3", 2, 1, 1000);
expect(result.content).toBe("Line 3"); // No trailing newline
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 0, 3, 1000);
const result = extractLinesWithByteLimit(windowsContent, 1000);
// Note: split("\n") will keep the \r characters
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
@@ -145,29 +85,18 @@ describe("extractLinesWithByteLimit", () => {
const largeLine = "x".repeat(1000);
const largeContent = Array(110).fill(largeLine).join("\n");
const result = extractLinesWithByteLimit(largeContent, 0, 200, 50000);
const result = extractLinesWithByteLimit(largeContent, 50000);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBeLessThan(50); // Should stop well before 200 lines
});
it("should handle line limit of 0", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 0, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0);
expect(result.linesRead).toBe(0);
expect(result.wasLimited).toBe(false);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 0, 1, 50000);
const result = extractLinesWithByteLimit(veryLongLine, 50000);
// Should return the line even though it exceeds the byte limit
// because we always allow at least one line if no lines have been added yet
expect(result.content).toBe(veryLongLine);
expect(result.actualEndLine).toBe(0);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});

View File

@@ -125,7 +125,6 @@ export function applyEnvironmentSettings(settings: ManagedSettings): void {
export interface ExtractLinesResult {
content: string;
actualEndLine: number;
wasLimited: boolean;
linesRead: number;
}
@@ -134,100 +133,55 @@ export interface ExtractLinesResult {
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param linesToSkip - Starting line number (0-based)
* @param linesToRead - Maximum number of lines to read
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
linesToSkip: number,
linesToRead: number,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "" || linesToRead === 0) {
if (linesToSkip === 0 && linesToRead > 0) {
return {
content: "",
actualEndLine: 0,
wasLimited: false,
linesRead: 1,
};
} else {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
if (fullContent === "") {
return {
content: "",
wasLimited: false,
linesRead: 1,
};
}
let linesSeen = 0;
let index = 0;
while (linesSeen < linesToSkip) {
const nextIndex = fullContent.indexOf("\n", index);
// There were not enough lines to skip.
if (nextIndex < 0) {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
linesSeen += 1;
index = nextIndex + 1;
}
// We've successfully skipped over all the lines we were supposed to.
// Now we can actually start reading!
const startIndex = index;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (linesSeen < linesToRead) {
while (true) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
const newContentLength = fullContent.length - startIndex;
if (linesSeen > 0 && newContentLength > maxContentLength) {
if (linesSeen > 0 && fullContent.length > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
contentLength = fullContent.length;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1 - startIndex;
const newContentLength = nextIndex + 1;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = nextIndex + 1;
}
}
// If we ended with a newline and we stopped due to byte limit or line limit (not end of file), remove the trailing newline
if (contentLength > 0 && fullContent[startIndex + contentLength - 1] === "\n") {
// Check if there's more content after our current position that we didn't read
if (startIndex + contentLength < fullContent.length) {
contentLength -= 1;
index = newContentLength;
}
}
return {
content: fullContent.slice(startIndex, startIndex + contentLength),
actualEndLine: linesSeen > 0 ? linesToSkip + linesSeen - 1 : linesToSkip,
content: fullContent.slice(0, contentLength),
wasLimited,
linesRead: linesSeen,
};