diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce715fbba..0c6b64273 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed +- Fixed read and edit previews to include the matching bracket line behind an ellipsis when a shown opening/closing bracket pairs outside the displayed range. - Fixed `tab.screenshot({ selector })` hanging for the entire cell budget on continuously-animating pages (WebGL / `backdrop-filter` "glass" effects). The element-screenshot path no longer routes through puppeteer's `scrollIntoViewIfNeeded()`, whose `IntersectionObserver` promise can stall indefinitely under heavy rendering; it now does a single instant `scrollIntoView` and captures with `scrollIntoView: false` (relying on `captureBeyondViewport`), so off-screen elements are still captured without the stall. - Fixed follow-up message submissions to forward pending clipboard-pasted images to `session.prompt` in both streaming and non-streaming flows - Fixed follow-up handling to clear consumed clipboard image state after submission so pasted images are not silently carried into later messages diff --git a/packages/coding-agent/src/edit/diff.ts b/packages/coding-agent/src/edit/diff.ts index de38be329..1cd9b11c9 100644 --- a/packages/coding-agent/src/edit/diff.ts +++ b/packages/coding-agent/src/edit/diff.ts @@ -6,6 +6,7 @@ */ import * as Diff from "diff"; import { resolveToCwd } from "../tools/path-utils"; +import { findMatchingBracketContextLines } from "../utils/matching-brackets"; import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import { readEditFileText } from "./read-file"; @@ -54,6 +55,94 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten return `${prefix}${lineNum}|${content}`; } +type DiffSource = "old" | "new"; + +interface ParsedNumberedDiffRow { + prefix: "+" | "-" | " "; + lineNumber: number; + content: string; + source: DiffSource; +} + +function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined { + const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row); + if (!match) return undefined; + const prefix = match[1] as "+" | "-" | " "; + const lineNumber = Number.parseInt(match[2], 10); + if (!Number.isFinite(lineNumber)) return undefined; + return { + prefix, + lineNumber, + content: match[3] ?? "", + source: prefix === "+" ? "new" : "old", + }; +} + +function isDiffChangeRow(row: string | undefined): boolean { + return row !== undefined && (row.startsWith("+") || row.startsWith("-")); +} + +function adjustedContextInsertIndex(rows: readonly string[], index: number): number { + let start = index; + while (start > 0 && isDiffChangeRow(rows[start - 1])) start--; + let end = index; + while (end < rows.length && isDiffChangeRow(rows[end])) end++; + return index > start && index < end ? end : index; +} + +function insertBracketContextRows( + rows: string[], + source: DiffSource, + contextLines: ReadonlyMap, + seenRows: Set, +): void { + const context = [...contextLines].sort(([left], [right]) => left - right); + for (const [lineNumber, text] of context) { + const row = formatNumberedDiffLine(" ", lineNumber, text); + if (seenRows.has(row)) continue; + + let insertIndex = rows.length; + let previousSourceLine: number | undefined; + let nextSourceLine: number | undefined; + for (let i = 0; i < rows.length; i++) { + const parsed = parseNumberedDiffRow(rows[i]); + if (!parsed || parsed.source !== source) continue; + if (parsed.lineNumber < lineNumber) { + previousSourceLine = parsed.lineNumber; + continue; + } + nextSourceLine = parsed.lineNumber; + insertIndex = i; + break; + } + + const chunk: string[] = []; + if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("..."); + chunk.push(row); + if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("..."); + + const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex); + rows.splice(adjustedIndex, 0, ...chunk); + for (const inserted of chunk) seenRows.add(inserted); + } +} + +function addMatchingBracketContextRows(rows: string[], oldLines: readonly string[], newLines: readonly string[]): void { + const oldVisible: number[] = []; + const newVisible: number[] = []; + const seenRows = new Set(rows); + + for (const row of rows) { + const parsed = parseNumberedDiffRow(row); + if (!parsed) continue; + if (parsed.source === "old") oldVisible.push(parsed.lineNumber); + else newVisible.push(parsed.lineNumber); + } + + insertBracketContextRows(rows, "old", findMatchingBracketContextLines(oldLines, oldVisible), seenRows); + insertBracketContextRows(rows, "new", findMatchingBracketContextLines(newLines, newVisible), seenRows); +} + /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). @@ -162,6 +251,8 @@ export function generateDiffString(oldContent: string, newContent: string, conte } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + return { diff: output.join("\n"), firstChangedLine }; } @@ -220,6 +311,8 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string } } + addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n")); + return { diff: output.join("\n"), firstChangedLine }; } 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 c468b74c7..5225a42a6 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -54,6 +54,10 @@ type ReadToolResultDetails = { }; conflictCount?: number; displayReadTargets?: unknown; + displayContent?: { + text?: string; + startLine?: number; + }; meta?: { source?: { type?: string; @@ -373,10 +377,13 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined; entry.conflictCount = conflictCount; entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success"; - // Store the text content for preview/expanded display + // Store clean display content for preview/expanded display when the read + // tool provides it; fall back to model-facing text for legacy results. + const displayContent = + typeof details?.displayContent?.text === "string" ? details.displayContent.text : undefined; const textContent = result.content?.find(c => c.type === "text")?.text; - if (textContent !== undefined) { - entry.contentText = textContent; + if (displayContent !== undefined || textContent !== undefined) { + entry.contentText = displayContent ?? textContent; } this.#updateDisplay(); } diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 4bdb25d28..70a658f3e 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -18,8 +18,8 @@ Append `:` to `path`. The bare path falls back to the default mode. - `:50` / `:50-` — read from line 50 onward. - `:50-200` — lines 50–200 inclusive. - `:50+150` — 150 lines starting at line 50. -- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines). -- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact bounds with no context padding. +- `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines; if an emitted bracket pairs with a line outside the window, that matching line is included behind an ellipsis). +- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact requested bounds plus matching bracket lines when a shown bracket's pair sits outside those bounds. - `:raw` — verbatim text; no anchors, no summary, no line prefixes. - `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order. - `:conflicts` — one-line-per-block index of every unresolved git merge conflict. diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 86124528f..e19e5587f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,7 +9,12 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; -import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store"; +import { + canonicalSnapshotKey, + getFileSnapshotStore, + recordFileSnapshot, + SNAPSHOT_MAX_BYTES, +} from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -33,6 +38,11 @@ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; +import { + buildLineEntriesWithMatchingBracketContext, + type LineEntry, + lineEntriesToPlainText, +} from "../utils/matching-brackets"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader"; import { @@ -108,6 +118,15 @@ 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 readBracketContextFullLines(absolutePath: string, fileSize: number): Promise { + if (fileSize > SNAPSHOT_MAX_BYTES) return undefined; + try { + return normalizeToLF(await Bun.file(absolutePath).text()).split("\n"); + } catch { + return undefined; + } +} + function isRemoteMountPath(absolutePath: string): boolean { return absolutePath.startsWith(REMOTE_MOUNT_PREFIX); } @@ -174,6 +193,21 @@ function formatTextWithMode( return text; } +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); +} + +function formatLineEntriesWithMode( + entries: readonly LineEntry[], + shouldAddHashLines: boolean, + shouldAddLineNumbers: boolean, +): string { + return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n"); +} + const BRACE_PAIRS: Record = { "{": "}", "(": ")", "[": "]" }; const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/; @@ -915,6 +949,21 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => { + const firstLine = entries.find(entry => entry.kind === "line"); + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; + const buildLineEntries = (endLineDisplay: number): LineEntry[] => + buildLineEntriesWithMatchingBracketContext(allLines, [ + { startLine: startLineDisplay, endLine: endLineDisplay }, + ]); let outputText: string; let truncationInfo: @@ -946,7 +995,12 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + const outputLines = truncation.outputLines ?? countTextLines(truncation.content); + const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay); details.truncation = truncation; truncationInfo = { result: truncation, @@ -956,10 +1010,16 @@ export class ReadTool implements AgentTool { const remaining = allLines.length - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(selectedContent, startLineDisplay); + outputText = + options.raw === true + ? formatText(selectedContent, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`; } else { - outputText = formatText(truncation.content, startLineDisplay); + outputText = + options.raw === true + ? formatText(truncation.content, startLineDisplay) + : formatLineEntries(buildLineEntries(endLine), startLineDisplay); } resultBuilder.text(outputText); @@ -1011,21 +1071,37 @@ export class ReadTool implements AgentTool { if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl); if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal); - const parts: string[] = []; 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); - const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n"); - const formatted = formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers); - parts.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted); - if (hashContext) emittedHashlineHeader = true; + visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd }); + if (options.raw === true) { + rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n")); + } } - const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : ""; + let outputText = ""; + if (options.raw === true) { + outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : ""; + } else if (visibleSpans.length > 0) { + const entries = buildLineEntriesWithMatchingBracketContext(allLines, visibleSpans); + const firstLine = entries.find(entry => entry.kind === "line"); + if (firstLine?.kind === "line") { + details.displayContent = { + text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS), + startLine: firstLine.lineNumber, + }; + } + 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}`; @@ -1046,6 +1122,7 @@ export class ReadTool implements AgentTool { async #readLocalFileMultiRange( absolutePath: string, ranges: readonly LineRange[], + fileSize: number, parsed: ParsedSelector, displayMode: { hashLines: boolean; lineNumbers: boolean }, suffixResolution: { from: string; to: string } | undefined, @@ -1053,6 +1130,7 @@ export class ReadTool implements AgentTool { ): Promise<{ outputText: string; columnTruncated: number; + displayContent?: { text: string; startLine: number }; bridgeResult?: AgentToolResult; }> { const rawSelector = isRawSelector(parsed); @@ -1085,7 +1163,11 @@ export class ReadTool implements AgentTool { const blocks: string[] = []; const notices: string[] = []; + const visibleSpans: Array<{ startLine: number; endLine: number }> = []; + const displayLineByNumber = new Map(); + const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize); let columnTruncated = 0; + let displayContent: { text: string; startLine: number } | undefined; for (const range of ranges) { const rangeStart = range.startLine - 1; // 0-indexed @@ -1125,11 +1207,38 @@ export class ReadTool implements AgentTool { } if (cloned) displayLines = cloned; } - const blockText = displayLines.join("\n"); - blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + const endLine = range.startLine + Math.max(0, displayLines.length - 1); + visibleSpans.push({ startLine: range.startLine, endLine }); + for (let i = 0; i < displayLines.length; i++) { + displayLineByNumber.set(range.startLine + i, displayLines[i] ?? ""); + } + if (!fullLines || rawSelector) { + const blockText = displayLines.join("\n"); + blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers)); + } } - let outputText = blocks.join("\n\n…\n\n"); + let outputText: string; + if (!rawSelector && fullLines && visibleSpans.length > 0) { + const entries = buildLineEntriesWithMatchingBracketContext(fullLines, visibleSpans, { + 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), + startLine: firstLine?.kind === "line" ? firstLine.lineNumber : (visibleSpans[0]?.startLine ?? 1), + }; + outputText = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + } else { + outputText = blocks.join("\n\n…\n\n"); + } if (shouldAddHashLines && outputText) { const tag = await recordFileSnapshot(this.session, absolutePath); if (tag) { @@ -1139,7 +1248,7 @@ export class ReadTool implements AgentTool { if (notices.length > 0) { outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n"); } - return { outputText, columnTruncated }; + return { outputText, columnTruncated, displayContent }; } async #readArchiveDirectory( @@ -1818,6 +1927,7 @@ export class ReadTool implements AgentTool { const multiResult = await this.#readLocalFileMultiRange( absolutePath, parsed.ranges, + fileSize, parsed, displayMode, suffixResolution, @@ -1826,7 +1936,7 @@ export class ReadTool implements AgentTool { if (multiResult.bridgeResult) return multiResult.bridgeResult; content = [{ type: "text", text: multiResult.outputText }]; sourcePath = absolutePath; - details = {}; + details = multiResult.displayContent ? { displayContent: multiResult.displayContent } : {}; if (multiResult.columnTruncated > 0) { columnTruncated = multiResult.columnTruncated; } @@ -1930,6 +2040,15 @@ export class ReadTool implements AgentTool { if (cloned) displayLines = cloned; } + const displayLineByNumber = new Map(); + 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; @@ -1979,6 +2098,32 @@ export class ReadTool implements AgentTool { emittedHashlineHeader = true; return prependHashlineHeader(formatted, hashContext); }; + const formatBracketAwareText = (): string | undefined => { + if (!bracketContextFullLines) return undefined; + const entries = buildLineEntriesWithMatchingBracketContext( + bracketContextFullLines, + [{ startLine: startLineDisplay, endLine: displayedEndLine }], + { + 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, + }; + const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers); + if (!hashContext || emittedHashlineHeader) return formatted; + emittedHashlineHeader = true; + return prependHashlineHeader(formatted, hashContext); + }; let outputText: string; @@ -2005,7 +2150,7 @@ export class ReadTool implements AgentTool { options: { direction: "head", startLine: startLineDisplay, totalFileLines }, }; } else if (truncation.truncated) { - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = { truncation }; sourcePath = absolutePath; truncationInfo = { @@ -2016,13 +2161,13 @@ export class ReadTool implements AgentTool { const remaining = totalFileLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`; details = {}; sourcePath = absolutePath; } else { // No truncation, no user limit exceeded - outputText = formatText(truncation.content, startLineDisplay); + outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay); details = {}; sourcePath = absolutePath; } diff --git a/packages/coding-agent/src/utils/matching-brackets.ts b/packages/coding-agent/src/utils/matching-brackets.ts new file mode 100644 index 000000000..23105b99c --- /dev/null +++ b/packages/coding-agent/src/utils/matching-brackets.ts @@ -0,0 +1,228 @@ +const OPEN_TO_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", +}; + +const CLOSE_TO_OPEN: Record = { + ")": "(", + "]": "[", + "}": "{", +}; + +export interface LineSpan { + startLine: number; + endLine: number; +} + +export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" }; + +interface StackEntry { + opener: string; + lineNumber: number; + text: string; + visible: boolean; +} + +type ScannerMode = "code" | "single" | "double" | "template" | "blockComment"; + +function normalizeLineSpans(spans: readonly LineSpan[], totalLines: number): LineSpan[] { + if (totalLines <= 0) return []; + const normalized: LineSpan[] = []; + for (const span of spans) { + const startLine = Math.max(1, Math.trunc(span.startLine)); + const endLine = Math.min(totalLines, Math.trunc(span.endLine)); + if (endLine < startLine) continue; + normalized.push({ startLine, endLine }); + } + if (normalized.length <= 1) return normalized; + normalized.sort((left, right) => left.startLine - right.startLine || left.endLine - right.endLine); + const merged: LineSpan[] = []; + for (const span of normalized) { + const previous = merged[merged.length - 1]; + if (previous && span.startLine <= previous.endLine + 1) { + previous.endLine = Math.max(previous.endLine, span.endLine); + continue; + } + merged.push({ ...span }); + } + return merged; +} + +function visibleLineNumbers(spans: readonly LineSpan[]): Set { + const visible = new Set(); + for (const span of spans) { + for (let line = span.startLine; line <= span.endLine; line++) { + visible.add(line); + } + } + return visible; +} + +function hasEveryLineVisible(visible: ReadonlySet, totalLines: number): boolean { + return totalLines > 0 && visible.size >= totalLines; +} + +function findMatchingStackIndex(stack: readonly StackEntry[], opener: string): number { + for (let index = stack.length - 1; index >= 0; index--) { + if (stack[index].opener === opener) return index; + } + return -1; +} + +function isHashCommentStart(line: string, index: number): boolean { + if (line[index] !== "#") return false; + for (let i = 0; i < index; i++) { + const ch = line[i]; + if (ch !== " " && ch !== "\t") return false; + } + return true; +} + +export function findMatchingBracketContextLines( + fullLines: readonly string[], + visibleLinesInput: ReadonlySet | readonly number[], +): Map { + const visible = visibleLinesInput instanceof Set ? visibleLinesInput : new Set(visibleLinesInput); + const context = new Map(); + if (visible.size === 0 || hasEveryLineVisible(visible, fullLines.length)) return context; + + const stack: StackEntry[] = []; + let mode: ScannerMode = "code"; + let escaped = false; + + for (let lineIndex = 0; lineIndex < fullLines.length; lineIndex++) { + const lineNumber = lineIndex + 1; + const line = fullLines[lineIndex] ?? ""; + const lineVisible = visible.has(lineNumber); + let index = 0; + while (index < line.length) { + const ch = line[index]; + const next = index + 1 < line.length ? line[index + 1] : ""; + + if (mode === "blockComment") { + if (ch === "*" && next === "/") { + mode = "code"; + index += 2; + continue; + } + index++; + continue; + } + + if (mode === "single" || mode === "double" || mode === "template") { + if (escaped) { + escaped = false; + index++; + continue; + } + if (ch === "\\") { + escaped = true; + index++; + continue; + } + if ( + (mode === "single" && ch === "'") || + (mode === "double" && ch === '"') || + (mode === "template" && ch === "`") + ) { + mode = "code"; + } + index++; + continue; + } + + if (ch === "/" && next === "/") break; + if (ch === "/" && next === "*") { + mode = "blockComment"; + index += 2; + continue; + } + if (isHashCommentStart(line, index)) break; + if (ch === "'") { + mode = "single"; + escaped = false; + index++; + continue; + } + if (ch === '"') { + mode = "double"; + escaped = false; + index++; + continue; + } + if (ch === "`") { + mode = "template"; + escaped = false; + index++; + continue; + } + + if (OPEN_TO_CLOSE[ch]) { + stack.push({ opener: ch, lineNumber, text: line, visible: lineVisible }); + index++; + continue; + } + + const opener = CLOSE_TO_OPEN[ch]; + if (opener) { + const matchIndex = findMatchingStackIndex(stack, opener); + if (matchIndex !== -1) { + const [matched] = stack.splice(matchIndex); + if (matched) { + if (lineVisible && !matched.visible) context.set(matched.lineNumber, matched.text); + if (matched.visible && !lineVisible) context.set(lineNumber, line); + } + } + } + + index++; + } + + if (mode === "single" || mode === "double") { + mode = "code"; + escaped = false; + } + } + + for (const lineNumber of visible) context.delete(lineNumber); + return context; +} + +export function buildLineEntriesWithMatchingBracketContext( + fullLines: readonly string[], + visibleSpans: readonly LineSpan[], + options: { + lineText?: (lineNumber: number, sourceText: string, context: boolean) => string; + } = {}, +): LineEntry[] { + const spans = normalizeLineSpans(visibleSpans, fullLines.length); + const visible = visibleLineNumbers(spans); + const context = findMatchingBracketContextLines(fullLines, visible); + const allLines = new Set(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) { + if (previousLine !== undefined && lineNumber > previousLine + 1) { + entries.push({ kind: "ellipsis" }); + } + const sourceText = fullLines[lineNumber - 1] ?? ""; + const isContext = context.has(lineNumber); + entries.push({ + kind: "line", + lineNumber, + text: options.lineText?.(lineNumber, sourceText, isContext) ?? sourceText, + context: isContext, + }); + previousLine = lineNumber; + } + + return entries; +} + +export function lineEntriesToPlainText(entries: readonly LineEntry[], ellipsis = "…"): string { + return entries.map(entry => (entry.kind === "ellipsis" ? ellipsis : entry.text)).join("\n"); +} diff --git a/packages/coding-agent/test/read-multi-range.test.ts b/packages/coding-agent/test/read-multi-range.test.ts index 6072369f8..00fc40bf8 100644 --- a/packages/coding-agent/test/read-multi-range.test.ts +++ b/packages/coding-agent/test/read-multi-range.test.ts @@ -69,6 +69,57 @@ describe("read tool multi-range selector", () => { expect(text).toContain("…"); }); + it("includes the matching closing bracket line outside a forward range", async () => { + const filePath = path.join(tmpDir, "brackets.ts"); + await fs.writeFile( + filePath, + [ + "function outer() {", + " const one = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return one + two + three + four;", + "}", + "after();", + ].join("\n"), + ); + + const tool = new ReadTool(createSession(tmpDir)); + const text = textOutput(await tool.execute("call-bracket-close", { path: `${filePath}:1-1` })); + + expect(text).toContain("function outer() {"); + expect(text).toContain("…"); + expect(text).toContain("}"); + expect(text).not.toContain("const four"); + expect(text).not.toContain("return one + two"); + }); + + it("includes the matching opening bracket line outside a reverse range", async () => { + const filePath = path.join(tmpDir, "brackets.ts"); + await fs.writeFile( + filePath, + [ + "function outer() {", + " const one = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return one + two + three + four;", + "}", + "after();", + ].join("\n"), + ); + + const tool = new ReadTool(createSession(tmpDir)); + const text = textOutput(await tool.execute("call-bracket-open", { path: `${filePath}:7-7` })); + + 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("merges overlapping ranges into a single contiguous block", async () => { const filePath = path.join(tmpDir, "numbered.txt"); await fs.writeFile(filePath, makeNumberedContent(20)); diff --git a/packages/coding-agent/test/tools/edit-diff.test.ts b/packages/coding-agent/test/tools/edit-diff.test.ts index 330d6c9eb..964540acc 100644 --- a/packages/coding-agent/test/tools/edit-diff.test.ts +++ b/packages/coding-agent/test/tools/edit-diff.test.ts @@ -22,4 +22,28 @@ describe("generateDiffString", () => { expect(diffLines).not.toContain(" 8|line 8"); expect(diffLines).not.toContain(" 12|line 12"); }); + + it("adds an elided matching bracket line when context stops before the closer", () => { + const oldLines = [ + "function outer() {", + " const value = 1;", + " const two = 2;", + " const three = 3;", + " const four = 4;", + " return value + two + three + four;", + "}", + ]; + const newLines = [...oldLines]; + newLines[0] = "function renamed() {"; + + const result = generateDiffString(oldLines.join("\n"), newLines.join("\n"), 1); + const diffLines = result.diff.split("\n"); + + expect(diffLines).toContain("-1|function outer() {"); + expect(diffLines).toContain("+1|function renamed() {"); + expect(diffLines).toContain("..."); + expect(diffLines).toContain(" 7|}"); + expect(diffLines).not.toContain(" 5| const four = 4;"); + expect(diffLines).not.toContain(" 6| return value + two + three + four;"); + }); }); diff --git a/packages/hashline/test/diff-preview.test.ts b/packages/hashline/test/diff-preview.test.ts index 8a72294ff..8450d0905 100644 --- a/packages/hashline/test/diff-preview.test.ts +++ b/packages/hashline/test/diff-preview.test.ts @@ -12,20 +12,8 @@ describe("buildCompactDiffPreview", () => { }); }); - it("renumbers context lines against the post-edit file after range expansion", () => { - const diff = [ - " 1|a1", - " 2|a2", - "-3|a3", - "-4|a4", - "+3|X", - "+4|Y", - "+5|Z", - " 5|a5", - " 6|a6", - " 7|a7", - ].join("\n"); + const diff = [" 1|a1", " 2|a2", "-3|a3", "-4|a4", "+3|X", "+4|Y", "+5|Z", " 5|a5", " 6|a6", " 7|a7"].join("\n"); const preview = buildCompactDiffPreview(diff);