@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
@@ -29,12 +29,14 @@ export class BashExecutionComponent extends Container {
29
29
  private contentContainer: Container;
30
30
  private ui: TUI;
31
31
 
32
- constructor(command: string, ui: TUI) {
32
+ constructor(command: string, ui: TUI, excludeFromContext = false) {
33
33
  super();
34
34
  this.command = command;
35
35
  this.ui = ui;
36
36
 
37
- const borderColor = (str: string) => theme.fg("bashMode", str);
37
+ // Use dim border for excluded-from-context commands (!! prefix)
38
+ const colorKey = excludeFromContext ? "dim" : "bashMode";
39
+ const borderColor = (str: string) => theme.fg(colorKey, str);
38
40
 
39
41
  // Add spacer
40
42
  this.addChild(new Spacer(1));
@@ -47,13 +49,13 @@ export class BashExecutionComponent extends Container {
47
49
  this.addChild(this.contentContainer);
48
50
 
49
51
  // Command header
50
- const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0);
52
+ const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
51
53
  this.contentContainer.addChild(header);
52
54
 
53
55
  // Loader
54
56
  this.loader = new Loader(
55
57
  ui,
56
- (spinner) => theme.fg("bashMode", spinner),
58
+ (spinner) => theme.fg(colorKey, spinner),
57
59
  (text) => theme.fg("muted", text),
58
60
  `Running${theme.format.ellipsis} (esc to cancel)`,
59
61
  getSymbolTheme().spinnerFrames,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  Editor,
3
+ isCapsLock,
3
4
  isCtrlC,
4
5
  isCtrlD,
5
6
  isCtrlG,
@@ -8,10 +9,13 @@ import {
8
9
  isCtrlP,
9
10
  isCtrlT,
10
11
  isCtrlV,
12
+ isCtrlY,
11
13
  isCtrlZ,
12
14
  isEscape,
13
15
  isShiftCtrlP,
14
16
  isShiftTab,
17
+ type KeyId,
18
+ matchesKey,
15
19
  } from "@oh-my-pi/pi-tui";
16
20
 
17
21
  /**
@@ -30,10 +34,41 @@ export class CustomEditor extends Editor {
30
34
  public onCtrlG?: () => void;
31
35
  public onCtrlZ?: () => void;
32
36
  public onQuestionMark?: () => void;
37
+ public onCapsLock?: () => void;
38
+ public onCtrlY?: () => void;
33
39
  /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
34
40
  public onCtrlV?: () => Promise<boolean>;
35
41
 
42
+ /** Custom key handlers from extensions */
43
+ private customKeyHandlers = new Map<KeyId, () => void>();
44
+
45
+ /**
46
+ * Register a custom key handler. Extensions use this for shortcuts.
47
+ */
48
+ setCustomKeyHandler(key: KeyId, handler: () => void): void {
49
+ this.customKeyHandlers.set(key, handler);
50
+ }
51
+
52
+ /**
53
+ * Remove a custom key handler.
54
+ */
55
+ removeCustomKeyHandler(key: KeyId): void {
56
+ this.customKeyHandlers.delete(key);
57
+ }
58
+
59
+ /**
60
+ * Clear all custom key handlers.
61
+ */
62
+ clearCustomKeyHandlers(): void {
63
+ this.customKeyHandlers.clear();
64
+ }
65
+
36
66
  handleInput(data: string): void {
67
+ if (isCapsLock(data) && this.onCapsLock) {
68
+ this.onCapsLock();
69
+ return;
70
+ }
71
+
37
72
  // Intercept Ctrl+V for image paste (async - fires and handles result)
38
73
  if (isCtrlV(data) && this.onCtrlV) {
39
74
  void this.onCtrlV();
@@ -46,6 +81,12 @@ export class CustomEditor extends Editor {
46
81
  return;
47
82
  }
48
83
 
84
+ // Intercept Ctrl+Y for voice input
85
+ if (isCtrlY(data) && this.onCtrlY) {
86
+ this.onCtrlY();
87
+ return;
88
+ }
89
+
49
90
  // Intercept Ctrl+Z for suspend
50
91
  if (isCtrlZ(data) && this.onCtrlZ) {
51
92
  this.onCtrlZ();
@@ -58,6 +99,12 @@ export class CustomEditor extends Editor {
58
99
  return;
59
100
  }
60
101
 
102
+ // Intercept Ctrl+Y for role-based model cycling
103
+ if (isCtrlY(data) && this.onCtrlY) {
104
+ this.onCtrlY();
105
+ return;
106
+ }
107
+
61
108
  // Intercept Ctrl+L for model selector
62
109
  if (isCtrlL(data) && this.onCtrlL) {
63
110
  this.onCtrlL();
@@ -116,6 +163,14 @@ export class CustomEditor extends Editor {
116
163
  return;
117
164
  }
118
165
 
166
+ // Check custom key handlers (extensions)
167
+ for (const [keyId, handler] of this.customKeyHandlers) {
168
+ if (matchesKey(data, keyId)) {
169
+ handler();
170
+ return;
171
+ }
172
+ }
173
+
119
174
  // Pass to parent for normal handling
120
175
  super.handleInput(data);
121
176
  }
@@ -0,0 +1,95 @@
1
+ import type { TextContent } from "@oh-my-pi/pi-ai";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
+ import type { MessageRenderer } from "../../../core/extensions/types";
5
+ import type { CustomMessage } from "../../../core/messages";
6
+ import { getMarkdownTheme, theme } from "../theme/theme";
7
+
8
+ /**
9
+ * Component that renders a custom message entry from extensions.
10
+ * Uses distinct styling to differentiate from user messages.
11
+ */
12
+ export class CustomMessageComponent extends Container {
13
+ private message: CustomMessage<unknown>;
14
+ private customRenderer?: MessageRenderer;
15
+ private box: Box;
16
+ private customComponent?: Component;
17
+ private _expanded = false;
18
+
19
+ constructor(message: CustomMessage<unknown>, customRenderer?: MessageRenderer) {
20
+ super();
21
+ this.message = message;
22
+ this.customRenderer = customRenderer;
23
+
24
+ this.addChild(new Spacer(1));
25
+
26
+ // Create box with custom background (used for default rendering)
27
+ this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
28
+
29
+ this.rebuild();
30
+ }
31
+
32
+ setExpanded(expanded: boolean): void {
33
+ if (this._expanded !== expanded) {
34
+ this._expanded = expanded;
35
+ this.rebuild();
36
+ }
37
+ }
38
+
39
+ private rebuild(): void {
40
+ // Remove previous content component
41
+ if (this.customComponent) {
42
+ this.removeChild(this.customComponent);
43
+ this.customComponent = undefined;
44
+ }
45
+ this.removeChild(this.box);
46
+
47
+ // Try custom renderer first - it handles its own styling
48
+ if (this.customRenderer) {
49
+ try {
50
+ const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
51
+ if (component) {
52
+ this.customComponent = component;
53
+ this.addChild(component);
54
+ return;
55
+ }
56
+ } catch {
57
+ // Fall through to default rendering
58
+ }
59
+ }
60
+
61
+ // Default rendering uses our box
62
+ this.addChild(this.box);
63
+ this.box.clear();
64
+
65
+ // Default rendering: label + content
66
+ const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
67
+ this.box.addChild(new Text(label, 0, 0));
68
+ this.box.addChild(new Spacer(1));
69
+
70
+ // Extract text content
71
+ let text: string;
72
+ if (typeof this.message.content === "string") {
73
+ text = this.message.content;
74
+ } else {
75
+ text = this.message.content
76
+ .filter((c): c is TextContent => c.type === "text")
77
+ .map((c) => c.text)
78
+ .join("\n");
79
+ }
80
+
81
+ // Limit lines when collapsed
82
+ if (!this._expanded) {
83
+ const lines = text.split("\n");
84
+ if (lines.length > 5) {
85
+ text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`;
86
+ }
87
+ }
88
+
89
+ this.box.addChild(
90
+ new Markdown(text, 0, 0, getMarkdownTheme(), {
91
+ color: (value: string) => theme.fg("customMessageText", value),
92
+ }),
93
+ );
94
+ }
95
+ }
@@ -230,6 +230,8 @@ export class ExtensionList implements Component {
230
230
 
231
231
  private getKindIcon(kind: ExtensionKind): string {
232
232
  switch (kind) {
233
+ case "extension-module":
234
+ return theme.icon.extensionTool;
233
235
  case "skill":
234
236
  return theme.icon.extensionSkill;
235
237
  case "tool":
@@ -316,6 +318,7 @@ export class ExtensionList implements Component {
316
318
  }
317
319
 
318
320
  const kindOrder: ExtensionKind[] = [
321
+ "extension-module",
319
322
  "skill",
320
323
  "tool",
321
324
  "slash-command",
@@ -347,6 +350,8 @@ export class ExtensionList implements Component {
347
350
 
348
351
  private getKindLabel(kind: ExtensionKind): string {
349
352
  switch (kind) {
353
+ case "extension-module":
354
+ return "Extension Modules";
350
355
  case "skill":
351
356
  return "Skills";
352
357
  case "tool":
@@ -4,7 +4,6 @@
4
4
  * Shows name, description, origin, status, and kind-specific preview.
5
5
  */
6
6
 
7
- import { readFileSync } from "node:fs";
8
7
  import { homedir } from "node:os";
9
8
  import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
10
9
  import { theme } from "../../theme/theme";
@@ -98,16 +97,22 @@ export class InspectorPanel implements Component {
98
97
  lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
99
98
 
100
99
  try {
101
- const content = readFileSync(path, "utf-8");
102
- const fileLines = content.split("\n").slice(0, 20);
103
-
104
- for (const line of fileLines) {
105
- const highlighted = this.highlightMarkdown(line);
106
- lines.push(truncateToWidth(highlighted, width - 2));
107
- }
108
-
109
- if (content.split("\n").length > 20) {
110
- lines.push(theme.fg("dim", "(truncated at line 20)"));
100
+ const content = Bun.file(path).text();
101
+ // Note: async call to sync context - will show empty on first render
102
+ // This is acceptable for preview which can populate on next render
103
+ if (typeof content === "object" && "then" in content) {
104
+ content.then((text: string) => {
105
+ const fileLines = text.split("\n").slice(0, 20);
106
+
107
+ for (const line of fileLines) {
108
+ const highlighted = this.highlightMarkdown(line);
109
+ lines.push(truncateToWidth(highlighted, width - 2));
110
+ }
111
+
112
+ if (text.split("\n").length > 20) {
113
+ lines.push(theme.fg("dim", "(truncated at line 20)"));
114
+ }
115
+ });
111
116
  }
112
117
  } catch (err) {
113
118
  lines.push(theme.fg("error", `Failed to read file: ${err instanceof Error ? err.message : String(err)}`));
@@ -261,6 +266,7 @@ export class InspectorPanel implements Component {
261
266
 
262
267
  private getKindBadge(kind: string): string {
263
268
  const kindColors: Record<string, string> = {
269
+ "extension-module": "accent",
264
270
  skill: "accent",
265
271
  rule: "success",
266
272
  tool: "warning",
@@ -296,7 +302,7 @@ export class InspectorPanel implements Component {
296
302
 
297
303
  private shortenPath(path: string): string {
298
304
  const home = homedir();
299
- if (path.startsWith(home)) {
305
+ if (home && path.startsWith(home)) {
300
306
  return `~${path.slice(home.length)}`;
301
307
  }
302
308
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ContextFile } from "../../../../capability/context-file";
7
+ import type { ExtensionModule } from "../../../../capability/extension-module";
7
8
  import type { Hook } from "../../../../capability/hook";
8
9
  import type { MCPServer } from "../../../../capability/mcp";
9
10
  import type { Prompt } from "../../../../capability/prompt";
@@ -128,6 +129,15 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
128
129
  // Capability may not be registered
129
130
  }
130
131
 
132
+ // Load extension modules
133
+ try {
134
+ const modules = loadSync<ExtensionModule>("extension-modules", loadOpts);
135
+ const nativeModules = modules.all.filter((module) => module._source.provider === "native");
136
+ addItems(nativeModules, "extension-module");
137
+ } catch {
138
+ // Capability may not be registered
139
+ }
140
+
131
141
  // Load MCP servers
132
142
  try {
133
143
  const mcps = loadSync<MCPServer>("mcps", loadOpts);
@@ -394,6 +404,8 @@ export function applyFilter(extensions: Extension[], query: string): Extension[]
394
404
  */
395
405
  function getKindDisplayName(kind: ExtensionKind): string {
396
406
  switch (kind) {
407
+ case "extension-module":
408
+ return "Extension Modules";
397
409
  case "skill":
398
410
  return "Skills";
399
411
  case "rule":
@@ -8,6 +8,7 @@ import type { SourceMeta } from "../../../../capability/types";
8
8
  * Extension kinds matching capability types.
9
9
  */
10
10
  export type ExtensionKind =
11
+ | "extension-module"
11
12
  | "skill"
12
13
  | "rule"
13
14
  | "tool"
@@ -0,0 +1,324 @@
1
+ import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
+ import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { dirname, join } from "path";
5
+ import type { AgentSession } from "../../../core/agent-session";
6
+ import { theme } from "../theme/theme";
7
+
8
+ /**
9
+ * Sanitize text for display in a single-line status.
10
+ * Removes newlines, tabs, carriage returns, and other control characters.
11
+ */
12
+ function sanitizeStatusText(text: string): string {
13
+ // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
14
+ return text
15
+ .replace(/[\r\n\t]/g, " ")
16
+ .replace(/ +/g, " ")
17
+ .trim();
18
+ }
19
+
20
+ /**
21
+ * Find the git root directory by walking up from cwd.
22
+ * Returns the path to .git/HEAD if found, null otherwise.
23
+ */
24
+ function findGitHeadPath(): string | null {
25
+ let dir = process.cwd();
26
+ while (true) {
27
+ const gitHeadPath = join(dir, ".git", "HEAD");
28
+ if (existsSync(gitHeadPath)) {
29
+ return gitHeadPath;
30
+ }
31
+ const parent = dirname(dir);
32
+ if (parent === dir) {
33
+ // Reached filesystem root
34
+ return null;
35
+ }
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Footer component that shows pwd, token stats, and context usage
42
+ */
43
+ export class FooterComponent implements Component {
44
+ private session: AgentSession;
45
+ private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
46
+ private gitWatcher: FSWatcher | null = null;
47
+ private onBranchChange: (() => void) | null = null;
48
+ private autoCompactEnabled: boolean = true;
49
+ private extensionStatuses: Map<string, string> = new Map();
50
+
51
+ constructor(session: AgentSession) {
52
+ this.session = session;
53
+ }
54
+
55
+ setAutoCompactEnabled(enabled: boolean): void {
56
+ this.autoCompactEnabled = enabled;
57
+ }
58
+
59
+ /**
60
+ * Set extension status text to display in the footer.
61
+ * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
62
+ * ANSI escape codes for styling are preserved.
63
+ * @param key - Unique key to identify this status
64
+ * @param text - Status text, or undefined to clear
65
+ */
66
+ setExtensionStatus(key: string, text: string | undefined): void {
67
+ if (text === undefined) {
68
+ this.extensionStatuses.delete(key);
69
+ } else {
70
+ this.extensionStatuses.set(key, text);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Set up a file watcher on .git/HEAD to detect branch changes.
76
+ * Call the provided callback when branch changes.
77
+ */
78
+ watchBranch(onBranchChange: () => void): void {
79
+ this.onBranchChange = onBranchChange;
80
+ this.setupGitWatcher();
81
+ }
82
+
83
+ private setupGitWatcher(): void {
84
+ // Clean up existing watcher
85
+ if (this.gitWatcher) {
86
+ this.gitWatcher.close();
87
+ this.gitWatcher = null;
88
+ }
89
+
90
+ const gitHeadPath = findGitHeadPath();
91
+ if (!gitHeadPath) {
92
+ return;
93
+ }
94
+
95
+ try {
96
+ this.gitWatcher = watch(gitHeadPath, () => {
97
+ this.cachedBranch = undefined; // Invalidate cache
98
+ if (this.onBranchChange) {
99
+ this.onBranchChange();
100
+ }
101
+ });
102
+ } catch {
103
+ // Silently fail if we can't watch
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Clean up the file watcher
109
+ */
110
+ dispose(): void {
111
+ if (this.gitWatcher) {
112
+ this.gitWatcher.close();
113
+ this.gitWatcher = null;
114
+ }
115
+ }
116
+
117
+ invalidate(): void {
118
+ // Invalidate cached branch so it gets re-read on next render
119
+ this.cachedBranch = undefined;
120
+ }
121
+
122
+ /**
123
+ * Get current git branch by reading .git/HEAD directly.
124
+ * Returns null if not in a git repo, branch name otherwise.
125
+ */
126
+ private getCurrentBranch(): string | null {
127
+ // Return cached value if available
128
+ if (this.cachedBranch !== undefined) {
129
+ return this.cachedBranch;
130
+ }
131
+
132
+ try {
133
+ const gitHeadPath = findGitHeadPath();
134
+ if (!gitHeadPath) {
135
+ this.cachedBranch = null;
136
+ return null;
137
+ }
138
+ const content = readFileSync(gitHeadPath, "utf8").trim();
139
+
140
+ if (content.startsWith("ref: refs/heads/")) {
141
+ // Normal branch: extract branch name
142
+ this.cachedBranch = content.slice(16);
143
+ } else {
144
+ // Detached HEAD state
145
+ this.cachedBranch = "detached";
146
+ }
147
+ } catch {
148
+ // Not in a git repo or error reading file
149
+ this.cachedBranch = null;
150
+ }
151
+
152
+ return this.cachedBranch;
153
+ }
154
+
155
+ render(width: number): string[] {
156
+ const state = this.session.state;
157
+
158
+ // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
159
+ let totalInput = 0;
160
+ let totalOutput = 0;
161
+ let totalCacheRead = 0;
162
+ let totalCacheWrite = 0;
163
+ let totalCost = 0;
164
+
165
+ for (const entry of this.session.sessionManager.getEntries()) {
166
+ if (entry.type === "message" && entry.message.role === "assistant") {
167
+ totalInput += entry.message.usage.input;
168
+ totalOutput += entry.message.usage.output;
169
+ totalCacheRead += entry.message.usage.cacheRead;
170
+ totalCacheWrite += entry.message.usage.cacheWrite;
171
+ totalCost += entry.message.usage.cost.total;
172
+ }
173
+ }
174
+
175
+ // Get last assistant message for context percentage calculation (skip aborted messages)
176
+ const lastAssistantMessage = state.messages
177
+ .slice()
178
+ .reverse()
179
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
180
+
181
+ // Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
182
+ const contextTokens = lastAssistantMessage
183
+ ? lastAssistantMessage.usage.input +
184
+ lastAssistantMessage.usage.output +
185
+ lastAssistantMessage.usage.cacheRead +
186
+ lastAssistantMessage.usage.cacheWrite
187
+ : 0;
188
+ const contextWindow = state.model?.contextWindow || 0;
189
+ const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
190
+ const contextPercent = contextPercentValue.toFixed(1);
191
+
192
+ // Format token counts (similar to web-ui)
193
+ const formatTokens = (count: number): string => {
194
+ if (count < 1000) return count.toString();
195
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
196
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
197
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
198
+ return `${Math.round(count / 1000000)}M`;
199
+ };
200
+
201
+ // Replace home directory with ~
202
+ let pwd = process.cwd();
203
+ const home = process.env.HOME || process.env.USERPROFILE;
204
+ if (home && pwd.startsWith(home)) {
205
+ pwd = `~${pwd.slice(home.length)}`;
206
+ }
207
+
208
+ // Add git branch if available
209
+ const branch = this.getCurrentBranch();
210
+ if (branch) {
211
+ pwd = `${pwd} (${branch})`;
212
+ }
213
+
214
+ // Truncate path if too long to fit width
215
+ if (pwd.length > width) {
216
+ const half = Math.floor(width / 2) - 2;
217
+ if (half > 0) {
218
+ const start = pwd.slice(0, half);
219
+ const end = pwd.slice(-(half - 1));
220
+ pwd = `${start}...${end}`;
221
+ } else {
222
+ pwd = pwd.slice(0, Math.max(1, width));
223
+ }
224
+ }
225
+
226
+ // Build stats line
227
+ const statsParts = [];
228
+ if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
229
+ if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
230
+ if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
231
+ if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
232
+
233
+ // Show cost with "(sub)" indicator if using OAuth subscription
234
+ const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
235
+ if (totalCost || usingSubscription) {
236
+ const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
237
+ statsParts.push(costStr);
238
+ }
239
+
240
+ // Colorize context percentage based on usage
241
+ let contextPercentStr: string;
242
+ const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
243
+ const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
244
+ if (contextPercentValue > 90) {
245
+ contextPercentStr = theme.fg("error", contextPercentDisplay);
246
+ } else if (contextPercentValue > 70) {
247
+ contextPercentStr = theme.fg("warning", contextPercentDisplay);
248
+ } else {
249
+ contextPercentStr = contextPercentDisplay;
250
+ }
251
+ statsParts.push(contextPercentStr);
252
+
253
+ let statsLeft = statsParts.join(" ");
254
+
255
+ // Add model name on the right side, plus thinking level if model supports it
256
+ const modelName = state.model?.id || "no-model";
257
+
258
+ // Add thinking level hint if model supports reasoning and thinking is enabled
259
+ let rightSide = modelName;
260
+ if (state.model?.reasoning) {
261
+ const thinkingLevel = state.thinkingLevel || "off";
262
+ if (thinkingLevel !== "off") {
263
+ rightSide = `${modelName} • ${thinkingLevel}`;
264
+ }
265
+ }
266
+
267
+ let statsLeftWidth = visibleWidth(statsLeft);
268
+ const rightSideWidth = visibleWidth(rightSide);
269
+
270
+ // If statsLeft is too wide, truncate it
271
+ if (statsLeftWidth > width) {
272
+ // Truncate statsLeft to fit width (no room for right side)
273
+ const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
274
+ statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`;
275
+ statsLeftWidth = visibleWidth(statsLeft);
276
+ }
277
+
278
+ // Calculate available space for padding (minimum 2 spaces between stats and model)
279
+ const minPadding = 2;
280
+ const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
281
+
282
+ let statsLine: string;
283
+ if (totalNeeded <= width) {
284
+ // Both fit - add padding to right-align model
285
+ const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
286
+ statsLine = statsLeft + padding + rightSide;
287
+ } else {
288
+ // Need to truncate right side
289
+ const availableForRight = width - statsLeftWidth - minPadding;
290
+ if (availableForRight > 3) {
291
+ // Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
292
+ const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
293
+ const truncatedPlain = plainRightSide.substring(0, availableForRight);
294
+ // For simplicity, just use plain truncated version (loses color, but fits)
295
+ const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
296
+ statsLine = statsLeft + padding + truncatedPlain;
297
+ } else {
298
+ // Not enough space for right side at all
299
+ statsLine = statsLeft;
300
+ }
301
+ }
302
+
303
+ // Apply dim to each part separately. statsLeft may contain color codes (for context %)
304
+ // that end with a reset, which would clear an outer dim wrapper. So we dim the parts
305
+ // before and after the colored section independently.
306
+ const dimStatsLeft = theme.fg("dim", statsLeft);
307
+ const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
308
+ const dimRemainder = theme.fg("dim", remainder);
309
+
310
+ const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
311
+
312
+ // Add extension statuses on a single line, sorted by key alphabetically
313
+ if (this.extensionStatuses.size > 0) {
314
+ const sortedStatuses = Array.from(this.extensionStatuses.entries())
315
+ .sort(([a], [b]) => a.localeCompare(b))
316
+ .map(([, text]) => sanitizeStatusText(text));
317
+ const statusLine = sortedStatuses.join(" ");
318
+ // Truncate to terminal width with dim ellipsis for consistency with footer style
319
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
320
+ }
321
+
322
+ return lines;
323
+ }
324
+ }
@@ -39,6 +39,7 @@ export class HookEditorComponent extends Container {
39
39
 
40
40
  // Create editor
41
41
  this.editor = new Editor(getEditorTheme());
42
+ this.editor.setUseTerminalCursor(true);
42
43
  if (prefill) {
43
44
  this.editor.setText(prefill);
44
45
  }