@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
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
  ),
package/src/tools/todo.ts CHANGED
@@ -927,6 +927,7 @@ export const todoToolRenderer = {
927
927
  sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
928
928
  state: options.isPartial ? "pending" : "success",
929
929
  borderColor: "borderMuted",
930
+ applyBg: false,
930
931
  width,
931
932
  };
932
933
  });
@@ -277,7 +277,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
277
277
  readonly label = "Write";
278
278
  readonly description: string;
279
279
  readonly parameters = writeSchema;
280
- readonly nonAbortable = true;
281
280
  readonly strict = true;
282
281
  readonly concurrency = "exclusive";
283
282
  readonly loadMode = "discoverable";
@@ -582,6 +581,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
582
581
  const batchRequest = getLspBatchRequest(context?.toolCall);
583
582
  const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest);
584
583
  invalidateFsScanAfterWrite(absolutePath);
584
+ this.session.bumpFileMutationVersion?.(absolutePath);
585
585
  this.session.fileSnapshotStore?.invalidate(absolutePath);
586
586
  this.session.conflictHistory?.invalidate(entry.id);
587
587
 
@@ -707,6 +707,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
707
707
 
708
708
  const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest);
709
709
  invalidateFsScanAfterWrite(absolutePath);
710
+ this.session.bumpFileMutationVersion?.(absolutePath);
710
711
  this.session.fileSnapshotStore?.invalidate(absolutePath);
711
712
  for (const entry of fileEntries) history.invalidate(entry.id);
712
713
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, text);
@@ -886,6 +887,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
886
887
 
887
888
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
888
889
  invalidateFsScanAfterWrite(absolutePath);
890
+ this.session.bumpFileMutationVersion?.(absolutePath);
889
891
  const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
890
892
 
891
893
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
@@ -1039,17 +1041,6 @@ export const writeToolRenderer = {
1039
1041
  });
1040
1042
  },
1041
1043
 
1042
- // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
1043
- // content top-anchored, so streamed chunks only append rows at the bottom.
1044
- // The collapsed preview slides a bounded tail window (`formatStreamingContent`
1045
- // with `WRITE_STREAMING_PREVIEW_LINES`) whose visible rows re-layout as the
1046
- // window moves — not append-only, but it never overflows the viewport, so its
1047
- // head is never at risk of being dropped regardless. `write` has no partial
1048
- // result (content streams as args), so `result` is ignored here.
1049
- isStreamingPreviewAppendOnly(args: WriteRenderArgs, options: RenderResultOptions, _result?: unknown): boolean {
1050
- return Boolean(options?.expanded && args.content);
1051
- },
1052
-
1053
1044
  renderResult(
1054
1045
  result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails; isError?: boolean },
1055
1046
  options: RenderResultOptions,
@@ -32,8 +32,6 @@ export interface CodeCellOptions {
32
32
  */
33
33
  codeTail?: boolean;
34
34
  expanded?: boolean;
35
- /** Animate the cell border with a sweeping segment while pending/running. */
36
- animate?: boolean;
37
35
  width: number;
38
36
  }
39
37
 
@@ -147,10 +145,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
147
145
  sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines });
148
146
  }
149
147
 
150
- return renderOutputBlock(
151
- { header: title, headerMeta: meta, state, sections, width, animate: options.animate },
152
- theme,
153
- );
148
+ return renderOutputBlock({ header: title, headerMeta: meta, state, sections, width }, theme);
154
149
  }
155
150
 
156
151
  export interface MarkdownCellOptions {