@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  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 +1 -7
  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 +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
@@ -1,3 +1,48 @@
1
+ export type SlashCommandSource = "extension" | "prompt" | "skill";
2
+
3
+ export type SlashCommandLocation = "user" | "project" | "path";
4
+
5
+ export interface SlashCommandInfo {
6
+ name: string;
7
+ description?: string;
8
+ source: SlashCommandSource;
9
+ location?: SlashCommandLocation;
10
+ path?: string;
11
+ }
12
+
13
+ export interface BuiltinSlashCommand {
14
+ name: string;
15
+ description: string;
16
+ }
17
+
18
+ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
19
+ { name: "settings", description: "Open settings menu" },
20
+ { name: "plan", description: "Toggle plan mode (agent plans before executing)" },
21
+ { name: "model", description: "Select model (opens selector UI)" },
22
+ { name: "export", description: "Export session to HTML file" },
23
+ { name: "dump", description: "Copy session transcript to clipboard" },
24
+ { name: "share", description: "Share session as a secret GitHub gist" },
25
+ { name: "browser", description: "Toggle browser headless vs visible mode" },
26
+ { name: "copy", description: "Copy last agent message to clipboard" },
27
+ { name: "session", description: "Show session info and stats" },
28
+ { name: "usage", description: "Show provider usage and limits" },
29
+ { name: "changelog", description: "Show changelog entries" },
30
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
31
+ { name: "extensions", description: "Open Extension Control Center dashboard" },
32
+ { name: "branch", description: "Create a new branch from a previous message" },
33
+ { name: "fork", description: "Create a new fork from a previous message" },
34
+ { name: "tree", description: "Navigate session tree (switch branches)" },
35
+ { name: "login", description: "Login with OAuth provider" },
36
+ { name: "logout", description: "Logout from OAuth provider" },
37
+ { name: "new", description: "Start a new session" },
38
+ { name: "compact", description: "Manually compact the session context" },
39
+ { name: "handoff", description: "Hand off session context to a new session" },
40
+ { name: "resume", description: "Resume a different session" },
41
+ { name: "background", description: "Detach UI and continue running in background" },
42
+ { name: "debug", description: "Write debug log (TUI state and messages)" },
43
+ { name: "exit", description: "Exit the application" },
44
+ ];
45
+
1
46
  import { slashCommandCapability } from "../capability/slash-command";
2
47
  import { renderPromptTemplate } from "../config/prompt-templates";
3
48
  import type { SlashCommand } from "../discovery";
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ export type {
62
62
  MessageRenderer,
63
63
  MessageRenderOptions,
64
64
  RegisteredCommand,
65
+ ToolCallEvent,
65
66
  ToolResultEvent,
66
67
  TurnEndEvent,
67
68
  TurnStartEvent,
@@ -222,6 +223,7 @@ export {
222
223
  type CustomMessageEntry,
223
224
  type FileEntry,
224
225
  getLatestCompactionEntry,
226
+ type ModeChangeEntry,
225
227
  type ModelChangeEntry,
226
228
  migrateSessionEntries,
227
229
  type NewSessionOptions,
@@ -238,22 +240,27 @@ export {
238
240
  // Tools (detail types and utilities)
239
241
  export {
240
242
  type BashToolDetails,
243
+ type BashToolInput,
241
244
  type BrowserToolDetails,
242
245
  DEFAULT_MAX_BYTES,
243
246
  DEFAULT_MAX_LINES,
244
247
  type FindOperations,
245
248
  type FindToolDetails,
249
+ type FindToolInput,
246
250
  type FindToolOptions,
247
251
  formatSize,
248
252
  type GrepOperations,
249
253
  type GrepToolDetails,
254
+ type GrepToolInput,
250
255
  type GrepToolOptions,
251
256
  type PythonToolDetails,
252
257
  type ReadToolDetails,
258
+ type ReadToolInput,
253
259
  type TruncationOptions,
254
260
  type TruncationResult,
255
261
  truncateHead,
256
262
  truncateLine,
257
263
  truncateTail,
258
264
  type WriteToolDetails,
265
+ type WriteToolInput,
259
266
  } from "./tools";
package/src/lsp/render.ts CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  TRUNCATE_LENGTHS,
20
20
  truncateToWidth,
21
21
  } from "../tools/render-utils";
22
- import { renderOutputBlock, renderStatusLine } from "../tui";
22
+ import { renderStatusLine } from "../tui";
23
+ import { CachedOutputBlock } from "../tui/output-block";
23
24
  import type { LspParams, LspToolDetails } from "./types";
24
25
 
25
26
  // =============================================================================
@@ -114,43 +115,16 @@ export function renderResult(
114
115
 
115
116
  const text = content.text;
116
117
  const lines = text.split("\n");
117
- const expanded = options.expanded;
118
-
119
- let label = "Result";
120
- let state: "success" | "warning" | "error" = "success";
121
- let bodyLines: string[] = [];
122
118
 
119
+ // Static type detection (result content doesn't change between renders)
123
120
  const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/);
124
- if (codeBlockMatch) {
125
- label = "Hover";
126
- bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme);
127
- } else {
128
- const errorMatch = text.match(/(\d+)\s+error\(s\)/);
129
- const warningMatch = text.match(/(\d+)\s+warning\(s\)/);
130
- if (errorMatch || warningMatch || text.includes(theme.status.error)) {
131
- label = "Diagnostics";
132
- const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0;
133
- const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0;
134
- state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success";
135
- bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme);
136
- } else {
137
- const refMatch = text.match(/(\d+)\s+reference\(s\)/);
138
- if (refMatch) {
139
- label = "References";
140
- bodyLines = renderReferences(refMatch, lines, expanded, theme);
141
- } else {
142
- const symbolsMatch = text.match(/Symbols in (.+):/);
143
- if (symbolsMatch) {
144
- label = "Symbols";
145
- bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme);
146
- } else {
147
- label = "Response";
148
- bodyLines = renderGeneric(text, lines, expanded, theme);
149
- }
150
- }
151
- }
152
- }
121
+ const errorMatch = text.match(/(\d+)\s+error\(s\)/);
122
+ const warningMatch = text.match(/(\d+)\s+warning\(s\)/);
123
+ const refMatch = text.match(/(\d+)\s+reference\(s\)/);
124
+ const symbolsMatch = text.match(/Symbols in (.+):/);
125
+ const hasStatusError = text.includes(theme.status.error);
153
126
 
127
+ // Static request info
154
128
  const request = args ?? result.details?.request;
155
129
  const requestLines: string[] = [];
156
130
  if (request?.file) {
@@ -175,14 +149,44 @@ export function renderResult(
175
149
  requestLines.push(theme.fg("dim", `include declaration: ${request.include_declaration ? "true" : "false"}`));
176
150
  }
177
151
 
178
- const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " ");
179
- const status = options.isPartial ? "running" : result.isError ? "error" : "success";
180
- const icon = formatStatusIcon(status, theme, options.spinnerFrame);
181
- const header = `${icon} LSP ${actionLabel}`;
152
+ const outputBlock = new CachedOutputBlock();
182
153
 
183
154
  return {
184
- render: (width: number) =>
185
- renderOutputBlock(
155
+ render(width: number): string[] {
156
+ // Read mutable state at render time
157
+ const { expanded, isPartial, spinnerFrame } = options;
158
+
159
+ // Determine label, state, bodyLines based on type + current expanded
160
+ let label = "Result";
161
+ let state: "success" | "warning" | "error" = "success";
162
+ let bodyLines: string[] = [];
163
+
164
+ if (codeBlockMatch) {
165
+ label = "Hover";
166
+ bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme);
167
+ } else if (errorMatch || warningMatch || hasStatusError) {
168
+ label = "Diagnostics";
169
+ const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0;
170
+ const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0;
171
+ state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success";
172
+ bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme);
173
+ } else if (refMatch) {
174
+ label = "References";
175
+ bodyLines = renderReferences(refMatch, lines, expanded, theme);
176
+ } else if (symbolsMatch) {
177
+ label = "Symbols";
178
+ bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme);
179
+ } else {
180
+ label = "Response";
181
+ bodyLines = renderGeneric(text, lines, expanded, theme);
182
+ }
183
+
184
+ const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " ");
185
+ const status = isPartial ? "running" : result.isError ? "error" : "success";
186
+ const icon = formatStatusIcon(status, theme, spinnerFrame);
187
+ const header = `${icon} LSP ${actionLabel}`;
188
+
189
+ return outputBlock.render(
186
190
  {
187
191
  header,
188
192
  state,
@@ -194,8 +198,11 @@ export function renderResult(
194
198
  applyBg: false,
195
199
  },
196
200
  theme,
197
- ),
198
- invalidate: () => {},
201
+ );
202
+ },
203
+ invalidate() {
204
+ outputBlock.invalidate();
205
+ },
199
206
  };
200
207
  }
201
208
 
package/src/lsp/utils.ts CHANGED
@@ -371,7 +371,7 @@ export function formatTextEdit(edit: TextEdit, maxLength = 50): string {
371
371
  const range = `${edit.range.start.line + 1}:${edit.range.start.character + 1}`;
372
372
  const preview =
373
373
  edit.newText.length > maxLength
374
- ? `${edit.newText.slice(0, maxLength).replace(/\n/g, "\\n")}...`
374
+ ? `${edit.newText.slice(0, maxLength).replace(/\n/g, "\\n")}…`
375
375
  : edit.newText.replace(/\n/g, "\\n");
376
376
  return `line ${range} ${theme.nav.cursor} "${preview}"`;
377
377
  }
@@ -530,7 +530,7 @@ export function extractHoverText(
530
530
  */
531
531
  export function truncate(str: string, maxLength: number): string {
532
532
  if (str.length <= maxLength) return str;
533
- return `${str.slice(0, maxLength - 3)}...`;
533
+ return `${str.slice(0, maxLength - 1)}…`;
534
534
  }
535
535
 
536
536
  /**
package/src/main.ts CHANGED
@@ -235,8 +235,8 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
235
235
  if (parsed.noSession) {
236
236
  return SessionManager.inMemory();
237
237
  }
238
- if (parsed.session) {
239
- const sessionArg = parsed.session;
238
+ if (typeof parsed.resume === "string") {
239
+ const sessionArg = parsed.resume;
240
240
  if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
241
241
  return await SessionManager.open(sessionArg, parsed.sessionDir);
242
242
  }
@@ -258,7 +258,7 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
258
258
  if (parsed.continue) {
259
259
  return await SessionManager.continueRecent(cwd, parsed.sessionDir);
260
260
  }
261
- // --resume is handled separately (needs picker UI)
261
+ // --resume without value is handled separately (needs picker UI)
262
262
  // If --session-dir provided without --continue/--resume, create new session there
263
263
  if (parsed.sessionDir) {
264
264
  return SessionManager.create(cwd, parsed.sessionDir);
@@ -495,26 +495,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
495
495
 
496
496
  if (parsedArgs.version) {
497
497
  writeStdout(VERSION);
498
- return;
498
+ process.exit(0);
499
499
  }
500
500
 
501
501
  if (parsedArgs.listModels !== undefined) {
502
502
  const searchPattern = typeof parsedArgs.listModels === "string" ? parsedArgs.listModels : undefined;
503
503
  await listModels(modelRegistry, searchPattern);
504
- return;
504
+ process.exit(0);
505
505
  }
506
506
 
507
507
  if (parsedArgs.export) {
508
+ let result: string;
508
509
  try {
509
510
  const outputPath = parsedArgs.messages.length > 0 ? parsedArgs.messages[0] : undefined;
510
- const result = await exportFromFile(parsedArgs.export, outputPath);
511
- writeStdout(`Exported to: ${result}`);
512
- return;
511
+ result = await exportFromFile(parsedArgs.export, outputPath);
513
512
  } catch (error: unknown) {
514
513
  const message = error instanceof Error ? error.message : "Failed to export session";
515
514
  writeStderr(chalk.red(`Error: ${message}`));
516
515
  process.exit(1);
517
516
  }
517
+ writeStdout(`Exported to: ${result}`);
518
+ process.exit(0);
518
519
  }
519
520
 
520
521
  if (parsedArgs.mode === "rpc" && parsedArgs.fileArgs.length > 0) {
@@ -571,8 +572,8 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
571
572
  debugStartup("main:createSessionManager");
572
573
  time("createSessionManager");
573
574
 
574
- // Handle --resume: show session picker
575
- if (parsedArgs.resume) {
575
+ // Handle --resume (no value): show session picker
576
+ if (parsedArgs.resume === true) {
576
577
  const sessions = await SessionManager.list(cwd, parsedArgs.sessionDir);
577
578
  time("SessionManager.list");
578
579
  if (sessions.length === 0) {
@@ -5,7 +5,7 @@
5
5
  * Messages are newline-delimited JSON.
6
6
  */
7
7
 
8
- import { readLines } from "@oh-my-pi/pi-utils";
8
+ import { readJsonl } from "@oh-my-pi/pi-utils";
9
9
  import { type Subprocess, spawn } from "bun";
10
10
  import type { JsonRpcResponse, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
11
11
 
@@ -72,13 +72,11 @@ export class StdioTransport implements MCPTransport {
72
72
 
73
73
  private async startReadLoop(): Promise<void> {
74
74
  if (!this.process?.stdout) return;
75
-
76
- const decoder = new TextDecoder();
77
75
  try {
78
- for await (const line of readLines(this.process.stdout)) {
76
+ for await (const line of readJsonl(this.process.stdout)) {
79
77
  if (!this._connected) break;
80
78
  try {
81
- this.handleMessage(JSON.parse(decoder.decode(line)) as JsonRpcResponse);
79
+ this.handleMessage(line as JsonRpcResponse);
82
80
  } catch {
83
81
  // Skip malformed lines
84
82
  }
@@ -82,14 +82,6 @@ export class CustomMessageComponent extends Container {
82
82
  .join("\n");
83
83
  }
84
84
 
85
- // Limit lines when collapsed
86
- if (!this._expanded) {
87
- const lines = text.split("\n");
88
- if (lines.length > 5) {
89
- text = `${lines.slice(0, 5).join("\n")}\n…`;
90
- }
91
- }
92
-
93
85
  this.box.addChild(
94
86
  new Markdown(text, 0, 0, getMarkdownTheme(), {
95
87
  color: (value: string) => theme.fg("customMessageText", value),
@@ -1,5 +1,6 @@
1
1
  import * as Diff from "diff";
2
2
  import { theme } from "../../modes/theme/theme";
3
+ import { replaceTabs } from "../../tools/render-utils";
3
4
 
4
5
  /**
5
6
  * Parse diff line to extract prefix, line number, and content.
@@ -11,13 +12,6 @@ function parseDiffLine(line: string): { prefix: string; lineNum: string; content
11
12
  return { prefix: match[1], lineNum: match[2] ?? "", content: match[3] };
12
13
  }
13
14
 
14
- /**
15
- * Replace tabs with spaces for consistent rendering.
16
- */
17
- function replaceTabs(text: string): string {
18
- return text.replace(/\t/g, " ");
19
- }
20
-
21
15
  /**
22
16
  * Compute word-level diff and render with inverse on changed parts.
23
17
  * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting.
@@ -211,11 +211,11 @@ export class FooterComponent implements Component {
211
211
 
212
212
  // Truncate path if too long to fit width
213
213
  if (pwd.length > width) {
214
- const half = Math.floor(width / 2) - 2;
215
- if (half > 0) {
214
+ const half = Math.floor(width / 2) - 1;
215
+ if (half > 1) {
216
216
  const start = pwd.slice(0, half);
217
217
  const end = pwd.slice(-(half - 1));
218
- pwd = `${start}...${end}`;
218
+ pwd = `${start}…${end}`;
219
219
  } else {
220
220
  pwd = pwd.slice(0, Math.max(1, width));
221
221
  }
@@ -269,7 +269,7 @@ export class FooterComponent implements Component {
269
269
  if (statsLeftWidth > width) {
270
270
  // Truncate statsLeft to fit width (no room for right side)
271
271
  const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
272
- statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`;
272
+ statsLeft = `${plainStatsLeft.substring(0, width - 1)}…`;
273
273
  statsLeftWidth = visibleWidth(statsLeft);
274
274
  }
275
275
 
@@ -398,6 +398,10 @@ export class ModelSelectorComponent extends Container {
398
398
  }
399
399
  } else if (this.filteredModels.length === 0) {
400
400
  this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
401
+ } else {
402
+ const selected = this.filteredModels[this.selectedIndex];
403
+ this.listContainer.addChild(new Spacer(1));
404
+ this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
401
405
  }
402
406
  }
403
407
 
@@ -1,8 +1,9 @@
1
1
  import * as path from "node:path";
2
- import { Text } from "@oh-my-pi/pi-tui";
2
+ import { Ellipsis, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
  import type { TodoItem } from "../../modes/types";
6
+ import { Hasher, type RenderCache } from "../../tui";
6
7
 
7
8
  const TODO_FILE_NAME = "todos.json";
8
9
 
@@ -29,6 +30,7 @@ export class TodoDisplayComponent {
29
30
  public todos: TodoItem[] = [];
30
31
  private expanded = false;
31
32
  private visible = false;
33
+ private cached: RenderCache | undefined;
32
34
 
33
35
  constructor(private readonly sessionFile: string | null) {}
34
36
 
@@ -44,26 +46,32 @@ export class TodoDisplayComponent {
44
46
  const data = await loadTodoFile(todoPath);
45
47
  this.todos = data?.todos ?? [];
46
48
  this.visible = this.todos.length > 0;
49
+ this.cached = undefined;
47
50
  }
48
51
 
49
52
  setTodos(todos: TodoItem[]): void {
50
53
  this.todos = todos;
51
54
  this.visible = this.todos.length > 0;
55
+ this.cached = undefined;
52
56
  }
53
57
 
54
58
  setExpanded(expanded: boolean): void {
55
59
  this.expanded = expanded;
60
+ this.cached = undefined;
56
61
  }
57
62
 
58
63
  isVisible(): boolean {
59
64
  return this.visible;
60
65
  }
61
66
 
62
- render(_width: number): string[] {
67
+ render(width: number): string[] {
63
68
  if (!this.visible || this.todos.length === 0) {
64
69
  return [];
65
70
  }
66
71
 
72
+ const key = new Hasher().bool(this.expanded).u32(width).digest();
73
+ if (this.cached?.key === key) return this.cached.lines;
74
+
67
75
  const lines: string[] = [];
68
76
  const maxItems = this.expanded ? this.todos.length : Math.min(5, this.todos.length);
69
77
  const hasMore = !this.expanded && this.todos.length > 5;
@@ -93,7 +101,9 @@ export class TodoDisplayComponent {
93
101
  lines.push(theme.fg("dim", ` ${theme.tree.hook} +${this.todos.length - 5} more (Ctrl+T to expand)`));
94
102
  }
95
103
 
96
- return lines;
104
+ const result = lines.map(l => truncateToWidth(l, width, Ellipsis.Omit));
105
+ this.cached = { key, lines: result };
106
+ return result;
97
107
  }
98
108
 
99
109
  getRenderedComponent(): Text | null {
@@ -12,7 +12,7 @@ import {
12
12
  Text,
13
13
  type TUI,
14
14
  } from "@oh-my-pi/pi-tui";
15
- import { sanitizeText } from "@oh-my-pi/pi-utils";
15
+ import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
16
16
  import type { Theme } from "../../modes/theme/theme";
17
17
  import { theme } from "../../modes/theme/theme";
18
18
  import { computeEditDiff, computePatchDiff, type EditDiffError, type EditDiffResult } from "../../patch";
@@ -89,6 +89,16 @@ export class ToolExecutionComponent extends Container {
89
89
  private spinnerInterval: ReturnType<typeof setInterval> | null = null;
90
90
  // Track if args are still being streamed (for edit/write spinner)
91
91
  private argsComplete = false;
92
+ private renderState: {
93
+ spinnerFrame: number;
94
+ expanded: boolean;
95
+ isPartial: boolean;
96
+ renderContext?: Record<string, unknown>;
97
+ } = {
98
+ spinnerFrame: 0,
99
+ expanded: false,
100
+ isPartial: true,
101
+ };
92
102
 
93
103
  constructor(
94
104
  toolName: string,
@@ -280,8 +290,9 @@ export class ToolExecutionComponent extends Container {
280
290
  const frameCount = theme.spinnerFrames.length;
281
291
  if (frameCount === 0) return;
282
292
  this.spinnerFrame = (this.spinnerFrame + 1) % frameCount;
283
- this.updateDisplay();
293
+ this.renderState.spinnerFrame = this.spinnerFrame;
284
294
  this.ui.requestRender();
295
+ // NO updateDisplay() — existing component closures read from renderState
285
296
  }, 80);
286
297
  } else if (!needsSpinner && this.spinnerInterval) {
287
298
  clearInterval(this.spinnerInterval);
@@ -322,6 +333,11 @@ export class ToolExecutionComponent extends Container {
322
333
  ? (text: string) => theme.bg("toolErrorBg", text)
323
334
  : (text: string) => theme.bg("toolSuccessBg", text);
324
335
 
336
+ // Sync shared mutable render state for component closures
337
+ this.renderState.expanded = this.expanded;
338
+ this.renderState.isPartial = this.isPartial;
339
+ this.renderState.spinnerFrame = this.spinnerFrame;
340
+
325
341
  // Check for custom tool rendering
326
342
  if (this.tool && (this.tool.renderCall || this.tool.renderResult)) {
327
343
  const tool = this.tool;
@@ -344,7 +360,8 @@ export class ToolExecutionComponent extends Container {
344
360
  }
345
361
  this.contentBox.addChild(component);
346
362
  }
347
- } catch {
363
+ } catch (err) {
364
+ logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) });
348
365
  // Fall back to default on error
349
366
  this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
350
367
  }
@@ -364,7 +381,7 @@ export class ToolExecutionComponent extends Container {
364
381
  ) => Component;
365
382
  const resultComponent = renderResult(
366
383
  { content: this.result.content as any, details: this.result.details, isError: this.result.isError },
367
- { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
384
+ this.renderState,
368
385
  theme,
369
386
  this.args,
370
387
  );
@@ -376,7 +393,8 @@ export class ToolExecutionComponent extends Container {
376
393
  }
377
394
  this.contentBox.addChild(component);
378
395
  }
379
- } catch {
396
+ } catch (err) {
397
+ logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) });
380
398
  // Fall back to showing raw output on error
381
399
  const output = this.getTextOutput();
382
400
  if (output) {
@@ -401,9 +419,7 @@ export class ToolExecutionComponent extends Container {
401
419
  if (shouldRenderCall) {
402
420
  // Render call component
403
421
  try {
404
- const callComponent = renderer.renderCall(this.args, theme, {
405
- spinnerFrame: this.spinnerFrame,
406
- });
422
+ const callComponent = renderer.renderCall(this.args, theme, this.renderState);
407
423
  if (callComponent) {
408
424
  // Ensure component has invalidate() method for Component interface
409
425
  const component = callComponent as any;
@@ -412,7 +428,8 @@ export class ToolExecutionComponent extends Container {
412
428
  }
413
429
  this.contentBox.addChild(component);
414
430
  }
415
- } catch {
431
+ } catch (err) {
432
+ logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) });
416
433
  // Fall back to default on error
417
434
  this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
418
435
  }
@@ -423,15 +440,11 @@ export class ToolExecutionComponent extends Container {
423
440
  try {
424
441
  // Build render context for tools that need extra state
425
442
  const renderContext = this.buildRenderContext();
443
+ this.renderState.renderContext = renderContext;
426
444
 
427
445
  const resultComponent = renderer.renderResult(
428
446
  { content: this.result.content as any, details: this.result.details, isError: this.result.isError },
429
- {
430
- expanded: this.expanded,
431
- isPartial: this.isPartial,
432
- spinnerFrame: this.spinnerFrame,
433
- renderContext,
434
- },
447
+ this.renderState,
435
448
  theme,
436
449
  this.args, // Pass args for tools that need them
437
450
  );
@@ -443,7 +456,8 @@ export class ToolExecutionComponent extends Container {
443
456
  }
444
457
  this.contentBox.addChild(component);
445
458
  }
446
- } catch {
459
+ } catch (err) {
460
+ logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) });
447
461
  // Fall back to showing raw output on error
448
462
  const output = this.getTextOutput();
449
463
  if (output) {