@oh-my-pi/pi-coding-agent 11.2.3 → 11.4.0

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 (102) hide show
  1. package/CHANGELOG.md +119 -4
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +41 -13
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +106 -4
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +77 -63
  56. package/src/prompts/system/plan-mode-active.md +6 -6
  57. package/src/prompts/system/system-prompt.md +2 -1
  58. package/src/prompts/tools/ask.md +2 -2
  59. package/src/prompts/tools/gemini-image.md +2 -2
  60. package/src/prompts/tools/lsp.md +2 -2
  61. package/src/prompts/tools/patch.md +1 -1
  62. package/src/prompts/tools/python.md +3 -3
  63. package/src/prompts/tools/task.md +7 -1
  64. package/src/prompts/tools/todo-write.md +2 -2
  65. package/src/prompts/tools/web-search.md +2 -2
  66. package/src/prompts/tools/write.md +2 -5
  67. package/src/sdk.ts +15 -11
  68. package/src/session/agent-session.ts +92 -34
  69. package/src/session/auth-storage.ts +2 -1
  70. package/src/session/blob-store.ts +105 -0
  71. package/src/session/session-manager.ts +107 -44
  72. package/src/task/executor.ts +19 -9
  73. package/src/task/render.ts +80 -58
  74. package/src/tools/ask.ts +28 -5
  75. package/src/tools/bash.ts +47 -39
  76. package/src/tools/browser.ts +248 -26
  77. package/src/tools/calculator.ts +42 -23
  78. package/src/tools/fetch.ts +33 -16
  79. package/src/tools/find.ts +57 -22
  80. package/src/tools/grep.ts +54 -25
  81. package/src/tools/index.ts +5 -5
  82. package/src/tools/notebook.ts +19 -6
  83. package/src/tools/path-utils.ts +26 -1
  84. package/src/tools/python.ts +20 -14
  85. package/src/tools/read.ts +21 -8
  86. package/src/tools/render-utils.ts +5 -45
  87. package/src/tools/ssh.ts +59 -53
  88. package/src/tools/submit-result.ts +2 -2
  89. package/src/tools/todo-write.ts +32 -14
  90. package/src/tools/truncate.ts +1 -1
  91. package/src/tools/write.ts +42 -26
  92. package/src/tui/code-cell.ts +1 -1
  93. package/src/tui/output-block.ts +61 -3
  94. package/src/tui/tree-list.ts +4 -4
  95. package/src/tui/utils.ts +71 -1
  96. package/src/utils/frontmatter.ts +1 -1
  97. package/src/utils/title-generator.ts +1 -1
  98. package/src/utils/tools-manager.ts +18 -2
  99. package/src/web/scrapers/osv.ts +4 -1
  100. package/src/web/scrapers/youtube.ts +1 -1
  101. package/src/web/search/index.ts +1 -1
  102. package/src/web/search/render.ts +96 -90
@@ -10,7 +10,8 @@ import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { type Theme, theme } from "../modes/theme/theme";
12
12
  import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
13
- import { renderOutputBlock, renderStatusLine } from "../tui";
13
+ import { renderStatusLine } from "../tui";
14
+ import { CachedOutputBlock } from "../tui/output-block";
14
15
  import { ensureTool } from "../utils/tools-manager";
15
16
  import { specialHandlers } from "../web/scrapers";
16
17
  import type { RenderResult } from "../web/scrapers/types";
@@ -980,7 +981,6 @@ export function renderFetchResult(
980
981
  options: RenderResultOptions,
981
982
  uiTheme: Theme = theme,
982
983
  ): Component {
983
- const { expanded } = options;
984
984
  const details = result.details;
985
985
 
986
986
  if (!details) {
@@ -1031,20 +1031,32 @@ export function renderFetchResult(
1031
1031
  metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
1032
1032
  }
1033
1033
 
1034
- const previewLimit = expanded ? 12 : 3;
1035
- const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1036
- const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…"));
1037
- const remaining = Math.max(0, contentLines.length - previewLines.length);
1038
- const contentPreviewLines =
1039
- previewLines.length > 0 ? previewLines.map(line => uiTheme.fg("dim", line)) : [uiTheme.fg("dim", "(no content)")];
1040
- if (remaining > 0) {
1041
- const hint = formatExpandHint(uiTheme, expanded, true);
1042
- contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`));
1043
- }
1034
+ const outputBlock = new CachedOutputBlock();
1035
+ let lastExpanded: boolean | undefined;
1036
+ let contentPreviewLines: string[] | undefined;
1044
1037
 
1045
1038
  return {
1046
- render: (width: number) =>
1047
- renderOutputBlock(
1039
+ render: (width: number) => {
1040
+ const { expanded } = options;
1041
+
1042
+ if (contentPreviewLines === undefined || lastExpanded !== expanded) {
1043
+ const previewLimit = expanded ? 12 : 3;
1044
+ const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1045
+ const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…"));
1046
+ const remaining = Math.max(0, contentLines.length - previewLines.length);
1047
+ contentPreviewLines =
1048
+ previewLines.length > 0
1049
+ ? previewLines.map(line => uiTheme.fg("dim", line))
1050
+ : [uiTheme.fg("dim", "(no content)")];
1051
+ if (remaining > 0) {
1052
+ const hint = formatExpandHint(uiTheme, expanded, true);
1053
+ contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`));
1054
+ }
1055
+ lastExpanded = expanded;
1056
+ outputBlock.invalidate();
1057
+ }
1058
+
1059
+ return outputBlock.render(
1048
1060
  {
1049
1061
  header,
1050
1062
  state: truncated ? "warning" : "success",
@@ -1056,8 +1068,13 @@ export function renderFetchResult(
1056
1068
  applyBg: false,
1057
1069
  },
1058
1070
  uiTheme,
1059
- ),
1060
- invalidate: () => {},
1071
+ );
1072
+ },
1073
+ invalidate: () => {
1074
+ outputBlock.invalidate();
1075
+ contentPreviewLines = undefined;
1076
+ lastExpanded = undefined;
1077
+ },
1061
1078
  };
1062
1079
  }
1063
1080
 
package/src/tools/find.ts CHANGED
@@ -11,7 +11,15 @@ import { renderPromptTemplate } from "../config/prompt-templates";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
14
- import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
14
+ import {
15
+ Ellipsis,
16
+ Hasher,
17
+ type RenderCache,
18
+ renderFileList,
19
+ renderStatusLine,
20
+ renderTreeList,
21
+ truncateToWidth,
22
+ } from "../tui";
15
23
  import type { ToolSession } from ".";
16
24
  import { applyListLimit } from "./list-limit";
17
25
  import type { OutputMeta } from "./output-meta";
@@ -27,6 +35,8 @@ const findSchema = Type.Object({
27
35
  limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })),
28
36
  });
29
37
 
38
+ export type FindToolInput = Static<typeof findSchema>;
39
+
30
40
  const DEFAULT_LIMIT = 1000;
31
41
  const GLOB_TIMEOUT_MS = 5000;
32
42
 
@@ -410,7 +420,7 @@ export const findToolRenderer = {
410
420
 
411
421
  renderResult(
412
422
  result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
413
- { expanded }: RenderResultOptions,
423
+ options: RenderResultOptions,
414
424
  uiTheme: Theme,
415
425
  args?: FindRenderArgs,
416
426
  ): Component {
@@ -444,17 +454,30 @@ export const findToolRenderer = {
444
454
  },
445
455
  uiTheme,
446
456
  );
447
- const listLines = renderTreeList(
448
- {
449
- items: lines,
450
- expanded,
451
- maxCollapsed: COLLAPSED_LIST_LIMIT,
452
- itemType: "file",
453
- renderItem: line => uiTheme.fg("accent", line),
457
+ let cached: RenderCache | undefined;
458
+ return {
459
+ render(width: number): string[] {
460
+ const { expanded } = options;
461
+ const key = new Hasher().bool(expanded).u32(width).digest();
462
+ if (cached?.key === key) return cached.lines;
463
+ const listLines = renderTreeList(
464
+ {
465
+ items: lines,
466
+ expanded,
467
+ maxCollapsed: COLLAPSED_LIST_LIMIT,
468
+ itemType: "file",
469
+ renderItem: line => uiTheme.fg("accent", line),
470
+ },
471
+ uiTheme,
472
+ );
473
+ const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
474
+ cached = { key, lines: result };
475
+ return result;
454
476
  },
455
- uiTheme,
456
- );
457
- return new Text([header, ...listLines].join("\n"), 0, 0);
477
+ invalidate() {
478
+ cached = undefined;
479
+ },
480
+ };
458
481
  }
459
482
 
460
483
  const fileCount = details?.fileCount ?? 0;
@@ -478,15 +501,6 @@ export const findToolRenderer = {
478
501
  uiTheme,
479
502
  );
480
503
 
481
- const fileLines = renderFileList(
482
- {
483
- files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
484
- expanded,
485
- maxCollapsed: COLLAPSED_LIST_LIMIT,
486
- },
487
- uiTheme,
488
- );
489
-
490
504
  const truncationReasons: string[] = [];
491
505
  if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`);
492
506
  if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
@@ -499,7 +513,28 @@ export const findToolRenderer = {
499
513
  extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
500
514
  }
501
515
 
502
- return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0);
516
+ let cached: RenderCache | undefined;
517
+ return {
518
+ render(width: number): string[] {
519
+ const { expanded } = options;
520
+ const key = new Hasher().bool(expanded).u32(width).digest();
521
+ if (cached?.key === key) return cached.lines;
522
+ const fileLines = renderFileList(
523
+ {
524
+ files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
525
+ expanded,
526
+ maxCollapsed: COLLAPSED_LIST_LIMIT,
527
+ },
528
+ uiTheme,
529
+ );
530
+ const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
531
+ cached = { key, lines: result };
532
+ return result;
533
+ },
534
+ invalidate() {
535
+ cached = undefined;
536
+ },
537
+ };
503
538
  },
504
539
  mergeCallAndResult: true,
505
540
  };
package/src/tools/grep.ts CHANGED
@@ -10,7 +10,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
13
- import { renderStatusLine, renderTreeList } from "../tui";
13
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import type { ToolSession } from ".";
15
15
  import type { OutputMeta } from "./output-meta";
16
16
  import { resolveToCwd } from "./path-utils";
@@ -32,6 +32,8 @@ const grepSchema = Type.Object({
32
32
  offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
33
33
  });
34
34
 
35
+ export type GrepToolInput = Static<typeof grepSchema>;
36
+
35
37
  const DEFAULT_MATCH_LIMIT = 100;
36
38
 
37
39
  export interface GrepToolDetails {
@@ -322,7 +324,7 @@ export const grepToolRenderer = {
322
324
 
323
325
  renderResult(
324
326
  result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean },
325
- { expanded }: RenderResultOptions,
327
+ options: RenderResultOptions,
326
328
  uiTheme: Theme,
327
329
  args?: GrepRenderArgs,
328
330
  ): Component {
@@ -346,17 +348,30 @@ export const grepToolRenderer = {
346
348
  { icon: "success", title: "Grep", description, meta: [formatCount("item", lines.length)] },
347
349
  uiTheme,
348
350
  );
349
- const listLines = renderTreeList(
350
- {
351
- items: lines,
352
- expanded,
353
- maxCollapsed: COLLAPSED_TEXT_LIMIT,
354
- itemType: "item",
355
- renderItem: line => uiTheme.fg("toolOutput", line),
351
+ let cached: RenderCache | undefined;
352
+ return {
353
+ render(width: number): string[] {
354
+ const { expanded } = options;
355
+ const key = new Hasher().bool(expanded).u32(width).digest();
356
+ if (cached?.key === key) return cached.lines;
357
+ const listLines = renderTreeList(
358
+ {
359
+ items: lines,
360
+ expanded,
361
+ maxCollapsed: COLLAPSED_TEXT_LIMIT,
362
+ itemType: "item",
363
+ renderItem: line => uiTheme.fg("toolOutput", line),
364
+ },
365
+ uiTheme,
366
+ );
367
+ const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
368
+ cached = { key, lines: result };
369
+ return result;
356
370
  },
357
- uiTheme,
358
- );
359
- return new Text([header, ...listLines].join("\n"), 0, 0);
371
+ invalidate() {
372
+ cached = undefined;
373
+ },
374
+ };
360
375
  }
361
376
 
362
377
  const matchCount = details?.matchCount ?? 0;
@@ -422,18 +437,6 @@ export const grepToolRenderer = {
422
437
  return count;
423
438
  };
424
439
 
425
- const maxCollapsed = expanded ? matchGroups.length : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
426
- const matchLines = renderTreeList(
427
- {
428
- items: matchGroups,
429
- expanded,
430
- maxCollapsed,
431
- itemType: "match",
432
- renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)),
433
- },
434
- uiTheme,
435
- );
436
-
437
440
  const truncationReasons: string[] = [];
438
441
  if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
439
442
  if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
@@ -444,7 +447,33 @@ export const grepToolRenderer = {
444
447
  const extraLines =
445
448
  truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : [];
446
449
 
447
- return new Text([header, ...matchLines, ...extraLines].join("\n"), 0, 0);
450
+ let cached: RenderCache | undefined;
451
+ return {
452
+ render(width: number): string[] {
453
+ const { expanded } = options;
454
+ const key = new Hasher().bool(expanded).u32(width).digest();
455
+ if (cached?.key === key) return cached.lines;
456
+ const maxCollapsed = expanded
457
+ ? matchGroups.length
458
+ : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
459
+ const matchLines = renderTreeList(
460
+ {
461
+ items: matchGroups,
462
+ expanded,
463
+ maxCollapsed,
464
+ itemType: "match",
465
+ renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)),
466
+ },
467
+ uiTheme,
468
+ );
469
+ const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
470
+ cached = { key, lines: result };
471
+ return result;
472
+ },
473
+ invalidate() {
474
+ cached = undefined;
475
+ },
476
+ };
448
477
  },
449
478
  mergeCallAndResult: true,
450
479
  };
@@ -67,17 +67,17 @@ export {
67
67
  webSearchLinkedinTool,
68
68
  } from "../web/search";
69
69
  export { AskTool, type AskToolDetails } from "./ask";
70
- export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
70
+ export { BashTool, type BashToolDetails, type BashToolInput, type BashToolOptions } from "./bash";
71
71
  export { BrowserTool, type BrowserToolDetails } from "./browser";
72
72
  export { CalculatorTool, type CalculatorToolDetails } from "./calculator";
73
73
  export { type ExitPlanModeDetails, ExitPlanModeTool } from "./exit-plan-mode";
74
74
  export { FetchTool, type FetchToolDetails } from "./fetch";
75
- export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find";
75
+ export { type FindOperations, FindTool, type FindToolDetails, type FindToolInput, type FindToolOptions } from "./find";
76
76
  export { setPreferredImageProvider } from "./gemini-image";
77
- export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolOptions } from "./grep";
77
+ export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolInput, type GrepToolOptions } from "./grep";
78
78
  export { NotebookTool, type NotebookToolDetails } from "./notebook";
79
79
  export { PythonTool, type PythonToolDetails, type PythonToolOptions } from "./python";
80
- export { ReadTool, type ReadToolDetails } from "./read";
80
+ export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read";
81
81
  export { reportFindingTool, type SubmitReviewDetails } from "./review";
82
82
  export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh";
83
83
  export { SubmitResultTool } from "./submit-result";
@@ -92,7 +92,7 @@ export {
92
92
  truncateLine,
93
93
  truncateTail,
94
94
  } from "./truncate";
95
- export { WriteTool, type WriteToolDetails } from "./write";
95
+ export { WriteTool, type WriteToolDetails, type WriteToolInput } from "./write";
96
96
 
97
97
  /** Tool type (AgentTool from pi-ai) */
98
98
  export type Tool = AgentTool<any, any, any>;
@@ -7,7 +7,7 @@ import { type Static, Type } from "@sinclair/typebox";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import type { ToolSession } from "../sdk";
10
- import { renderCodeCell, renderStatusLine } from "../tui";
10
+ import { Hasher, type RenderCache, renderCodeCell, renderStatusLine } from "../tui";
11
11
  import { resolveToCwd } from "./path-utils";
12
12
  import { formatCount, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
13
13
 
@@ -221,7 +221,7 @@ export const notebookToolRenderer = {
221
221
 
222
222
  renderResult(
223
223
  result: { content: Array<{ type: string; text?: string }>; details?: NotebookToolDetails },
224
- { expanded }: RenderResultOptions,
224
+ options: RenderResultOptions,
225
225
  uiTheme: Theme,
226
226
  args?: NotebookRenderArgs,
227
227
  ): Component {
@@ -252,9 +252,16 @@ export const notebookToolRenderer = {
252
252
 
253
253
  const notebookPath = args?.notebookPath ?? args?.notebook_path;
254
254
  const notebookLabel = notebookPath ? `${actionLabel} ${notebookPath}` : "Notebook";
255
+ let cached: RenderCache | undefined;
256
+
255
257
  return {
256
- render: (width: number) =>
257
- renderCodeCell(
258
+ render: (width: number): string[] => {
259
+ // REACTIVE: read mutable options at render time
260
+ const { expanded } = options;
261
+ const key = new Hasher().bool(expanded).u32(width).digest();
262
+ if (cached?.key === key) return cached.lines;
263
+
264
+ const lines = renderCodeCell(
258
265
  {
259
266
  code: codeText,
260
267
  language,
@@ -266,8 +273,14 @@ export const notebookToolRenderer = {
266
273
  width,
267
274
  },
268
275
  uiTheme,
269
- ),
270
- invalidate: () => {},
276
+ );
277
+
278
+ cached = { key, lines };
279
+ return lines;
280
+ },
281
+ invalidate: () => {
282
+ cached = undefined;
283
+ },
271
284
  };
272
285
  },
273
286
  mergeCallAndResult: true,
@@ -33,8 +33,33 @@ function fileExists(filePath: string): boolean {
33
33
  }
34
34
  }
35
35
 
36
+ function normalizeAtPrefix(filePath: string): string {
37
+ if (!filePath.startsWith("@")) return filePath;
38
+
39
+ const withoutAt = filePath.slice(1);
40
+
41
+ // We only treat a leading "@" as a shorthand for a small set of well-known
42
+ // syntaxes. This avoids mangling literal paths like "@my-file.txt".
43
+ if (
44
+ withoutAt.startsWith("/") ||
45
+ withoutAt === "~" ||
46
+ withoutAt.startsWith("~/") ||
47
+ // Windows absolute paths (drive letters / UNC / root-relative)
48
+ path.win32.isAbsolute(withoutAt) ||
49
+ // Internal URL shorthands
50
+ withoutAt.startsWith("agent://") ||
51
+ withoutAt.startsWith("artifact://") ||
52
+ withoutAt.startsWith("skill://") ||
53
+ withoutAt.startsWith("rule://")
54
+ ) {
55
+ return withoutAt;
56
+ }
57
+
58
+ return filePath;
59
+ }
60
+
36
61
  export function expandPath(filePath: string): string {
37
- const normalized = normalizeUnicodeSpaces(filePath);
62
+ const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
38
63
  if (normalized === "~") {
39
64
  return os.homedir();
40
65
  }
@@ -832,22 +832,20 @@ export const pythonToolRenderer = {
832
832
  uiTheme: Theme,
833
833
  ): Component {
834
834
  const ui = new ToolUIKit(uiTheme);
835
- const { renderContext } = options;
836
835
  const details = result.details;
837
836
 
838
- const expanded = renderContext?.expanded ?? options.expanded;
839
- const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
840
- const output = renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
837
+ const output =
838
+ options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
841
839
 
842
840
  const jsonOutputs = details?.jsonOutputs ?? [];
843
841
  const jsonLines = jsonOutputs.flatMap((value, index) => {
844
842
  const header = `JSON output ${index + 1}`;
845
- const treeLines = renderJsonTree(value, uiTheme, expanded);
843
+ const treeLines = renderJsonTree(value, uiTheme, options.renderContext?.expanded ?? options.expanded);
846
844
  return [header, ...treeLines];
847
845
  });
848
846
 
849
847
  const truncation = details?.meta?.truncation;
850
- const timeoutSeconds = renderContext?.timeout;
848
+ const timeoutSeconds = options.renderContext?.timeout;
851
849
  const timeoutLine =
852
850
  typeof timeoutSeconds === "number"
853
851
  ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
@@ -875,13 +873,12 @@ export const pythonToolRenderer = {
875
873
  // Cache state following Box pattern
876
874
  let cached: { key: string; width: number; result: string[] } | undefined;
877
875
 
878
- const buildCacheKey = (spinnerFrame: number | undefined): string => {
879
- return `${expanded}|${previewLines}|${spinnerFrame}`;
880
- };
881
-
882
876
  return {
883
877
  render: (width: number): string[] => {
884
- const key = buildCacheKey(options.spinnerFrame);
878
+ // Read mutable state at render time
879
+ const expanded = options.renderContext?.expanded ?? options.expanded;
880
+ const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
881
+ const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
885
882
  if (cached && cached.key === key && cached.width === width) {
886
883
  return cached.result;
887
884
  }
@@ -951,7 +948,11 @@ export const pythonToolRenderer = {
951
948
  const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
952
949
 
953
950
  const statusEvents = details?.statusEvents ?? [];
954
- const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
951
+ const statusLines = renderStatusEvents(
952
+ statusEvents,
953
+ uiTheme,
954
+ options.renderContext?.expanded ?? options.expanded,
955
+ );
955
956
 
956
957
  if (!combinedOutput && statusLines.length === 0) {
957
958
  const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
@@ -965,7 +966,7 @@ export const pythonToolRenderer = {
965
966
  return new Text(lines.join("\n"), 0, 0);
966
967
  }
967
968
 
968
- if (expanded) {
969
+ if (options.renderContext?.expanded ?? options.expanded) {
969
970
  const styledOutput = combinedOutput
970
971
  .split("\n")
971
972
  .map(line => uiTheme.fg("toolOutput", line))
@@ -988,14 +989,18 @@ export const pythonToolRenderer = {
988
989
  let cachedWidth: number | undefined;
989
990
  let cachedLines: string[] | undefined;
990
991
  let cachedSkipped: number | undefined;
992
+ let cachedPreviewLines: number | undefined;
991
993
 
992
994
  return {
993
995
  render: (width: number): string[] => {
994
- if (cachedLines === undefined || cachedWidth !== width) {
996
+ // Read mutable state at render time
997
+ const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
998
+ if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
995
999
  const result = truncateToVisualLines(textContent, previewLines, width);
996
1000
  cachedLines = result.visualLines;
997
1001
  cachedSkipped = result.skippedCount;
998
1002
  cachedWidth = width;
1003
+ cachedPreviewLines = previewLines;
999
1004
  }
1000
1005
  const outputLines: string[] = [];
1001
1006
  if (cachedSkipped && cachedSkipped > 0) {
@@ -1025,6 +1030,7 @@ export const pythonToolRenderer = {
1025
1030
  cachedWidth = undefined;
1026
1031
  cachedLines = undefined;
1027
1032
  cachedSkipped = undefined;
1033
+ cachedPreviewLines = undefined;
1028
1034
  },
1029
1035
  };
1030
1036
  },
package/src/tools/read.ts CHANGED
@@ -7,14 +7,15 @@ import { FileType, glob } from "@oh-my-pi/pi-natives";
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
10
- import { Type } from "@sinclair/typebox";
10
+ import { type Static, Type } from "@sinclair/typebox";
11
11
  import { CONFIG_DIR_NAME } from "../config";
12
12
  import { renderPromptTemplate } from "../config/prompt-templates";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
15
15
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
16
16
  import type { ToolSession } from "../sdk";
17
- import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui";
17
+ import { renderCodeCell, renderStatusLine } from "../tui";
18
+ import { CachedOutputBlock } from "../tui/output-block";
18
19
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
19
20
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
20
21
  import { ensureTool } from "../utils/tools-manager";
@@ -516,6 +517,8 @@ const readSchema = Type.Object({
516
517
  lines: Type.Optional(Type.Boolean({ description: "Prepend line numbers to output (default: false)" })),
517
518
  });
518
519
 
520
+ export type ReadToolInput = Static<typeof readSchema>;
521
+
519
522
  export interface ReadToolDetails {
520
523
  truncation?: TruncationResult;
521
524
  isDirectory?: boolean;
@@ -1113,9 +1116,10 @@ export const readToolRenderer = {
1113
1116
  );
1114
1117
  const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
1115
1118
  const lines = [...detailLines, ...warningLines];
1119
+ const outputBlock = new CachedOutputBlock();
1116
1120
  return {
1117
1121
  render: (width: number) =>
1118
- renderOutputBlock(
1122
+ outputBlock.render(
1119
1123
  {
1120
1124
  header,
1121
1125
  state: "success",
@@ -1129,7 +1133,7 @@ export const readToolRenderer = {
1129
1133
  },
1130
1134
  uiTheme,
1131
1135
  ),
1132
- invalidate: () => {},
1136
+ invalidate: () => outputBlock.invalidate(),
1133
1137
  };
1134
1138
  }
1135
1139
 
@@ -1139,9 +1143,12 @@ export const readToolRenderer = {
1139
1143
  const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
1140
1144
  title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
1141
1145
  }
1146
+ let cachedWidth: number | undefined;
1147
+ let cachedLines: string[] | undefined;
1142
1148
  return {
1143
- render: (width: number) =>
1144
- renderCodeCell(
1149
+ render: (width: number) => {
1150
+ if (cachedLines && cachedWidth === width) return cachedLines;
1151
+ cachedLines = renderCodeCell(
1145
1152
  {
1146
1153
  code: contentText,
1147
1154
  language: lang,
@@ -1152,8 +1159,14 @@ export const readToolRenderer = {
1152
1159
  width,
1153
1160
  },
1154
1161
  uiTheme,
1155
- ),
1156
- invalidate: () => {},
1162
+ );
1163
+ cachedWidth = width;
1164
+ return cachedLines;
1165
+ },
1166
+ invalidate: () => {
1167
+ cachedWidth = undefined;
1168
+ cachedLines = undefined;
1169
+ },
1157
1170
  };
1158
1171
  },
1159
1172
  mergeCallAndResult: true,