@oh-my-pi/pi-coding-agent 5.1.1 → 5.2.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.2.1] - 2026-01-14
6
+ ### Fixed
7
+
8
+ - Fixed stale diagnostic results by tracking diagnostic versions before file sync operations
9
+ - Fixed race condition where LSP diagnostics could return outdated results after file modifications
10
+
11
+ ## [5.2.0] - 2026-01-14
12
+
13
+ ### Added
14
+
15
+ - Added `withLines` parameter to read tool for optional line number output (default: true, cat -n format)
16
+
17
+ ### Changed
18
+
19
+ - Changed find/grep/ls tool output to render inline without background box for cleaner visual flow
20
+
21
+ ### Fixed
22
+
23
+ - Fixed task tool abort to return partial results instead of failing (completed tasks preserved, cancelled tasks shown as skipped)
24
+ - Fixed TUI crash when bash output metadata lines exceed terminal width on narrow terminals
25
+ - Fixed find tool not matching `**/filename` patterns (was incorrectly using `--full-path` for glob depth wildcards)
26
+
5
27
  ## [5.1.1] - 2026-01-14
6
28
 
7
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "5.1.1",
3
+ "version": "5.2.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "5.1.1",
43
- "@oh-my-pi/pi-ai": "5.1.1",
44
- "@oh-my-pi/pi-git-tool": "5.1.1",
45
- "@oh-my-pi/pi-tui": "5.1.1",
42
+ "@oh-my-pi/pi-agent-core": "5.2.1",
43
+ "@oh-my-pi/pi-ai": "5.2.1",
44
+ "@oh-my-pi/pi-git-tool": "5.2.1",
45
+ "@oh-my-pi/pi-tui": "5.2.1",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@silvia-odwyer/photon-node": "^0.3.4",
48
48
  "@sinclair/typebox": "^0.34.46",
@@ -1,7 +1,7 @@
1
1
  import { relative, resolve, sep } from "node:path";
2
2
  import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
- import { Text } from "@oh-my-pi/pi-tui";
4
+ import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
6
6
  import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
7
7
  import type { Theme } from "../../modes/interactive/theme/theme";
@@ -293,16 +293,15 @@ export const bashToolRenderer = {
293
293
  const outputLines: string[] = [];
294
294
  if (cachedSkipped && cachedSkipped > 0) {
295
295
  outputLines.push("");
296
- outputLines.push(
297
- uiTheme.fg(
298
- "dim",
299
- `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
300
- ),
296
+ const skippedLine = uiTheme.fg(
297
+ "dim",
298
+ `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
301
299
  );
300
+ outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
302
301
  }
303
302
  outputLines.push(...cachedLines);
304
303
  if (warningLine) {
305
- outputLines.push(warningLine);
304
+ outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
306
305
  }
307
306
  return outputLines;
308
307
  },
@@ -218,7 +218,11 @@ export function createFindTool(session: ToolSession, options?: FindToolOptions):
218
218
  // When pattern contains path separators (e.g. "reports/**"), use --full-path
219
219
  // so fd matches against the full path, not just the filename.
220
220
  // Also prepend **/ to anchor the pattern at any depth in the search path.
221
- const hasPathSeparator = pattern.includes("/") || pattern.includes("\\");
221
+ // Note: "**/foo.rs" is a glob construct (filename at any depth), not a path.
222
+ // Only patterns with real path components like "foo/bar" or "foo/**/bar" need --full-path.
223
+ const patternWithoutLeadingStarStar = pattern.replace(/^\*\*\//, "");
224
+ const hasPathSeparator =
225
+ patternWithoutLeadingStarStar.includes("/") || patternWithoutLeadingStarStar.includes("\\");
222
226
  const effectivePattern = hasPathSeparator && !pattern.startsWith("**/") ? `**/${pattern}` : pattern;
223
227
  const args: string[] = [
224
228
  "--glob", // Use glob pattern
@@ -418,10 +422,11 @@ interface FindRenderArgs {
418
422
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
419
423
 
420
424
  export const findToolRenderer = {
425
+ inline: true,
421
426
  renderCall(args: FindRenderArgs, uiTheme: Theme): Component {
422
427
  const ui = createToolUIKit(uiTheme);
423
428
  const label = ui.title("Find");
424
- let text = `${label} ${uiTheme.fg("accent", args.pattern || "*")}`;
429
+ let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "*")}`;
425
430
 
426
431
  const meta: string[] = [];
427
432
  if (args.path) meta.push(`in ${args.path}`);
@@ -436,15 +441,16 @@ export const findToolRenderer = {
436
441
  },
437
442
 
438
443
  renderResult(
439
- result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails },
444
+ result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
440
445
  { expanded }: RenderResultOptions,
441
446
  uiTheme: Theme,
442
447
  ): Component {
443
448
  const ui = createToolUIKit(uiTheme);
444
449
  const details = result.details;
445
450
 
446
- if (details?.error) {
447
- return new Text(ui.errorMessage(details.error), 0, 0);
451
+ if (result.isError || details?.error) {
452
+ const errorText = details?.error || result.content?.find((c) => c.type === "text")?.text || "Unknown error";
453
+ return new Text(` ${ui.errorMessage(errorText)}`, 0, 0);
448
454
  }
449
455
 
450
456
  const hasDetailedData = details?.fileCount !== undefined;
@@ -452,7 +458,7 @@ export const findToolRenderer = {
452
458
 
453
459
  if (!hasDetailedData) {
454
460
  if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") {
455
- return new Text(ui.emptyMessage("No files found"), 0, 0);
461
+ return new Text(` ${ui.emptyMessage("No files found")}`, 0, 0);
456
462
  }
457
463
 
458
464
  const lines = textContent.split("\n").filter((l) => l.trim());
@@ -464,15 +470,15 @@ export const findToolRenderer = {
464
470
  const icon = uiTheme.styledSymbol("status.success", "success");
465
471
  const summary = ui.count("file", lines.length);
466
472
  const expandHint = ui.expandHint(expanded, hasMore);
467
- let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
473
+ let text = ` ${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
468
474
 
469
475
  for (let i = 0; i < displayLines.length; i++) {
470
476
  const isLast = i === displayLines.length - 1 && remaining === 0;
471
477
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
472
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`;
478
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`;
473
479
  }
474
480
  if (remaining > 0) {
475
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "file"))}`;
481
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "file"))}`;
476
482
  }
477
483
  return new Text(text, 0, 0);
478
484
  }
@@ -482,7 +488,7 @@ export const findToolRenderer = {
482
488
  const files = details?.files ?? [];
483
489
 
484
490
  if (fileCount === 0) {
485
- return new Text(ui.emptyMessage("No files found"), 0, 0);
491
+ return new Text(` ${ui.emptyMessage("No files found")}`, 0, 0);
486
492
  }
487
493
 
488
494
  const icon = uiTheme.styledSymbol("status.success", "success");
@@ -492,7 +498,7 @@ export const findToolRenderer = {
492
498
  const hasMoreFiles = files.length > maxFiles;
493
499
  const expandHint = ui.expandHint(expanded, hasMoreFiles);
494
500
 
495
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
501
+ let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
496
502
 
497
503
  const truncationReasons: string[] = [];
498
504
  if (details?.resultLimitReached) {
@@ -515,12 +521,12 @@ export const findToolRenderer = {
515
521
  const entryIcon = isDir
516
522
  ? uiTheme.fg("accent", uiTheme.icon.folder)
517
523
  : uiTheme.fg("muted", uiTheme.getLangIcon(lang));
518
- text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry)}`;
524
+ text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry)}`;
519
525
  }
520
526
 
521
527
  if (hasMoreFiles) {
522
528
  const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
523
- text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
529
+ text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
524
530
  "muted",
525
531
  ui.moreItems(files.length - maxFiles, "file"),
526
532
  )}`;
@@ -528,7 +534,7 @@ export const findToolRenderer = {
528
534
  }
529
535
 
530
536
  if (hasTruncation) {
531
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
537
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
532
538
  }
533
539
 
534
540
  return new Text(text, 0, 0);
@@ -621,10 +621,11 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
621
621
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
622
622
 
623
623
  export const grepToolRenderer = {
624
+ inline: true,
624
625
  renderCall(args: GrepRenderArgs, uiTheme: Theme): Component {
625
626
  const ui = createToolUIKit(uiTheme);
626
627
  const label = ui.title("Grep");
627
- let text = `${label} ${uiTheme.fg("accent", args.pattern || "?")}`;
628
+ let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "?")}`;
628
629
 
629
630
  const meta: string[] = [];
630
631
  if (args.path) meta.push(`in ${args.path}`);
@@ -647,15 +648,16 @@ export const grepToolRenderer = {
647
648
  },
648
649
 
649
650
  renderResult(
650
- result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails },
651
+ result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean },
651
652
  { expanded }: RenderResultOptions,
652
653
  uiTheme: Theme,
653
654
  ): Component {
654
655
  const ui = createToolUIKit(uiTheme);
655
656
  const details = result.details;
656
657
 
657
- if (details?.error) {
658
- return new Text(ui.errorMessage(details.error), 0, 0);
658
+ if (result.isError || details?.error) {
659
+ const errorText = details?.error || result.content?.find((c) => c.type === "text")?.text || "Unknown error";
660
+ return new Text(` ${ui.errorMessage(errorText)}`, 0, 0);
659
661
  }
660
662
 
661
663
  const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
@@ -663,7 +665,7 @@ export const grepToolRenderer = {
663
665
  if (!hasDetailedData) {
664
666
  const textContent = result.content?.find((c) => c.type === "text")?.text;
665
667
  if (!textContent || textContent === "No matches found") {
666
- return new Text(ui.emptyMessage("No matches found"), 0, 0);
668
+ return new Text(` ${ui.emptyMessage("No matches found")}`, 0, 0);
667
669
  }
668
670
 
669
671
  const lines = textContent.split("\n").filter((line) => line.trim() !== "");
@@ -675,16 +677,16 @@ export const grepToolRenderer = {
675
677
  const icon = uiTheme.styledSymbol("status.success", "success");
676
678
  const summary = ui.count("item", lines.length);
677
679
  const expandHint = ui.expandHint(expanded, hasMore);
678
- let text = `${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
680
+ let text = ` ${icon} ${uiTheme.fg("dim", summary)}${expandHint}`;
679
681
 
680
682
  for (let i = 0; i < displayLines.length; i++) {
681
683
  const isLast = i === displayLines.length - 1 && remaining === 0;
682
684
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
683
- text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", displayLines[i])}`;
685
+ text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", displayLines[i])}`;
684
686
  }
685
687
 
686
688
  if (remaining > 0) {
687
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "item"))}`;
689
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "item"))}`;
688
690
  }
689
691
 
690
692
  return new Text(text, 0, 0);
@@ -697,7 +699,7 @@ export const grepToolRenderer = {
697
699
  const files = details?.files ?? [];
698
700
 
699
701
  if (matchCount === 0) {
700
- return new Text(ui.emptyMessage("No matches found"), 0, 0);
702
+ return new Text(` ${ui.emptyMessage("No matches found")}`, 0, 0);
701
703
  }
702
704
 
703
705
  const icon = uiTheme.styledSymbol("status.success", "success");
@@ -715,7 +717,7 @@ export const grepToolRenderer = {
715
717
  const hasMoreFiles = fileEntries.length > maxFiles;
716
718
  const expandHint = ui.expandHint(expanded, hasMoreFiles);
717
719
 
718
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
720
+ let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`;
719
721
 
720
722
  const truncationReasons: string[] = [];
721
723
  if (details?.matchLimitReached) {
@@ -748,12 +750,12 @@ export const grepToolRenderer = {
748
750
  entry.count !== undefined
749
751
  ? ` ${uiTheme.fg("dim", `(${entry.count} match${entry.count !== 1 ? "es" : ""})`)}`
750
752
  : "";
751
- text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry.path)}${countLabel}`;
753
+ text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry.path)}${countLabel}`;
752
754
  }
753
755
 
754
756
  if (hasMoreFiles) {
755
757
  const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
756
- text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
758
+ text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg(
757
759
  "muted",
758
760
  ui.moreItems(fileEntries.length - maxFiles, "file"),
759
761
  )}`;
@@ -761,7 +763,7 @@ export const grepToolRenderer = {
761
763
  }
762
764
 
763
765
  if (hasTruncation) {
764
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
766
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
765
767
  }
766
768
 
767
769
  return new Text(text, 0, 0);
@@ -13,6 +13,7 @@ import {
13
13
  formatBytes,
14
14
  formatCount,
15
15
  formatEmptyMessage,
16
+ formatErrorMessage,
16
17
  formatExpandHint,
17
18
  formatMeta,
18
19
  formatMoreItems,
@@ -205,9 +206,10 @@ interface LsRenderArgs {
205
206
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
206
207
 
207
208
  export const lsToolRenderer = {
209
+ inline: true,
208
210
  renderCall(args: LsRenderArgs, uiTheme: Theme): Component {
209
211
  const label = uiTheme.fg("toolTitle", uiTheme.bold("Ls"));
210
- let text = `${label} ${uiTheme.fg("accent", args.path || ".")}`;
212
+ let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.path || ".")}`;
211
213
 
212
214
  const meta: string[] = [];
213
215
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
@@ -217,18 +219,22 @@ export const lsToolRenderer = {
217
219
  },
218
220
 
219
221
  renderResult(
220
- result: { content: Array<{ type: string; text?: string }>; details?: LsToolDetails },
222
+ result: { content: Array<{ type: string; text?: string }>; details?: LsToolDetails; isError?: boolean },
221
223
  { expanded }: RenderResultOptions,
222
224
  uiTheme: Theme,
223
225
  ): Component {
224
226
  const details = result.details;
225
227
  const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
226
228
 
229
+ if (result.isError) {
230
+ return new Text(` ${formatErrorMessage(textContent, uiTheme)}`, 0, 0);
231
+ }
232
+
227
233
  if (
228
234
  (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") &&
229
235
  (!details?.entries || details.entries.length === 0)
230
236
  ) {
231
- return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0);
237
+ return new Text(` ${formatEmptyMessage("Empty directory", uiTheme)}`, 0, 0);
232
238
  }
233
239
 
234
240
  let entries: string[] = details?.entries ? [...details.entries] : [];
@@ -238,7 +244,7 @@ export const lsToolRenderer = {
238
244
  }
239
245
 
240
246
  if (entries.length === 0) {
241
- return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0);
247
+ return new Text(` ${formatEmptyMessage("Empty directory", uiTheme)}`, 0, 0);
242
248
  }
243
249
 
244
250
  let dirCount = details?.dirCount;
@@ -267,7 +273,7 @@ export const lsToolRenderer = {
267
273
  const hasMoreEntries = entries.length > maxEntries;
268
274
  const expandHint = formatExpandHint(uiTheme, expanded, hasMoreEntries);
269
275
 
270
- let text = `${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${expandHint}`;
276
+ let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${expandHint}`;
271
277
 
272
278
  const truncationReasons: string[] = [];
273
279
  if (details?.entryLimitReached) {
@@ -290,19 +296,19 @@ export const lsToolRenderer = {
290
296
  ? uiTheme.fg("accent", uiTheme.icon.folder)
291
297
  : uiTheme.fg("muted", uiTheme.getLangIcon(lang));
292
298
  const entryColor = isDir ? "accent" : "toolOutput";
293
- text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg(entryColor, entry)}`;
299
+ text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg(entryColor, entry)}`;
294
300
  }
295
301
 
296
302
  if (hasMoreEntries) {
297
303
  const moreEntriesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last;
298
- text += `\n ${uiTheme.fg("dim", moreEntriesBranch)} ${uiTheme.fg(
304
+ text += `\n ${uiTheme.fg("dim", moreEntriesBranch)} ${uiTheme.fg(
299
305
  "muted",
300
306
  formatMoreItems(entries.length - maxEntries, "entry", uiTheme),
301
307
  )}`;
302
308
  }
303
309
 
304
310
  if (hasTruncation) {
305
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
311
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
306
312
  "warning",
307
313
  `truncated: ${truncationReasons.join(", ")}`,
308
314
  )}`;
@@ -331,12 +331,14 @@ async function waitForDiagnostics(
331
331
  uri: string,
332
332
  timeoutMs = 3000,
333
333
  signal?: AbortSignal,
334
+ minVersion?: number,
334
335
  ): Promise<Diagnostic[]> {
335
336
  const start = Date.now();
336
337
  while (Date.now() - start < timeoutMs) {
337
338
  signal?.throwIfAborted();
338
339
  const diagnostics = client.diagnostics.get(uri);
339
- if (diagnostics !== undefined) return diagnostics;
340
+ const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
341
+ if (diagnostics !== undefined && versionOk) return diagnostics;
340
342
  await sleep(100);
341
343
  }
342
344
  return client.diagnostics.get(uri) ?? [];
@@ -462,12 +464,35 @@ export interface FileDiagnosticsResult {
462
464
  formatter?: FileFormatResult;
463
465
  }
464
466
 
467
+ /** Captured diagnostic versions per server (before sync) */
468
+ type DiagnosticVersions = Map<string, number>;
469
+
470
+ /**
471
+ * Capture current diagnostic versions for all LSP servers.
472
+ * Call this BEFORE syncing content to detect stale diagnostics later.
473
+ */
474
+ async function captureDiagnosticVersions(
475
+ cwd: string,
476
+ servers: Array<[string, ServerConfig]>,
477
+ ): Promise<DiagnosticVersions> {
478
+ const versions = new Map<string, number>();
479
+ await Promise.allSettled(
480
+ servers.map(async ([serverName, serverConfig]) => {
481
+ if (serverConfig.createClient) return;
482
+ const client = await getOrCreateClient(serverConfig, cwd);
483
+ versions.set(serverName, client.diagnosticsVersion);
484
+ }),
485
+ );
486
+ return versions;
487
+ }
488
+
465
489
  /**
466
490
  * Get diagnostics for a file using LSP or custom linter client.
467
491
  *
468
492
  * @param absolutePath - Absolute path to the file
469
493
  * @param cwd - Working directory for LSP config resolution
470
494
  * @param servers - Servers to query diagnostics for
495
+ * @param minVersions - Minimum diagnostic versions per server (to detect stale results)
471
496
  * @returns Diagnostic results or undefined if no servers
472
497
  */
473
498
  async function getDiagnosticsForFile(
@@ -475,6 +500,7 @@ async function getDiagnosticsForFile(
475
500
  cwd: string,
476
501
  servers: Array<[string, ServerConfig]>,
477
502
  signal?: AbortSignal,
503
+ minVersions?: DiagnosticVersions,
478
504
  ): Promise<FileDiagnosticsResult | undefined> {
479
505
  if (servers.length === 0) {
480
506
  return undefined;
@@ -499,8 +525,9 @@ async function getDiagnosticsForFile(
499
525
  // Default: use LSP
500
526
  const client = await getOrCreateClient(serverConfig, cwd);
501
527
  signal?.throwIfAborted();
502
- // Content already synced + didSave sent, just wait for diagnostics
503
- const diagnostics = await waitForDiagnostics(client, uri, 3000, signal);
528
+ // Content already synced + didSave sent, wait for fresh diagnostics
529
+ const minVersion = minVersions?.get(serverName);
530
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, signal, minVersion);
504
531
  return { serverName, diagnostics };
505
532
  }),
506
533
  );
@@ -675,6 +702,9 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
675
702
  const getWritePromise = once(() => writeContent(finalContent));
676
703
  const useCustomFormatter = enableFormat && customLinterServers.length > 0;
677
704
 
705
+ // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
706
+ const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
707
+
678
708
  let formatter: FileFormatResult | undefined;
679
709
  let diagnostics: FileDiagnosticsResult | undefined;
680
710
  try {
@@ -710,9 +740,9 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
710
740
  // 5. Notify saved to LSP servers
711
741
  await notifyFileSaved(dst, cwd, lspServers, operationSignal);
712
742
 
713
- // 6. Get diagnostics from all servers
743
+ // 6. Get diagnostics from all servers (wait for fresh results)
714
744
  if (enableDiagnostics) {
715
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal);
745
+ diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal, minVersions);
716
746
  }
717
747
  });
718
748
  } catch {
@@ -829,8 +859,9 @@ export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema,
829
859
  continue;
830
860
  }
831
861
  const client = await getOrCreateClient(serverConfig, session.cwd);
862
+ const minVersion = client.diagnosticsVersion;
832
863
  await refreshFile(client, resolved);
833
- const diagnostics = await waitForDiagnostics(client, uri);
864
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, minVersion);
834
865
  allDiagnostics.push(...diagnostics);
835
866
  } catch {
836
867
  // Server failed, continue with others
@@ -1091,8 +1122,9 @@ export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema,
1091
1122
  };
1092
1123
  }
1093
1124
 
1125
+ const actionsMinVersion = client.diagnosticsVersion;
1094
1126
  await refreshFile(client, targetFile);
1095
- const diagnostics = await waitForDiagnostics(client, uri);
1127
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, actionsMinVersion);
1096
1128
  const endLine = (end_line ?? line ?? 1) - 1;
1097
1129
  const endCharacter = (end_character ?? column ?? 1) - 1;
1098
1130
  const range = { start: position, end: { line: endLine, character: endCharacter } };
@@ -417,6 +417,7 @@ const readSchema = Type.Object({
417
417
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
418
418
  offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
419
419
  limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
420
+ lines: Type.Optional(Type.Boolean({ description: "Prepend line numbers to output (default: true)" })),
420
421
  });
421
422
 
422
423
  export interface ReadToolDetails {
@@ -436,7 +437,7 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
436
437
  parameters: readSchema,
437
438
  execute: async (
438
439
  toolCallId: string,
439
- { path: readPath, offset, limit }: { path: string; offset?: number; limit?: number },
440
+ { path: readPath, offset, limit, lines }: { path: string; offset?: number; limit?: number; lines?: boolean },
440
441
  signal?: AbortSignal,
441
442
  ) => {
442
443
  const absolutePath = resolveReadPath(readPath, session.cwd);
@@ -599,6 +600,20 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
599
600
  // Apply truncation (respects both line and byte limits)
600
601
  const truncation = truncateHead(selectedContent);
601
602
 
603
+ // Add line numbers if requested (default: true)
604
+ const shouldAddLineNumbers = lines !== false;
605
+ const prependLineNumbers = (text: string, startNum: number): string => {
606
+ const lines = text.split("\n");
607
+ const lastLineNum = startNum + lines.length - 1;
608
+ const padWidth = String(lastLineNum).length;
609
+ return lines
610
+ .map((line, i) => {
611
+ const lineNum = String(startNum + i).padStart(padWidth, " ");
612
+ return `${lineNum}\t${line}`;
613
+ })
614
+ .join("\n");
615
+ };
616
+
602
617
  let outputText: string;
603
618
 
604
619
  if (truncation.firstLineExceedsLimit) {
@@ -607,8 +622,8 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
607
622
  const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
608
623
  const shownSize = formatSize(snippet.bytes);
609
624
 
610
- outputText = snippet.text;
611
- if (outputText.length > 0) {
625
+ outputText = shouldAddLineNumbers ? prependLineNumbers(snippet.text, startLineDisplay) : snippet.text;
626
+ if (snippet.text.length > 0) {
612
627
  outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
613
628
  firstLineBytes,
614
629
  )}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
@@ -623,7 +638,9 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
623
638
  const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
624
639
  const nextOffset = endLineDisplay + 1;
625
640
 
626
- outputText = truncation.content;
641
+ outputText = shouldAddLineNumbers
642
+ ? prependLineNumbers(truncation.content, startLineDisplay)
643
+ : truncation.content;
627
644
 
628
645
  if (truncation.truncatedBy === "lines") {
629
646
  outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
@@ -638,11 +655,15 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
638
655
  const remaining = allLines.length - (startLine + userLimitedLines);
639
656
  const nextOffset = startLine + userLimitedLines + 1;
640
657
 
641
- outputText = truncation.content;
658
+ outputText = shouldAddLineNumbers
659
+ ? prependLineNumbers(truncation.content, startLineDisplay)
660
+ : truncation.content;
642
661
  outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
643
662
  } else {
644
663
  // No truncation, no user limit exceeded
645
- outputText = truncation.content;
664
+ outputText = shouldAddLineNumbers
665
+ ? prependLineNumbers(truncation.content, startLineDisplay)
666
+ : truncation.content;
646
667
  }
647
668
 
648
669
  content = [{ type: "text", text: outputText }];
@@ -34,6 +34,8 @@ type ToolRenderer = {
34
34
  args?: unknown,
35
35
  ) => Component;
36
36
  mergeCallAndResult?: boolean;
37
+ /** Render without background box, inline in the response flow */
38
+ inline?: boolean;
37
39
  };
38
40
 
39
41
  export const toolRenderers: Record<string, ToolRenderer> = {
@@ -29,6 +29,7 @@ import {
29
29
  MAX_AGENTS_IN_DESCRIPTION,
30
30
  MAX_CONCURRENCY,
31
31
  MAX_PARALLEL_TASKS,
32
+ type SingleResult,
32
33
  type TaskToolDetails,
33
34
  taskSchema,
34
35
  } from "./types";
@@ -337,7 +338,7 @@ export async function createTaskTool(
337
338
  emitProgress();
338
339
 
339
340
  // Execute in parallel with concurrency limit
340
- const results = await mapWithConcurrencyLimit(
341
+ const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
341
342
  tasksWithContext,
342
343
  MAX_CONCURRENCY,
343
344
  async (task, index) => {
@@ -371,6 +372,29 @@ export async function createTaskTool(
371
372
  signal,
372
373
  );
373
374
 
375
+ // Fill in skipped tasks (undefined entries from abort) with placeholder results
376
+ const results: SingleResult[] = partialResults.map((result, index) => {
377
+ if (result !== undefined) return result;
378
+ const task = tasksWithContext[index];
379
+ return {
380
+ index,
381
+ taskId: task.taskId,
382
+ agent: agentName,
383
+ agentSource: agent.source,
384
+ task: task.task,
385
+ description: task.description,
386
+ exitCode: 1,
387
+ output: "",
388
+ stderr: "Skipped (cancelled before start)",
389
+ truncated: false,
390
+ durationMs: 0,
391
+ tokens: 0,
392
+ modelOverride,
393
+ error: "Skipped",
394
+ aborted: true,
395
+ };
396
+ });
397
+
374
398
  // Aggregate usage from executor results (already accumulated incrementally)
375
399
  const aggregatedUsage = createUsageTotals();
376
400
  let hasAggregatedUsage = false;
@@ -391,10 +415,11 @@ export async function createTaskTool(
391
415
 
392
416
  // Build final output - match plugin format
393
417
  const successCount = results.filter((r) => r.exitCode === 0).length;
418
+ const cancelledCount = results.filter((r) => r.aborted).length;
394
419
  const totalDuration = Date.now() - startTime;
395
420
 
396
421
  const summaries = results.map((r) => {
397
- const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
422
+ const status = r.aborted ? "cancelled" : r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
398
423
  const output = r.output.trim() || r.stderr.trim() || "(no output)";
399
424
  const preview = output.split("\n").slice(0, 5).join("\n");
400
425
  const meta = r.outputMeta
@@ -403,13 +428,14 @@ export async function createTaskTool(
403
428
  return `[${r.agent}] ${status}${meta} ${r.taskId}\n${preview}`;
404
429
  });
405
430
 
406
- const outputIds = results.map((r) => r.taskId);
431
+ const outputIds = results.filter((r) => !r.aborted || r.output.trim()).map((r) => r.taskId);
407
432
  const outputHint =
408
433
  outputIds.length > 0 ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}` : "";
409
434
  const schemaNote = schemaOverridden
410
435
  ? `\n\nNote: Agent '${agentName}' has a fixed output schema; your 'output' parameter was ignored.\nRequired schema: ${JSON.stringify(agent.output)}`
411
436
  : "";
412
- const summary = `${successCount}/${results.length} succeeded [${formatDuration(
437
+ const cancelledNote = aborted && cancelledCount > 0 ? ` (${cancelledCount} cancelled)` : "";
438
+ const summary = `${successCount}/${results.length} succeeded${cancelledNote} [${formatDuration(
413
439
  totalDuration,
414
440
  )}]\n\n${summaries.join("\n\n---\n\n")}${outputHint}${schemaNote}`;
415
441
 
@@ -4,31 +4,43 @@
4
4
 
5
5
  import { MAX_CONCURRENCY } from "./types";
6
6
 
7
+ /** Result of parallel execution */
8
+ export interface ParallelResult<R> {
9
+ /** Results array - undefined entries indicate tasks that were skipped due to abort */
10
+ results: (R | undefined)[];
11
+ /** Whether execution was aborted before all tasks completed */
12
+ aborted: boolean;
13
+ }
14
+
7
15
  /**
8
16
  * Execute items with a concurrency limit using a worker pool pattern.
9
17
  * Results are returned in the same order as input items.
10
- * Fails fast on first error - does not wait for other workers to complete.
18
+ *
19
+ * On abort: returns partial results with `aborted: true`. Completed tasks are preserved,
20
+ * in-progress tasks will complete with their abort handling, skipped tasks are `undefined`.
21
+ *
22
+ * On error: fails fast - does not wait for other workers to complete.
11
23
  *
12
24
  * @param items - Items to process
13
25
  * @param concurrency - Maximum concurrent operations
14
26
  * @param fn - Async function to execute for each item
15
- * @param signal - Optional abort signal to stop scheduling work
27
+ * @param signal - Optional abort signal to stop scheduling new work
16
28
  */
17
29
  export async function mapWithConcurrencyLimit<T, R>(
18
30
  items: T[],
19
31
  concurrency: number,
20
32
  fn: (item: T, index: number) => Promise<R>,
21
33
  signal?: AbortSignal,
22
- ): Promise<R[]> {
34
+ ): Promise<ParallelResult<R>> {
23
35
  const limit = Math.max(1, Math.min(concurrency, items.length, MAX_CONCURRENCY));
24
- const results: R[] = new Array(items.length);
36
+ const results: (R | undefined)[] = new Array(items.length);
25
37
  let nextIndex = 0;
26
38
 
27
39
  // Create internal abort controller to cancel workers on any rejection
28
40
  const abortController = new AbortController();
29
41
  const workerSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
30
42
 
31
- // Promise that rejects on first error - used to fail fast
43
+ // Promise that rejects on first error - used to fail fast (not for abort)
32
44
  let rejectFirst: (error: unknown) => void;
33
45
  const firstErrorPromise = new Promise<never>((_, reject) => {
34
46
  rejectFirst = reject;
@@ -36,15 +48,20 @@ export async function mapWithConcurrencyLimit<T, R>(
36
48
 
37
49
  const worker = async (): Promise<void> => {
38
50
  while (true) {
39
- workerSignal.throwIfAborted();
51
+ // On abort, stop picking up new work - but don't throw
52
+ if (workerSignal.aborted) return;
40
53
  const index = nextIndex++;
41
54
  if (index >= items.length) return;
42
55
  try {
43
56
  results[index] = await fn(items[index], index);
44
57
  } catch (error) {
45
- abortController.abort();
46
- rejectFirst(error);
47
- throw error;
58
+ // On abort, the fn itself handles it and returns a result
59
+ // Only propagate non-abort errors
60
+ if (!workerSignal.aborted) {
61
+ abortController.abort();
62
+ rejectFirst(error);
63
+ throw error;
64
+ }
48
65
  }
49
66
  }
50
67
  };
@@ -53,13 +70,16 @@ export async function mapWithConcurrencyLimit<T, R>(
53
70
  const workers = Array(limit)
54
71
  .fill(null)
55
72
  .map(() => worker());
56
- await Promise.race([Promise.all(workers), firstErrorPromise]);
57
73
 
58
- // Check external abort
59
- if (signal?.aborted) {
60
- const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
61
- throw reason;
74
+ try {
75
+ await Promise.race([Promise.all(workers), firstErrorPromise]);
76
+ } catch (error) {
77
+ // If aborted, don't rethrow - return partial results
78
+ if (signal?.aborted) {
79
+ return { results, aborted: true };
80
+ }
81
+ throw error;
62
82
  }
63
83
 
64
- return results;
84
+ return { results, aborted: signal?.aborted ?? false };
65
85
  }
@@ -390,7 +390,8 @@ export class ToolExecutionComponent extends Container {
390
390
  } else if (this.toolName in toolRenderers) {
391
391
  // Built-in tools with renderers
392
392
  const renderer = toolRenderers[this.toolName];
393
- this.contentBox.setBgFn(bgFn);
393
+ // Inline renderers skip background styling
394
+ this.contentBox.setBgFn(renderer.inline ? undefined : bgFn);
394
395
  this.contentBox.clear();
395
396
 
396
397
  const shouldRenderCall = !this.result || !renderer.mergeCallAndResult;
@@ -345,9 +345,8 @@ export class InteractiveMode implements InteractiveModeContext {
345
345
  this.ui.start();
346
346
  this.isInitialized = true;
347
347
 
348
- // Set terminal title
349
- const cwdBasename = path.basename(process.cwd());
350
- this.ui.terminal.setTitle(`pi - ${cwdBasename}`);
348
+ // Set initial terminal title (will be updated when session title is generated)
349
+ this.ui.terminal.setTitle("π");
351
350
 
352
351
  // Initialize hooks with TUI-based UI context
353
352
  await this.initHooksAndCustomTools();
@@ -5,7 +5,7 @@ Usage:
5
5
  - By default, it reads up to {{DEFAULT_MAX_LINES}} lines starting from the beginning of the file
6
6
  - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
7
7
  - Any lines longer than 500 characters will be truncated
8
- - Results are returned using cat -n format, with line numbers starting at 1
8
+ - By default, results include line numbers (cat -n format). Use `lines: false` to omit them
9
9
  - This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
10
10
  - This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.
11
11
  - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.