@oh-my-pi/pi-coding-agent 15.10.2 → 15.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/edit/index.d.ts +0 -1
  4. package/dist/types/lsp/index.d.ts +0 -5
  5. package/dist/types/main.d.ts +11 -0
  6. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  7. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  8. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  9. package/dist/types/modes/components/session-selector.d.ts +16 -7
  10. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  11. package/dist/types/modes/types.d.ts +4 -0
  12. package/dist/types/session/messages.d.ts +11 -8
  13. package/dist/types/session/yield-queue.d.ts +10 -1
  14. package/dist/types/tools/eval-render.d.ts +0 -1
  15. package/dist/types/tools/index.d.ts +31 -0
  16. package/dist/types/tools/path-utils.d.ts +5 -1
  17. package/dist/types/tools/read.d.ts +2 -1
  18. package/dist/types/tools/render-utils.d.ts +3 -1
  19. package/dist/types/tools/renderers.d.ts +0 -15
  20. package/dist/types/tools/write.d.ts +0 -2
  21. package/dist/types/tui/code-cell.d.ts +0 -2
  22. package/dist/types/tui/hyperlink.d.ts +5 -7
  23. package/dist/types/tui/output-block.d.ts +0 -18
  24. package/package.json +9 -9
  25. package/src/cli/gallery-cli.ts +4 -0
  26. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  27. package/src/cli/gallery-fixtures/fs.ts +68 -1
  28. package/src/cli/gallery-fixtures/types.ts +8 -1
  29. package/src/commit/agentic/agent.ts +1 -0
  30. package/src/edit/hashline/diff.ts +86 -0
  31. package/src/edit/hashline/execute.ts +14 -1
  32. package/src/edit/index.ts +31 -17
  33. package/src/edit/renderer.ts +116 -31
  34. package/src/eval/js/shared/prelude.txt +26 -10
  35. package/src/internal-urls/docs-index.generated.ts +4 -4
  36. package/src/lsp/index.ts +128 -52
  37. package/src/main.ts +54 -14
  38. package/src/modes/components/assistant-message.ts +3 -15
  39. package/src/modes/components/late-diagnostics-message.ts +60 -0
  40. package/src/modes/components/plan-review-overlay.ts +26 -5
  41. package/src/modes/components/read-tool-group.ts +415 -35
  42. package/src/modes/components/session-selector.ts +89 -35
  43. package/src/modes/components/tool-execution.ts +7 -49
  44. package/src/modes/components/transcript-container.ts +108 -32
  45. package/src/modes/controllers/event-controller.ts +6 -1
  46. package/src/modes/controllers/input-controller.ts +10 -2
  47. package/src/modes/types.ts +4 -0
  48. package/src/modes/utils/ui-helpers.ts +26 -5
  49. package/src/prompts/system/manual-continue.md +7 -0
  50. package/src/prompts/system/plan-mode-active.md +56 -72
  51. package/src/prompts/tools/eval.md +3 -1
  52. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  53. package/src/sdk.ts +59 -1
  54. package/src/session/agent-session.ts +5 -3
  55. package/src/session/messages.ts +21 -14
  56. package/src/session/session-manager.ts +2 -2
  57. package/src/session/yield-queue.ts +20 -2
  58. package/src/task/executor.ts +1 -0
  59. package/src/tiny/title-client.ts +6 -1
  60. package/src/tools/bash.ts +0 -7
  61. package/src/tools/eval-render.ts +4 -23
  62. package/src/tools/find.ts +148 -106
  63. package/src/tools/index.ts +32 -0
  64. package/src/tools/path-utils.ts +19 -22
  65. package/src/tools/read.ts +16 -8
  66. package/src/tools/render-utils.ts +3 -1
  67. package/src/tools/renderers.ts +0 -15
  68. package/src/tools/ssh.ts +0 -1
  69. package/src/tools/todo.ts +1 -0
  70. package/src/tools/write.ts +3 -12
  71. package/src/tui/code-cell.ts +1 -6
  72. package/src/tui/hyperlink.ts +13 -23
  73. package/src/tui/output-block.ts +2 -97
@@ -1,10 +1,11 @@
1
+ import * as path from "node:path";
1
2
  import type { Component } from "@oh-my-pi/pi-tui";
2
3
  import { Container, Text } from "@oh-my-pi/pi-tui";
3
4
  import { InternalUrlRouter } from "../../internal-urls";
4
5
  import { getLanguageFromPath, theme } from "../../modes/theme/theme";
5
- import { splitPathAndSel } from "../../tools/path-utils";
6
+ import { parseLineRanges, selectorLineRanges, splitPathAndSel } from "../../tools/path-utils";
6
7
  import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
7
- import { renderCodeCell } from "../../tui";
8
+ import { fileHyperlink, renderCodeCell, tryResolveInternalUrlSync } from "../../tui";
8
9
  import type { ToolExecutionHandle } from "./tool-execution";
9
10
 
10
11
  /**
@@ -46,11 +47,19 @@ type ReadToolSuffixResolution = {
46
47
  };
47
48
 
48
49
  type ReadToolResultDetails = {
50
+ resolvedPath?: string;
49
51
  suffixResolution?: {
50
52
  from?: string;
51
53
  to?: string;
52
54
  };
53
55
  conflictCount?: number;
56
+ displayReadTargets?: unknown;
57
+ meta?: {
58
+ source?: {
59
+ type?: string;
60
+ value?: string;
61
+ };
62
+ };
54
63
  };
55
64
 
56
65
  type ReadToolGroupOptions = {
@@ -67,6 +76,8 @@ function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadTo
67
76
  type ReadEntry = {
68
77
  toolCallId: string;
69
78
  path: string;
79
+ displayPaths?: string[];
80
+ linkPath?: string;
70
81
  status: "pending" | "success" | "warning" | "error";
71
82
  correctedFrom?: string;
72
83
  contentText?: string;
@@ -76,6 +87,197 @@ type ReadEntry = {
76
87
  /** Number of code lines to show in collapsed preview mode */
77
88
  const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
78
89
 
90
+ type ReadDisplayTarget = {
91
+ entry: ReadEntry;
92
+ targetPath: string;
93
+ basePath: string;
94
+ linkPath?: string;
95
+ selector?: string;
96
+ };
97
+
98
+ type ReadSummaryRow = {
99
+ targetPath: string;
100
+ basePath: string;
101
+ targets: ReadDisplayTarget[];
102
+ };
103
+
104
+ const READ_STATUS_RANK: Record<ReadEntry["status"], number> = {
105
+ success: 0,
106
+ pending: 1,
107
+ warning: 2,
108
+ error: 3,
109
+ };
110
+
111
+ const URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
112
+
113
+ function getDisplayReadTargets(details: ReadToolResultDetails | undefined): string[] | undefined {
114
+ if (!Array.isArray(details?.displayReadTargets)) return undefined;
115
+ const targets = details.displayReadTargets
116
+ .filter((target): target is string => typeof target === "string")
117
+ .map(target => target.trim())
118
+ .filter(target => target.length > 0);
119
+ return targets.length > 0 ? targets : undefined;
120
+ }
121
+
122
+ function displayPathWithSuffixResolution(currentPath: string, suffixResolution: ReadToolSuffixResolution): string {
123
+ const currentSelector = splitPathAndSel(currentPath).sel;
124
+ if (!currentSelector || splitPathAndSel(suffixResolution.to).sel) return suffixResolution.to;
125
+ return `${suffixResolution.to}:${currentSelector}`;
126
+ }
127
+
128
+ function readSourceFsPath(details: ReadToolResultDetails | undefined): string | undefined {
129
+ const source = details?.meta?.source;
130
+ return source?.type === "path" && typeof source.value === "string" ? source.value : undefined;
131
+ }
132
+
133
+ function readResultLinkPath(details: ReadToolResultDetails | undefined): string | undefined {
134
+ return typeof details?.resolvedPath === "string" ? details.resolvedPath : readSourceFsPath(details);
135
+ }
136
+
137
+ function readTargetLinkPath(basePath: string, entryLinkPath: string | undefined): string | undefined {
138
+ if (entryLinkPath) return entryLinkPath;
139
+ const resolvedInternalPath = tryResolveInternalUrlSync(basePath);
140
+ if (resolvedInternalPath) return resolvedInternalPath;
141
+ return path.isAbsolute(basePath) ? basePath : undefined;
142
+ }
143
+
144
+ function firstSelectorLine(selector: string | undefined): number | undefined {
145
+ try {
146
+ return selectorLineRanges(selector)?.[0].startLine;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ function firstSelectorLineForTargets(targets: ReadDisplayTarget[]): number | undefined {
153
+ let line: number | undefined;
154
+ for (const target of targets) {
155
+ const targetLine = firstSelectorLine(target.selector);
156
+ if (targetLine === undefined) continue;
157
+ if (line === undefined || targetLine < line) line = targetLine;
158
+ }
159
+ return line;
160
+ }
161
+
162
+ function linkPathForTargets(targets: ReadDisplayTarget[]): string | undefined {
163
+ for (const target of targets) {
164
+ if (target.linkPath) return target.linkPath;
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ function selectorChunkIsLineRangeList(chunk: string): boolean {
170
+ const trimmed = chunk.trim();
171
+ if (!trimmed) return false;
172
+ try {
173
+ return parseLineRanges(trimmed) !== null;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function nextTopLevelToken(input: string, start: number): string {
180
+ let braceDepth = 0;
181
+ for (let i = start; i < input.length; i++) {
182
+ const ch = input[i];
183
+ if (ch === "\\" && i + 1 < input.length) {
184
+ i++;
185
+ continue;
186
+ }
187
+ if (ch === "{") {
188
+ braceDepth++;
189
+ continue;
190
+ }
191
+ if (ch === "}") {
192
+ if (braceDepth > 0) braceDepth--;
193
+ continue;
194
+ }
195
+ if (braceDepth === 0 && (ch === "," || ch === ";")) {
196
+ return input.slice(start, i);
197
+ }
198
+ }
199
+ return input.slice(start);
200
+ }
201
+
202
+ function commaContinuesLineRangeSelector(input: string, partStart: number, commaIndex: number): boolean {
203
+ const currentPart = input.slice(partStart, commaIndex).trim();
204
+ if (!splitPathAndSel(currentPart).sel) return false;
205
+ return selectorChunkIsLineRangeList(nextTopLevelToken(input, commaIndex + 1));
206
+ }
207
+
208
+ function splitReadDisplayPathSpecs(rawPath: string): string[] {
209
+ const normalized = rawPath.trim();
210
+ if (!normalized || URL_LIKE_RE.test(normalized)) return [rawPath];
211
+
212
+ const parts: string[] = [];
213
+ let braceDepth = 0;
214
+ let partStart = 0;
215
+ for (let i = 0; i < normalized.length; i++) {
216
+ const ch = normalized[i];
217
+ if (ch === "\\" && i + 1 < normalized.length) {
218
+ i++;
219
+ continue;
220
+ }
221
+ if (ch === "{") {
222
+ braceDepth++;
223
+ continue;
224
+ }
225
+ if (ch === "}") {
226
+ if (braceDepth > 0) braceDepth--;
227
+ continue;
228
+ }
229
+ if (braceDepth !== 0 || (ch !== "," && ch !== ";")) continue;
230
+ if (ch === "," && commaContinuesLineRangeSelector(normalized, partStart, i)) continue;
231
+ parts.push(normalized.slice(partStart, i).trim());
232
+ partStart = i + 1;
233
+ }
234
+ parts.push(normalized.slice(partStart).trim());
235
+
236
+ const cleanParts = parts.filter(part => part.length > 0);
237
+ if (cleanParts.length <= 1) return [rawPath];
238
+ return cleanParts.every(part => splitPathAndSel(part).sel !== undefined) ? cleanParts : [rawPath];
239
+ }
240
+
241
+ function splitSelectorDisplayParts(sel: string | undefined): Array<string | undefined> {
242
+ if (!sel) return [undefined];
243
+ const chunks = sel.split(":");
244
+ if (chunks.length === 1) {
245
+ if (!selectorChunkIsLineRangeList(sel) || !sel.includes(",")) return [sel];
246
+ return sel
247
+ .split(",")
248
+ .map(chunk => chunk.trim())
249
+ .filter(chunk => chunk.length > 0);
250
+ }
251
+ if (chunks.length === 2) {
252
+ const [left, right] = chunks as [string, string];
253
+ const leftIsRange = selectorChunkIsLineRangeList(left);
254
+ const rightIsRange = selectorChunkIsLineRangeList(right);
255
+ if (leftIsRange && left.includes(",")) {
256
+ return left
257
+ .split(",")
258
+ .map(chunk => chunk.trim())
259
+ .filter(chunk => chunk.length > 0)
260
+ .map(chunk => `${chunk}:${right}`);
261
+ }
262
+ if (rightIsRange && right.includes(",")) {
263
+ return right
264
+ .split(",")
265
+ .map(chunk => chunk.trim())
266
+ .filter(chunk => chunk.length > 0)
267
+ .map(chunk => `${left}:${chunk}`);
268
+ }
269
+ }
270
+ return [sel];
271
+ }
272
+
273
+ function formatMergedSelectorParts(selectors: string[]): string {
274
+ if (selectors.length <= 3) return selectors.join(",");
275
+ const first = selectors[0]!;
276
+ const second = selectors[1]!;
277
+ const last = selectors[selectors.length - 1]!;
278
+ return `${first},${second},…,${last}`;
279
+ }
280
+
79
281
  export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
80
282
  #entries = new Map<string, ReadEntry>();
81
283
  #text: Text;
@@ -89,6 +291,9 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
89
291
  // (see TranscriptContainer / NativeScrollbackLiveRegion). The controller calls
90
292
  // `finalize()` once the run breaks so the block can commit to native scrollback.
91
293
  #finalized = false;
294
+ // Forced terminal even with a still-pending entry: the turn ended (abort or
295
+ // completion) so no late result is coming. Set via `seal()`.
296
+ #sealed = false;
92
297
 
93
298
  constructor(options: ReadToolGroupOptions = {}) {
94
299
  super();
@@ -99,13 +304,36 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
99
304
  }
100
305
 
101
306
  isTranscriptBlockFinalized(): boolean {
102
- return this.#finalized;
307
+ if (this.#sealed) return true;
308
+ if (!this.#finalized) return false;
309
+ // Closed to new entries, but a still-pending entry means its result is in
310
+ // flight — parallel reads can finalize the group (a sibling tool starts and
311
+ // breaks the run) before a read's `tool_execution_end` lands. Stay live so
312
+ // the late result repaints instead of freezing the pending preview into
313
+ // native scrollback on ED3-risk terminals (#issue: stuck "Read <path>").
314
+ return !this.#hasPendingEntries();
315
+ }
316
+
317
+ #hasPendingEntries(): boolean {
318
+ for (const entry of this.#entries.values()) {
319
+ if (entry.status === "pending") return true;
320
+ }
321
+ return false;
103
322
  }
104
323
 
105
324
  finalize(): void {
106
325
  this.#finalized = true;
107
326
  }
108
327
 
328
+ /**
329
+ * Force the group terminal even if an entry never received its result (the
330
+ * turn aborted or ended). Lets it freeze and stop pinning the transcript live
331
+ * region instead of lingering on a pending preview until the next thaw.
332
+ */
333
+ seal(): void {
334
+ this.#sealed = true;
335
+ }
336
+
109
337
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
110
338
  if (!toolCallId) return;
111
339
  const basePath = args.file_path || args.path || "";
@@ -131,11 +359,15 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
131
359
  if (isPartial) return;
132
360
  const details = result.details as ReadToolResultDetails | undefined;
133
361
  const suffixResolution = getSuffixResolution(details);
362
+ const displayPaths = getDisplayReadTargets(details);
363
+ entry.linkPath = readResultLinkPath(details);
134
364
  if (suffixResolution) {
135
- entry.path = suffixResolution.to;
365
+ entry.path = displayPathWithSuffixResolution(entry.path, suffixResolution);
136
366
  entry.correctedFrom = suffixResolution.from;
367
+ entry.displayPaths = undefined;
137
368
  } else {
138
369
  entry.correctedFrom = undefined;
370
+ entry.displayPaths = displayPaths;
139
371
  }
140
372
  const conflictCount =
141
373
  typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined;
@@ -164,42 +396,42 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
164
396
 
165
397
  #updateDisplay(): void {
166
398
  const entries = [...this.#entries.values()];
399
+ const displayTargets = this.#displayTargetsForEntries(entries);
400
+ const displayRows = this.#buildSummaryRows(displayTargets);
167
401
 
168
402
  // Clear previous children and rebuild the summary and preview blocks.
169
403
  this.clear();
170
404
  this.#text = new Text("", 0, 0);
171
405
 
172
- if (entries.length === 0) {
406
+ if (displayRows.length === 0) {
173
407
  this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);
174
408
  this.addChild(this.#text);
175
409
  return;
176
410
  }
177
411
 
178
- if (entries.length === 1) {
179
- const entry = entries[0];
180
- if (!this.#shouldRenderPreview(entry)) {
181
- const statusSymbol = this.#formatStatus(entry.status);
182
- const pathDisplay = this.#formatPath(entry);
412
+ if (displayRows.length === 1) {
413
+ const row = displayRows[0]!;
414
+ if (!this.#shouldRenderPreviewRow(row)) {
415
+ const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets));
416
+ const pathDisplay = this.#formatRowPath(row);
183
417
  this.#text.setText(
184
418
  ` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(),
185
419
  );
186
420
  this.addChild(this.#text);
187
421
  }
188
- if (this.#shouldRenderPreview(entry)) {
422
+ for (const entry of this.#previewEntriesForRow(row)) {
189
423
  this.#addContentPreview(entry);
190
424
  }
191
425
  return;
192
426
  }
193
427
 
194
- const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${entries.length})`)}`;
428
+ const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${displayRows.length})`)}`;
195
429
  const lines = [` ${theme.format.bullet} ${header}`];
196
430
  const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry));
197
- const total = entriesWithoutPreview.length;
198
- for (const [index, entry] of entriesWithoutPreview.entries()) {
199
- const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
200
- const statusPrefix = entry.status === "success" ? "" : `${this.#formatStatus(entry.status)} `;
201
- const pathDisplay = this.#formatPath(entry);
202
- lines.push(` ${theme.fg("dim", connector)} ${statusPrefix}${pathDisplay}`.trimEnd());
431
+ const summaryTargets = this.#displayTargetsForEntries(entriesWithoutPreview);
432
+ const rows = this.#buildSummaryRows(summaryTargets);
433
+ for (const [index, row] of rows.entries()) {
434
+ this.#appendSummaryRow(lines, row, index, rows.length);
203
435
  }
204
436
 
205
437
  this.#text.setText(lines.join("\n"));
@@ -212,16 +444,177 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
212
444
  }
213
445
  }
214
446
 
447
+ #displayTargetsForEntries(entries: ReadEntry[]): ReadDisplayTarget[] {
448
+ const targets: ReadDisplayTarget[] = [];
449
+ for (const entry of entries) {
450
+ const pathSpecs = entry.displayPaths ?? splitReadDisplayPathSpecs(entry.path);
451
+ const useEntryLinkPath = pathSpecs.length === 1;
452
+ for (const pathSpec of pathSpecs) {
453
+ const split = splitPathAndSel(pathSpec);
454
+ const linkPath = readTargetLinkPath(split.path, useEntryLinkPath ? entry.linkPath : undefined);
455
+ for (const selector of splitSelectorDisplayParts(split.sel)) {
456
+ targets.push({
457
+ entry,
458
+ targetPath: selector ? `${split.path}:${selector}` : pathSpec,
459
+ basePath: split.path,
460
+ linkPath,
461
+ selector,
462
+ });
463
+ }
464
+ }
465
+ }
466
+ return targets;
467
+ }
468
+
469
+ #buildSummaryRows(targets: ReadDisplayTarget[]): ReadSummaryRow[] {
470
+ const selectorTargetsByBasePath = new Map<string, ReadDisplayTarget[]>();
471
+ for (const target of targets) {
472
+ if (!target.selector) continue;
473
+ const existing = selectorTargetsByBasePath.get(target.basePath);
474
+ if (existing) existing.push(target);
475
+ else selectorTargetsByBasePath.set(target.basePath, [target]);
476
+ }
477
+
478
+ const mergeableBasePaths = new Set<string>();
479
+ for (const [basePath, baseTargets] of selectorTargetsByBasePath) {
480
+ if (basePath && baseTargets.length > 1) {
481
+ mergeableBasePaths.add(basePath);
482
+ }
483
+ }
484
+
485
+ const emittedMergedRows = new Set<string>();
486
+ const rows: ReadSummaryRow[] = [];
487
+ for (const target of targets) {
488
+ if (target.selector && mergeableBasePaths.has(target.basePath)) {
489
+ if (!emittedMergedRows.has(target.basePath)) {
490
+ const mergedTargets = selectorTargetsByBasePath.get(target.basePath) ?? [target];
491
+ rows.push({
492
+ targetPath: `${target.basePath}:${formatMergedSelectorParts(
493
+ mergedTargets
494
+ .map(mergedTarget => mergedTarget.selector)
495
+ .filter(selector => selector !== undefined),
496
+ )}`,
497
+ basePath: target.basePath,
498
+ targets: mergedTargets,
499
+ });
500
+ emittedMergedRows.add(target.basePath);
501
+ }
502
+ continue;
503
+ }
504
+ rows.push({ targetPath: target.targetPath, basePath: target.basePath, targets: [target] });
505
+ }
506
+ return rows;
507
+ }
508
+
509
+ #appendSummaryRow(lines: string[], row: ReadSummaryRow, index: number, total: number): void {
510
+ const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
511
+ lines.push(` ${theme.fg("dim", connector)} ${this.#formatRow(row)}`.trimEnd());
512
+ }
513
+
514
+ #formatRow(row: ReadSummaryRow): string {
515
+ const status = this.#statusForTargets(row.targets);
516
+ const statusPrefix = status === "success" ? "" : `${this.#formatStatus(status)} `;
517
+ return `${statusPrefix}${this.#formatRowPath(row)}`;
518
+ }
519
+
520
+ #formatRowPath(row: ReadSummaryRow): string {
521
+ return this.#formatPathValue(row.targetPath, {
522
+ correctedFrom: this.#correctedFromForTargets(row.targets),
523
+ conflictCount: this.#conflictCountForTargets(row.targets),
524
+ line: firstSelectorLineForTargets(row.targets),
525
+ linkPath: linkPathForTargets(row.targets),
526
+ });
527
+ }
528
+
529
+ #statusForTargets(targets: ReadDisplayTarget[]): ReadEntry["status"] {
530
+ let status: ReadEntry["status"] = "success";
531
+ for (const target of targets) {
532
+ if (READ_STATUS_RANK[target.entry.status] > READ_STATUS_RANK[status]) {
533
+ status = target.entry.status;
534
+ }
535
+ }
536
+ return status;
537
+ }
538
+
539
+ #correctedFromForTargets(targets: ReadDisplayTarget[]): string | undefined {
540
+ for (const target of targets) {
541
+ if (target.entry.correctedFrom) return target.entry.correctedFrom;
542
+ }
543
+ return undefined;
544
+ }
545
+
546
+ #conflictCountForTargets(targets: ReadDisplayTarget[]): number | undefined {
547
+ let conflictCount = 0;
548
+ for (const target of targets) {
549
+ if (target.entry.conflictCount && target.entry.conflictCount > conflictCount) {
550
+ conflictCount = target.entry.conflictCount;
551
+ }
552
+ }
553
+ return conflictCount > 0 ? conflictCount : undefined;
554
+ }
555
+
556
+ #previewEntriesForRow(row: ReadSummaryRow): ReadEntry[] {
557
+ const entries: ReadEntry[] = [];
558
+ const seen = new Set<string>();
559
+ for (const target of row.targets) {
560
+ if (seen.has(target.entry.toolCallId) || !this.#shouldRenderPreview(target.entry)) continue;
561
+ entries.push(target.entry);
562
+ seen.add(target.entry.toolCallId);
563
+ }
564
+ return entries;
565
+ }
566
+
567
+ #shouldRenderPreviewRow(row: ReadSummaryRow): boolean {
568
+ return this.#previewEntriesForRow(row).length > 0;
569
+ }
570
+
571
+ #formatPathValue(
572
+ value: string,
573
+ options: { correctedFrom?: string; conflictCount?: number; line?: number; linkPath?: string } = {},
574
+ ): string {
575
+ const split = splitPathAndSel(value);
576
+ const selectorSuffix = split.sel ? `:${split.sel}` : "";
577
+ const baseValue = split.sel ? split.path : value;
578
+ const filePath = shortenPath(baseValue);
579
+ let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
580
+ if (filePath && options.linkPath) {
581
+ const linkOptions = options.line !== undefined ? { line: options.line } : undefined;
582
+ pathDisplay = fileHyperlink(options.linkPath, pathDisplay, linkOptions);
583
+ }
584
+ if (selectorSuffix) {
585
+ pathDisplay += theme.fg("accent", selectorSuffix);
586
+ }
587
+ if (options.correctedFrom) {
588
+ pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(options.correctedFrom)})`);
589
+ }
590
+ pathDisplay += this.#formatConflictBadge(options.conflictCount);
591
+ return pathDisplay;
592
+ }
593
+
594
+ #formatConflictBadge(conflictCount: number | undefined): string {
595
+ if (!conflictCount || conflictCount <= 0) return "";
596
+ const n = conflictCount;
597
+ return ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
598
+ }
599
+
215
600
  /**
216
601
  * Add a code-cell content preview below the entry summary.
217
602
  * When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with a "… N more lines ⟨<key>: Expand⟩" hint.
218
603
  * When expanded: shows full content.
219
604
  */
220
605
  #addContentPreview(entry: ReadEntry): void {
221
- const lang = getLanguageFromPath(splitPathAndSel(entry.path).path);
222
- const filePath = shortenPath(entry.path);
223
- const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
224
- const title = filePath ? `Read ${filePath}${correctionSuffix}` : "Read";
606
+ const split = splitPathAndSel(entry.path);
607
+ const lang = getLanguageFromPath(split.path);
608
+ const pathValue = shortenPath(entry.path);
609
+ const pathDisplay = pathValue
610
+ ? this.#formatPathValue(entry.path, {
611
+ correctedFrom: entry.correctedFrom,
612
+ conflictCount: entry.conflictCount,
613
+ line: firstSelectorLine(split.sel),
614
+ linkPath: readTargetLinkPath(split.path, entry.linkPath),
615
+ })
616
+ : "";
617
+ const title = pathDisplay ? `Read ${pathDisplay}` : "Read";
225
618
  let cachedWidth: number | undefined;
226
619
  let cachedLines: string[] | undefined;
227
620
  const expanded = this.#expanded;
@@ -255,19 +648,6 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
255
648
  return this.#showContentPreview && entry.contentText !== undefined;
256
649
  }
257
650
 
258
- #formatPath(entry: ReadEntry): string {
259
- const filePath = shortenPath(entry.path);
260
- let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
261
- if (entry.correctedFrom) {
262
- pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
263
- }
264
- if (entry.conflictCount && entry.conflictCount > 0) {
265
- const n = entry.conflictCount;
266
- pathDisplay += ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
267
- }
268
- return pathDisplay;
269
- }
270
-
271
651
  #formatStatus(status: ReadEntry["status"]): string {
272
652
  if (status === "success") {
273
653
  return theme.fg("text", theme.status.enabled);