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

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 (95) hide show
  1. package/CHANGELOG.md +66 -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/eval/__tests__/js-context-manager.test.d.ts +1 -0
  5. package/dist/types/eval/bridge-timeout.d.ts +1 -1
  6. package/dist/types/eval/{llm-bridge.d.ts → completion-bridge.d.ts} +8 -8
  7. package/dist/types/eval/idle-timeout.d.ts +1 -1
  8. package/dist/types/lsp/index.d.ts +0 -5
  9. package/dist/types/main.d.ts +11 -0
  10. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  11. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  12. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  13. package/dist/types/modes/components/session-selector.d.ts +16 -7
  14. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  15. package/dist/types/modes/types.d.ts +4 -0
  16. package/dist/types/session/messages.d.ts +11 -8
  17. package/dist/types/session/yield-queue.d.ts +10 -1
  18. package/dist/types/tools/eval-render.d.ts +0 -1
  19. package/dist/types/tools/index.d.ts +31 -0
  20. package/dist/types/tools/path-utils.d.ts +5 -1
  21. package/dist/types/tools/read.d.ts +2 -1
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/renderers.d.ts +0 -15
  24. package/dist/types/tools/write.d.ts +0 -2
  25. package/dist/types/tui/code-cell.d.ts +0 -2
  26. package/dist/types/tui/hyperlink.d.ts +5 -7
  27. package/dist/types/tui/output-block.d.ts +0 -18
  28. package/package.json +9 -9
  29. package/src/cli/gallery-cli.ts +4 -0
  30. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  31. package/src/cli/gallery-fixtures/fs.ts +68 -1
  32. package/src/cli/gallery-fixtures/types.ts +8 -1
  33. package/src/commit/agentic/agent.ts +1 -0
  34. package/src/edit/hashline/diff.ts +86 -0
  35. package/src/edit/hashline/execute.ts +14 -1
  36. package/src/edit/index.ts +31 -17
  37. package/src/edit/renderer.ts +116 -31
  38. package/src/eval/__tests__/agent-bridge.test.ts +13 -0
  39. package/src/eval/__tests__/{llm-bridge.test.ts → completion-bridge.test.ts} +60 -54
  40. package/src/eval/__tests__/js-context-manager.test.ts +241 -0
  41. package/src/eval/agent-bridge.ts +6 -1
  42. package/src/eval/bridge-timeout.ts +1 -1
  43. package/src/eval/{llm-bridge.ts → completion-bridge.ts} +30 -27
  44. package/src/eval/idle-timeout.ts +1 -1
  45. package/src/eval/js/context-manager.ts +66 -6
  46. package/src/eval/js/shared/prelude.txt +28 -12
  47. package/src/eval/js/tool-bridge.ts +3 -3
  48. package/src/eval/js/worker-entry.ts +6 -0
  49. package/src/eval/py/prelude.py +3 -3
  50. package/src/internal-urls/docs-index.generated.ts +8 -7
  51. package/src/lsp/index.ts +128 -52
  52. package/src/main.ts +54 -14
  53. package/src/modes/components/assistant-message.ts +3 -15
  54. package/src/modes/components/late-diagnostics-message.ts +60 -0
  55. package/src/modes/components/plan-review-overlay.ts +26 -5
  56. package/src/modes/components/read-tool-group.ts +415 -35
  57. package/src/modes/components/session-selector.ts +89 -35
  58. package/src/modes/components/tips.txt +1 -1
  59. package/src/modes/components/tool-execution.ts +7 -49
  60. package/src/modes/components/transcript-container.ts +108 -32
  61. package/src/modes/controllers/event-controller.ts +6 -1
  62. package/src/modes/controllers/input-controller.ts +10 -2
  63. package/src/modes/types.ts +4 -0
  64. package/src/modes/utils/ui-helpers.ts +26 -5
  65. package/src/prompts/system/manual-continue.md +7 -0
  66. package/src/prompts/system/plan-mode-active.md +56 -72
  67. package/src/prompts/system/tiny-title-system.md +1 -1
  68. package/src/prompts/system/title-system.md +16 -3
  69. package/src/prompts/system/workflow-notice.md +1 -1
  70. package/src/prompts/tools/eval.md +6 -4
  71. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  72. package/src/sdk.ts +59 -1
  73. package/src/session/agent-session.ts +5 -3
  74. package/src/session/messages.ts +21 -14
  75. package/src/session/session-manager.ts +2 -2
  76. package/src/session/yield-queue.ts +20 -2
  77. package/src/task/executor.ts +1 -0
  78. package/src/tiny/title-client.ts +6 -1
  79. package/src/tools/bash.ts +0 -7
  80. package/src/tools/eval-render.ts +6 -25
  81. package/src/tools/eval.ts +1 -1
  82. package/src/tools/find.ts +148 -106
  83. package/src/tools/index.ts +32 -0
  84. package/src/tools/path-utils.ts +19 -22
  85. package/src/tools/read.ts +16 -8
  86. package/src/tools/render-utils.ts +3 -1
  87. package/src/tools/renderers.ts +0 -15
  88. package/src/tools/ssh.ts +0 -1
  89. package/src/tools/todo.ts +1 -0
  90. package/src/tools/write.ts +3 -12
  91. package/src/tui/code-cell.ts +1 -6
  92. package/src/tui/hyperlink.ts +13 -23
  93. package/src/tui/output-block.ts +2 -97
  94. package/src/utils/title-generator.ts +2 -2
  95. /package/dist/types/eval/__tests__/{llm-bridge.test.d.ts → completion-bridge.test.d.ts} +0 -0
@@ -16,9 +16,8 @@ import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } f
16
16
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
17
17
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
18
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
19
- import { shimmerEnabled } from "../modes/theme/shimmer";
20
19
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
20
+ import { markFramedBlockComponent, renderCodeCell } from "../tui";
22
21
  import {
23
22
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
23
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -247,7 +246,7 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
247
246
  sh: "icon.package",
248
247
  env: "icon.package",
249
248
  batch: "icon.package",
250
- llm: "icon.package",
249
+ completion: "icon.package",
251
250
  log: "icon.package",
252
251
  phase: "icon.package",
253
252
  };
@@ -316,7 +315,7 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
316
315
  case "batch":
317
316
  parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
318
317
  break;
319
- case "llm":
318
+ case "completion":
320
319
  if (data.model) parts.push(String(data.model));
321
320
  if (data.tier && data.tier !== data.model) parts.push(`(${data.tier})`);
322
321
  parts.push(`${data.chars ?? 0} chars`);
@@ -491,8 +490,7 @@ export const evalToolRenderer = {
491
490
 
492
491
  return markFramedBlockComponent({
493
492
  render: (width: number): string[] => {
494
- const animate = options.isPartial && shimmerEnabled();
495
- const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
493
+ const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
496
494
  if (cached && cached.key === key && cached.width === width) {
497
495
  return cached.result;
498
496
  }
@@ -510,13 +508,9 @@ export const evalToolRenderer = {
510
508
  status: "pending",
511
509
  width,
512
510
  // Always render the full source: the code is fixed input, not the
513
- // streaming part, so it is never compacted. While still pending
514
- // (args streaming) the block is not yet committed to native
515
- // scrollback — its head is only committed once a result exists and
516
- // the code has finalized (see `isStreamingPreviewAppendOnly`).
511
+ // streaming part, so it is never compacted.
517
512
  codeMaxLines: Number.POSITIVE_INFINITY,
518
513
  expanded: options.expanded,
519
- animate,
520
514
  },
521
515
  uiTheme,
522
516
  );
@@ -579,8 +573,7 @@ export const evalToolRenderer = {
579
573
  render: (width: number): string[] => {
580
574
  const expanded = options.renderContext?.expanded ?? options.expanded;
581
575
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
582
- const animate = options.isPartial && shimmerEnabled();
583
- const key = `${expanded}|${previewLines}|${options.spinnerFrame}|${animate ? borderShimmerTick() : 0}`;
576
+ const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
584
577
  if (cached && cached.key === key && cached.width === width) {
585
578
  return cached.result;
586
579
  }
@@ -622,7 +615,6 @@ export const evalToolRenderer = {
622
615
  codeMaxLines: Number.POSITIVE_INFINITY,
623
616
  expanded,
624
617
  width,
625
- animate,
626
618
  },
627
619
  uiTheme,
628
620
  );
@@ -752,17 +744,6 @@ export const evalToolRenderer = {
752
744
  };
753
745
  },
754
746
 
755
- // Append-only once a result exists (args complete → code finalized). The code
756
- // is rendered in full as a fixed top-anchored prefix, and the streamed stdout
757
- // below it only appends rows at the bottom, so the scrolled-off head commits
758
- // to native scrollback instead of being yanked — collapsed or expanded, since
759
- // the collapsed output cap keeps its sliding tail in the bottom live region.
760
- // Returns false while still pending: the code is mid-stream (args incomplete)
761
- // and its header still reads "pending", so committing it would strand a stale
762
- // pending preview in history.
763
- isStreamingPreviewAppendOnly(_args: EvalRenderArgs, _options: RenderResultOptions, result?: unknown): boolean {
764
- return result != null;
765
- },
766
747
  mergeCallAndResult: true,
767
748
  inline: true,
768
749
  };
package/src/tools/eval.ts CHANGED
@@ -326,7 +326,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
326
326
  const cell = cells[i];
327
327
  const backend = cell.resolved.backend;
328
328
  // The per-cell `timeout` is a budget on the cell runtime's *own*
329
- // work. Host-side `agent()`/`parallel()`/`llm()` bridge calls suspend
329
+ // work. Host-side `agent()`/`parallel()`/`completion()` bridge calls suspend
330
330
  // that budget entirely and restart a fresh timeout window when control
331
331
  // returns to Python/JS. Compute, stdout, `log()`/`phase()`, and
332
332
  // ordinary tool calls all count against the budget. The watchdog drives
package/src/tools/find.ts CHANGED
@@ -117,6 +117,12 @@ export interface FindToolOptions {
117
117
  operations?: FindOperations;
118
118
  }
119
119
 
120
+ interface FindTarget {
121
+ searchPath: string;
122
+ globPattern: string;
123
+ hasGlob: boolean;
124
+ }
125
+
120
126
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
121
127
  readonly name = "find";
122
128
  readonly approval = "read" as const;
@@ -193,15 +199,31 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
193
199
  }
194
200
 
195
201
  const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
196
- const parsedPattern = multiPattern ? null : parseFindPattern(effectivePatterns[0] ?? ".");
197
- const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
198
- const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
199
- const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
200
- const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath);
201
-
202
- if (searchPath === "/") {
203
- throw new ToolError("Searching from root directory '/' is not allowed");
202
+ const isSingle = !multiPattern;
203
+ const targets: FindTarget[] = multiPattern
204
+ ? multiPattern.targets.map(target => ({
205
+ searchPath: resolveToCwd(target.basePath, this.session.cwd),
206
+ globPattern: target.globPattern,
207
+ hasGlob: target.hasGlob,
208
+ }))
209
+ : [
210
+ (() => {
211
+ const parsed = parseFindPattern(effectivePatterns[0] ?? ".");
212
+ return {
213
+ searchPath: resolveToCwd(parsed.basePath, this.session.cwd),
214
+ globPattern: parsed.globPattern,
215
+ hasGlob: parsed.hasGlob,
216
+ };
217
+ })(),
218
+ ];
219
+ const scopePath = multiPattern?.scopePath ?? formatScopePath(targets[0].searchPath);
220
+
221
+ for (const target of targets) {
222
+ if (target.searchPath === "/") {
223
+ throw new ToolError("Searching from root directory '/' is not allowed");
224
+ }
204
225
  }
226
+
205
227
  const requestedLimit = limit ?? DEFAULT_LIMIT;
206
228
  if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
207
229
  throw new ToolError("Limit must be a positive number");
@@ -213,9 +235,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
213
235
  const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
214
236
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
215
237
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
216
- const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
238
+ const formatMatchPath = (matchPath: string, base: string, fileType?: natives.FileType): string => {
217
239
  const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
218
- const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
240
+ const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(base, matchPath);
219
241
  return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
220
242
  trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
221
243
  });
@@ -276,45 +298,41 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
276
298
  return resultBuilder.done();
277
299
  };
278
300
 
301
+ // Walk each user path as its own root and run the globs concurrently.
302
+ // Collapsing multiple paths to a shared base would force the walker to
303
+ // traverse and stat every unrelated sibling under that ancestor; per-path
304
+ // roots keep each scan bounded to exactly what the user asked for.
279
305
  if (this.#customOps?.glob) {
280
- if (!(await this.#customOps.exists(searchPath))) {
281
- throw new ToolError(`Path not found: ${scopePath}`);
282
- }
283
-
284
- if (!hasGlob && this.#customOps.stat) {
285
- const stat = await this.#customOps.stat(searchPath);
286
- if (stat.isFile()) {
287
- return buildResult([scopePath]);
306
+ const customOps = this.#customOps;
307
+ const perTarget = await Promise.all(
308
+ targets.map(async target => {
309
+ if (!(await customOps.exists(target.searchPath))) {
310
+ if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
311
+ return [] as string[];
312
+ }
313
+ if (!target.hasGlob && customOps.stat) {
314
+ const stat = await customOps.stat(target.searchPath);
315
+ if (stat.isFile()) return [formatScopePath(target.searchPath)];
316
+ }
317
+ const results = await customOps.glob(target.globPattern, target.searchPath, {
318
+ ignore: ["**/node_modules/**", "**/.git/**"],
319
+ limit: effectiveLimit,
320
+ });
321
+ return results.map(matchPath => formatMatchPath(matchPath, target.searchPath));
322
+ }),
323
+ );
324
+ const seen = new Set<string>();
325
+ const merged: string[] = [];
326
+ for (const group of perTarget) {
327
+ for (const entry of group) {
328
+ if (seen.has(entry)) continue;
329
+ seen.add(entry);
330
+ merged.push(entry);
288
331
  }
289
332
  }
290
-
291
- const results = await this.#customOps.glob(globPattern, searchPath, {
292
- ignore: ["**/node_modules/**", "**/.git/**"],
293
- limit: effectiveLimit,
294
- });
295
- const relativized = results.map(p => formatMatchPath(p));
296
-
297
- return buildResult(relativized);
298
- }
299
-
300
- let searchStat: fs.Stats;
301
- try {
302
- searchStat = await fs.promises.stat(searchPath);
303
- } catch (err) {
304
- if (isEnoent(err)) {
305
- throw new ToolError(`Path not found: ${scopePath}`);
306
- }
307
- throw err;
308
- }
309
-
310
- if (!hasGlob && searchStat.isFile()) {
311
- return buildResult([scopePath]);
312
- }
313
- if (!searchStat.isDirectory()) {
314
- throw new ToolError(`Path is not a directory: ${searchPath}`);
333
+ return buildResult(merged);
315
334
  }
316
335
 
317
- let matches: natives.GlobMatch[];
318
336
  const onUpdateMatches: string[] = [];
319
337
  const onUpdateMtimes: number[] = [];
320
338
  const updateIntervalMs = 200;
@@ -335,87 +353,111 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
335
353
  details,
336
354
  });
337
355
  };
338
- const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
339
- if (err || combinedSignal.aborted || !match?.path) return;
340
- const relativePath = formatMatchPath(match.path, match.fileType);
341
- onUpdateMatches.push(relativePath);
342
- onUpdateMtimes.push(match.mtime ?? 0);
343
- emitUpdate();
344
- };
345
-
346
- const doGlob = async (useGitignore: boolean) =>
347
- untilAborted(combinedSignal, () =>
348
- natives.glob(
349
- {
350
- pattern: globPattern,
351
- path: searchPath,
352
- hidden: includeHidden,
353
- maxResults: effectiveLimit,
354
- sortByMtime: true,
355
- gitignore: useGitignore,
356
- // parseFindPattern explicitly prepends "**/" when the user's
357
- // pattern begins with a glob (so `*.ts` becomes `**/*.ts`).
358
- // Anything that arrives here without "**/" was scoped to a
359
- // single directory by the user (e.g. `dir/*`); disable the
360
- // native auto-recursion so `dir/*` does not silently match
361
- // `dir/sub/nested.ts`.
362
- recursive: false,
363
- signal: combinedSignal,
364
- },
365
- onMatch,
366
- ),
367
- );
356
+ const streamed = new Set<string>();
357
+ const makeOnMatch =
358
+ (base: string) =>
359
+ (err: Error | null, match: natives.GlobMatch | null): void => {
360
+ if (err || combinedSignal.aborted || !match?.path) return;
361
+ const relativePath = formatMatchPath(match.path, base, match.fileType);
362
+ if (streamed.has(relativePath)) return;
363
+ streamed.add(relativePath);
364
+ onUpdateMatches.push(relativePath);
365
+ onUpdateMtimes.push(match.mtime ?? 0);
366
+ emitUpdate();
367
+ };
368
368
 
369
369
  let timedOut = false;
370
- try {
371
- const result = await doGlob(useGitignore);
372
- // Native glob returns a bounded mtime-ranked set; keep the JS sort for
373
- // deterministic ordering across cached and uncached native paths.
374
- result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
375
- matches = result.matches;
376
- } catch (error) {
377
- if (error instanceof Error && error.name === "AbortError") {
378
- if (timeoutSignal.aborted && !signal?.aborted) {
379
- timedOut = true;
380
- matches = [];
381
- } else {
370
+ const runTarget = async (target: FindTarget): Promise<Array<{ path: string; mtime: number }>> => {
371
+ throwIfAborted(signal);
372
+ let stat: fs.Stats;
373
+ try {
374
+ stat = await fs.promises.stat(target.searchPath);
375
+ } catch (err) {
376
+ if (isEnoent(err)) {
377
+ if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
378
+ return [];
379
+ }
380
+ throw err;
381
+ }
382
+ if (!target.hasGlob && stat.isFile()) {
383
+ return [{ path: formatScopePath(target.searchPath), mtime: stat.mtimeMs }];
384
+ }
385
+ if (!stat.isDirectory()) {
386
+ if (isSingle) throw new ToolError(`Path is not a directory: ${target.searchPath}`);
387
+ return [];
388
+ }
389
+ try {
390
+ const result = await untilAborted(combinedSignal, () =>
391
+ natives.glob(
392
+ {
393
+ pattern: target.globPattern,
394
+ path: target.searchPath,
395
+ hidden: includeHidden,
396
+ maxResults: effectiveLimit,
397
+ sortByMtime: true,
398
+ gitignore: useGitignore,
399
+ // parseFindPattern explicitly prepends "**/" when the user's
400
+ // pattern begins with a glob (so `*.ts` becomes `**/*.ts`).
401
+ // Anything that arrives here without "**/" was scoped to a
402
+ // single directory by the user (e.g. `dir/*`); disable the
403
+ // native auto-recursion so `dir/*` does not silently match
404
+ // `dir/sub/nested.ts`.
405
+ recursive: false,
406
+ signal: combinedSignal,
407
+ },
408
+ makeOnMatch(target.searchPath),
409
+ ),
410
+ );
411
+ throwIfAborted(signal);
412
+ const out: Array<{ path: string; mtime: number }> = [];
413
+ for (const match of result.matches) {
414
+ if (!match.path) continue;
415
+ out.push({
416
+ path: formatMatchPath(match.path, target.searchPath, match.fileType),
417
+ mtime: match.mtime ?? 0,
418
+ });
419
+ }
420
+ return out;
421
+ } catch (error) {
422
+ if (error instanceof Error && error.name === "AbortError") {
423
+ if (timeoutSignal.aborted && !signal?.aborted) {
424
+ timedOut = true;
425
+ return [];
426
+ }
382
427
  throw new ToolAbortError();
383
428
  }
384
- } else {
385
429
  throw error;
386
430
  }
387
- }
431
+ };
432
+
433
+ const perTarget = await Promise.all(targets.map(runTarget));
388
434
 
389
435
  if (timedOut) {
390
436
  // Drain the partial matches accumulated during streaming and return them
391
437
  // instead of throwing — empty results after a multi-second wait force the
392
438
  // caller to retry blind, which is the worst possible outcome.
393
- const seen = new Set<string>();
394
- const partial: Array<{ p: string; m: number }> = [];
395
- for (let i = 0; i < onUpdateMatches.length; i++) {
396
- const entry = onUpdateMatches[i];
397
- if (seen.has(entry)) continue;
398
- seen.add(entry);
399
- partial.push({ p: entry, m: onUpdateMtimes[i] ?? 0 });
400
- }
439
+ const partial = onUpdateMatches.map((entry, index) => ({ p: entry, m: onUpdateMtimes[index] ?? 0 }));
401
440
  partial.sort((a, b) => b.m - a.m);
402
- const sortedPaths = partial.map(e => e.p);
441
+ const sortedPaths = partial.map(entry => entry.p);
403
442
  const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
404
443
  const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
405
444
  return buildResult(sortedPaths, { notice, forceTruncated: true });
406
445
  }
407
446
 
408
- const relativized: string[] = [];
409
- for (const match of matches) {
410
- throwIfAborted(signal);
411
- if (!match.path) {
412
- continue;
447
+ // Merge per-target results: native glob already ranks each target's own
448
+ // matches by mtime and caps them at the limit, so a global mtime re-sort
449
+ // plus dedup yields the correct top-N across all roots.
450
+ const seen = new Set<string>();
451
+ const merged: Array<{ path: string; mtime: number }> = [];
452
+ for (const group of perTarget) {
453
+ for (const entry of group) {
454
+ if (seen.has(entry.path)) continue;
455
+ seen.add(entry.path);
456
+ merged.push(entry);
413
457
  }
414
-
415
- relativized.push(formatMatchPath(match.path, match.fileType));
416
458
  }
417
-
418
- return buildResult(relativized);
459
+ merged.sort((a, b) => b.mtime - a.mtime);
460
+ return buildResult(merged.map(entry => entry.path));
419
461
  });
420
462
  }
421
463
  }
@@ -117,6 +117,29 @@ export type {
117
117
  DiscoverableToolSource,
118
118
  } from "../tool-discovery/tool-index";
119
119
 
120
+ /**
121
+ * A late LSP diagnostics result that arrived after the edit/write tool already
122
+ * returned. Surfaced to the model and the transcript via
123
+ * {@link ToolSession.queueDeferredDiagnostics}, batched through the session
124
+ * yield queue like background-job results.
125
+ */
126
+ export interface DeferredDiagnosticsEntry {
127
+ /** Absolute path the diagnostics belong to (the renderer shortens it). */
128
+ path: string;
129
+ /** One-line severity summary, e.g. "2 errors". */
130
+ summary: string;
131
+ /** Formatted, ready-to-display diagnostic lines. */
132
+ messages: string[];
133
+ /** True when any message is error severity. */
134
+ errored: boolean;
135
+ /**
136
+ * Evaluated at injection time (in the dispatcher's stale check): drop the entry
137
+ * when a newer mutation to the same file has superseded it, so the model never
138
+ * sees diagnostics for stale content.
139
+ */
140
+ isStale(): boolean;
141
+ }
142
+
120
143
  /** Session context for tool factories */
121
144
  export interface ToolSession {
122
145
  /** Current working directory */
@@ -284,6 +307,15 @@ export interface ToolSession {
284
307
 
285
308
  /** Queue a hidden message to be injected at the next agent turn. */
286
309
  queueDeferredMessage?(message: CustomMessage): void;
310
+ /** Queue late LSP diagnostics (arrived after an edit/write returned) to be shown
311
+ * in the transcript and delivered to the model at the next yield, like background
312
+ * job results. */
313
+ queueDeferredDiagnostics?(entry: DeferredDiagnosticsEntry): void;
314
+ /** Bump and return the session-global mutation counter for `path`. Edit/write
315
+ * tools call this on every file mutation so stale late-diagnostics can be dropped. */
316
+ bumpFileMutationVersion?(path: string): number;
317
+ /** Read the current session-global mutation counter for `path` (0 if never mutated). */
318
+ getFileMutationVersion?(path: string): number;
287
319
  /** Get the active OpenTelemetry config so subagent dispatch can forward
288
320
  * the parent's tracer/hooks with the subagent's own identity stamped. */
289
321
  getTelemetry?: () => AgentTelemetryConfig | undefined;
@@ -572,9 +572,14 @@ export interface ResolvedMultiSearchPath {
572
572
  targets?: ResolvedSearchTarget[];
573
573
  }
574
574
 
575
- export interface ResolvedMultiFindPattern {
575
+ export interface ResolvedFindTarget {
576
576
  basePath: string;
577
577
  globPattern: string;
578
+ hasGlob: boolean;
579
+ }
580
+
581
+ export interface ResolvedMultiFindPattern {
582
+ targets: ResolvedFindTarget[];
578
583
  scopePath: string;
579
584
  }
580
585
 
@@ -782,30 +787,22 @@ async function resolveFindPatternItems(
782
787
  return undefined;
783
788
  }
784
789
 
785
- const parsedItems = await Promise.all(
786
- patternItems.map(async item => {
787
- const parsedPattern = parseFindPattern(item);
788
- const absoluteBasePath = resolveToCwd(parsedPattern.basePath, cwd);
789
- const stat = await fs.promises.stat(absoluteBasePath);
790
- return { raw: item, parsedPattern, absoluteBasePath, stat };
791
- }),
792
- );
793
-
794
- const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
795
- const combinedPatterns = parsedItems.map(item => {
796
- const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
797
- if (item.parsedPattern.hasGlob) {
798
- return joinRelativeGlob(relativeBasePath, item.parsedPattern.globPattern);
799
- }
800
- if (item.stat.isDirectory()) {
801
- return joinRelativeGlob(relativeBasePath, "**/*");
802
- }
803
- return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
790
+ // Each path becomes its own walk root. Collapsing to a shared common ancestor
791
+ // (and filtering with a brace-union glob) would force the walker to traverse
792
+ // and stat every unrelated sibling under that ancestor — two paths under
793
+ // $HOME would scan all of $HOME. The find tool fans these targets out in
794
+ // parallel instead, so every scan stays bounded to exactly one requested path.
795
+ const targets = patternItems.map(item => {
796
+ const parsedPattern = parseFindPattern(item);
797
+ return {
798
+ basePath: resolveToCwd(parsedPattern.basePath, cwd),
799
+ globPattern: parsedPattern.globPattern,
800
+ hasGlob: parsedPattern.hasGlob,
801
+ };
804
802
  });
805
803
 
806
804
  return {
807
- basePath: commonBasePath,
808
- globPattern: buildBraceUnion(combinedPatterns) ?? "**/*",
805
+ targets,
809
806
  scopePath: toScopeDisplay(patternItems, cwd),
810
807
  };
811
808
  }
package/src/tools/read.ts CHANGED
@@ -575,6 +575,8 @@ export interface ReadToolDetails {
575
575
  summary?: { lines: number; elidedSpans: number; elidedLines: number };
576
576
  /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
577
577
  conflictCount?: number;
578
+ /** Paths recovered from a delimited read argument; used only by the TUI to render one call as multiple read rows. */
579
+ displayReadTargets?: string[];
578
580
  }
579
581
 
580
582
  type ReadParams = ReadToolInput;
@@ -670,7 +672,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
670
672
  readonly loadMode = "essential";
671
673
  readonly description: string;
672
674
  readonly parameters = readSchema;
673
- readonly nonAbortable = true;
674
675
  readonly strict = true;
675
676
 
676
677
  readonly #autoResizeImages: boolean;
@@ -704,6 +705,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
704
705
  const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`;
705
706
  const notes = [notice];
706
707
  const content: Array<TextContent | ImageContent> = [];
708
+ const displayReadTargets: string[] = [];
707
709
  let pendingText = notice;
708
710
  const flushText = () => {
709
711
  if (pendingText.length === 0) return;
@@ -717,6 +719,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
717
719
  for (const part of parts) {
718
720
  try {
719
721
  const result = await this.execute("read-delimited-part", { path: part }, signal);
722
+ displayReadTargets.push(result.details?.suffixResolution?.to ?? part);
720
723
  for (const block of result.content) {
721
724
  if (block.type === "text") {
722
725
  appendText(block.text);
@@ -730,12 +733,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
730
733
  const message = error instanceof Error ? error.message : String(error);
731
734
  const errorNote = `Could not read ${part}: ${message}`;
732
735
  notes.push(errorNote);
736
+ displayReadTargets.push(part);
733
737
  appendText(`[${errorNote}]`);
734
738
  }
735
739
  }
736
740
  flushText();
737
741
 
738
- return toolResult<ReadToolDetails>({ notes }).content(content).done();
742
+ return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
739
743
  }
740
744
 
741
745
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -1648,7 +1652,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1648
1652
  throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1649
1653
  }
1650
1654
  const { offset, limit } = selToOffsetLimit(parsed);
1651
- const dirResult = await this.#readDirectory(absolutePath, offset, limit, signal);
1655
+ // Directory listings are deterministic and fast; never abort them mid-scan
1656
+ // (an interrupt would otherwise surface a misleading "Operation aborted").
1657
+ const dirResult = await this.#readDirectory(absolutePath, offset, limit, undefined);
1652
1658
  if (suffixResolution) {
1653
1659
  dirResult.details ??= {};
1654
1660
  dirResult.details.suffixResolution = suffixResolution;
@@ -1815,7 +1821,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1815
1821
  parsed,
1816
1822
  displayMode,
1817
1823
  suffixResolution,
1818
- signal,
1824
+ undefined, // plain-file read: deterministic and fast, never abort mid-read
1819
1825
  );
1820
1826
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
1821
1827
  content = [{ type: "text", text: multiResult.outputText }];
@@ -1874,7 +1880,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1874
1880
  maxLinesToCollect,
1875
1881
  maxBytesForRead,
1876
1882
  selectedLineLimit,
1877
- signal,
1883
+ undefined, // plain-file read: deterministic and fast, never abort mid-read
1878
1884
  );
1879
1885
 
1880
1886
  const {
@@ -2368,11 +2374,13 @@ function formatReadPathLink(
2368
2374
  const plainDisplayPath = options.suffixResolution
2369
2375
  ? shortenPath(options.suffixResolution.to)
2370
2376
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
2371
- const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath);
2377
+ const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
2378
+ const target =
2379
+ options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath) ?? absoluteInputPath;
2372
2380
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
2373
2381
  const linkOptions = line !== undefined ? { line } : undefined;
2374
- const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
2375
- return `${displayPath}${selectorSuffix}`;
2382
+ const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
2383
+ return `${linkedPath}${selectorSuffix}`;
2376
2384
  }
2377
2385
 
2378
2386
  export const readToolRenderer = {
@@ -338,6 +338,7 @@ export function formatDiagnostics(
338
338
  expanded: boolean,
339
339
  theme: Theme,
340
340
  getLangIcon: (filePath: string) => string,
341
+ options?: { title?: string },
341
342
  ): string {
342
343
  if (diag.messages.length === 0) return "";
343
344
 
@@ -369,7 +370,8 @@ export function formatDiagnostics(
369
370
  ? theme.styledSymbol("status.error", "error")
370
371
  : theme.styledSymbol("status.warning", "warning");
371
372
  const summary = sanitizeDiagnosticDisplayText(diag.summary);
372
- let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${summary})`)}`;
373
+ const summaryTag = summary ? ` ${theme.fg("dim", `(${summary})`)}` : "";
374
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", options?.title ?? "Diagnostics")}${summaryTag}`;
373
375
 
374
376
  const maxDiags = expanded ? diag.messages.length : 5;
375
377
  let diagsShown = 0;
@@ -40,21 +40,6 @@ export type ToolRenderer = {
40
40
  args?: unknown,
41
41
  ) => Component;
42
42
  mergeCallAndResult?: boolean;
43
- /**
44
- * While a tool's preview is still streaming, report whether the
45
- * currently-rendered preview is append-only: its rows only grow at the bottom
46
- * and never re-layout above the bottom live region (a full, top-anchored
47
- * content/code preview). The transcript reports this up to the TUI so a
48
- * streaming preview taller than the viewport commits its scrolled-off head to
49
- * native scrollback instead of dropping it (see
50
- * `ToolExecutionComponent.isTranscriptBlockAppendOnly`). `result` is the
51
- * latest (possibly partial) tool result, or `undefined` before one exists —
52
- * `eval`/`bash` use its presence to defer committing until the streamed input
53
- * (code) has finalized. Omit (or return `false`) for previews that slide a
54
- * tail window or later collapse to a compact result — committing their head
55
- * would strand stale rows.
56
- */
57
- isStreamingPreviewAppendOnly?: (args: unknown, options: RenderResultOptions, result?: unknown) => boolean;
58
43
  /** Render without background box, inline in the response flow */
59
44
  inline?: boolean;
60
45
  };
package/src/tools/ssh.ts CHANGED
@@ -252,7 +252,6 @@ export const sshToolRenderer = {
252
252
  state: "pending",
253
253
  sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
254
254
  width,
255
- animate: true,
256
255
  },
257
256
  uiTheme,
258
257
  ),