import { type ElidedRange, formatSingleLine } from "@oh-my-pi/pi-tui/tools/read"; import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { countNewlines } from "@oh-my-pi/pi-utils"; import { getEditStore } from "../edit/store"; import { formatHashlineHeader, formatNumberedLines, splitAddressableFileLines, } from "@oh-my-pi/pi-tui/tools/hashline-format"; import { normalizeToLF } from "../edit/normalize"; import { isMarkdownPath } from "@oh-my-pi/pi-tui/theme"; import type { ToolSession } from "../sdk"; import { DEFAULT_MAX_BYTES, noTruncResult, type TruncationResult, truncateHead, truncateHeadBytes, } from "@oh-my-pi/pi-tui/tools/streaming-output"; import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText, spansCoverEveryLine, warmBlockContext, } from "../utils/block-context"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { formatPathRelativeToCwd } from "./path-utils"; import { type LineRange } from "@oh-my-pi/pi-tui/tools/line-ranges"; import type { ReadToolDetails, ReadTruncationStats } from "@oh-my-pi/pi-tui/tools/read"; import { isRawSelector, type ParsedSelector, resolveTailSelector, selToOffsetLimit } from "./read-selector"; import { formatBytes, shortenPath } from "@oh-my-pi/pi-tui/render/render-utils"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import type { TruncationMetaInput } from "./output-meta"; import { toolResult } from "./tool-result"; import { cfgReadRenderMarkdown } from "./settings"; export function toReadTruncationStats(result: TruncationResult): ReadTruncationStats { const { content: _content, ...stats } = result; return stats; } /** Keep descriptive diagnostics out of raw source text while retaining them in the result. */ export function appendReadNotice(text: string, notice: string, details: ReadToolDetails, separator = "\n"): string { if (details.raw) { (details.notes ??= []).push(notice); return text; } return text ? `${text}${separator}${notice}` : notice; } /** Carry raw mode through readers that pass suffix-resolution metadata to the shared prefix emitter. */ export function markRawSuffixResolution(resolution: ReadToolDetails["suffixResolution"], raw: boolean): void { if (resolution && raw) resolution.raw = true; } function prependLineNumbers(text: string, startNum: number): string { const textLines = text.split("\n"); return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n"); } export interface HashlineHeaderContext { header: string; tag: string; fullText?: string; } export function formatReadHashlineHeader(displayPath: string, tag: string): string { // In-workspace reads keep their workspace-relative path (e.g. // `src/settings.json`), not just the basename: collapsing to the bare name // made a header ambiguous whenever another same-named file exists at cwd — // the edit tool would resolve the bare name against cwd, hit the wrong // file, and reject the valid edit via the snapshot-tag guard (the authored // path exists, so Patcher's tag-path recovery never runs). The relative // path stays directly resolvable against cwd and names the file uniquely. // Out-of-workspace reads use an absolute displayPath; `shortenPath` keeps // `~/.claude/...` (round-trips through resolveToCwd's ~ expansion) instead // of leaking the full home path into the read output. const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath; return formatHashlineHeader(anchor, tag); } function recordFullHashlineContext( session: ToolSession, absolutePath: string | undefined, displayPath: string, fullText: string, sourceStartLine?: number, ): HashlineHeaderContext | undefined { if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined; // An excerpt must never replace the backing file's full edit snapshot. const normalized = sourceStartLine === undefined ? normalizeToLF(fullText) : undefined; const tag = normalized === undefined ? getEditStore(session).recordSnapshotFile(absolutePath) : getEditStore(session).recordSnapshot(absolutePath, normalized); if (!tag) return undefined; return { header: formatReadHashlineHeader(displayPath, tag), tag, fullText: normalized, }; } export async function readHashlineHeaderContext( session: ToolSession, absolutePath: string, cwd: string, displayPath?: string, ): Promise { return hashlineHeaderContextForText(session, absolutePath, cwd, await Bun.file(absolutePath).text(), displayPath); } /** * {@link readHashlineHeaderContext} for a caller that already holds the file's * full text, so the file is not reopened just to hash it. Line endings are * normalized here, exactly as the reading variant does. */ export function hashlineHeaderContextForText( session: ToolSession, absolutePath: string, cwd: string, fullText: string, displayPath: string = formatPathRelativeToCwd(absolutePath, cwd), ): HashlineHeaderContext { const context = recordFullHashlineContext(session, absolutePath, displayPath, fullText); if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`); return context; } export function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext { return { header: formatReadHashlineHeader(displayPath, tag), tag }; } export function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string { return context ? `${context.header}\n${text}` : text; } export function formatTextWithMode( text: string, startNum: number, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): string { if (shouldAddHashLines) return formatNumberedLines(text, startNum); if (shouldAddLineNumbers) return prependLineNumbers(text, startNum); return text; } export const BRACKET_CONTEXT_ELLIPSIS = "…"; function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string { if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS; return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers); } export function formatLineEntriesWithMode( entries: readonly LineEntry[], shouldAddHashLines: boolean, shouldAddLineNumbers: boolean, ): string { return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n"); } /** Line count of file content: 0 for empty text, otherwise N newlines ⇒ N+1 lines. */ export function countTextLines(text: string): number { return text.length === 0 ? 0 : countNewlines(text) + 1; } /** Count addressable lines without splitting; a terminal LF closes the last line. */ function countSplitLines(text: string): number { if (text.length === 0) return 0; return countNewlines(text) + (text.endsWith("\n") ? 0 : 1); } /** Raw reads retain the addressable empty line of an empty source, as disk reads do. */ function countRawLines(text: string): number { return text.length === 0 ? 1 : countSplitLines(text); } /** `lines.slice(start, end).join("\n")` as a substring of the `text` that `lines` was split from. */ function sliceLineRange(text: string, lines: readonly string[], start: number, end: number): string { let from = 0; for (let i = 0; i < start; i++) from += lines[i].length + 1; let to = from; for (let i = start; i < end; i++) to += lines[i].length + 1; return text.slice(from, Math.max(from, to - 1)); } /** * Slice a raw range without splitting the whole text. Open-ended reads retain * the terminal newline when it closes the last addressable line. */ function sliceRawLineRange(text: string, start: number, end: number, includeFinalNewline: boolean): string { let from = 0; for (let i = 0; i < start; i++) from = text.indexOf("\n", from) + 1; let to = from; for (let i = start; i < end; i++) { const newlineAt = text.indexOf("\n", to); to = newlineAt === -1 ? text.length + 1 : newlineAt + 1; } return text.slice(from, Math.max(from, to - (includeFinalNewline ? 0 : 1))); } export function contiguousLineNumbers(startLine: number, count: number): number[] { const lines: number[] = []; for (let offset = 0; offset < count; offset++) lines.push(startLine + offset); return lines; } export function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] { const lines: number[] = []; for (const span of spans) { for (let line = span.startLine; line <= span.endLine; line++) lines.push(line); } return lines; } function recordInMemorySeenLines( session: ToolSession, absolutePath: string | undefined, fullText: string, seenLines: readonly number[] | undefined, sourceStartLine?: number, ): void { if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return; if (sourceStartLine !== undefined) { getEditStore(session).recordSnapshotFile( absolutePath, seenLines.map(line => line + sourceStartLine - 1), ); } else { getEditStore(session).recordSnapshot(absolutePath, normalizeToLF(fullText), [...seenLines]); } } function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] { const lines: number[] = []; for (const entry of entries) { if (entry.kind === "line") lines.push(entry.lineNumber); } return lines; } /** Sample ranges shown in the footer to demonstrate the multi-range syntax. */ const FOOTER_RANGE_SAMPLES = 2; /** * Footer appended to summarized reads telling the model how to recover the * elided body. Without this hint, agents either ignore the `…`/`{ … }` * markers or burn a turn guessing the right selector (see issue #1046). The * footer demonstrates the multi-range selector syntax with concrete sample * ranges drawn from the actual elision so the model re-reads only what it * needs instead of falling back to `:raw` or whole-file reads. */ export function formatSummaryElisionFooter( readPath: string, elidedRanges: ReadonlyArray, elidedLines: number, ): string { if (elidedRanges.length === 0) return ""; const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES); const selector = elidedRanges .slice(0, sampleCount) .map(r => `${r.start}-${r.end}`) .join(","); const example = `${readPath}:${selector}`; const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`; return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`; } export const READ_CHUNK_SIZE = 8 * 1024; /** * Context lines added around an explicit range read. Anchor-stale failures * cluster on edits whose anchors land just outside the most recent read * window, but the data (`scripts/session-stats/analyze_selector_reads.py`) * shows most follow-up reads are disjoint hops, not adjacent extensions — * so symmetric padding rarely pays for itself. * * Leading=1 catches accidental single-line reads where the anchor is the * line immediately above the requested start. Trailing=3 buffers the * common case where the agent asks for a narrow range and then needs the * next few lines to disambiguate an anchor. */ export const RANGE_LEADING_CONTEXT_LINES = 1; export const RANGE_TRAILING_CONTEXT_LINES = 3; /** * Expand a [start, end) range with leading/trailing context lines on the * sides where the user actually constrained the range. A start of 0 (no * explicit offset) does not get leading context — that's already an * open-ended read from the top. */ function expandRangeWithContext( requestedStart: number, requestedEnd: number, totalLines: number, expandStart: boolean, expandEnd: boolean, ): { startLine: number; endLine: number } { return { startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart, endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd, }; } /** Options shared by the in-memory text builders; `raw` flips the line split to verbatim `\n` segments. */ export interface InMemoryTextOptions { details?: ReadToolDetails; sourcePath?: string; /** Excerpt provenance: input line 1 maps to this backing-file line. */ sourceStartLine?: number; sourceUrl?: string; sourceInternal?: string; entityLabel: string; ignoreResultLimits?: boolean; raw?: boolean; immutable?: boolean; } /** * Render any read selector against in-memory text. Pins `:-N` tails to the * text's addressable line count, then dispatches multi-range selectors to * {@link buildInMemoryMultiRangeResult} and everything else to * {@link buildInMemoryTextResult}. Raw mode is derived from the selector. */ export async function buildInMemorySelectorResult( session: ToolSession, text: string, parsed: ParsedSelector, options: Omit, ): Promise> { const raw = isRawSelector(parsed); const totalLines = raw ? countRawLines(text) : countSplitLines(text); const sel = resolveTailSelector(parsed, totalLines); if (sel.kind === "lines" && sel.ranges.length > 1) { return buildInMemoryMultiRangeResult(session, text, sel.ranges, { ...options, raw }); } const { offset, limit } = selToOffsetLimit(sel); return buildInMemoryTextResult(session, text, offset, limit, { ...options, raw }); } export async function buildInMemoryTextResult( session: ToolSession, text: string, offset: number | undefined, limit: number | undefined, options: InMemoryTextOptions, ): Promise> { const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable }); const details = options.details ?? {}; const rawDisplay = options.raw === true; if (rawDisplay) details.raw = true; // Raw mode addresses verbatim `\n` segments and shows no block context, so it // slices the window straight out of `text`; only addressable reads need every line. const allLines = rawDisplay ? undefined : splitAddressableFileLines(text); const totalLines = allLines ? allLines.length : countRawLines(text); details.totalLines = totalLines; // User-requested 0-indexed range start. Lines BEFORE this are leading // context (added below if offset is explicit). const requestedStart = offset ? Math.max(0, offset - 1) : 0; const ignoreResultLimits = options.ignoreResultLimits ?? false; const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, totalLines) : totalLines; // Expand only on sides the user actually constrained: leading context // when offset>1, trailing context when a finite limit was set. Raw mode // never expands — without line numbers the padding is indistinguishable // from requested content, so `raw:31-31` must return line 31 and nothing // else (verbatim-extraction contract). const expanded = expandRangeWithContext( requestedStart, requestedEnd, totalLines, !rawDisplay && offset !== undefined && offset > 1, !rawDisplay && limit !== undefined, ); let startLine = expanded.startLine; const endLine = expanded.endLine; const resultBuilder = toolResult(details); if (options.sourcePath) { resultBuilder.sourcePath(options.sourcePath); } if (options.sourceUrl) { resultBuilder.sourceUrl(options.sourceUrl); } if (options.sourceInternal) { resultBuilder.sourceInternal(options.sourceInternal); } if (requestedStart >= totalLines) { const suggestion = totalLines === 0 ? `The ${options.entityLabel} is empty.` : `Use :1 to read from the start, or :${totalLines} to read the last line.`; const notice = `Line ${requestedStart + 1} is beyond end of ${options.entityLabel} (${totalLines} lines total). ${suggestion}`; return resultBuilder.text(appendReadNotice("", notice, details)).done(); } // Measure the range as a substring of `text` (equal to joining it) so a large range isn't // copied only for `truncateHead` to keep its head. Branches that emit the whole range // re-join it so the result never pins `text` through a substring. let selectedRange = allLines ? sliceLineRange(text, allLines, startLine, endLine) : sliceRawLineRange( text, startLine, endLine, limit === undefined && endLine === totalLines && text.endsWith("\n"), ); let truncation = ignoreResultLimits ? noTruncResult(selectedRange) : truncateHead(selectedRange); // The leading context line must not cost the requested start line: when the byte budget // stops at or inside it, `:` would widen to the same context and repeat this page. // The whole text is in memory, so drop the context and page from the requested line. if ( allLines && startLine < requestedStart && (truncation.firstLineExceedsLimit || (truncation.truncated && (truncation.outputLines ?? 0) <= requestedStart - startLine)) ) { startLine = requestedStart; selectedRange = sliceLineRange(text, allLines, startLine, endLine); truncation = truncateHead(selectedRange); } const startLineDisplay = startLine + 1; const joinSelectedLines = (): string => (allLines ? allLines.slice(startLine, endLine) : selectedRange.split("\n")).join("\n"); const userLimitedLines = limit !== undefined ? endLine - startLine : undefined; // Any display short of the whole text shows block context around it. if ( !rawDisplay && options.sourcePath && options.sourceStartLine === undefined && !truncation.firstLineExceedsLimit && (startLine > 0 || endLine < totalLines || truncation.truncated) ) { await warmBlockContext({ path: options.sourcePath, text }); } const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; details.rowPrefixes = shouldAddHashLines || shouldAddLineNumbers; const hashContext = shouldAddHashLines && options.sourcePath ? recordFullHashlineContext( session, options.sourcePath, formatPathRelativeToCwd(options.sourcePath, session.cwd), text, options.sourceStartLine, ) : undefined; let emittedHashlineHeader = false; let seenLines: number[] | undefined; let rawSeenLines: number[] | undefined; const formatText = (content: string, startNum: number): string => { startNum += (options.sourceStartLine ?? 1) - 1; const lineCount = countTextLines(content); details.displayContent = { text: content, startLine: startNum, lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i), }; if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, lineCount); const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => { const sourceOffset = (options.sourceStartLine ?? 1) - 1; if (sourceOffset !== 0) { entries = entries.map(entry => entry.kind === "line" ? { ...entry, lineNumber: entry.lineNumber + sourceOffset } : entry, ); } startNum += sourceOffset; const firstLine = entries.find(entry => entry.kind === "line"); details.displayContent = { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum, lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)), }; if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries); const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); if (!hashContext || emittedHashlineHeader) return formatted; emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; const buildLineEntries = (lines: readonly string[], endLineDisplay: number): LineEntry[] => buildLineEntriesWithBlockContext(lines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], { path: options.sourceStartLine === undefined ? options.sourcePath : undefined, text, }); let outputText: string; let truncationInfo: { result: TruncationResult; options: TruncationMetaInput } | undefined; if (truncation.firstLineExceedsLimit) { const firstLine = allLines ? (allLines[startLine] ?? "") : selectedRange.split("\n", 1)[0]; const firstLineBytes = Buffer.byteLength(firstLine, "utf-8"); const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES); if (shouldAddHashLines) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`; } else { outputText = formatText(snippet.text, startLineDisplay); } if (snippet.text.length === 0) { outputText = `[Line ${startLineDisplay} is ${formatBytes( firstLineBytes, )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`; } details.truncation = toReadTruncationStats(truncation); truncationInfo = { result: truncation, options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines }, }; } else if (truncation.truncated) { const outputLines = truncation.outputLines ?? countTextLines(truncation.content); const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1); if (!allLines) { rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines); outputText = formatText(truncation.content, startLineDisplay); } else { outputText = formatLineEntries(buildLineEntries(allLines, endLineDisplay), startLineDisplay); } details.truncation = toReadTruncationStats(truncation); truncationInfo = { result: truncation, // Without `maxBytes` the notice reports the bytes shown as the limit. options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines, maxBytes: DEFAULT_MAX_BYTES, }, }; } else if (!rawDisplay && userLimitedLines !== undefined && startLine + userLimitedLines < totalLines) { const remaining = totalLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; if (!allLines) { rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines); outputText = formatText(joinSelectedLines(), startLineDisplay); } else { outputText = formatLineEntries(buildLineEntries(allLines, endLine), startLineDisplay); } outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`; } else { if (!allLines) { rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine); outputText = formatText(joinSelectedLines(), startLineDisplay); } else { outputText = formatLineEntries(buildLineEntries(allLines, endLine), startLineDisplay); } } if (hashContext?.tag && options.sourcePath && seenLines) { getEditStore(session).recordSeenLines(options.sourcePath, hashContext.tag, seenLines); } if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) { recordInMemorySeenLines(session, options.sourcePath, text, rawSeenLines, options.sourceStartLine); } resultBuilder.text(outputText); if (truncationInfo) { resultBuilder.truncation(truncationInfo.result, truncationInfo.options); } return resultBuilder.done(); } /** * Render a multi-range read against in-memory text. Each range emits a * formatted block with its own anchors / line numbers, blocks are joined * with an elision separator, and ranges past EOF surface as `[…]` notices * so the model can correct the next call. No leading/trailing context is * added — multi-range callers always specify exact bounds. */ export async function buildInMemoryMultiRangeResult( session: ToolSession, text: string, ranges: readonly LineRange[], options: Omit, ): Promise> { const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable }); const details = options.details ?? {}; if (options.raw) details.raw = true; // Raw mode only emits the requested windows, so it never splits the whole text. const allLines = options.raw === true ? undefined : splitAddressableFileLines(text); const totalLines = allLines ? allLines.length : countRawLines(text); details.totalLines = totalLines; const shouldAddHashLines = displayMode.hashLines; const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers; details.rowPrefixes = shouldAddHashLines || shouldAddLineNumbers; const hashContext = shouldAddHashLines && options.sourcePath ? recordFullHashlineContext( session, options.sourcePath, formatPathRelativeToCwd(options.sourcePath, session.cwd), text, options.sourceStartLine, ) : undefined; let emittedHashlineHeader = false; let seenLines: number[] | undefined; const resultBuilder = toolResult(details); if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath); if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl); if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal); const outOfBounds: LineRange[] = []; const visibleSpans: Array<{ startLine: number; endLine: number }> = []; const rawParts: string[] = []; for (const range of ranges) { if (range.startLine > totalLines) { outOfBounds.push(range); continue; } const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines); visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd }); if (!allLines) { // Re-join (as the split path did) so the part never pins `text` through a substring. rawParts.push( sliceRawLineRange( text, range.startLine - 1, effectiveEnd, range.endLine === undefined && effectiveEnd === totalLines && text.endsWith("\n"), ) .split("\n") .join("\n"), ); } } let outputText = ""; if (!allLines) { outputText = rawParts.join("\n"); } else if (visibleSpans.length > 0) { if ( options.sourcePath && options.sourceStartLine === undefined && !spansCoverEveryLine(visibleSpans, totalLines) ) { await warmBlockContext({ path: options.sourcePath, text }); } let entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourceStartLine === undefined ? options.sourcePath : undefined, text, }); const sourceOffset = (options.sourceStartLine ?? 1) - 1; if (sourceOffset !== 0) { entries = entries.map(entry => entry.kind === "line" ? { ...entry, lineNumber: entry.lineNumber + sourceOffset } : entry, ); } if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries); const firstLine = entries.find(entry => entry.kind === "line"); if (firstLine?.kind === "line") { details.displayContent = { text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), startLine: firstLine.lineNumber, lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)), }; } const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted; if (hashContext) emittedHashlineHeader = true; } const notices: string[] = []; for (const range of outOfBounds) { const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`; notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`); } const finalText = notices.length > 0 ? appendReadNotice(outputText, notices.join("\n"), details) : outputText; if (hashContext?.tag && options.sourcePath && seenLines) { getEditStore(session).recordSeenLines(options.sourcePath, hashContext.tag, seenLines); } if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) { recordInMemorySeenLines( session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans), options.sourceStartLine, ); } resultBuilder.text(finalText); return resultBuilder.done(); } export function decodeUtf8Text(bytes: Uint8Array): string | null { if (bytes.indexOf(0) !== -1) return null; try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; } } export function prependSuffixResolutionNotice( text: string, suffixResolution?: ReadToolDetails["suffixResolution"], ): string { if (!suffixResolution || suffixResolution.raw) return text; const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`; return text ? `${notice}\n${text}` : notice; } /** * Tag Markdown reads for the TUI's formatted preview, gated on the opt-in * `read.renderMarkdown` setting. Off by default; when disabled, no local * read is tagged `text/markdown`, so the renderer output is identical to * the pre-setting behavior. Internal-URL reads keep their protocol-supplied * `contentType` and render as Markdown regardless of the setting. */ export function markMarkdownContentType( session: ToolSession, details: ReadToolDetails, filePath: string, ): ReadToolDetails { if (!details.contentType && cfgReadRenderMarkdown.get(session.settings) && isMarkdownPath(filePath)) { details.contentType = "text/markdown"; } return details; }