diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 887ca0827..00102a789 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -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) }, diff --git a/packages/coding-agent/src/cli/read-cli.ts b/packages/coding-agent/src/cli/read-cli.ts new file mode 100644 index 000000000..7047eb356 --- /dev/null +++ b/packages/coding-agent/src/cli/read-cli.ts @@ -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 { + 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); + } +} diff --git a/packages/coding-agent/src/commands/read.ts b/packages/coding-agent/src/commands/read.ts new file mode 100644 index 000000000..99cd5b7de --- /dev/null +++ b/packages/coding-agent/src/commands/read.ts @@ -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 { + const { args, flags } = await this.parse(Read); + + const cmd: ReadCommandArgs = { + path: args.path ?? "", + sel: flags.sel, + }; + + await initTheme(); + await runReadCommand(cmd); + } +} diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index 37a4ed7f5..e7ddc5b9d 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -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)})`); diff --git a/packages/coding-agent/src/prompts/tools/grep-chunk.md b/packages/coding-agent/src/prompts/tools/grep-chunk.md new file mode 100644 index 000000000..a727f5d7e --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/grep-chunk.md @@ -0,0 +1,14 @@ +Searches files using regex and annotates each match with its containing chunk path. + + +- 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. + + + +- 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. + diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md new file mode 100644 index 000000000..21444b4f0 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -0,0 +1,94 @@ +Reads files using syntax-aware chunks. + + +## 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` or `L-L`). 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-L` 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. + + + +- 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. + diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 7f1f85973..3953b3f48 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -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 @@ -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. diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index de5b29d78..3c64f8dc6 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -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}`; } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 35c83b64b..3e36809d6 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -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; @@ -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 { 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 { @@ -496,10 +544,10 @@ export class ReadTool implements AgentTool { 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 { 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 { _onUpdate?: AgentToolUpdateCallback, _toolContext?: AgentToolContext, ): Promise> { - 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 { 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 { 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 { } 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 { } 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 { 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({ + 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 { 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({ + 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 { 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({ 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 { 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 { 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; }