diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f8588eb0..41d333da2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,20 +1,24 @@ # Changelog ## [Unreleased] + ### Changed - Rewrote the plan-mode active prompt (`prompts/system/plan-mode-active.md`) from scratch to stop producing shallow plans. Reframed the artifact as an **execution spec** a fresh agent runs after the planning conversation is cleared/compacted (zero design decisions for the implementer) rather than a brevity-capped summary. Folded high-consensus requirements into the existing sections as inline, conditional rules — no new boilerplate sections: ordered Approach steps that keep the build/tests green after each step (sequencing); exact signatures/literals for new or load-bearing symbols (contracts); full callsite list + clean cutover for renames/signature-changes/removals; Verification that must exercise the new behavior (input → observable output) with run preconditions, not just build/typecheck; Assumptions restricted to user-overridable choices plus pre-decided fallbacks for load-bearing assumptions; a provenance rule (plan facts must come from a read this session; unverified claims flagged inline); and bans on conversation back-references and decision-free sections (Non-Goals/Alternatives/Risks/Future Work). Kept the decision-complete self-check and the brevity-vs-completeness tiebreak (completeness wins). Render contract (Handlebars vars/conditionals) unchanged; verified across all `planExists`/`reentry`/`iterative` branch combinations. -### Fixed - -- Fixed session search to return all sessions unchanged when the query is blank -- Fixed duplicate session suggestions by deduplicating history matches by session path when merging metadata and prompt-history results -- Fixed `/resume` search ranking so sessions whose prompts or metadata match the query now prefer prompt recency and recent literal matches instead of letting older earlier-title fuzzy matches outrank a just-used session. - ### Removed - Removed the animated pending border ("shimmer") on running `bash`, `eval`, and `ssh` execution blocks. While pending, a block now shows a static accent border instead of sweeping a dark segment around its bottom edge; `display.shimmer` still governs the working-status line and `task` row animations. +### Fixed + +- Fixed read-group summaries for multi-path `read` results to use result-provided display targets so each resolved path is shown as its own row +- Fixed read-group range summaries to abbreviate long merged selectors with ellipsis to keep repeated-file range rows readable +- Fixed read-group TUI summaries so a single delimited `read` call renders as separate read rows, and repeated reads of the same file collapse under one file with full-file/range children. +- Fixed session search to return all sessions unchanged when the query is blank +- Fixed duplicate session suggestions by deduplicating history matches by session path when merging metadata and prompt-history results +- Fixed `/resume` search ranking so sessions whose prompts or metadata match the query now prefer prompt recency and recent literal matches instead of letting older earlier-title fuzzy matches outrank a just-used session. + ## [15.10.2] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts index a8a6b3ea0..cc217011e 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts @@ -45,7 +45,7 @@ const groupedReadTargets = [ const groupedReadDelimitedPath = groupedReadTargets.join(","); const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts"; -const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1270-1274`; +const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`; function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult { return { content: [{ type: "text", text }], details, isError }; @@ -53,7 +53,6 @@ function textResult(text: string, details?: unknown, isError?: boolean): Gallery function addGroupedReadArgs(component: ReadToolGroupComponent): void { component.updateArgs({ path: groupedReadDelimitedPath }, "read-delimited"); - component.updateArgs({ path: groupedReadRepeatedFile }, "read-full"); component.updateArgs({ path: groupedReadRepeatedRanges }, "read-ranges"); } @@ -82,7 +81,6 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, false, "read-delimited", ); - component.updateResult(textResult("Read the full render module."), false, "read-full"); if (state === "error") { component.updateResult( @@ -93,7 +91,7 @@ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, return component.render(width); } - component.updateResult(textResult("Read three render.ts ranges."), false, "read-ranges"); + component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges"); return component.render(width); } 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 1af94f4ba..a543cf504 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -2,7 +2,7 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Container, Text } from "@oh-my-pi/pi-tui"; import { InternalUrlRouter } from "../../internal-urls"; import { getLanguageFromPath, theme } from "../../modes/theme/theme"; -import { splitPathAndSel } from "../../tools/path-utils"; +import { parseLineRanges, splitPathAndSel } from "../../tools/path-utils"; import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils"; import { renderCodeCell } from "../../tui"; import type { ToolExecutionHandle } from "./tool-execution"; @@ -51,6 +51,7 @@ type ReadToolResultDetails = { to?: string; }; conflictCount?: number; + displayReadTargets?: unknown; }; type ReadToolGroupOptions = { @@ -67,6 +68,7 @@ function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadTo type ReadEntry = { toolCallId: string; path: string; + displayPaths?: string[]; status: "pending" | "success" | "warning" | "error"; correctedFrom?: string; contentText?: string; @@ -76,6 +78,149 @@ type ReadEntry = { /** Number of code lines to show in collapsed preview mode */ const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED; +type ReadDisplayTarget = { + entry: ReadEntry; + targetPath: string; + basePath: string; + selector?: string; +}; + +type ReadSummaryRow = { + targetPath: string; + basePath: string; + targets: ReadDisplayTarget[]; +}; + +const READ_STATUS_RANK: Record = { + success: 0, + pending: 1, + warning: 2, + error: 3, +}; + +const URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i; + +function getDisplayReadTargets(details: ReadToolResultDetails | undefined): string[] | undefined { + if (!Array.isArray(details?.displayReadTargets)) return undefined; + const targets = details.displayReadTargets + .filter((target): target is string => typeof target === "string") + .map(target => target.trim()) + .filter(target => target.length > 0); + return targets.length > 0 ? targets : undefined; +} + +function selectorChunkIsLineRangeList(chunk: string): boolean { + const trimmed = chunk.trim(); + if (!trimmed) return false; + try { + return parseLineRanges(trimmed) !== null; + } catch { + return false; + } +} + +function nextTopLevelToken(input: string, start: number): string { + let braceDepth = 0; + for (let i = start; i < input.length; i++) { + const ch = input[i]; + if (ch === "\\" && i + 1 < input.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth === 0 && (ch === "," || ch === ";")) { + return input.slice(start, i); + } + } + return input.slice(start); +} + +function commaContinuesLineRangeSelector(input: string, partStart: number, commaIndex: number): boolean { + const currentPart = input.slice(partStart, commaIndex).trim(); + if (!splitPathAndSel(currentPart).sel) return false; + return selectorChunkIsLineRangeList(nextTopLevelToken(input, commaIndex + 1)); +} + +function splitReadDisplayPathSpecs(rawPath: string): string[] { + const normalized = rawPath.trim(); + if (!normalized || URL_LIKE_RE.test(normalized)) return [rawPath]; + + const parts: string[] = []; + let braceDepth = 0; + let partStart = 0; + for (let i = 0; i < normalized.length; i++) { + const ch = normalized[i]; + if (ch === "\\" && i + 1 < normalized.length) { + i++; + continue; + } + if (ch === "{") { + braceDepth++; + continue; + } + if (ch === "}") { + if (braceDepth > 0) braceDepth--; + continue; + } + if (braceDepth !== 0 || (ch !== "," && ch !== ";")) continue; + if (ch === "," && commaContinuesLineRangeSelector(normalized, partStart, i)) continue; + parts.push(normalized.slice(partStart, i).trim()); + partStart = i + 1; + } + parts.push(normalized.slice(partStart).trim()); + + const cleanParts = parts.filter(part => part.length > 0); + if (cleanParts.length <= 1) return [rawPath]; + return cleanParts.every(part => splitPathAndSel(part).sel !== undefined) ? cleanParts : [rawPath]; +} + +function splitSelectorDisplayParts(sel: string | undefined): Array { + if (!sel) return [undefined]; + const chunks = sel.split(":"); + if (chunks.length === 1) { + if (!selectorChunkIsLineRangeList(sel) || !sel.includes(",")) return [sel]; + return sel + .split(",") + .map(chunk => chunk.trim()) + .filter(chunk => chunk.length > 0); + } + if (chunks.length === 2) { + const [left, right] = chunks as [string, string]; + const leftIsRange = selectorChunkIsLineRangeList(left); + const rightIsRange = selectorChunkIsLineRangeList(right); + if (leftIsRange && left.includes(",")) { + return left + .split(",") + .map(chunk => chunk.trim()) + .filter(chunk => chunk.length > 0) + .map(chunk => `${chunk}:${right}`); + } + if (rightIsRange && right.includes(",")) { + return right + .split(",") + .map(chunk => chunk.trim()) + .filter(chunk => chunk.length > 0) + .map(chunk => `${left}:${chunk}`); + } + } + return [sel]; +} + +function formatMergedSelectorParts(selectors: string[]): string { + if (selectors.length <= 3) return selectors.join(","); + const first = selectors[0]!; + const second = selectors[1]!; + const last = selectors[selectors.length - 1]!; + return `${first},${second},…,${last}`; +} + export class ReadToolGroupComponent extends Container implements ToolExecutionHandle { #entries = new Map(); #text: Text; @@ -131,11 +276,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa if (isPartial) return; const details = result.details as ReadToolResultDetails | undefined; const suffixResolution = getSuffixResolution(details); + const displayPaths = getDisplayReadTargets(details); if (suffixResolution) { entry.path = suffixResolution.to; entry.correctedFrom = suffixResolution.from; + entry.displayPaths = undefined; } else { entry.correctedFrom = undefined; + entry.displayPaths = displayPaths; } const conflictCount = typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined; @@ -164,42 +312,42 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa #updateDisplay(): void { const entries = [...this.#entries.values()]; + const displayTargets = this.#displayTargetsForEntries(entries); + const displayRows = this.#buildSummaryRows(displayTargets); // Clear previous children and rebuild the summary and preview blocks. this.clear(); this.#text = new Text("", 0, 0); - if (entries.length === 0) { + if (displayRows.length === 0) { this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`); this.addChild(this.#text); return; } - if (entries.length === 1) { - const entry = entries[0]; - if (!this.#shouldRenderPreview(entry)) { - const statusSymbol = this.#formatStatus(entry.status); - const pathDisplay = this.#formatPath(entry); + if (displayRows.length === 1) { + const row = displayRows[0]!; + if (!this.#shouldRenderPreviewRow(row)) { + const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets)); + const pathDisplay = this.#formatRowPath(row); this.#text.setText( ` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(), ); this.addChild(this.#text); } - if (this.#shouldRenderPreview(entry)) { + for (const entry of this.#previewEntriesForRow(row)) { this.#addContentPreview(entry); } return; } - const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${entries.length})`)}`; + const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${displayRows.length})`)}`; const lines = [` ${theme.format.bullet} ${header}`]; const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry)); - const total = entriesWithoutPreview.length; - for (const [index, entry] of entriesWithoutPreview.entries()) { - const connector = index === total - 1 ? theme.tree.last : theme.tree.branch; - const statusPrefix = entry.status === "success" ? "" : `${this.#formatStatus(entry.status)} `; - const pathDisplay = this.#formatPath(entry); - lines.push(` ${theme.fg("dim", connector)} ${statusPrefix}${pathDisplay}`.trimEnd()); + const summaryTargets = this.#displayTargetsForEntries(entriesWithoutPreview); + const rows = this.#buildSummaryRows(summaryTargets); + for (const [index, row] of rows.entries()) { + this.#appendSummaryRow(lines, row, index, rows.length); } this.#text.setText(lines.join("\n")); @@ -212,6 +360,141 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa } } + #displayTargetsForEntries(entries: ReadEntry[]): ReadDisplayTarget[] { + const targets: ReadDisplayTarget[] = []; + for (const entry of entries) { + const pathSpecs = entry.displayPaths ?? splitReadDisplayPathSpecs(entry.path); + for (const pathSpec of pathSpecs) { + const split = splitPathAndSel(pathSpec); + for (const selector of splitSelectorDisplayParts(split.sel)) { + targets.push({ + entry, + targetPath: selector ? `${split.path}:${selector}` : pathSpec, + basePath: split.path, + selector, + }); + } + } + } + return targets; + } + + #buildSummaryRows(targets: ReadDisplayTarget[]): ReadSummaryRow[] { + const selectorTargetsByBasePath = new Map(); + for (const target of targets) { + if (!target.selector) continue; + const existing = selectorTargetsByBasePath.get(target.basePath); + if (existing) existing.push(target); + else selectorTargetsByBasePath.set(target.basePath, [target]); + } + + const mergeableBasePaths = new Set(); + for (const [basePath, baseTargets] of selectorTargetsByBasePath) { + if (basePath && baseTargets.length > 1) { + mergeableBasePaths.add(basePath); + } + } + + const emittedMergedRows = new Set(); + const rows: ReadSummaryRow[] = []; + for (const target of targets) { + if (target.selector && mergeableBasePaths.has(target.basePath)) { + if (!emittedMergedRows.has(target.basePath)) { + const mergedTargets = selectorTargetsByBasePath.get(target.basePath) ?? [target]; + rows.push({ + targetPath: `${target.basePath}:${formatMergedSelectorParts( + mergedTargets + .map(mergedTarget => mergedTarget.selector) + .filter(selector => selector !== undefined), + )}`, + basePath: target.basePath, + targets: mergedTargets, + }); + emittedMergedRows.add(target.basePath); + } + continue; + } + rows.push({ targetPath: target.targetPath, basePath: target.basePath, targets: [target] }); + } + return rows; + } + + #appendSummaryRow(lines: string[], row: ReadSummaryRow, index: number, total: number): void { + const connector = index === total - 1 ? theme.tree.last : theme.tree.branch; + lines.push(` ${theme.fg("dim", connector)} ${this.#formatRow(row)}`.trimEnd()); + } + + #formatRow(row: ReadSummaryRow): string { + const status = this.#statusForTargets(row.targets); + const statusPrefix = status === "success" ? "" : `${this.#formatStatus(status)} `; + return `${statusPrefix}${this.#formatRowPath(row)}`; + } + + #formatRowPath(row: ReadSummaryRow): string { + return this.#formatPathValue(row.targetPath, { + correctedFrom: this.#correctedFromForTargets(row.targets), + conflictCount: this.#conflictCountForTargets(row.targets), + }); + } + + #statusForTargets(targets: ReadDisplayTarget[]): ReadEntry["status"] { + let status: ReadEntry["status"] = "success"; + for (const target of targets) { + if (READ_STATUS_RANK[target.entry.status] > READ_STATUS_RANK[status]) { + status = target.entry.status; + } + } + return status; + } + + #correctedFromForTargets(targets: ReadDisplayTarget[]): string | undefined { + for (const target of targets) { + if (target.entry.correctedFrom) return target.entry.correctedFrom; + } + return undefined; + } + + #conflictCountForTargets(targets: ReadDisplayTarget[]): number | undefined { + let conflictCount = 0; + for (const target of targets) { + if (target.entry.conflictCount && target.entry.conflictCount > conflictCount) { + conflictCount = target.entry.conflictCount; + } + } + return conflictCount > 0 ? conflictCount : undefined; + } + + #previewEntriesForRow(row: ReadSummaryRow): ReadEntry[] { + const entries: ReadEntry[] = []; + const seen = new Set(); + for (const target of row.targets) { + if (seen.has(target.entry.toolCallId) || !this.#shouldRenderPreview(target.entry)) continue; + entries.push(target.entry); + seen.add(target.entry.toolCallId); + } + return entries; + } + + #shouldRenderPreviewRow(row: ReadSummaryRow): boolean { + return this.#previewEntriesForRow(row).length > 0; + } + + #formatPathValue(value: string, options: { correctedFrom?: string; conflictCount?: number } = {}): string { + const filePath = shortenPath(value); + let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…"); + if (options.correctedFrom) { + pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(options.correctedFrom)})`); + } + pathDisplay += this.#formatConflictBadge(options.conflictCount); + return pathDisplay; + } + + #formatConflictBadge(conflictCount: number | undefined): string { + if (!conflictCount || conflictCount <= 0) return ""; + const n = conflictCount; + return ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`; + } + /** * Add a code-cell content preview below the entry summary. * When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with a "… N more lines ⟨: Expand⟩" hint. @@ -255,19 +538,6 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa return this.#showContentPreview && entry.contentText !== undefined; } - #formatPath(entry: ReadEntry): string { - const filePath = shortenPath(entry.path); - let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…"); - if (entry.correctedFrom) { - pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`); - } - if (entry.conflictCount && entry.conflictCount > 0) { - const n = entry.conflictCount; - pathDisplay += ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`; - } - return pathDisplay; - } - #formatStatus(status: ReadEntry["status"]): string { if (status === "success") { return theme.fg("text", theme.status.enabled); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 25b2d49b9..d097d892c 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -575,6 +575,8 @@ export interface ReadToolDetails { summary?: { lines: number; elidedSpans: number; elidedLines: number }; /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */ conflictCount?: number; + /** Paths recovered from a delimited read argument; used only by the TUI to render one call as multiple read rows. */ + displayReadTargets?: string[]; } type ReadParams = ReadToolInput; @@ -704,6 +706,7 @@ export class ReadTool implements AgentTool { const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`; const notes = [notice]; const content: Array = []; + const displayReadTargets: string[] = []; let pendingText = notice; const flushText = () => { if (pendingText.length === 0) return; @@ -717,6 +720,7 @@ export class ReadTool implements AgentTool { for (const part of parts) { try { const result = await this.execute("read-delimited-part", { path: part }, signal); + displayReadTargets.push(result.details?.suffixResolution?.to ?? part); for (const block of result.content) { if (block.type === "text") { appendText(block.text); @@ -730,12 +734,13 @@ export class ReadTool implements AgentTool { const message = error instanceof Error ? error.message : String(error); const errorNote = `Could not read ${part}: ${message}`; notes.push(errorNote); + displayReadTargets.push(part); appendText(`[${errorNote}]`); } } flushText(); - return toolResult({ notes }).content(content).done(); + return toolResult({ notes, displayReadTargets }).content(content).done(); } async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise { diff --git a/packages/coding-agent/test/gallery-cli.test.ts b/packages/coding-agent/test/gallery-cli.test.ts index d16d45811..9526f1108 100644 --- a/packages/coding-agent/test/gallery-cli.test.ts +++ b/packages/coding-agent/test/gallery-cli.test.ts @@ -73,12 +73,11 @@ describe("gallery harness", () => { const success = Bun.stripANSI((await renderGalleryState("read_group", fixture, "success", 140)).join("\n")); const renderPathMatches = success.match(/packages\/coding-agent\/src\/task\/render\.ts/g) ?? []; - expect(success).toContain("Read (7)"); + expect(success).toContain("Read (4)"); expect(renderPathMatches).toHaveLength(1); - expect(success).toContain("full file"); - expect(success).toContain(":507-605"); - expect(success).toContain(":1070-1194"); - expect(success).toContain(":1270-1274"); + expect(success).toContain("packages/coding-agent/src/task/render.ts:507-605,1070-1194,…,1270-1274"); + expect(success).not.toContain("1210-1240"); + expect(success).not.toContain("full file"); }); it("falls back to a generic fixture for registry tools without curated sample data", () => { diff --git a/packages/coding-agent/test/read-tool-group.test.ts b/packages/coding-agent/test/read-tool-group.test.ts index 2ae827823..25c75f3cb 100644 --- a/packages/coding-agent/test/read-tool-group.test.ts +++ b/packages/coding-agent/test/read-tool-group.test.ts @@ -68,6 +68,65 @@ describe("ReadToolGroupComponent", () => { expect(plain).not.toContain(`${themeModule.theme.tree.last} ${themeModule.theme.status.enabled}`); }); + it("splits a single selector-delimited read argument into child rows", () => { + const component = new ReadToolGroupComponent(); + component.updateArgs({ path: "/tmp/one.ts:1-2,/tmp/two.ts:3-4;/tmp/three.ts:5-6" }, "read-many"); + component.updateResult({ content: [{ type: "text", text: "combined" }] }, false, "read-many"); + + const plain = Bun.stripANSI(component.render(120).join("\n")); + + expect(plain).toContain("Read (3)"); + expect(plain).toContain(`${themeModule.theme.tree.branch} /tmp/one.ts:1-2`); + expect(plain).toContain(`${themeModule.theme.tree.branch} /tmp/two.ts:3-4`); + expect(plain).toContain(`${themeModule.theme.tree.last} /tmp/three.ts:5-6`); + }); + + it("merges multi-range selectors into one file row", () => { + const component = new ReadToolGroupComponent(); + component.updateArgs({ path: "/tmp/example.ts:5-10,20-30" }, "read-ranges"); + component.updateResult({ content: [{ type: "text", text: "ranges" }] }, false, "read-ranges"); + + const plain = Bun.stripANSI(component.render(120).join("\n")); + + expect(plain).toContain("Read /tmp/example.ts:5-10,20-30"); + expect(plain).not.toContain("Read (2)"); + expect(plain).not.toContain("full file"); + }); + + it("merges repeated same-file ranges and truncates long selector lists", () => { + const component = new ReadToolGroupComponent(); + component.updateArgs({ path: "/tmp/render.ts:507-605" }, "read-one"); + component.updateArgs({ path: "/tmp/render.ts:1070-1194,1210-1240,1270-1274" }, "read-more"); + component.updateResult({ content: [{ type: "text", text: "one" }] }, false, "read-one"); + component.updateResult({ content: [{ type: "text", text: "more" }] }, false, "read-more"); + + const plain = Bun.stripANSI(component.render(120).join("\n")); + const pathMatches = plain.match(/\/tmp\/render\.ts/g) ?? []; + + expect(pathMatches).toHaveLength(1); + expect(plain).toContain("/tmp/render.ts:507-605,1070-1194,…,1270-1274"); + expect(plain).not.toContain("1210-1240"); + }); + + it("uses result-provided recovered targets for delimited reads", () => { + const component = new ReadToolGroupComponent(); + component.updateArgs({ path: "/tmp/one.ts /tmp/two.ts" }, "read-recovered"); + component.updateResult( + { + content: [{ type: "text", text: "combined" }], + details: { displayReadTargets: ["/tmp/one.ts", "/tmp/two.ts"] }, + }, + false, + "read-recovered", + ); + + const plain = Bun.stripANSI(component.render(120).join("\n")); + + expect(plain).toContain("Read (2)"); + expect(plain).toContain(`${themeModule.theme.tree.branch} /tmp/one.ts`); + expect(plain).toContain(`${themeModule.theme.tree.last} /tmp/two.ts`); + }); + it("renders warning previews with warning styling instead of success styling", () => { const component = new ReadToolGroupComponent({ showContentPreview: true }); component.updateArgs({ path: "/tmp/example.ts" }, "read-1");