@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -3,7 +3,7 @@
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
5
 
6
- import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
6
+ import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
7
7
  import { theme } from "../theme/theme";
8
8
  import { DynamicBorder } from "./dynamic-border";
9
9
 
@@ -83,8 +83,8 @@ export class HookSelectorComponent extends Container {
83
83
  this.onSelectCallback(selected);
84
84
  }
85
85
  }
86
- // Escape
87
- else if (isEscape(keyData)) {
86
+ // Escape or Ctrl+C
87
+ else if (isEscape(keyData) || isCtrlC(keyData)) {
88
88
  this.onCancelCallback();
89
89
  }
90
90
  }
@@ -6,6 +6,7 @@ import {
6
6
  isArrowLeft,
7
7
  isArrowRight,
8
8
  isArrowUp,
9
+ isCtrlC,
9
10
  isEnter,
10
11
  isEscape,
11
12
  isShiftTab,
@@ -202,7 +203,7 @@ export class ModelSelectorComponent extends Container {
202
203
  }));
203
204
  } else {
204
205
  // Refresh to pick up any changes to models.json
205
- this.modelRegistry.refresh();
206
+ await this.modelRegistry.refresh();
206
207
 
207
208
  // Check for models.json errors
208
209
  const loadError = this.modelRegistry.getError();
@@ -212,7 +213,7 @@ export class ModelSelectorComponent extends Container {
212
213
 
213
214
  // Load available models (built-in models still work even if models.json failed)
214
215
  try {
215
- const availableModels = await this.modelRegistry.getAvailable();
216
+ const availableModels = this.modelRegistry.getAvailable();
216
217
  models = availableModels.map((model: Model<any>) => ({
217
218
  provider: model.provider,
218
219
  id: model.id,
@@ -490,8 +491,8 @@ export class ModelSelectorComponent extends Container {
490
491
  return;
491
492
  }
492
493
 
493
- // Escape - close selector
494
- if (isEscape(keyData)) {
494
+ // Escape or Ctrl+C - close selector
495
+ if (isEscape(keyData) || isCtrlC(keyData)) {
495
496
  this.onCancelCallback();
496
497
  return;
497
498
  }
@@ -527,8 +528,8 @@ export class ModelSelectorComponent extends Container {
527
528
  return;
528
529
  }
529
530
 
530
- // Escape - close menu only
531
- if (isEscape(keyData)) {
531
+ // Escape or Ctrl+C - close menu only
532
+ if (isEscape(keyData) || isCtrlC(keyData)) {
532
533
  this.closeMenu();
533
534
  return;
534
535
  }
@@ -1,5 +1,5 @@
1
1
  import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
2
- import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
2
+ import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
3
  import type { AuthStorage } from "../../../core/auth-storage";
4
4
  import { theme } from "../theme/theme";
5
5
  import { DynamicBorder } from "./dynamic-border";
@@ -128,8 +128,8 @@ export class OAuthSelectorComponent extends Container {
128
128
  this.updateList();
129
129
  }
130
130
  }
131
- // Escape
132
- else if (isEscape(keyData)) {
131
+ // Escape or Ctrl+C
132
+ else if (isEscape(keyData) || isCtrlC(keyData)) {
133
133
  this.onCancelCallback();
134
134
  }
135
135
  }
@@ -11,6 +11,7 @@
11
11
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
12
12
  import { getCapabilities } from "@oh-my-pi/pi-tui";
13
13
  import type {
14
+ NotificationMethod,
14
15
  SettingsManager,
15
16
  StatusLinePreset,
16
17
  StatusLineSeparatorStyle,
@@ -96,21 +97,59 @@ export const SETTINGS_DEFS: SettingDef[] = [
96
97
  condition: () => !!getCapabilities().images,
97
98
  },
98
99
  {
99
- id: "queueMode",
100
+ id: "voiceEnabled",
101
+ tab: "config",
102
+ type: "boolean",
103
+ label: "Voice mode",
104
+ description: "Enable realtime voice input/output (Ctrl+Y toggle, auto-send on silence)",
105
+ get: (sm) => sm.getVoiceEnabled(),
106
+ set: (sm, v) => sm.setVoiceEnabled(v),
107
+ },
108
+ {
109
+ id: "completionNotification",
100
110
  tab: "config",
101
111
  type: "enum",
102
- label: "Queue mode",
112
+ label: "Completion notification",
113
+ description: "Notify when the agent completes",
114
+ values: ["auto", "bell", "osc99", "osc9", "off"],
115
+ get: (sm) => sm.getNotificationOnComplete(),
116
+ set: (sm, v) => sm.setNotificationOnComplete(v as NotificationMethod),
117
+ },
118
+ {
119
+ id: "autoResizeImages",
120
+ tab: "config",
121
+ type: "boolean",
122
+ label: "Auto-resize images",
123
+ description: "Resize large images to 2000x2000 max for better model compatibility",
124
+ get: (sm) => sm.getImageAutoResize(),
125
+ set: (sm, v) => sm.setImageAutoResize(v),
126
+ },
127
+ {
128
+ id: "steeringMode",
129
+ tab: "config",
130
+ type: "enum",
131
+ label: "Steering mode",
103
132
  description: "How to process queued messages while agent is working",
104
133
  values: ["one-at-a-time", "all"],
105
- get: (sm) => sm.getQueueMode(),
106
- set: (sm, v) => sm.setQueueMode(v as "all" | "one-at-a-time"), // Also handled in session
134
+ get: (sm) => sm.getSteeringMode(),
135
+ set: (sm, v) => sm.setSteeringMode(v as "all" | "one-at-a-time"), // Also handled in session
136
+ },
137
+ {
138
+ id: "followUpMode",
139
+ tab: "config",
140
+ type: "enum",
141
+ label: "Follow-up mode",
142
+ description: "How to drain follow-up messages after a turn completes",
143
+ values: ["one-at-a-time", "all"],
144
+ get: (sm) => sm.getFollowUpMode(),
145
+ set: (sm, v) => sm.setFollowUpMode(v as "one-at-a-time" | "all"), // Also handled in session
107
146
  },
108
147
  {
109
148
  id: "interruptMode",
110
149
  tab: "config",
111
150
  type: "enum",
112
151
  label: "Interrupt mode",
113
- description: "When to process queued messages: immediately (interrupt tools) or wait for turn to complete",
152
+ description: "When steering messages interrupt tool execution",
114
153
  values: ["immediate", "wait"],
115
154
  get: (sm) => sm.getInterruptMode(),
116
155
  set: (sm, v) => sm.setInterruptMode(v as "immediate" | "wait"), // Also handled in session
@@ -133,6 +172,16 @@ export const SETTINGS_DEFS: SettingDef[] = [
133
172
  get: (sm) => sm.getCollapseChangelog(),
134
173
  set: (sm, v) => sm.setCollapseChangelog(v),
135
174
  },
175
+ {
176
+ id: "doubleEscapeAction",
177
+ tab: "config",
178
+ type: "enum",
179
+ label: "Double-escape action",
180
+ description: "Action when pressing Escape twice with empty editor",
181
+ values: ["tree", "branch"],
182
+ get: (sm) => sm.getDoubleEscapeAction(),
183
+ set: (sm, v) => sm.setDoubleEscapeAction(v as "branch" | "tree"),
184
+ },
136
185
  {
137
186
  id: "bashInterceptor",
138
187
  tab: "config",
@@ -359,7 +408,7 @@ export const SETTINGS_DEFS: SettingDef[] = [
359
408
  id: "statusLineShowHooks",
360
409
  tab: "status",
361
410
  type: "boolean",
362
- label: "Show hook status",
411
+ label: "Show extension status",
363
412
  description: "Display hook status messages below status line",
364
413
  get: (sm) => sm.getStatusLineShowHookStatus(),
365
414
  set: (sm, v) => sm.setStatusLineShowHookStatus(v),
@@ -22,8 +22,8 @@ export function getSeparator(style: StatusLineSeparatorStyle, theme: Theme): Sep
22
22
  left: theme.sep.powerlineThinLeft,
23
23
  right: theme.sep.powerlineThinRight,
24
24
  endCaps: {
25
- left: theme.sep.powerlineThinRight,
26
- right: theme.sep.powerlineThinLeft,
25
+ left: theme.sep.powerlineRight,
26
+ right: theme.sep.powerlineLeft,
27
27
  useBgAsFg: true,
28
28
  },
29
29
  };
@@ -46,8 +46,8 @@ export function getSeparator(style: StatusLineSeparatorStyle, theme: Theme): Sep
46
46
  left: theme.sep.powerlineThinLeft,
47
47
  right: theme.sep.powerlineThinRight,
48
48
  endCaps: {
49
- left: theme.sep.powerlineThinRight,
50
- right: theme.sep.powerlineThinLeft,
49
+ left: theme.sep.powerlineRight,
50
+ right: theme.sep.powerlineLeft,
51
51
  useBgAsFg: true,
52
52
  },
53
53
  };
@@ -1,8 +1,7 @@
1
- import { execSync } from "node:child_process";
2
- import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
3
- import { dirname, join } from "node:path";
4
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
5
2
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
+ import { type FSWatcher, watch } from "fs";
4
+ import { dirname, join } from "path";
6
5
  import type { AgentSession } from "../../../core/agent-session";
7
6
  import type { StatusLineSegmentOptions, StatusLineSettings } from "../../../core/settings-manager";
8
7
  import { theme } from "../theme/theme";
@@ -23,11 +22,11 @@ function sanitizeStatusText(text: string): string {
23
22
  }
24
23
 
25
24
  /** Find the git root directory by walking up from cwd */
26
- function findGitHeadPath(): string | null {
25
+ async function findGitHeadPath(): Promise<string | null> {
27
26
  let dir = process.cwd();
28
27
  while (true) {
29
28
  const gitHeadPath = join(dir, ".git", "HEAD");
30
- if (existsSync(gitHeadPath)) {
29
+ if (await Bun.file(gitHeadPath).exists()) {
31
30
  return gitHeadPath;
32
31
  }
33
32
  const parent = dirname(dir);
@@ -98,19 +97,20 @@ export class StatusLineComponent implements Component {
98
97
  this.gitWatcher = null;
99
98
  }
100
99
 
101
- const gitHeadPath = findGitHeadPath();
102
- if (!gitHeadPath) return;
103
-
104
- try {
105
- this.gitWatcher = watch(gitHeadPath, () => {
106
- this.cachedBranch = undefined;
107
- if (this.onBranchChange) {
108
- this.onBranchChange();
109
- }
110
- });
111
- } catch {
112
- // Silently fail
113
- }
100
+ findGitHeadPath().then((gitHeadPath) => {
101
+ if (!gitHeadPath) return;
102
+
103
+ try {
104
+ this.gitWatcher = watch(gitHeadPath, () => {
105
+ this.cachedBranch = undefined;
106
+ if (this.onBranchChange) {
107
+ this.onBranchChange();
108
+ }
109
+ });
110
+ } catch {
111
+ // Silently fail
112
+ }
113
+ });
114
114
  }
115
115
 
116
116
  dispose(): void {
@@ -129,24 +129,27 @@ export class StatusLineComponent implements Component {
129
129
  return this.cachedBranch;
130
130
  }
131
131
 
132
- try {
133
- const gitHeadPath = findGitHeadPath();
132
+ // Note: synchronous call to async function - will return undefined on first call
133
+ // This is acceptable since it's a cached value that will update on next render
134
+ findGitHeadPath().then(async (gitHeadPath) => {
134
135
  if (!gitHeadPath) {
135
136
  this.cachedBranch = null;
136
- return null;
137
+ return;
137
138
  }
138
- const content = readFileSync(gitHeadPath, "utf8").trim();
139
+ try {
140
+ const content = (await Bun.file(gitHeadPath).text()).trim();
139
141
 
140
- if (content.startsWith("ref: refs/heads/")) {
141
- this.cachedBranch = content.slice(16);
142
- } else {
143
- this.cachedBranch = "detached";
142
+ if (content.startsWith("ref: refs/heads/")) {
143
+ this.cachedBranch = content.slice(16);
144
+ } else {
145
+ this.cachedBranch = "detached";
146
+ }
147
+ } catch {
148
+ this.cachedBranch = null;
144
149
  }
145
- } catch {
146
- this.cachedBranch = null;
147
- }
150
+ });
148
151
 
149
- return this.cachedBranch;
152
+ return this.cachedBranch ?? null;
150
153
  }
151
154
 
152
155
  private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
@@ -156,12 +159,19 @@ export class StatusLineComponent implements Component {
156
159
  }
157
160
 
158
161
  try {
159
- const output = execSync("git status --porcelain 2>/dev/null", {
160
- encoding: "utf8",
161
- timeout: 1000,
162
- stdio: ["pipe", "pipe", "pipe"],
162
+ const result = Bun.spawnSync(["git", "status", "--porcelain"], {
163
+ stdout: "pipe",
164
+ stderr: "pipe",
163
165
  });
164
166
 
167
+ if (!result.success) {
168
+ this.cachedGitStatus = null;
169
+ this.gitStatusLastFetch = now;
170
+ return null;
171
+ }
172
+
173
+ const output = result.stdout.toString("utf8");
174
+
165
175
  let staged = 0;
166
176
  let unstaged = 0;
167
177
  let untracked = 0;
@@ -333,7 +343,7 @@ export class StatusLineComponent implements Component {
333
343
  ? separatorDef.endCaps.right
334
344
  : separatorDef.endCaps.left
335
345
  : "";
336
- const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : sepAnsi;
346
+ const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
337
347
  const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
338
348
 
339
349
  let content = bgAnsi + fgAnsi;
@@ -1,4 +1,5 @@
1
- import * as os from "node:os";
1
+ import { homedir } from "node:os";
2
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import {
3
4
  Box,
4
5
  Container,
@@ -11,10 +12,10 @@ import {
11
12
  type TUI,
12
13
  } from "@oh-my-pi/pi-tui";
13
14
  import stripAnsi from "strip-ansi";
14
- import type { CustomTool } from "../../../core/custom-tools/types";
15
15
  import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff";
16
16
  import { toolRenderers } from "../../../core/tools/renderers";
17
17
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate";
18
+ import { convertToPng } from "../../../utils/image-convert";
18
19
  import { sanitizeBinaryOutput } from "../../../utils/shell";
19
20
  import { getLanguageFromPath, highlightCode, theme } from "../theme/theme";
20
21
  import { renderDiff } from "./diff";
@@ -202,7 +203,9 @@ function formatDiagnostics(diag: { errored: boolean; summary: string; messages:
202
203
  const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
203
204
 
204
205
  // File header with icon
205
- output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("muted", theme.icon.file)} ${theme.fg("accent", filePath)}`;
206
+ const fileLang = getLanguageFromPath(filePath);
207
+ const fileIcon = theme.fg("muted", theme.getLangIcon(fileLang));
208
+ output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
206
209
  shown++;
207
210
 
208
211
  // Render diagnostics for this file
@@ -310,8 +313,8 @@ function formatArgsPreview(
310
313
  * Convert absolute path to tilde notation if it's in home directory
311
314
  */
312
315
  function shortenPath(path: string): string {
313
- const home = os.homedir();
314
- if (path.startsWith(home)) {
316
+ const home = homedir();
317
+ if (home && path.startsWith(home)) {
315
318
  return `~${path.slice(home.length)}`;
316
319
  }
317
320
  return path;
@@ -341,7 +344,7 @@ export class ToolExecutionComponent extends Container {
341
344
  private expanded = false;
342
345
  private showImages: boolean;
343
346
  private isPartial = true;
344
- private customTool?: CustomTool;
347
+ private tool?: AgentTool;
345
348
  private ui: TUI;
346
349
  private cwd: string;
347
350
  private result?: {
@@ -352,6 +355,8 @@ export class ToolExecutionComponent extends Container {
352
355
  // Cached edit diff preview (computed when args arrive, before tool executes)
353
356
  private editDiffPreview?: EditDiffResult | EditDiffError;
354
357
  private editDiffArgsKey?: string; // Track which args the preview is for
358
+ // Cached converted images for Kitty protocol (which requires PNG), keyed by index
359
+ private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
355
360
  // Spinner animation for partial task results
356
361
  private spinnerFrame = 0;
357
362
  private spinnerInterval: ReturnType<typeof setInterval> | null = null;
@@ -360,7 +365,7 @@ export class ToolExecutionComponent extends Container {
360
365
  toolName: string,
361
366
  args: any,
362
367
  options: ToolExecutionOptions = {},
363
- customTool: CustomTool | undefined,
368
+ tool: AgentTool | undefined,
364
369
  ui: TUI,
365
370
  cwd: string = process.cwd(),
366
371
  ) {
@@ -368,7 +373,7 @@ export class ToolExecutionComponent extends Container {
368
373
  this.toolName = toolName;
369
374
  this.args = args;
370
375
  this.showImages = options.showImages ?? true;
371
- this.customTool = customTool;
376
+ this.tool = tool;
372
377
  this.ui = ui;
373
378
  this.cwd = cwd;
374
379
 
@@ -380,7 +385,8 @@ export class ToolExecutionComponent extends Container {
380
385
 
381
386
  // Use Box for custom tools, bash, or built-in tools that have renderers
382
387
  const hasRenderer = toolName in toolRenderers;
383
- if (customTool || toolName === "bash" || hasRenderer) {
388
+ const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult);
389
+ if (hasCustomRenderer || toolName === "bash" || hasRenderer) {
384
390
  this.addChild(this.contentBox);
385
391
  } else {
386
392
  this.addChild(this.contentText);
@@ -447,6 +453,39 @@ export class ToolExecutionComponent extends Container {
447
453
  this.isPartial = isPartial;
448
454
  this.updateSpinnerAnimation();
449
455
  this.updateDisplay();
456
+ // Convert non-PNG images to PNG for Kitty protocol (async)
457
+ this.maybeConvertImagesForKitty();
458
+ }
459
+
460
+ /**
461
+ * Convert non-PNG images to PNG for Kitty graphics protocol.
462
+ * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
463
+ */
464
+ private maybeConvertImagesForKitty(): void {
465
+ const caps = getCapabilities();
466
+ // Only needed for Kitty protocol
467
+ if (caps.images !== "kitty") return;
468
+ if (!this.result) return;
469
+
470
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
471
+
472
+ for (let i = 0; i < imageBlocks.length; i++) {
473
+ const img = imageBlocks[i];
474
+ if (!img.data || !img.mimeType) continue;
475
+ // Skip if already PNG or already converted
476
+ if (img.mimeType === "image/png") continue;
477
+ if (this.convertedImages.has(i)) continue;
478
+
479
+ // Convert async
480
+ const index = i;
481
+ convertToPng(img.data, img.mimeType).then((converted) => {
482
+ if (converted) {
483
+ this.convertedImages.set(index, converted);
484
+ this.updateDisplay();
485
+ this.ui.requestRender();
486
+ }
487
+ });
488
+ }
450
489
  }
451
490
 
452
491
  /**
@@ -497,17 +536,23 @@ export class ToolExecutionComponent extends Container {
497
536
  : (text: string) => theme.bg("toolSuccessBg", text);
498
537
 
499
538
  // Check for custom tool rendering
500
- if (this.customTool) {
539
+ if (this.tool && (this.tool.renderCall || this.tool.renderResult)) {
540
+ const tool = this.tool;
501
541
  // Custom tools use Box for flexible component rendering
502
542
  this.contentBox.setBgFn(bgFn);
503
543
  this.contentBox.clear();
504
544
 
505
545
  // Render call component
506
- if (this.customTool.renderCall) {
546
+ if (tool.renderCall) {
507
547
  try {
508
- const callComponent = this.customTool.renderCall(this.args, theme);
548
+ const callComponent = tool.renderCall(this.args, theme);
509
549
  if (callComponent) {
510
- this.contentBox.addChild(callComponent);
550
+ // Ensure component has invalidate() method for Component interface
551
+ const component = callComponent as any;
552
+ if (!component.invalidate) {
553
+ component.invalidate = () => {};
554
+ }
555
+ this.contentBox.addChild(component);
511
556
  }
512
557
  } catch {
513
558
  // Fall back to default on error
@@ -519,15 +564,20 @@ export class ToolExecutionComponent extends Container {
519
564
  }
520
565
 
521
566
  // Render result component if we have a result
522
- if (this.result && this.customTool.renderResult) {
567
+ if (this.result && tool.renderResult) {
523
568
  try {
524
- const resultComponent = this.customTool.renderResult(
569
+ const resultComponent = tool.renderResult(
525
570
  { content: this.result.content as any, details: this.result.details },
526
571
  { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
527
572
  theme,
528
573
  );
529
574
  if (resultComponent) {
530
- this.contentBox.addChild(resultComponent);
575
+ // Ensure component has invalidate() method for Component interface
576
+ const component = resultComponent as any;
577
+ if (!component.invalidate) {
578
+ component.invalidate = () => {};
579
+ }
580
+ this.contentBox.addChild(component);
531
581
  }
532
582
  } catch {
533
583
  // Fall back to showing raw output on error
@@ -558,7 +608,12 @@ export class ToolExecutionComponent extends Container {
558
608
  try {
559
609
  const callComponent = renderer.renderCall(this.args, theme);
560
610
  if (callComponent) {
561
- this.contentBox.addChild(callComponent);
611
+ // Ensure component has invalidate() method for Component interface
612
+ const component = callComponent as any;
613
+ if (!component.invalidate) {
614
+ component.invalidate = () => {};
615
+ }
616
+ this.contentBox.addChild(component);
562
617
  }
563
618
  } catch {
564
619
  // Fall back to default on error
@@ -574,7 +629,12 @@ export class ToolExecutionComponent extends Container {
574
629
  theme,
575
630
  );
576
631
  if (resultComponent) {
577
- this.contentBox.addChild(resultComponent);
632
+ // Ensure component has invalidate() method for Component interface
633
+ const component = resultComponent as any;
634
+ if (!component.invalidate) {
635
+ component.invalidate = () => {};
636
+ }
637
+ this.contentBox.addChild(component);
578
638
  }
579
639
  } catch {
580
640
  // Fall back to showing raw output on error
@@ -604,14 +664,25 @@ export class ToolExecutionComponent extends Container {
604
664
  const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
605
665
  const caps = getCapabilities();
606
666
 
607
- for (const img of imageBlocks) {
667
+ for (let i = 0; i < imageBlocks.length; i++) {
668
+ const img = imageBlocks[i];
608
669
  if (caps.images && this.showImages && img.data && img.mimeType) {
670
+ // Use converted PNG for Kitty protocol if available
671
+ const converted = this.convertedImages.get(i);
672
+ const imageData = converted?.data ?? img.data;
673
+ const imageMimeType = converted?.mimeType ?? img.mimeType;
674
+
675
+ // For Kitty, skip non-PNG images that haven't been converted yet
676
+ if (caps.images === "kitty" && imageMimeType !== "image/png") {
677
+ continue;
678
+ }
679
+
609
680
  const spacer = new Spacer(1);
610
681
  this.addChild(spacer);
611
682
  this.imageSpacers.push(spacer);
612
683
  const imageComponent = new Image(
613
- img.data,
614
- img.mimeType,
684
+ imageData,
685
+ imageMimeType,
615
686
  { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
616
687
  { maxWidthCells: 60 },
617
688
  );
@@ -840,6 +911,8 @@ export class ToolExecutionComponent extends Container {
840
911
  } else if (this.toolName === "edit") {
841
912
  const rawPath = this.args?.file_path || this.args?.path || "";
842
913
  const path = shortenPath(rawPath);
914
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
915
+ const editIcon = theme.fg("muted", theme.getLangIcon(editLanguage));
843
916
 
844
917
  // Build path display, appending :line if we have diff info
845
918
  let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
@@ -852,9 +925,8 @@ export class ToolExecutionComponent extends Container {
852
925
  pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
853
926
  }
854
927
 
855
- text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
928
+ text = `${theme.fg("toolTitle", theme.bold("edit"))} ${editIcon} ${pathDisplay}`;
856
929
 
857
- const editLanguage = getLanguageFromPath(rawPath) ?? "text";
858
930
  const editLineCount = countLines(this.args?.newText ?? this.args?.oldText ?? "");
859
931
  text += `\n${formatMetadataLine(editLineCount, editLanguage)}`;
860
932