Syntax-highlight Edit, Write, and Read tool calls (#1218)
Highlighting reuses the existing code-block tokenizer and LRU cache, runs lazily on expand, and falls back to plain text above a size cap so a large Read can't stall the main thread. The diff highlighter reconstructs the old and new file text by position rather than from @@ line ranges, so diffs that arrive without real hunk headers still highlight. Read content is normalized server-side: the cat -n line-number gutter some providers emit is stripped so content is uniformly raw source, with the first line number surfaced as offset for the client to rebuild the gutter.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
extractCodexShellOutput,
|
||||
flattenReadContent as flattenToolReadContent,
|
||||
nonEmptyString,
|
||||
stripReadLineNumberGutter,
|
||||
truncateDiffText,
|
||||
} from "./tool-call-mapper-utils.js";
|
||||
|
||||
@@ -776,11 +777,15 @@ export function toReadToolDetail(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripped = output?.content ? stripReadLineNumberGutter(output.content) : undefined;
|
||||
const content = stripped?.content ?? output?.content;
|
||||
const offset = input?.offset ?? stripped?.startLine;
|
||||
|
||||
return {
|
||||
type: "read",
|
||||
filePath,
|
||||
...(output?.content ? { content: output.content } : {}),
|
||||
...(input?.offset !== undefined ? { offset: input.offset } : {}),
|
||||
...(content ? { content } : {}),
|
||||
...(offset !== undefined ? { offset } : {}),
|
||||
...(input?.limit !== undefined ? { limit: input.limit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,81 @@ export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
);
|
||||
}
|
||||
|
||||
export interface StrippedReadContent {
|
||||
content: string;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
// Claude's Read tool returns `cat -n`-style content: each line prefixed with a
|
||||
// right-aligned line number and a tab (`␣␣␣1\timport ...`). Other providers
|
||||
// return raw source. We strip the gutter here so `read.content` is uniformly
|
||||
// raw source across providers, and surface the first line number as `offset`
|
||||
// so the client can rebuild the gutter itself. Guarded tightly (first line must
|
||||
// match, near-total match ratio, strictly sequential numbering) so real source
|
||||
// is never mistaken for a gutter.
|
||||
const READ_GUTTER_LINE = /^\s*(\d+)\t(.*)$/;
|
||||
|
||||
export function stripReadLineNumberGutter(
|
||||
content: string | undefined,
|
||||
): StrippedReadContent | undefined {
|
||||
const text = nonEmptyString(content);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
const stripped: string[] = [];
|
||||
let nonEmpty = 0;
|
||||
let matched = 0;
|
||||
let startLine: number | undefined;
|
||||
let prevNumber: number | undefined;
|
||||
let sequential = true;
|
||||
let firstNonEmptyMatched = false;
|
||||
let sawNonEmpty = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) {
|
||||
stripped.push(line);
|
||||
continue;
|
||||
}
|
||||
nonEmpty += 1;
|
||||
const match = line.match(READ_GUTTER_LINE);
|
||||
if (!match) {
|
||||
if (!sawNonEmpty) {
|
||||
return undefined;
|
||||
}
|
||||
stripped.push(line);
|
||||
sawNonEmpty = true;
|
||||
continue;
|
||||
}
|
||||
if (!sawNonEmpty) {
|
||||
firstNonEmptyMatched = true;
|
||||
}
|
||||
sawNonEmpty = true;
|
||||
matched += 1;
|
||||
const lineNumber = Number.parseInt(match[1], 10);
|
||||
if (startLine === undefined) {
|
||||
startLine = lineNumber;
|
||||
}
|
||||
if (prevNumber !== undefined && lineNumber !== prevNumber + 1) {
|
||||
sequential = false;
|
||||
}
|
||||
prevNumber = lineNumber;
|
||||
stripped.push(match[2]);
|
||||
}
|
||||
|
||||
if (!firstNonEmptyMatched || !sequential || nonEmpty === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Sequential numbering from the first line is already a strong signal; the
|
||||
// ratio only rejects source that has a couple of coincidental matches.
|
||||
if (matched / nonEmpty < 0.5) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { content: stripped.join("\n"), startLine };
|
||||
}
|
||||
|
||||
export function truncateDiffText(
|
||||
text: string | undefined,
|
||||
maxChars: number = 12_000,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toReadToolDetail } from "./tool-call-detail-primitives.js";
|
||||
import { stripReadLineNumberGutter } from "./tool-call-mapper-utils.js";
|
||||
|
||||
describe("stripReadLineNumberGutter", () => {
|
||||
it("strips a cat -n gutter and reports the first line number", () => {
|
||||
const result = stripReadLineNumberGutter(
|
||||
["1\timport a from 'a';", "2\t", "3\tconst x = 1;"].join("\n"),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
content: ["import a from 'a';", "", "const x = 1;"].join("\n"),
|
||||
startLine: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles right-aligned numbers and a non-1 start offset", () => {
|
||||
const result = stripReadLineNumberGutter([" 41\tconst y = 2;", " 42\treturn y;"].join("\n"));
|
||||
expect(result).toEqual({
|
||||
content: ["const y = 2;", "return y;"].join("\n"),
|
||||
startLine: 41,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a trailing non-gutter notice but still strips the body", () => {
|
||||
const result = stripReadLineNumberGutter(
|
||||
["1\tline one", "2\tline two", "<system-reminder>truncated</system-reminder>"].join("\n"),
|
||||
);
|
||||
expect(result?.content).toBe(
|
||||
["line one", "line two", "<system-reminder>truncated</system-reminder>"].join("\n"),
|
||||
);
|
||||
expect(result?.startLine).toBe(1);
|
||||
});
|
||||
|
||||
it("does not strip raw source without a gutter", () => {
|
||||
expect(stripReadLineNumberGutter("import a from 'a';\nconst x = 1;")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not strip when numbering is not sequential", () => {
|
||||
expect(stripReadLineNumberGutter("1\tfoo\n5\tbar")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not strip when the first non-empty line lacks a gutter", () => {
|
||||
expect(stripReadLineNumberGutter("header line\n1\tfoo\n2\tbar")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty content", () => {
|
||||
expect(stripReadLineNumberGutter(undefined)).toBeUndefined();
|
||||
expect(stripReadLineNumberGutter("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toReadToolDetail gutter normalization", () => {
|
||||
it("strips the gutter from output content and derives offset", () => {
|
||||
const detail = toReadToolDetail(
|
||||
{ filePath: "/repo/src/index.ts" },
|
||||
{ content: "10\tconst a = 1;\n11\tconst b = 2;" },
|
||||
);
|
||||
expect(detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "/repo/src/index.ts",
|
||||
content: "const a = 1;\nconst b = 2;",
|
||||
offset: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit input offset over the gutter start", () => {
|
||||
const detail = toReadToolDetail(
|
||||
{ filePath: "/repo/src/index.ts", offset: 10, limit: 2 },
|
||||
{ content: "10\tconst a = 1;\n11\tconst b = 2;" },
|
||||
);
|
||||
expect(detail).toMatchObject({ offset: 10, limit: 2, content: "const a = 1;\nconst b = 2;" });
|
||||
});
|
||||
|
||||
it("leaves raw content untouched", () => {
|
||||
const detail = toReadToolDetail(
|
||||
{ filePath: "/repo/src/index.ts" },
|
||||
{ content: "const a = 1;\nconst b = 2;" },
|
||||
);
|
||||
expect(detail).toEqual({
|
||||
type: "read",
|
||||
filePath: "/repo/src/index.ts",
|
||||
content: "const a = 1;\nconst b = 2;",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user