fix(coding-agent): fixed grouped read output by splitting and merging selector rows

- Split top-level and delimited read selectors into separate rows before grouping.
- Merged duplicate same-file read selectors into one summarized row with ellipsis truncation.
- Computed grouped-read status and totals from aggregated rows for accurate summaries.
- Updated changelog entries and fixtures to document/read expectations.
This commit is contained in:
can1357
2026-06-08 04:51:25 +02:00
parent 54404878bf
commit 97ae0fd34d
6 changed files with 379 additions and 44 deletions
+10 -6
View File
@@ -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
@@ -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);
}
@@ -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<ReadEntry["status"], number> = {
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<string | undefined> {
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<string, ReadEntry>();
#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<string, ReadDisplayTarget[]>();
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<string>();
for (const [basePath, baseTargets] of selectorTargetsByBasePath) {
if (basePath && baseTargets.length > 1) {
mergeableBasePaths.add(basePath);
}
}
const emittedMergedRows = new Set<string>();
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<string>();
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 ⟨<key>: 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);
+6 -1
View File
@@ -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<typeof readSchema, ReadToolDetails> {
const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`;
const notes = [notice];
const content: Array<TextContent | ImageContent> = [];
const displayReadTargets: string[] = [];
let pendingText = notice;
const flushText = () => {
if (pendingText.length === 0) return;
@@ -717,6 +720,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
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<typeof readSchema, ReadToolDetails> {
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<ReadToolDetails>({ notes }).content(content).done();
return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
}
async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -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", () => {
@@ -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");