diff --git a/packages/coding-agent/src/tools/read-format.ts b/packages/coding-agent/src/tools/read-format.ts index 7e2e9a2cb..cc3fe52b9 100644 --- a/packages/coding-agent/src/tools/read-format.ts +++ b/packages/coding-agent/src/tools/read-format.ts @@ -1,5 +1,10 @@ import * as path from "node:path"; -import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline"; +import { + formatHashlineHeader, + formatNumberedLine, + formatNumberedLines, + splitAddressableFileLines, +} from "@oh-my-pi/hashline"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLines } from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; @@ -290,7 +295,7 @@ export function buildInMemoryTextResult( ): AgentToolResult { const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable }); const details = options.details ?? {}; - const allLines = text.split("\n"); + const allLines = options.raw === true ? text.split("\n") : splitAddressableFileLines(text); const totalLines = allLines.length; details.totalLines = totalLines; // User-requested 0-indexed range start. Lines BEFORE this are leading diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 26983562e..afd081847 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { splitAddressableFileLines } from "@oh-my-pi/hashline"; import { type } from "@oh-my-pi/omptype"; import type { AgentTool, @@ -119,12 +120,17 @@ const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES; async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise { if (fileSize > SNAPSHOT_MAX_BYTES) return undefined; try { - return normalizeToLF(await Bun.file(absolutePath).text()).split("\n"); + return splitAddressableFileLines(normalizeToLF(await Bun.file(absolutePath).text())); } catch { return undefined; } } +interface StreamFileLinesOptions { + includeTerminalNewline?: boolean; + stopScanAfterCollect?: boolean; +} + async function streamLinesFromFile( filePath: string, startLine: number, @@ -132,7 +138,7 @@ async function streamLinesFromFile( maxBytes: number, selectedLineLimit: number | null, signal?: AbortSignal, - stopScanAfterCollect = false, + options: StreamFileLinesOptions = {}, ): Promise<{ lines: string[]; totalFileLines: number; @@ -141,9 +147,12 @@ async function streamLinesFromFile( firstLinePreview?: { text: string; bytes: number }; firstLineByteLength?: number; selectedBytesTotal: number; + /** Whether the fully scanned source ended in a newline. */ + hasTrailingNewline: boolean; /** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */ reachedEof: boolean; }> { + const { includeTerminalNewline = false, stopScanAfterCollect = false } = options; const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE); const collectedLines: string[] = []; let lineIndex = 0; @@ -311,7 +320,7 @@ async function streamLinesFromFile( } } - if (reachedEof && (endedWithNewline || currentLineLength > 0 || !sawAnyByte)) { + if (reachedEof && (currentLineLength > 0 || !sawAnyByte || (endedWithNewline && includeTerminalNewline))) { finalizeLine(); } @@ -330,6 +339,7 @@ async function streamLinesFromFile( firstLineByteLength, selectedBytesTotal, reachedEof, + hasTrailingNewline: reachedEof && endedWithNewline, }; } @@ -692,7 +702,7 @@ export class ReadTool implements AgentTool { maxBytesForRead, maxLines, signal, - fileSize > SNAPSHOT_MAX_BYTES, // giant file: collected ranges don't need an exact EOF line count + { includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES }, ); totalFileLines = streamResult.totalFileLines; collectedLines = streamResult.lines; @@ -1256,7 +1266,7 @@ export class ReadTool implements AgentTool { maxBytesForRead, selectedLineLimit, undefined, // plain-file read: deterministic and fast, never abort mid-read - fileSize > SNAPSHOT_MAX_BYTES, // giant file: don't scan to EOF just for an exact line count + { includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES }, ); const { @@ -1267,6 +1277,7 @@ export class ReadTool implements AgentTool { firstLinePreview, firstLineByteLength, reachedEof, + hasTrailingNewline, } = streamResult; // Check if offset is out of bounds - return graceful message instead of throwing @@ -1347,7 +1358,7 @@ export class ReadTool implements AgentTool { const tag = isWholeFile ? getFileSnapshotStore(this.session).record( canonicalSnapshotKey(absolutePath), - normalizeToLF(collectedLines.join("\n")), + normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`), ) : await recordFileSnapshot(this.session, absolutePath); if (tag) { @@ -1694,7 +1705,7 @@ export class ReadTool implements AgentTool { maxBytesForRead, selectedLineLimit, signal, - artifact.size > SNAPSHOT_MAX_BYTES, + { includeTerminalNewline: rawSelector, stopScanAfterCollect: artifact.size > SNAPSHOT_MAX_BYTES }, ); const { lines: collectedLines, diff --git a/packages/coding-agent/src/utils/file-mentions.ts b/packages/coding-agent/src/utils/file-mentions.ts index b7e5a0d19..15aaf3e46 100644 --- a/packages/coding-agent/src/utils/file-mentions.ts +++ b/packages/coding-agent/src/utils/file-mentions.ts @@ -7,7 +7,12 @@ */ import * as fs from "node:fs/promises"; import path from "node:path"; -import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline"; +import { + formatHashlineHeader, + formatNumberedLines, + type SnapshotStore, + splitAddressableFileLines, +} from "@oh-my-pi/hashline"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { formatAge, formatBytes, isProbablyBinary, readImageMetadata } from "@oh-my-pi/pi-utils"; @@ -270,7 +275,8 @@ export async function generateFileMentionMessages( const content = await Bun.file(absolutePath).text(); const snapshotStore = options?.useHashLines ? options.snapshotStore : undefined; const normalized = snapshotStore ? normalizeToLF(content) : content; - let { output, lineCount } = buildTextOutput(normalized); + const displayText = snapshotStore ? splitAddressableFileLines(normalized).join("\n") : normalized; + let { output, lineCount } = buildTextOutput(displayText); if (snapshotStore) { const tag = snapshotStore.record(canonicalSnapshotKey(absolutePath), normalized); output = `${formatHashlineHeader(resolvedPath, tag)}\n${formatNumberedLines(output)}`; diff --git a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts index d30ba44e5..68ae20b6b 100644 --- a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts +++ b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts @@ -182,4 +182,25 @@ describe("read tool column truncation vs hashline snapshot", () => { const after = await fs.readFile(filePath, "utf8"); expect(after).toBe(`intro\n${longLine}\nepilogue\n`); }); + + it("keeps a genuine blank line editable without exposing the EOF sentinel", async () => { + const filePath = path.join(tmpDir, "eof-blank.txt"); + await fs.writeFile(filePath, "first\n\nlast\n"); + + const session = createSession(tmpDir); + const readText = textOutput(await new ReadTool(session).execute("call-eof-blank", { path: filePath })); + expect(readText).toContain("1:first\n2:\n3:last"); + expect(readText).not.toContain("\n4:"); + + const { header } = extractHeader(readText); + await applyEditWithTag({ + session, + tmpDir, + filePath, + header, + patchBody: "CUT 2\n", + }); + + expect(await fs.readFile(filePath, "utf8")).toBe("first\nlast\n"); + }); }); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 94d10f892..fb8d50ce7 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,9 +2,6 @@ ## [Unreleased] -### Fixed - -- Stopped `formatNumberedLines` from exposing a terminal newline as an editable blank line; deleting that synthetic row previously produced a misleading no-op. ### Added @@ -13,6 +10,7 @@ ### Fixed - Fixed Rust lifetimes blinding the delimiter-balance scanner. `'` entered string state to end-of-line, so `&'static str {` hid its opening brace; a replacement range swallowing such a signature line looked balance-neutral and the mid-block advisory never fired, silently deleting the signature. Single-quote lexing is now language-aware: on `.rs` targets, `'` opens a literal only when it lexes as a real char literal (`'a'`, `'\n'`, `'\u{7FFF}'`), and lifetimes stay ordinary characters — apostrophes are never paired across lifetimes, which would swallow the delimiters between them (`<'a>(x: &'a str)`). +- Fixed hashline reads exposing a terminal newline as an editable blank row. `splitAddressableFileLines` now removes that sentinel before consumers build line anchors while retaining genuine blank lines. ## [17.2.12] - 2026-08-08 diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 88895a03b..33af52d28 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -140,12 +140,19 @@ export function formatNumberedLine(lineNumber: number, line: string): string { } /** - * Format file text with hashline-mode line-number prefixes for display. - * A terminal newline terminates the preceding line; it is not an addressable - * blank line. + * Split LF-delimited file text into lines hashline anchors can address. + * A terminal newline terminates the preceding line; it is not content. */ -export function formatNumberedLines(text: string, startLine = 1): string { +export function splitAddressableFileLines(text: string): string[] { const lines = text.split("\n"); if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); - return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n"); + return lines; +} + +/** Format file text with hashline-mode line-number prefixes for display. */ +export function formatNumberedLines(text: string, startLine = 1): string { + return text + .split("\n") + .map((line, i) => formatNumberedLine(startLine + i, line)) + .join("\n"); } diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts index ac3fe967f..d6060af1b 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -5,6 +5,7 @@ import { parseLid, parsePatch, parsePatchStreaming, + splitAddressableFileLines, Tokenizer, } from "@oh-my-pi/hashline"; @@ -105,9 +106,14 @@ describe("hashline format v4", () => { expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n"); }); - it("does not expose the terminal newline as an editable blank line", () => { - expect(formatNumberedLines("a\nb\n")).toBe("1:a\n2:b"); - expect(formatNumberedLines("a\nb\n\n")).toBe("1:a\n2:b\n3:"); + it("separates terminal newline sentinels from addressable file lines", () => { + expect(splitAddressableFileLines("a\nb\n")).toEqual(["a", "b"]); + expect(splitAddressableFileLines("a\nb\n\n")).toEqual(["a", "b", ""]); + }); + + it("keeps a selected terminal blank line when formatting", () => { + const selected = splitAddressableFileLines("a\n\nb\n").slice(0, 2).join("\n"); + expect(formatNumberedLines(selected)).toBe("1:a\n2:"); }); it("treats a cut range ending at the trailing sentinel as ending at the last real line", () => {