@oh-my-pi/pi-coding-agent 15.10.1 → 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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -141,6 +141,9 @@ export class PlanReviewOverlay implements Component {
141
141
  #bodyClickRows = new Set<number>();
142
142
  /** 1-based column at/under which a region-row click targets the sidebar. */
143
143
  #sidebarClickMaxCol = 0;
144
+ /** Option index the pointer is currently hovering, or undefined. Updated from
145
+ * motion mouse reports and cleared when the pointer leaves the option rows. */
146
+ #hoveredOption: number | undefined;
144
147
 
145
148
  #annotating = false;
146
149
  #input: Input;
@@ -315,9 +318,10 @@ export class PlanReviewOverlay implements Component {
315
318
  * Hit-test an SGR mouse report (`\x1b[<b;x;yM/m`) against the click maps the
316
319
  * last render recorded. Returns true when consumed. The fullscreen overlay
317
320
  * paints from screen row 0, so a 1-based mouse row maps directly to the
318
- * rendered-line index. Wheel scrolls the body; a left click on an option
319
- * activates it (select + confirm), on a ToC row jumps to that section, and on
320
- * the body column focuses the body.
321
+ * rendered-line index. Wheel scrolls the body; pointer motion lights up the
322
+ * hovered option row; a left click on an option activates it (select +
323
+ * confirm), on a ToC row jumps to that section, and on the body column focuses
324
+ * the body.
321
325
  */
322
326
  #handleMouse(data: string): boolean {
323
327
  const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
@@ -331,7 +335,13 @@ export class PlanReviewOverlay implements Component {
331
335
  return true;
332
336
  }
333
337
  if (match[4] !== "M") return true; // release
334
- if (button & 32) return true; // motion/drag
338
+ if (button & 32) {
339
+ // Motion (hover or drag): light up the option row under the pointer so a
340
+ // mouse user gets the same affordance the keyboard cursor gives. Any
341
+ // non-option row clears the highlight.
342
+ this.#setHoveredOption(this.#optionClickRows.get(row));
343
+ return true;
344
+ }
335
345
  if ((button & 3) !== 0) return true; // not the left button
336
346
  const optionIndex = this.#optionClickRows.get(row);
337
347
  if (optionIndex !== undefined) {
@@ -355,6 +365,12 @@ export class PlanReviewOverlay implements Component {
355
365
  return true;
356
366
  }
357
367
 
368
+ /** Set the hovered option from a hit-tested row, ignoring disabled rows and
369
+ * non-option rows (both clear the highlight). */
370
+ #setHoveredOption(index: number | undefined): void {
371
+ this.#hoveredOption = index !== undefined && !this.#disabled.has(index) ? index : undefined;
372
+ }
373
+
358
374
  #cycleRegion(direction: number): void {
359
375
  // Sidebar is skipped from the cycle when it is not shown.
360
376
  const regions: Focus[] = this.#sidebarShown ? ["toc", "body", "actions"] : ["body", "actions"];
@@ -611,14 +627,19 @@ export class PlanReviewOverlay implements Component {
611
627
  return this.#options.map((label, i) => {
612
628
  const selected = i === this.#selectedIndex;
613
629
  const isDisabled = this.#disabled.has(i);
630
+ const hovered = !isDisabled && i === this.#hoveredOption;
614
631
  // The cursor marks the selected option; it dims when actions are not the
615
632
  // focused region so the active region's highlight stays unambiguous.
616
633
  const cursor = selected ? theme.fg(active ? "accent" : "dim", `${theme.nav.cursor} `) : " ";
617
- const text = isDisabled
634
+ let text = isDisabled
618
635
  ? theme.fg("dim", label)
619
636
  : selected && active
620
637
  ? theme.bold(theme.fg("accent", label))
621
638
  : theme.fg("text", label);
639
+ // A pointer hovering an option paints a highlight band behind its label,
640
+ // distinct from the keyboard selection (cursor glyph + bold accent) which
641
+ // stays where it is. One space of padding gives the band a button shape.
642
+ if (hovered) text = theme.bg("selectedBg", ` ${text} `);
622
643
  return cursor + text;
623
644
  });
624
645
  }
@@ -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);