feat(read): replace offset/limit with sel parameter for line ranges and chunk selectors

This commit is contained in:
can1357
2026-04-06 18:40:56 +02:00
parent 03fd34de63
commit 4258ca3b8a
9 changed files with 356 additions and 43 deletions
+1
View File
@@ -48,6 +48,7 @@ const commands: CommandEntry[] = [
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
{ name: "read", load: () => import("./commands/read").then(m => m.default) },
{ name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
{ name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
{ name: "setup", load: () => import("./commands/setup").then(m => m.default) },
+36
View File
@@ -0,0 +1,36 @@
/**
* Read CLI command handler.
*
* Handles `omp read` subcommand — emits chunk-mode read output for a file.
*/
import * as path from "node:path";
import chalk from "chalk";
import { getLanguageFromPath } from "../modes/theme/theme";
import { formatChunkedRead } from "../tools/chunk-tree";
export interface ReadCommandArgs {
path: string;
sel?: string;
}
export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
const filePath = path.resolve(cmd.path);
const file = Bun.file(filePath);
if (!(await file.exists())) {
console.error(chalk.red(`Error: File not found: ${cmd.path}`));
process.exit(1);
}
const readPath = cmd.sel ? `${filePath}:${cmd.sel}` : filePath;
const language = getLanguageFromPath(filePath);
const cwd = process.cwd();
try {
const result = await formatChunkedRead({ filePath, readPath, cwd, language });
console.log(result.text);
} catch (err) {
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
}
@@ -0,0 +1,33 @@
/**
* Chunk-mode read tool.
*/
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { type ReadCommandArgs, runReadCommand } from "../cli/read-cli";
import { initTheme } from "../modes/theme/theme";
export default class Read extends Command {
static description = "Read a file as a chunk tree";
static args = {
path: Args.string({ description: "File path to read", required: true }),
};
static flags = {
sel: Flags.string({
char: "s",
description: "Chunk selector or line range (e.g. class_Foo.fn_bar, L10-L20)",
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Read);
const cmd: ReadCommandArgs = {
path: args.path ?? "",
sel: flags.sel,
};
await initTheme();
await runReadCommand(cmd);
}
}
@@ -7,8 +7,7 @@ import type { ToolExecutionHandle } from "./tool-execution";
type ReadRenderArgs = {
path?: string;
file_path?: string;
offset?: number;
limit?: number;
sel?: string;
};
type ReadToolSuffixResolution = {
@@ -33,8 +32,7 @@ function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadTo
type ReadEntry = {
toolCallId: string;
path: string;
offset?: number;
limit?: number;
sel?: string;
status: "pending" | "success" | "warning" | "error";
correctedFrom?: string;
};
@@ -56,13 +54,11 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
toolCallId,
path: rawPath,
offset: args.offset,
limit: args.limit,
sel: args.sel,
status: "pending",
};
entry.path = rawPath;
entry.offset = args.offset;
entry.limit = args.limit;
entry.sel = args.sel;
this.#entries.set(toolCallId, entry);
this.#updateDisplay();
}
@@ -132,10 +128,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
#formatPath(entry: ReadEntry): string {
const filePath = shortenPath(entry.path);
let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
if (entry.offset !== undefined || entry.limit !== undefined) {
const startLine = entry.offset ?? 1;
const endLine = entry.limit !== undefined ? startLine + entry.limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
if (entry.sel) {
pathDisplay += theme.fg("warning", `:${entry.sel}`);
}
if (entry.correctedFrom) {
pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
@@ -0,0 +1,14 @@
Searches files using regex and annotates each match with its containing chunk path.
<instruction>
- Output lines look like `src/server.ts:class_Server.fn_handleError ln:220 | private handleError(…)`.
- Use the reported chunk path with `read(path="src/server.ts", sel="class_Server.fn_handleError")` for full context.
- Regex syntax, globs, file types, and `.gitignore` handling are the same as normal grep mode.
- Output format: `path:chunk_path ln:N | content` — the chunk path uniquely identifies a location within the file and can be used directly in subsequent read calls.
- Chunk paths work across JS/TS/TSX, Python, Rust, and Go files.
</instruction>
<critical>
- You **MUST** use `grep` instead of shelling out to `rg` or `grep`.
- After grep finds a relevant hit, follow up with `read` on the chunk path instead of widening grep repeatedly.
</critical>
@@ -0,0 +1,94 @@
Reads files using syntax-aware chunks.
<instruction>
## Parameters
- `path` -- file path or URL; may include `:selector` suffix as an alternative to `sel`
- `sel` -- optional selector (see table below)
- `timeout` -- seconds, for URLs only
## Selectors
|Input|Behavior|
|---|---|
|_(omitted)_|Render the file root chunk|
|`class_Foo`|Read a chunk by path|
|`class_Foo.fn_bar`|Read a nested chunk path|
|`L50` or `L50-L120`|Absolute **file** line range: show chunks that overlap those lines, or that contain a child under those lines (e.g. Go receiver methods under a type whose header is outside the range)|
|`raw`|Read full raw file content (no chunk rendering)|
Each anchor line shows `<:name#CCCC>` or `<.name#CCCC>` — `#CCCC` is the edit checksum. Copy it when editing with `chunk-edit`.
Selectors can overlap. For example, Rust `attribute_N` chunks and the following `enum_` / `struct_` chunk may cover the same leading doc-comment or attribute lines. Use the attribute selector when you only want to edit the attached comments/attributes; use the type selector when you want to replace the declaration together with those attached lines.
If a `path:chunk` suffix and `sel` are both provided, `sel` wins unless `path` carries the chunk selector and `sel` is a line range (`L<n>` or `L<n>-L<m>`). In that case `L…` is still **absolute file lines**, clipped to that chunk; if the range does not overlap the chunk, the tool reports the chunk’s file line span and a suggested `sel=`. Missing chunk paths return `[Chunk not found]`.
The header `N lines` reports the actual file line count for a file-root read. For nested chunk selectors, it reports the lines this selector currently renders, not a raw parser field. That count can be larger than the chunk’s lexical header when the renderer groups related descendants under the parent (for example Go receiver methods shown beneath their receiver type).
Code rows use **absolute file line numbers** in the gutter. Middle elisions use `sel=L<start>-L<end>` with the same absolute indices. `chunk-edit` **splice** `beg`/`end` use those same **absolute file line numbers** (see `chunk-edit` tool docs).
Rendered gap lines are visual context, not checksum ownership. If you need to edit the separator between two chunks, use zero-width `splice` on the adjacent chunk boundary instead of `replace` on either neighboring chunk.
## Examples
`read(path="src/math.ts")`
```text
│ src/math.ts · 120 lines · ts · #A744
│
5 │ export function sum(values: readonly number[]): number {
│ <:sum#3286>
6 │ return values.reduce((total, value) => total + value, 0);
7 │ }
10 │ export class Calculator {
│ <:Calculator#5D36>
11 │ multiply(left: number, right: number): number {
│ <.multiply#B592>
12 │ return left * right;
13 │ }
14 │ }
```
`read(path="src/math.ts", sel="class_Calculator")`
```text
│ src/math.ts:class_Calculator · 5 lines · ts · #5D36
│
10 │ export class Calculator {
│ <:Calculator#5D36>
11 │ multiply(left: number, right: number): number {
│ <.multiply#B592>
12 │ return left * right;
13 │ }
14 │ }
```
`read(path="src/math.ts", sel="L7-L12")`
```text
[Notice: chunk view scoped to requested lines L7-L12; non-overlapping lines omitted.]
│ src/math.ts · 120 lines · ts · #A744
│
```
`read(path="src/math.ts:class_Calculator.fn_square", sel="L11-L12")`
```text
│ src/math.ts:class_Calculator.fn_square · 3 lines · ts · #C9A8
│
11 │ square(value: number): number {
12 │ return this.multiply(value, value);
```
## Language Support
Chunk trees: JavaScript, TypeScript, TSX, Python, Rust, Go. Others use blank-line fallback.
</instruction>
<critical>
- You **MUST** use `read` instead of shell commands for file reading.
- You **MUST** copy the current checksum before editing a chunk with `chunk-edit`.
- You **MUST** not assume chunk names; always read the current output first.
</critical>
@@ -4,9 +4,23 @@ Reads the content at the specified path or URL.
The `read` tool is a multi-purpose tool that can be used to inspect all kinds of files and URLs.
- You **MUST** parallelize reads when exploring related files
## Parameters
- `path` -- file path or URL (required)
- `sel` -- optional selector for line ranges or raw mode
- `timeout` -- seconds, for URLs only
## Selectors
|`sel` value|Behavior|
|---|---|
|*(omitted)*|Read full file (up to {{DEFAULT_LIMIT}} lines)|
|`L50`|Read from line 50 onward|
|`L50-L120`|Read lines 50 through 120|
|`raw`|Raw content without transformations (for URLs: untouched HTML)|
Max {{DEFAULT_MAX_LINES}} lines per call.
# Filesystem
- Reads up to {{DEFAULT_LIMIT}} lines by default
- Use `offset` and `limit` for large files; max {{DEFAULT_MAX_LINES}} lines per call
{{#if IS_HASHLINE_MODE}}
- If reading from FS, result will be prefixed with anchors: `41#ZZ:def alpha():`
{{else}}
@@ -26,7 +40,7 @@ When used against a directory, or an archive root, the tool will return a list o
# URLs
- Extract information from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, technical blogs, RSS/Atom feeds, JSON endpoints
- `raw: true` for untouched HTML or debugging
- `sel="raw"` for untouched HTML or debugging
- `timeout` to override the default request timeout
</instruction>
@@ -35,6 +49,6 @@ When used against a directory, or an archive root, the tool will return a list o
- You **MUST** use `read` instead of `ls` for directory listings.
- You **MUST** use `read` instead of shelling out to `tar` or `unzip` for supported archive reads.
- You **MUST** always include the `path` parameter, NEVER call `read` with empty arguments `{}`.
- When reading specific line ranges, use `offset` and `limit`: `read(path="file", offset=50, limit=100)` not `cat -n file | sed`.
- You **MAY** use `offset` and `limit` with URL reads; the tool will paginate the cached fetched output.
- When reading specific line ranges, use `sel`: `read(path="file", sel="L50-L150")` not `cat -n file | sed`.
- You **MAY** use `sel` with URL reads; the tool will paginate the cached fetched output.
</critical>
@@ -747,6 +747,6 @@ export function formatHeadTruncationNotice(
const totalFileLines = options.totalFileLines ?? truncation.totalLines;
const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
const nextOffset = endLineDisplay + 1;
const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use sel=L${nextOffset} to continue]`;
return `\n\n${notice}`;
}
+152 -25
View File
@@ -14,6 +14,7 @@ import type { InternalUrl } from "../internal-urls/types";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
import { computeLineHash } from "../patch/hashline";
import readDescription from "../prompts/tools/read.md" with { type: "text" };
import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" };
import type { ToolSession } from "../sdk";
import {
DEFAULT_MAX_BYTES,
@@ -25,6 +26,7 @@ import {
} from "../session/streaming-output";
import { renderCodeCell, renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import { resolveEditMode } from "../utils/edit-mode";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import {
ImageInputTooLargeError,
@@ -35,6 +37,13 @@ import {
import { convertFileWithMarkit } from "../utils/markit";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
import {
type ChunkReadTarget,
chunkSplicesEnabled,
formatChunkedRead,
parseChunkReadPath,
parseChunkSelector,
} from "./chunk-tree";
import {
executeReadUrl,
isReadableUrlPath,
@@ -354,10 +363,8 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
const readSchema = Type.Object({
path: Type.String({ description: "Path or URL to read" }),
offset: Type.Optional(Type.Number({ description: "Line number to start from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines" })),
sel: Type.Optional(Type.String({ description: "Selector: chunk path, L10-L50, or raw" })),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20)" })),
raw: Type.Optional(Type.Boolean({ description: "If set, returns raw content without transformations" })),
});
export type ReadToolInput = Static<typeof readSchema>;
@@ -368,6 +375,7 @@ export interface ReadToolDetails {
isDirectory?: boolean;
resolvedPath?: string;
suffixResolution?: { from: string; to: string };
chunk?: ChunkReadTarget;
url?: string;
finalUrl?: string;
contentType?: string;
@@ -378,6 +386,43 @@ export interface ReadToolDetails {
type ReadParams = ReadToolInput;
/** Parsed representation of the `sel` parameter. */
type ParsedSelector =
| { kind: "none" }
| { kind: "raw" }
| { kind: "lines"; startLine: number; endLine: number | undefined }
| { kind: "chunk"; selector: string };
const LINE_RANGE_RE = /^L(\d+)(?:-L?(\d+))?$/i;
function parseSel(sel: string | undefined): ParsedSelector {
if (!sel || sel.length === 0) return { kind: "none" };
const normalizedSelector = parseChunkSelector(sel).selector ?? sel;
if (normalizedSelector === "raw") return { kind: "raw" };
const lineMatch = LINE_RANGE_RE.exec(normalizedSelector);
if (lineMatch) {
const rawStart = Number.parseInt(lineMatch[1]!, 10);
if (rawStart < 1) {
throw new ToolError("L0 is invalid; lines are 1-indexed. Use sel=L1.");
}
const rawEnd = lineMatch[2] ? Number.parseInt(lineMatch[2], 10) : undefined;
if (rawEnd !== undefined && rawEnd < rawStart) {
throw new ToolError(`Invalid range L${rawStart}-L${rawEnd}: end must be >= start.`);
}
return { kind: "lines", startLine: rawStart, endLine: rawEnd };
}
return { kind: "chunk", selector: normalizedSelector };
}
/** Convert a line-range selector to the offset/limit pair used by internal pagination. */
function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: number } {
if (parsed.kind === "lines") {
const limit = parsed.endLine !== undefined ? parsed.endLine - parsed.startLine + 1 : undefined;
return { offset: parsed.startLine, limit };
}
return {};
}
interface ResolvedArchiveReadPath {
absolutePath: string;
archiveSubPath: string;
@@ -410,12 +455,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
);
this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
this.description = renderPromptTemplate(readDescription, {
DEFAULT_LIMIT: String(this.#defaultLimit),
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
IS_HASHLINE_MODE: displayMode.hashLines,
IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
});
this.description =
resolveEditMode(session) === "chunk"
? renderPromptTemplate(readChunkDescription, { chunkSplices: chunkSplicesEnabled() })
: renderPromptTemplate(readDescription, {
DEFAULT_LIMIT: String(this.#defaultLimit),
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
IS_HASHLINE_MODE: displayMode.hashLines,
IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
});
}
async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -496,10 +544,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const suggestion =
allLines.length === 0
? `The ${options.entityLabel} is empty.`
: `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`;
: `Use sel=L1 to read from the start, or sel=L${allLines.length} to read the last line.`;
return resultBuilder
.text(
`Offset ${offset} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
`Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
)
.done();
}
@@ -556,7 +604,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const nextOffset = startLine + userLimitedLines + 1;
outputText = formatText(selectedContent, startLineDisplay);
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use offset=${nextOffset} to continue]`;
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=L${nextOffset} to continue]`;
} else {
outputText = formatText(truncation.content, startLineDisplay);
}
@@ -674,22 +722,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
_onUpdate?: AgentToolUpdateCallback<ReadToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<ReadToolDetails>> {
let { path: readPath, offset, limit, timeout, raw } = params;
const displayMode = resolveFileDisplayMode(this.session);
let { path: readPath, sel, timeout } = params;
if (readPath.startsWith("file://")) {
readPath = expandPath(readPath);
}
const displayMode = resolveFileDisplayMode(this.session);
const chunkMode = resolveEditMode(this.session) === "chunk";
// Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(readPath)) {
const parsed = parseSel(sel);
const { offset, limit } = selToOffsetLimit(parsed);
return this.#handleInternalUrl(readPath, offset, limit);
}
if (isReadableUrlPath(readPath)) {
const parsed = parseSel(sel);
if (!this.session.settings.get("fetch.enabled")) {
throw new ToolError("URL reads are disabled by settings.");
}
const raw = parsed.kind === "raw";
const { offset, limit } = selToOffsetLimit(parsed);
if (offset !== undefined || limit !== undefined) {
const cached = await loadReadUrlCacheEntry(this.session, { path: readPath, timeout, raw }, signal, {
ensureArtifact: true,
@@ -704,12 +758,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
return executeReadUrl(this.session, { path: readPath, timeout, raw }, signal);
}
const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
const parsedReadPath = chunkMode ? parseChunkReadPath(readPath) : { filePath: readPath };
const localReadPath = parsedReadPath.filePath;
const pathSelectorParsed = chunkMode ? parseSel(parsedReadPath.selector) : { kind: "none" as const };
const pathChunkSelector = pathSelectorParsed.kind === "chunk" ? pathSelectorParsed.selector : undefined;
const selectorInput = sel ?? parsedReadPath.selector;
const parsed = parseSel(selectorInput);
const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
if (archivePath) {
const { offset, limit } = selToOffsetLimit(parsed);
return this.#readArchive(readPath, offset, limit, archivePath, signal);
}
let absolutePath = resolveReadPath(readPath, this.session.cwd);
let absolutePath = resolveReadPath(localReadPath, this.session.cwd);
let suffixResolution: { from: string; to: string } | undefined;
let isDirectory = false;
@@ -722,14 +784,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
if (isNotFoundError(error)) {
// Attempt unique suffix resolution before falling back to fuzzy suggestions
if (!isRemoteMountPath(absolutePath)) {
const suffixMatch = await findUniqueSuffixMatch(readPath, this.session.cwd, signal);
const suffixMatch = await findUniqueSuffixMatch(localReadPath, this.session.cwd, signal);
if (suffixMatch) {
try {
const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
absolutePath = suffixMatch.absolutePath;
fileSize = retryStat.size;
isDirectory = retryStat.isDirectory();
suffixResolution = { from: readPath, to: suffixMatch.displayPath };
suffixResolution = { from: localReadPath, to: suffixMatch.displayPath };
} catch {
// Suffix match candidate no longer stats — fall through to error path
}
@@ -737,7 +799,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
}
if (!suffixResolution) {
throw new ToolError(`Path '${readPath}' not found`);
throw new ToolError(`Path '${localReadPath}' not found`);
}
} else {
throw error;
@@ -745,7 +807,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
}
if (isDirectory) {
const dirResult = await this.#readDirectory(absolutePath, limit, signal);
const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
if (suffixResolution) {
dirResult.details ??= {};
dirResult.details.suffixResolution = suffixResolution;
@@ -756,6 +818,44 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
const ext = path.extname(absolutePath).toLowerCase();
if (chunkMode && parsed.kind !== "raw") {
const absoluteLineRange =
pathChunkSelector && parsed.kind === "lines"
? { startLine: parsed.startLine, endLine: parsed.endLine }
: undefined;
const chunkReadPath = pathChunkSelector
? `${localReadPath}:${pathChunkSelector}`
: parsed.kind === "chunk"
? selectorInput
? `${localReadPath}:${selectorInput}`
: localReadPath
: parsed.kind === "lines"
? parsed.endLine !== undefined
? `${localReadPath}:L${parsed.startLine}-L${parsed.endLine}`
: `${localReadPath}:L${parsed.startLine}`
: localReadPath;
const chunkResult = await formatChunkedRead({
filePath: absolutePath,
readPath: chunkReadPath,
cwd: this.session.cwd,
language: getLanguageFromPath(absolutePath),
omitChecksum: !(this.session.hasEditTool ?? true),
absoluteLineRange,
});
let text = chunkResult.text;
if (suffixResolution) {
text = prependSuffixResolutionNotice(text, suffixResolution);
}
return toolResult<ReadToolDetails>({
resolvedPath: absolutePath,
suffixResolution,
chunk: chunkResult.chunk,
})
.text(text)
.sourcePath(absolutePath)
.done();
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails = {};
@@ -843,7 +943,32 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
}
} else {
// Read as text using streaming to avoid loading huge files into memory
// Chunk mode: dispatch to chunk tree unless raw or line range requested
if (chunkMode && parsed.kind !== "raw" && parsed.kind !== "lines") {
const chunkSel = parsed.kind === "chunk" ? parsed.selector : undefined;
const chunkResult = await formatChunkedRead({
filePath: absolutePath,
readPath: chunkSel ? `${localReadPath}:${chunkSel}` : localReadPath,
cwd: this.session.cwd,
language: getLanguageFromPath(absolutePath),
omitChecksum: !(this.session.hasEditTool ?? true),
});
let text = chunkResult.text;
if (suffixResolution) {
text = prependSuffixResolutionNotice(text, suffixResolution);
}
return toolResult<ReadToolDetails>({
resolvedPath: absolutePath,
suffixResolution,
chunk: chunkResult.chunk,
})
.text(text)
.sourcePath(absolutePath)
.done();
}
// Raw text or line-range mode
const { offset, limit } = selToOffsetLimit(parsed);
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
@@ -873,9 +998,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const suggestion =
totalFileLines === 0
? "The file is empty."
: `Use offset=1 to read from the start, or offset=${totalFileLines} to read the last line.`;
: `Use sel=L1 to read from the start, or sel=L${totalFileLines} to read the last line.`;
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
.text(`Offset ${offset} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
.text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
.done();
}
@@ -942,7 +1067,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const nextOffset = startLine + userLimitedLines + 1;
outputText = formatText(truncation.content, startLineDisplay);
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
outputText += `\n\n[${remaining} more lines in file. Use sel=L${nextOffset} to continue]`;
details = {};
sourcePath = absolutePath;
} else {
@@ -1100,9 +1225,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
interface ReadRenderArgs {
path?: string;
file_path?: string;
sel?: string;
timeout?: number;
// Legacy fields from old schema — tolerated for in-flight tool calls during transition
offset?: number;
limit?: number;
timeout?: number;
raw?: boolean;
}