/** * Override read: recognized images and NUL-containing files delegate to Pi's * built-in read; valid UTF-8 text outputs "lineNo#hash│content". * * Hashes are computed from the current content on the fly — nothing is stored. * The hash is `(line number, content)`, recomputed and checked at edit time, so * no snapshot is needed to verify an anchor later. * * @module pi-hashline-edit/pi */ import { createReadToolDefinition, detectSupportedImageMimeTypeFromFile, getLanguageFromPath, highlightCode, type ReadToolInput, type ReadToolDetails, type ExtensionToolContext, type Theme, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { scanTextLines } from "./text-stream.ts"; import { createAnchorFormatter, displayCarriageReturns, parseHashline } from "./anchor-format.ts"; import { canonicalPath, serializePath } from "./path.ts"; import { renderToolError, reportLines, splitReport } from "./render.ts"; import { POSITIVE_SAFE_INTEGER, STRICT_SAMPLING } from "./schema.ts"; import { errnoCode } from "../core/errors.ts"; import { throwIfCancelled } from "./error-text.ts"; import { reportResult, runTool, type ReportDetails } from "./report.ts"; import { defaultOutputBudgets, formatByteSize, type OutputBudgetProvider } from "./budgets.ts"; import type { HashlineEditConfig } from "./config.ts"; import { createArgumentPreparer } from "./argument-validation.ts"; const DEFAULT_OFFSET = 1; /** Pi's read details for a delegated native read; the report states this read's own facts. */ type ReadDetails = (ReadToolDetails & { nativeRead?: true }) & Partial; /** * First result line: ` · lines`, optionally ` (from line )` and * ` · no trailing newline`. A missing final newline is a byte-level fact the * numbered rows cannot show, so the header, which the model never copies into an * edit `body`, states it. READ_HEADER is the TUI's parser for this exact line. */ function formatReadHeader( path: string, totalLines: number, start: number, finalNewline: boolean, ): string { const shownFrom = start > DEFAULT_OFFSET ? ` (from line ${start})` : ""; return `${path} · ${totalLines} lines${shownFrom}${finalNewline ? "" : " · no trailing newline"}`; } const READ_HEADER = /^(.+?) · (\d+ lines(?: \(from line \d+\))?(?: · no trailing newline)?)$/s; /** * Render the expanded read body for the TUI: color the header, strip the * `LINE#HASH│` prefix from every anchor line to ` N: content`, and * syntax-highlight the code block by the file's language (falls back to a * single `toolOutput` color when the language is unknown or the highlight * line count diverges). Notices are report facts, rendered from the report. */ function renderReadBody(raw: string, path: string, theme: Theme): string { const lines = raw.split("\n"); const out: string[] = []; // Header: " · lines", optionally followed by " (from line )" // and/or " · no trailing newline". let bodyStart = 0; const h = lines[0].match(READ_HEADER); if (h) { out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`)); bodyStart = 1; } // Collect anchor rows (full content). const lineNos: string[] = []; const codeContents: string[] = []; for (let i = bodyStart; i < lines.length; i++) { const row = parseHashline(lines[i]); if (!row) break; lineNos.push(row.lineNo); codeContents.push(row.content); } // Syntax-highlight the whole block so multi-line constructs stay correct. const detabbed = codeContents.map((l) => l.replace(/\t/g, " ")); const lang = getLanguageFromPath(path); let rendered: string[]; if (lang) { const hl = highlightCode(detabbed.join("\n"), lang); // Guard against highlighters that reshape line count: fall back to plain. rendered = hl.length === detabbed.length ? hl : detabbed.map((l) => theme.fg("toolOutput", l)); } else { rendered = detabbed.map((l) => theme.fg("toolOutput", l)); } for (let i = 0; i < rendered.length; i++) { out.push(theme.fg("dim", ` ${lineNos[i]}: `) + rendered[i]); } return out.join("\n"); } /** Build the read override (a ToolDefinition fragment for registerTool). */ export function makeReadOverride( cwd: string, config: HashlineEditConfig, budgets: OutputBudgetProvider = defaultOutputBudgets, ): ToolDefinition< ReturnType["parameters"], ReadDetails | undefined > { const { hashLen } = config; const { defaultLimit } = config.read; const builtin = createReadToolDefinition(cwd); // A TypeBox object, not a spread of Pi's plain JSON schema: argument diagnostics pick // single fields from it, which needs TypeBox's own schema kinds. const parameters = Type.Object( { path: Type.String({ ...builtin.parameters.properties.path, minLength: 1 }), offset: Type.Optional( Type.Number({ ...POSITIVE_SAFE_INTEGER, description: `1-based line to start from (default ${DEFAULT_OFFSET}). For a large file, set offset and limit to read only the relevant section.`, }), ), limit: Type.Optional( Type.Number({ ...POSITIVE_SAFE_INTEGER, description: `Maximum lines to read (default ${defaultLimit}).`, }), ), }, { additionalProperties: false }, ); return { name: "read" as const, label: "read", description: "Read a file. Text lines show as LINE#HASH│content anchors for edit; CRLF line endings show as LF.", promptSnippet: "Read files with editable line anchors", parameters: parameters as typeof builtin.parameters, prepareArguments: createArgumentPreparer("read", parameters, undefined, budgets), constrainedSampling: STRICT_SAMPLING, renderShell: "default" as const, renderCall: builtin.renderCall, renderResult(result, options, theme, context) { const { isPartial, expanded } = options; if (isPartial) return new Text(theme.fg("warning", "Reading…"), 0, 0); if (context.isError) return renderToolError(result, "read", theme, expanded); const { content, report } = splitReport(result); if (result.details?.nativeRead) return builtin.renderResult!({ ...result, content }, options, theme, context); // Collapsed (not expanded): show nothing — the call line carries the // title, matching the built-in read's fold behavior. if (!expanded) return new Text("", 0, 0); const raw = content[0]?.type === "text" ? content[0].text : ""; return new Text( [ renderReadBody(raw, String(context.args?.path ?? ""), theme), ...(report ? reportLines(report, theme) : []), ].join("\n"), 0, 0, ); }, async execute( toolCallId: string, params: ReadToolInput, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionToolContext, ) { const budget = budgets(); const maxBytes = config.read.maxKiB === undefined ? budget.readBytes : config.read.maxKiB * 1024; return runTool("read", { path: params.path, signal, budget }, async () => { throwIfCancelled(signal); const start = params.offset ?? DEFAULT_OFFSET; const limit = params.limit ?? defaultLimit; const anchors = createAnchorFormatter(hashLen); const report = { tool: "read" as const, path: params.path }; const readNative = async () => { const result = await builtin.execute(toolCallId, params, signal, onUpdate, ctx); return reportResult(report, result.content, { ...result.details, nativeRead: true as const, }); }; const absPath = canonicalPath(cwd, params.path); try { if (await detectSupportedImageMimeTypeFromFile(absPath)) { return readNative(); } } catch { return readNative(); } const rows: string[] = []; const crExpansion = Buffer.byteLength(displayCarriageReturns("\r")) - 1; let outputBytes = 0; let truncated = false; let firstLineExceedsLimit = false; let stats: Awaited>; try { stats = await scanTextLines( absPath, (number) => number >= start && number - start < limit, (line) => { if (truncated) return; const rowBytes = line.byteLength + line.carriageReturns * crExpansion + Buffer.byteLength(anchors.row(line.number, "")); const nextBytes = outputBytes + rowBytes + (rows.length > 0 ? 1 : 0); if (nextBytes > maxBytes) { truncated = true; firstLineExceedsLimit = rows.length === 0; return; } rows.push(anchors.row(line.number, line.text!)); outputBytes = nextBytes; }, { signal, maxLineBytes: maxBytes }, ); } catch (error) { // Keep native filesystem diagnostics without retrying decoding or cancellation failures. if (!signal?.aborted && errnoCode(error) !== undefined) { return readNative(); } throw error; } if (stats.hasNul) return readNative(); // Rows were returned and more remain. A caller's own limit is a chosen window: its header // and rows already show where it ends, so only a window the extension cut (default limit // or byte budget) gets a report. const pagination = (params.limit === undefined || truncated) && rows.length > 0 && start + rows.length <= stats.totalLines ? { start, end: start + (rows.length - 1), totalLines: stats.totalLines, nextOffset: start + rows.length, } : undefined; const header = `${formatReadHeader(serializePath(cwd, absPath), stats.totalLines, start, stats.finalNewline)}\n`; const limitText = formatByteSize(maxBytes); return reportResult( { ...report, pagination, outputLimit: truncated && !firstLineExceedsLimit ? limitText : undefined, lineTooLong: firstLineExceedsLimit ? { line: start, limit: limitText } : undefined, }, [{ type: "text" as const, text: header + rows.join("\n") }], ); }); }, }; }