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:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user