Revert "Merge PR #5812: fix(read): honor exact line selector bounds (@roboomp)"
This reverts commit58c71d5b50, reversing changes made to7c7227bc8e.
This commit is contained in:
@@ -131,6 +131,7 @@
|
||||
### Added
|
||||
|
||||
- Added `PI_CONFIG_FILES`, a platform-delimited (`:` on Unix, `;` on Windows) environment path-list of settings overlays loaded before `--config` overlays, so wrapper scripts can inject settings without argv surgery ([#5685](https://github.com/can1357/oh-my-pi/issues/5685)).
|
||||
- Fixed the `/extensions` dashboard tab labeled "Agents (standard)" being confused with the `/agents` subagents feature — the `.agent`/`.agents` config-standard provider now presents as "Agent Dirs (.agent/.agents)" since it lists skills, rules, prompts, commands, and context/system files, never subagents ([#5821](https://github.com/can1357/oh-my-pi/issues/5821)).
|
||||
|
||||
## [17.0.2] - 2026-07-17
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from "../session/streaming-output";
|
||||
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
|
||||
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
||||
import { buildLineEntries, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
|
||||
import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
|
||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||
import {
|
||||
ImageInputTooLargeError,
|
||||
@@ -164,7 +164,7 @@ const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
|
||||
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
||||
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
|
||||
|
||||
async function readSmallFileLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
|
||||
async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
|
||||
if (fileSize > SNAPSHOT_MAX_BYTES) return undefined;
|
||||
try {
|
||||
return normalizeToLF(await Bun.file(absolutePath).text()).split("\n");
|
||||
@@ -395,6 +395,40 @@ function formatSummaryElisionFooter(
|
||||
}
|
||||
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.
|
||||
*/
|
||||
const RANGE_LEADING_CONTEXT_LINES = 1;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function streamLinesFromFile(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
@@ -1271,10 +1305,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
const details = options.details ?? {};
|
||||
const allLines = text.split("\n");
|
||||
const totalLines = allLines.length;
|
||||
// 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 startLine = requestedStart;
|
||||
const ignoreResultLimits = options.ignoreResultLimits ?? false;
|
||||
const endLine = limit !== undefined ? Math.min(startLine + limit, allLines.length) : allLines.length;
|
||||
const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
|
||||
// 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 rawDisplay = options.raw === true;
|
||||
const expanded = expandRangeWithContext(
|
||||
requestedStart,
|
||||
requestedEnd,
|
||||
allLines.length,
|
||||
!rawDisplay && offset !== undefined && offset > 1,
|
||||
!rawDisplay && limit !== undefined,
|
||||
);
|
||||
const startLine = expanded.startLine;
|
||||
const endLineExpanded = expanded.endLine;
|
||||
const startLineDisplay = startLine + 1;
|
||||
|
||||
const resultBuilder = toolResult(details);
|
||||
@@ -1300,6 +1350,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
.done();
|
||||
}
|
||||
|
||||
const endLine = endLineExpanded;
|
||||
const selectedContent = allLines.slice(startLine, endLine).join("\n");
|
||||
const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
|
||||
const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
|
||||
@@ -1344,8 +1395,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
emittedHashlineHeader = true;
|
||||
return prependHashlineHeader(formatted, hashContext);
|
||||
};
|
||||
const buildSelectedLineEntries = (endLineDisplay: number): LineEntry[] =>
|
||||
buildLineEntries(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }]);
|
||||
const buildLineEntries = (endLineDisplay: number): LineEntry[] =>
|
||||
buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], {
|
||||
path: options.sourcePath,
|
||||
});
|
||||
|
||||
let outputText: string;
|
||||
let truncationInfo:
|
||||
@@ -1383,7 +1436,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
|
||||
outputText = formatText(truncation.content, startLineDisplay);
|
||||
} else {
|
||||
outputText = formatLineEntries(buildSelectedLineEntries(endLineDisplay), startLineDisplay);
|
||||
outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
|
||||
}
|
||||
details.truncation = truncation;
|
||||
truncationInfo = {
|
||||
@@ -1398,7 +1451,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
|
||||
outputText = formatText(selectedContent, startLineDisplay);
|
||||
} else {
|
||||
outputText = formatLineEntries(buildSelectedLineEntries(endLine), startLineDisplay);
|
||||
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
|
||||
}
|
||||
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
|
||||
} else {
|
||||
@@ -1406,7 +1459,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
|
||||
outputText = formatText(truncation.content, startLineDisplay);
|
||||
} else {
|
||||
outputText = formatLineEntries(buildSelectedLineEntries(endLine), startLineDisplay);
|
||||
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,8 +1480,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
* 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. Context is never added because
|
||||
* multi-range callers always specify exact bounds.
|
||||
* so the model can correct the next call. No leading/trailing context is
|
||||
* added — multi-range callers always specify exact bounds.
|
||||
*/
|
||||
#buildInMemoryMultiRangeResult(
|
||||
text: string,
|
||||
@@ -1485,7 +1538,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (options.raw === true) {
|
||||
outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
|
||||
} else if (visibleSpans.length > 0) {
|
||||
const entries = buildLineEntries(allLines, visibleSpans);
|
||||
const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
|
||||
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
|
||||
const firstLine = entries.find(entry => entry.kind === "line");
|
||||
if (firstLine?.kind === "line") {
|
||||
@@ -1569,7 +1622,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
const notices: string[] = [];
|
||||
const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
|
||||
const displayLineByNumber = new Map<number, string>();
|
||||
const fullLines = rawSelector ? undefined : await readSmallFileLines(absolutePath, fileSize);
|
||||
const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
|
||||
let columnTruncated = 0;
|
||||
let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
|
||||
|
||||
@@ -1637,9 +1690,23 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
|
||||
let outputText: string;
|
||||
if (!rawSelector && fullLines && visibleSpans.length > 0) {
|
||||
const entries = buildLineEntries(fullLines, visibleSpans, {
|
||||
lineText: (lineNumber, sourceText) => displayLineByNumber.get(lineNumber) ?? sourceText,
|
||||
});
|
||||
const entries = buildLineEntriesWithBlockContext(
|
||||
fullLines,
|
||||
visibleSpans,
|
||||
{ path: absolutePath },
|
||||
{
|
||||
lineText: (lineNumber, sourceText) => {
|
||||
const visibleText = displayLineByNumber.get(lineNumber);
|
||||
if (visibleText !== undefined) return visibleText;
|
||||
if (maxColumns <= 0) return sourceText;
|
||||
const truncated = truncateLine(sourceText, maxColumns);
|
||||
if (truncated.wasTruncated) {
|
||||
columnTruncated = maxColumns;
|
||||
}
|
||||
return truncated.text;
|
||||
},
|
||||
},
|
||||
);
|
||||
const firstLine = entries.find(entry => entry.kind === "line");
|
||||
displayContent = {
|
||||
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
|
||||
@@ -2446,7 +2513,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
// Raw text or line-range mode
|
||||
const { offset, limit } = selToOffsetLimit(parsed);
|
||||
// Try ACP bridge first — editor's in-memory buffer is source of truth.
|
||||
// Request full text so local range rendering preserves exact bounds and line numbers.
|
||||
// Request full text so local range rendering keeps normal context and line numbers.
|
||||
const bridgePromise = this.#routeReadThroughBridge(absolutePath);
|
||||
if (bridgePromise !== undefined) {
|
||||
try {
|
||||
@@ -2468,15 +2535,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
// User-requested 0-indexed range start. Lines BEFORE this become
|
||||
// leading context (added below if offset is explicit). Raw mode
|
||||
// never adds context: without line numbers the padding is
|
||||
// indistinguishable from requested content, so `raw:31-31` must
|
||||
// return line 31 and nothing else.
|
||||
const rawSelector = isRawSelector(parsed);
|
||||
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
|
||||
const startLine = requestedStart;
|
||||
const expandStart = !rawSelector && offset !== undefined && offset > 1;
|
||||
const expandEnd = !rawSelector && limit !== undefined;
|
||||
const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
|
||||
const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
|
||||
const startLine = requestedStart - leadingContext;
|
||||
const startLineDisplay = startLine + 1;
|
||||
|
||||
const DEFAULT_LIMIT = this.#defaultLimit;
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
|
||||
const selectedLineLimit = effectiveLimit;
|
||||
const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
|
||||
const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
|
||||
// Scale byte budget with line limit so the configured line count actually fits.
|
||||
// Assume ~512 bytes/line average; never go below the shared default.
|
||||
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
|
||||
@@ -2538,6 +2614,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (cloned) displayLines = cloned;
|
||||
}
|
||||
|
||||
const displayLineByNumber = new Map<number, string>();
|
||||
for (let i = 0; i < displayLines.length; i++) {
|
||||
displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? "");
|
||||
}
|
||||
const bracketContextFullLines = rawSelector
|
||||
? undefined
|
||||
: await readBracketContextFullLines(absolutePath, fileSize);
|
||||
const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1);
|
||||
|
||||
const selectedContent = displayLines.join("\n");
|
||||
const userLimitedLines = collectedLines.length;
|
||||
|
||||
@@ -2594,6 +2679,36 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
emittedHashlineHeader = true;
|
||||
return prependHashlineHeader(formatted, hashContext);
|
||||
};
|
||||
const formatBracketAwareText = (): string | undefined => {
|
||||
if (!bracketContextFullLines) return undefined;
|
||||
const entries = buildLineEntriesWithBlockContext(
|
||||
bracketContextFullLines,
|
||||
[{ startLine: startLineDisplay, endLine: displayedEndLine }],
|
||||
{ path: absolutePath },
|
||||
{
|
||||
lineText: (lineNumber, sourceText) => {
|
||||
const visibleText = displayLineByNumber.get(lineNumber);
|
||||
if (visibleText !== undefined) return visibleText;
|
||||
if (maxColumns <= 0) return sourceText;
|
||||
const truncated = truncateLine(sourceText, maxColumns);
|
||||
if (truncated.wasTruncated) {
|
||||
columnTruncated = maxColumns;
|
||||
}
|
||||
return truncated.text;
|
||||
},
|
||||
},
|
||||
);
|
||||
const firstLine = entries.find(entry => entry.kind === "line");
|
||||
capturedDisplayContent = {
|
||||
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
|
||||
startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startLineDisplay,
|
||||
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
|
||||
};
|
||||
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
|
||||
if (!hashContext || emittedHashlineHeader) return formatted;
|
||||
emittedHashlineHeader = true;
|
||||
return prependHashlineHeader(formatted, hashContext);
|
||||
};
|
||||
|
||||
let outputText: string;
|
||||
|
||||
@@ -2624,7 +2739,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
},
|
||||
};
|
||||
} else if (truncation.truncated) {
|
||||
outputText = formatText(truncation.content, startLineDisplay);
|
||||
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
|
||||
details = { truncation };
|
||||
sourcePath = absolutePath;
|
||||
truncationInfo = {
|
||||
@@ -2638,7 +2753,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
} else if (startLine + userLimitedLines < totalFileLines || !reachedEof) {
|
||||
const nextOffset = startLine + userLimitedLines + 1;
|
||||
|
||||
outputText = formatText(truncation.content, startLineDisplay);
|
||||
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
|
||||
outputText += reachedEof
|
||||
? `\n\n[${totalFileLines - (startLine + userLimitedLines)} more lines in file. Use :${nextOffset} to continue]`
|
||||
: `\n\n[More lines in file (${formatBytes(fileSize)} total; not scanned to EOF). Use :${nextOffset} to continue]`;
|
||||
@@ -2646,7 +2761,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
sourcePath = absolutePath;
|
||||
} else {
|
||||
// No truncation, no user limit exceeded
|
||||
outputText = formatText(truncation.content, startLineDisplay);
|
||||
outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
|
||||
details = {};
|
||||
sourcePath = absolutePath;
|
||||
}
|
||||
@@ -2866,11 +2981,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
|
||||
const { offset, limit } = selToOffsetLimit(parsedSel);
|
||||
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
|
||||
const startLine = requestedStart;
|
||||
// Raw mode never adds context lines — see the plain-file range path.
|
||||
const expandStart = !rawSelector && offset !== undefined && offset > 1;
|
||||
const expandEnd = !rawSelector && limit !== undefined;
|
||||
const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
|
||||
const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
|
||||
const startLine = requestedStart - leadingContext;
|
||||
const startLineDisplay = startLine + 1;
|
||||
const effectiveLimit = limit ?? this.#defaultLimit;
|
||||
const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
|
||||
const selectedLineLimit = effectiveLimit;
|
||||
const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
|
||||
const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
|
||||
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
|
||||
const streamResult = await streamLinesFromFile(
|
||||
artifact.path,
|
||||
|
||||
@@ -266,24 +266,27 @@ export function findBlockContextLines(
|
||||
return nativeBlockContext(fullLines, visible, source) ?? lexicalBracketContext(fullLines, visible);
|
||||
}
|
||||
|
||||
interface LineEntryOptions {
|
||||
lineText?: (lineNumber: number, sourceText: string, context: boolean) => string;
|
||||
}
|
||||
|
||||
function buildEntries(
|
||||
/**
|
||||
* Build display entries for `visibleSpans` plus any off-window block-boundary
|
||||
* lines, in source order, with `{ kind: "ellipsis" }` markers inserted across
|
||||
* non-contiguous gaps. `options.lineText` lets callers substitute display text
|
||||
* (e.g. column-truncated lines) for a given line number.
|
||||
*/
|
||||
export function buildLineEntriesWithBlockContext(
|
||||
fullLines: readonly string[],
|
||||
visible: ReadonlySet<number>,
|
||||
context: ReadonlyMap<number, string> | undefined,
|
||||
options: LineEntryOptions,
|
||||
visibleSpans: readonly LineSpan[],
|
||||
source: BlockContextSource = {},
|
||||
options: {
|
||||
lineText?: (lineNumber: number, sourceText: string, context: boolean) => string;
|
||||
} = {},
|
||||
): LineEntry[] {
|
||||
const sorted = [...visible];
|
||||
if (context) {
|
||||
for (const lineNumber of context.keys()) {
|
||||
if (!visible.has(lineNumber)) sorted.push(lineNumber);
|
||||
}
|
||||
}
|
||||
sorted.sort((left, right) => left - right);
|
||||
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
|
||||
const visible = visibleLineNumbers(spans);
|
||||
const context = findBlockContextLines(fullLines, visible, source);
|
||||
const allLines = new Set<number>(visible);
|
||||
for (const lineNumber of context.keys()) allLines.add(lineNumber);
|
||||
|
||||
const sorted = [...allLines].sort((left, right) => left - right);
|
||||
const entries: LineEntry[] = [];
|
||||
let previousLine: number | undefined;
|
||||
for (const lineNumber of sorted) {
|
||||
@@ -291,7 +294,7 @@ function buildEntries(
|
||||
entries.push({ kind: "ellipsis" });
|
||||
}
|
||||
const sourceText = fullLines[lineNumber - 1] ?? "";
|
||||
const isContext = context?.has(lineNumber) === true;
|
||||
const isContext = context.has(lineNumber);
|
||||
entries.push({
|
||||
kind: "line",
|
||||
lineNumber,
|
||||
@@ -304,33 +307,6 @@ function buildEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Build display entries for exactly the requested spans, separated by ellipses. */
|
||||
export function buildLineEntries(
|
||||
fullLines: readonly string[],
|
||||
visibleSpans: readonly LineSpan[],
|
||||
options: LineEntryOptions = {},
|
||||
): LineEntry[] {
|
||||
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
|
||||
return buildEntries(fullLines, visibleLineNumbers(spans), undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build display entries for `visibleSpans` plus any off-window block-boundary
|
||||
* lines, in source order, with `{ kind: "ellipsis" }` markers inserted across
|
||||
* non-contiguous gaps. `options.lineText` lets callers substitute display text
|
||||
* (e.g. column-truncated lines) for a given line number.
|
||||
*/
|
||||
export function buildLineEntriesWithBlockContext(
|
||||
fullLines: readonly string[],
|
||||
visibleSpans: readonly LineSpan[],
|
||||
source: BlockContextSource = {},
|
||||
options: LineEntryOptions = {},
|
||||
): LineEntry[] {
|
||||
const spans = normalizeLineSpans(visibleSpans, fullLines.length);
|
||||
const visible = visibleLineNumbers(spans);
|
||||
return buildEntries(fullLines, visible, findBlockContextLines(fullLines, visible, source), options);
|
||||
}
|
||||
|
||||
export function lineEntriesToPlainText(entries: readonly LineEntry[], ellipsis = "…"): string {
|
||||
return entries.map(entry => (entry.kind === "ellipsis" ? ellipsis : entry.text)).join("\n");
|
||||
}
|
||||
|
||||
@@ -79,8 +79,6 @@ describe("read tool multi-range selector", () => {
|
||||
expect(text).toContain("line 20");
|
||||
expect(text).toContain("line 21");
|
||||
expect(text).toContain("line 22");
|
||||
expect(text).not.toMatch(/^2:line 2$/m);
|
||||
expect(text).not.toMatch(/^6:line 6$/m);
|
||||
// Lines between the ranges must be elided
|
||||
expect(text).not.toContain("line 10");
|
||||
expect(text).not.toContain("line 19");
|
||||
@@ -88,7 +86,7 @@ describe("read tool multi-range selector", () => {
|
||||
expect(text).toContain("…");
|
||||
});
|
||||
|
||||
it("does not add a closing bracket outside a forward range", async () => {
|
||||
it("includes the matching closing bracket line outside a forward range", async () => {
|
||||
const filePath = path.join(tmpDir, "brackets.ts");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
@@ -108,13 +106,13 @@ describe("read tool multi-range selector", () => {
|
||||
const text = textOutput(await tool.execute("call-bracket-close", { path: `${filePath}:1-1` }));
|
||||
|
||||
expect(text).toContain("function outer() {");
|
||||
expect(text).not.toContain("…");
|
||||
expect(text).not.toMatch(/^7:}$/m);
|
||||
expect(text).toContain("…");
|
||||
expect(text).toContain("}");
|
||||
expect(text).not.toContain("const four");
|
||||
expect(text).not.toContain("return one + two");
|
||||
});
|
||||
|
||||
it("does not add an opening bracket outside a reverse range", async () => {
|
||||
it("includes the matching opening bracket line outside a reverse range", async () => {
|
||||
const filePath = path.join(tmpDir, "brackets.ts");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
@@ -133,14 +131,13 @@ describe("read tool multi-range selector", () => {
|
||||
const tool = new ReadTool(createSession(tmpDir));
|
||||
const text = textOutput(await tool.execute("call-bracket-open", { path: `${filePath}:7-7` }));
|
||||
|
||||
expect(text).toMatch(/^7:}$/m);
|
||||
expect(text).not.toContain("function outer() {");
|
||||
expect(text).not.toContain("…");
|
||||
expect(text.indexOf("function outer() {")).toBeLessThan(text.indexOf("}"));
|
||||
expect(text).toContain("…");
|
||||
expect(text).not.toContain("const one = 1");
|
||||
expect(text).not.toContain("const four = 4");
|
||||
});
|
||||
|
||||
it("does not add Python syntactic boundaries outside a range", async () => {
|
||||
it("uses tree-sitter syntactic spans for indentation languages (Python)", async () => {
|
||||
const filePath = path.join(tmpDir, "module.py");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
@@ -159,11 +156,15 @@ describe("read tool multi-range selector", () => {
|
||||
);
|
||||
|
||||
const tool = new ReadTool(createSession(tmpDir));
|
||||
// Read only the `def` header (expands by a few trailing context lines).
|
||||
// Python has no closing delimiter, so a bracket scan would surface
|
||||
// nothing; tree-sitter surfaces the def's last body line (9) as the
|
||||
// block boundary, behind an ellipsis for the skipped middle.
|
||||
const text = textOutput(await tool.execute("call-py-def", { path: `${filePath}:1-1` }));
|
||||
|
||||
expect(text).toContain("def greet(name):");
|
||||
expect(text).not.toContain("…");
|
||||
expect(text).not.toContain("return a + b + c + d + e + f + g + len(name)");
|
||||
expect(text).toContain("…");
|
||||
expect(text).toContain("return a + b + c + d + e + f + g + len(name)");
|
||||
expect(text).not.toContain("trailing = 1");
|
||||
});
|
||||
|
||||
@@ -178,7 +179,7 @@ describe("read tool multi-range selector", () => {
|
||||
|
||||
// All lines from the merged range present
|
||||
for (const i of [3, 4, 5, 6, 7, 8, 9]) {
|
||||
expect(text).toMatch(new RegExp(`^${i}:line ${i}$`, "m"));
|
||||
expect(text).toContain(`line ${i}\n`);
|
||||
}
|
||||
// No separator because ranges merged into one contiguous block
|
||||
expect(text).not.toContain("…");
|
||||
|
||||
@@ -77,7 +77,6 @@ describe("read tool large artifact handling", () => {
|
||||
|
||||
expect(output).toContain("line-001");
|
||||
expect(output).toContain("line-003");
|
||||
expect(output).not.toContain("line-004");
|
||||
expect(output).toContain("Artifact storage:");
|
||||
expect(output).toContain("artifact://0:raw:N-M");
|
||||
expect(output).not.toContain("line-400");
|
||||
|
||||
@@ -137,7 +137,6 @@ describe("read PDF with a line-range selector", () => {
|
||||
.join("\n");
|
||||
expect(selectorText).toContain("pdf line 2");
|
||||
expect(selectorText).toContain("pdf line 3");
|
||||
expect(selectorText).not.toContain("pdf line 1");
|
||||
|
||||
expect(convert).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
|
||||
@@ -59,12 +59,14 @@ describe("read tool raw range exactness", () => {
|
||||
expect(output.trimEnd()).toBe("L01\nL02");
|
||||
});
|
||||
|
||||
it("returns exactly the requested numbered range without context padding", async () => {
|
||||
it("keeps context padding for numbered range reads", async () => {
|
||||
// Numbered mode intentionally pads (leading anchor buffer + trailing
|
||||
// disambiguation lines) — line numbers make the padding self-describing.
|
||||
const result = await tool.execute("call-numbered", { path: `${filePath}:31-31` });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain("L31");
|
||||
expect(output).not.toContain("L30");
|
||||
expect(output).not.toContain("L32");
|
||||
expect(output).toContain("L30");
|
||||
expect(output).toContain("L32");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user