@oh-my-pi/pi-coding-agent 15.11.0 → 15.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/cli.js +678 -657
  3. package/dist/types/capability/mcp.d.ts +1 -0
  4. package/dist/types/config/settings-schema.d.ts +49 -4
  5. package/dist/types/export/html/template.generated.d.ts +1 -1
  6. package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
  7. package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
  9. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  10. package/dist/types/extensibility/hooks/types.d.ts +8 -4
  11. package/dist/types/irc/bus.d.ts +15 -2
  12. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  13. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  14. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  15. package/dist/types/mcp/types.d.ts +2 -0
  16. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  18. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  19. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  21. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  22. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  23. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  25. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  26. package/dist/types/modes/theme/theme.d.ts +3 -2
  27. package/dist/types/session/agent-session.d.ts +17 -3
  28. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  29. package/dist/types/task/index.d.ts +3 -3
  30. package/dist/types/tools/bash.d.ts +1 -1
  31. package/dist/types/tools/browser/attach.d.ts +4 -4
  32. package/dist/types/tools/browser/registry.d.ts +1 -0
  33. package/dist/types/tools/irc.d.ts +3 -2
  34. package/dist/types/tools/path-utils.d.ts +0 -4
  35. package/dist/types/tools/render-utils.d.ts +22 -0
  36. package/package.json +11 -11
  37. package/src/capability/mcp.ts +1 -0
  38. package/src/cli/gallery-cli.ts +5 -4
  39. package/src/config/mcp-schema.json +4 -0
  40. package/src/config/settings-schema.ts +55 -4
  41. package/src/edit/renderer.ts +96 -46
  42. package/src/exec/bash-executor.ts +21 -6
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +6 -1
  45. package/src/extensibility/custom-commands/loader.ts +3 -1
  46. package/src/extensibility/custom-commands/types.ts +6 -3
  47. package/src/extensibility/custom-tools/loader.ts +4 -7
  48. package/src/extensibility/custom-tools/types.ts +8 -4
  49. package/src/extensibility/extensions/loader.ts +2 -1
  50. package/src/extensibility/extensions/types.ts +2 -2
  51. package/src/extensibility/hooks/loader.ts +3 -1
  52. package/src/extensibility/hooks/types.ts +8 -4
  53. package/src/internal-urls/docs-index.generated.ts +8 -8
  54. package/src/irc/bus.ts +14 -3
  55. package/src/lsp/defaults.json +6 -0
  56. package/src/lsp/render.ts +2 -28
  57. package/src/mcp/manager.ts +3 -0
  58. package/src/mcp/oauth-discovery.ts +27 -2
  59. package/src/mcp/oauth-flow.ts +47 -1
  60. package/src/mcp/transports/stdio.ts +3 -0
  61. package/src/mcp/types.ts +2 -0
  62. package/src/memories/index.ts +2 -0
  63. package/src/modes/acp/acp-agent.ts +4 -67
  64. package/src/modes/components/assistant-message.ts +15 -0
  65. package/src/modes/components/btw-panel.ts +5 -1
  66. package/src/modes/components/mcp-add-wizard.ts +13 -0
  67. package/src/modes/components/plan-review-overlay.ts +32 -3
  68. package/src/modes/components/settings-selector.ts +2 -0
  69. package/src/modes/components/status-line/component.ts +22 -12
  70. package/src/modes/components/status-line/types.ts +3 -0
  71. package/src/modes/components/transcript-container.ts +99 -18
  72. package/src/modes/components/tree-selector.ts +6 -1
  73. package/src/modes/controllers/event-controller.ts +28 -4
  74. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  75. package/src/modes/controllers/selector-controller.ts +4 -0
  76. package/src/modes/controllers/streaming-reveal.ts +16 -8
  77. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  78. package/src/modes/interactive-mode.ts +41 -2
  79. package/src/modes/rpc/rpc-client.ts +32 -0
  80. package/src/modes/rpc/rpc-mode.ts +82 -7
  81. package/src/modes/rpc/rpc-types.ts +23 -0
  82. package/src/modes/theme/theme.ts +13 -7
  83. package/src/modes/utils/ui-helpers.ts +13 -4
  84. package/src/prompts/memories/consolidation_system.md +4 -0
  85. package/src/prompts/system/irc-autoreply.md +6 -0
  86. package/src/prompts/system/irc-incoming.md +1 -1
  87. package/src/prompts/tools/bash.md +1 -0
  88. package/src/prompts/tools/irc.md +1 -1
  89. package/src/prompts/tools/task.md +7 -2
  90. package/src/session/agent-session.ts +120 -10
  91. package/src/slash-commands/available-commands.ts +105 -0
  92. package/src/task/index.ts +15 -10
  93. package/src/task/render.ts +10 -4
  94. package/src/tools/bash.ts +5 -1
  95. package/src/tools/browser/attach.ts +26 -7
  96. package/src/tools/browser/registry.ts +11 -1
  97. package/src/tools/irc.ts +16 -4
  98. package/src/tools/job.ts +7 -3
  99. package/src/tools/path-utils.ts +22 -15
  100. package/src/tools/render-utils.ts +56 -0
  101. package/src/tools/write.ts +65 -47
  102. package/src/web/search/providers/anthropic.ts +29 -4
@@ -163,7 +163,7 @@ export class StreamingRevealController {
163
163
  this.#hideThinkingBlock = this.#getHideThinkingBlock();
164
164
  this.#smoothStreaming = this.#getSmoothStreaming();
165
165
  if (!this.#smoothStreaming) {
166
- component.updateContent(message);
166
+ component.updateContent(message, { transient: true });
167
167
  return;
168
168
  }
169
169
  const total = this.#visibleUnits(message);
@@ -171,10 +171,12 @@ export class StreamingRevealController {
171
171
  // A tool call is a transcript-order boundary: finish any leading
172
172
  // assistant text before EventController renders the separate tool card.
173
173
  this.#revealed = total;
174
- component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf));
174
+ component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
175
+ transient: true,
176
+ });
175
177
  return;
176
178
  }
177
- this.#renderCurrent(total);
179
+ this.#renderCurrent();
178
180
  this.#syncTimer(total);
179
181
  }
180
182
 
@@ -182,7 +184,7 @@ export class StreamingRevealController {
182
184
  this.#target = message;
183
185
  if (!this.#component) return;
184
186
  if (!this.#smoothStreaming) {
185
- this.#component.updateContent(message);
187
+ this.#component.updateContent(message, { transient: true });
186
188
  return;
187
189
  }
188
190
  const total = this.#visibleUnits(message);
@@ -193,13 +195,16 @@ export class StreamingRevealController {
193
195
  this.#stopTimer();
194
196
  this.#component.updateContent(
195
197
  buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf),
198
+ {
199
+ transient: true,
200
+ },
196
201
  );
197
202
  return;
198
203
  }
199
204
  if (this.#revealed > total) {
200
205
  this.#revealed = total;
201
206
  }
202
- this.#renderCurrent(total);
207
+ this.#renderCurrent();
203
208
  this.#syncTimer(total);
204
209
  }
205
210
 
@@ -225,11 +230,14 @@ export class StreamingRevealController {
225
230
  return total;
226
231
  }
227
232
 
228
- #renderCurrent(total = this.#target ? this.#visibleUnits(this.#target) : 0): void {
233
+ #renderCurrent(): void {
229
234
  if (!this.#target || !this.#component) return;
235
+ // Every controller render is an in-flight streaming snapshot, even when
236
+ // smooth reveal has temporarily caught up to the current target. The
237
+ // message_end handler performs the only stable non-transient render.
230
238
  this.#component.updateContent(
231
239
  buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock, this.#countOf),
232
- { transient: this.#revealed < total },
240
+ { transient: true },
233
241
  );
234
242
  }
235
243
 
@@ -269,7 +277,7 @@ export class StreamingRevealController {
269
277
  }
270
278
  this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
271
279
  component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
272
- transient: this.#revealed < total,
280
+ transient: true,
273
281
  });
274
282
  this.#requestRender();
275
283
  if (this.#revealed >= total) {
@@ -0,0 +1,174 @@
1
+ import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
2
+ import { nextStep, STREAMING_REVEAL_FRAME_MS } from "./streaming-reveal";
3
+
4
+ /** Minimal component surface the reveal pushes frames into. */
5
+ type ToolArgsRevealComponent = {
6
+ updateArgs(args: unknown, toolCallId?: string): void;
7
+ };
8
+
9
+ type ToolArgsRevealControllerOptions = {
10
+ getSmoothStreaming(): boolean;
11
+ requestRender(): void;
12
+ };
13
+
14
+ type RevealEntry = {
15
+ component: ToolArgsRevealComponent | undefined;
16
+ /** Latest raw streamed argument text (JSON for function tools, raw text for custom tools). */
17
+ target: string;
18
+ /** Revealed UTF-16 code units of `target`. */
19
+ revealed: number;
20
+ /** Custom-tool raw input: display args are `{ input: prefix }`, never parsed as JSON. */
21
+ rawInput: boolean;
22
+ };
23
+
24
+ /** Clamp a slice end into `text`, never splitting a surrogate pair: a prefix
25
+ * ending on a high surrogate would feed a lone surrogate into the parsed
26
+ * preview args (providers decode UTF-8 incrementally, so the raw stream
27
+ * itself never contains one). */
28
+ function clampSliceEnd(text: string, end: number): number {
29
+ if (end <= 0) return 0;
30
+ if (end >= text.length) return text.length;
31
+ const code = text.charCodeAt(end - 1);
32
+ return code >= 0xd800 && code <= 0xdbff ? end + 1 : end;
33
+ }
34
+
35
+ /** Display args for a revealed raw-stream prefix. Function-tool prefixes are
36
+ * re-parsed with the same streaming-tolerant parser providers use, so every
37
+ * frame is a state the provider itself could have produced; custom tools
38
+ * mirror the provider's `{ input }` shape. `__partialJson` carries the
39
+ * matching raw prefix for renderers that read it directly (bash env preview,
40
+ * edit strategies). */
41
+ function buildDisplayArgs(prefix: string, rawInput: boolean): Record<string, unknown> {
42
+ const base: Record<string, unknown> = rawInput ? { input: prefix } : parseStreamingJson(prefix);
43
+ return { ...base, __partialJson: prefix };
44
+ }
45
+
46
+ /**
47
+ * Paces streamed tool-call arguments the same way StreamingRevealController
48
+ * paces assistant text: providers that deliver `partialJson` in large batches
49
+ * (or throttle their partial parses) would otherwise make write/edit/bash
50
+ * streaming previews jump in chunks. Each pending tool call reveals its raw
51
+ * argument stream at the shared 30fps cadence with the same adaptive
52
+ * catch-up step, re-parsing the revealed prefix per frame.
53
+ *
54
+ * Reveal units are UTF-16 code units of the raw stream, not graphemes —
55
+ * the prefix goes through a JSON parser rather than straight to the screen,
56
+ * so only surrogate-pair integrity matters (see {@link clampSliceEnd}).
57
+ */
58
+ export class ToolArgsRevealController {
59
+ readonly #getSmoothStreaming: () => boolean;
60
+ readonly #requestRender: () => void;
61
+ readonly #entries = new Map<string, RevealEntry>();
62
+ #timer: NodeJS.Timeout | undefined;
63
+
64
+ constructor(options: ToolArgsRevealControllerOptions) {
65
+ this.#getSmoothStreaming = options.getSmoothStreaming;
66
+ this.#requestRender = options.requestRender;
67
+ }
68
+
69
+ /**
70
+ * Record the latest streamed argument text for a tool call and return the
71
+ * args to render right now. With smoothing disabled the full target passes
72
+ * through in the caller's legacy shape (`{ ...args, __partialJson }`).
73
+ */
74
+ setTarget(
75
+ id: string,
76
+ partialJson: string,
77
+ rawInput: boolean,
78
+ fullArgs: Record<string, unknown>,
79
+ ): Record<string, unknown> {
80
+ if (!this.#getSmoothStreaming()) {
81
+ // Toggle may flip mid-call: drop any live entry so ticks stop.
82
+ this.#entries.delete(id);
83
+ return { ...fullArgs, __partialJson: partialJson };
84
+ }
85
+ let entry = this.#entries.get(id);
86
+ if (!entry) {
87
+ entry = { component: undefined, target: partialJson, revealed: 0, rawInput };
88
+ this.#entries.set(id, entry);
89
+ } else {
90
+ // Streams only append; a non-prefix target means a rewind — snap into range.
91
+ if (!partialJson.startsWith(entry.target)) {
92
+ entry.revealed = Math.min(entry.revealed, partialJson.length);
93
+ }
94
+ entry.target = partialJson;
95
+ }
96
+ entry.revealed = clampSliceEnd(entry.target, entry.revealed);
97
+ this.#syncTimer();
98
+ return buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput);
99
+ }
100
+
101
+ /** Attach the component future ticks push frames into. */
102
+ bind(id: string, component: ToolArgsRevealComponent): void {
103
+ const entry = this.#entries.get(id);
104
+ if (entry) entry.component = component;
105
+ }
106
+
107
+ /** Final arguments arrived (the JSON closed): drop the reveal so the
108
+ * caller's final-args render wins immediately, mirroring how assistant
109
+ * text snaps to the full message at message_end. */
110
+ finish(id: string): void {
111
+ this.#entries.delete(id);
112
+ if (this.#entries.size === 0) this.#stopTimer();
113
+ }
114
+
115
+ /** Snap every live entry to its full received stream and clear. Used at
116
+ * message_end (abort/error mid-stream) so sealed components freeze showing
117
+ * everything that arrived rather than a mid-reveal prefix. */
118
+ flushAll(): void {
119
+ for (const [id, entry] of this.#entries) {
120
+ if (entry.component && entry.revealed < entry.target.length) {
121
+ entry.component.updateArgs(buildDisplayArgs(entry.target, entry.rawInput), id);
122
+ }
123
+ }
124
+ this.#entries.clear();
125
+ this.#stopTimer();
126
+ }
127
+
128
+ /** Clear without pushing (teardown). */
129
+ stop(): void {
130
+ this.#entries.clear();
131
+ this.#stopTimer();
132
+ }
133
+
134
+ #syncTimer(): void {
135
+ for (const entry of this.#entries.values()) {
136
+ if (entry.revealed < entry.target.length) {
137
+ this.#startTimer();
138
+ return;
139
+ }
140
+ }
141
+ this.#stopTimer();
142
+ }
143
+
144
+ #startTimer(): void {
145
+ if (this.#timer) return;
146
+ this.#timer = setInterval(() => {
147
+ this.#tick();
148
+ }, STREAMING_REVEAL_FRAME_MS);
149
+ this.#timer.unref?.();
150
+ }
151
+
152
+ #stopTimer(): void {
153
+ if (!this.#timer) return;
154
+ clearInterval(this.#timer);
155
+ this.#timer = undefined;
156
+ }
157
+
158
+ #tick(): void {
159
+ let advanced = false;
160
+ for (const [id, entry] of this.#entries) {
161
+ const backlog = entry.target.length - entry.revealed;
162
+ if (backlog <= 0 || !entry.component) continue;
163
+ entry.revealed = clampSliceEnd(entry.target, entry.revealed + nextStep(backlog));
164
+ entry.component.updateArgs(buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput), id);
165
+ advanced = true;
166
+ }
167
+ if (advanced) {
168
+ this.#requestRender();
169
+ } else {
170
+ // Every entry caught up (or unbound); setTarget restarts on growth.
171
+ this.#stopTimer();
172
+ }
173
+ }
174
+ }
@@ -496,8 +496,12 @@ export class InteractiveMode implements InteractiveModeContext {
496
496
  }
497
497
 
498
498
  playWelcomeIntro(): void {
499
- this.#welcomeComponent?.playIntro(() => this.ui.requestRender());
499
+ const welcome = this.#welcomeComponent;
500
+ // Component-scoped: the intro only mutates the welcome box's own rows,
501
+ // so a resumed long transcript is not re-walked per animation frame.
502
+ welcome?.playIntro(() => this.ui.requestComponentRender(welcome));
500
503
  }
504
+
501
505
  async init(options: InteractiveModeInitOptions = {}): Promise<void> {
502
506
  if (this.isInitialized) return;
503
507
 
@@ -1050,6 +1054,7 @@ export class InteractiveMode implements InteractiveModeContext {
1050
1054
  separator: settings.get("statusLine.separator"),
1051
1055
  showHookStatus: settings.get("statusLine.showHookStatus"),
1052
1056
  sessionAccent: settings.get("statusLine.sessionAccent"),
1057
+ transparent: settings.get("statusLine.transparent"),
1053
1058
  segmentOptions: settings.get("statusLine.segmentOptions"),
1054
1059
  });
1055
1060
  }
@@ -1780,6 +1785,7 @@ export class InteractiveMode implements InteractiveModeContext {
1780
1785
  onPick: choice => finish(choice),
1781
1786
  onCancel: () => finish(undefined),
1782
1787
  onExternalEditor: dialogOptions?.onExternalEditor,
1788
+ onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
1783
1789
  onPlanEdited: dialogOptions?.onPlanEdited,
1784
1790
  onFeedbackChange: dialogOptions?.onFeedbackChange,
1785
1791
  },
@@ -1894,6 +1900,37 @@ export class InteractiveMode implements InteractiveModeContext {
1894
1900
  }
1895
1901
  }
1896
1902
 
1903
+ async #openPlanAnnotationInExternalEditor(draft: string, commit: (text: string | null) => void): Promise<void> {
1904
+ const editorCmd = getEditorCommand();
1905
+ if (!editorCmd) {
1906
+ this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1907
+ return;
1908
+ }
1909
+
1910
+ let ttyHandle: fs.FileHandle | null = null;
1911
+ try {
1912
+ ttyHandle = await this.#openEditorTerminalHandle();
1913
+ this.ui.stop();
1914
+
1915
+ const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
1916
+ ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
1917
+ : ["inherit", "inherit", "inherit"];
1918
+
1919
+ const result = await openInEditor(editorCmd, draft, { extension: ".md", stdio });
1920
+ if (result !== null) {
1921
+ commit(result);
1922
+ }
1923
+ } catch (error) {
1924
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
1925
+ } finally {
1926
+ if (ttyHandle) {
1927
+ await ttyHandle.close();
1928
+ }
1929
+ this.ui.start();
1930
+ this.ui.requestRender(true);
1931
+ }
1932
+ }
1933
+
1897
1934
  async #applyPlanExecutionModel(entry: ResolvedRoleModel | undefined): Promise<void> {
1898
1935
  if (!entry) return;
1899
1936
  try {
@@ -3036,7 +3073,9 @@ export class InteractiveMode implements InteractiveModeContext {
3036
3073
  this.#voiceAnimationInterval = setInterval(() => {
3037
3074
  this.#voiceHue = (this.#voiceHue + 8) % 360;
3038
3075
  this.#updateMicIcon();
3039
- this.ui.requestRender();
3076
+ // Component-scoped: the hue sweep only recolors the editor's cursor
3077
+ // glyph, so the transcript subtree is reused per animation frame.
3078
+ this.ui.requestComponentRender(this.editor);
3040
3079
  }, 60);
3041
3080
  }
3042
3081
 
@@ -13,6 +13,8 @@ import type { FileSink } from "bun";
13
13
  import type { BashResult } from "../../exec/bash-executor";
14
14
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
15
15
  import type {
16
+ RpcAvailableCommandsUpdateFrame,
17
+ RpcAvailableSlashCommand,
16
18
  RpcCommand,
17
19
  RpcExtensionUIRequest,
18
20
  RpcHandoffResult,
@@ -63,6 +65,7 @@ export type RpcSessionEventListener = (event: AgentSessionEvent) => void;
63
65
  export type RpcSubagentLifecycleListener = (payload: RpcSubagentLifecycleFrame["payload"]) => void;
64
66
  export type RpcSubagentProgressListener = (payload: RpcSubagentProgressFrame["payload"]) => void;
65
67
  export type RpcSubagentEventListener = (payload: RpcSubagentEventFrame["payload"]) => void;
68
+ export type RpcAvailableCommandsUpdateListener = (commands: RpcAvailableSlashCommand[]) => void;
66
69
 
67
70
  export interface RpcClientToolContext<TDetails = unknown> {
68
71
  toolCallId: string;
@@ -161,6 +164,11 @@ function isRpcSubagentEventFrame(value: unknown): value is RpcSubagentEventFrame
161
164
  return value.type === "subagent_event" && isRecord(value.payload);
162
165
  }
163
166
 
167
+ function isRpcAvailableCommandsUpdateFrame(value: unknown): value is RpcAvailableCommandsUpdateFrame {
168
+ if (!isRecord(value)) return false;
169
+ return value.type === "available_commands_update" && Array.isArray(value.commands);
170
+ }
171
+
164
172
  function isRpcHostToolCallRequest(value: unknown): value is RpcHostToolCallRequest {
165
173
  if (!isRecord(value)) return false;
166
174
  return (
@@ -202,6 +210,7 @@ export class RpcClient {
202
210
  #subagentLifecycleListeners = new Set<RpcSubagentLifecycleListener>();
203
211
  #subagentProgressListeners = new Set<RpcSubagentProgressListener>();
204
212
  #subagentEventListeners = new Set<RpcSubagentEventListener>();
213
+ #availableCommandsUpdateListeners = new Set<RpcAvailableCommandsUpdateListener>();
205
214
  #pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
206
215
  new Map();
207
216
  #customTools: RpcClientCustomTool[] = [];
@@ -377,6 +386,14 @@ export class RpcClient {
377
386
  return () => this.#subagentEventListeners.delete(listener);
378
387
  }
379
388
 
389
+ /**
390
+ * Subscribe to slash-command availability updates emitted by the RPC server.
391
+ */
392
+ onAvailableCommandsUpdate(listener: RpcAvailableCommandsUpdateListener): () => void {
393
+ this.#availableCommandsUpdateListeners.add(listener);
394
+ return () => this.#availableCommandsUpdateListeners.delete(listener);
395
+ }
396
+
380
397
  /**
381
398
  * Get collected stderr output (useful for debugging).
382
399
  */
@@ -511,6 +528,14 @@ export class RpcClient {
511
528
  return this.#getData<{ models: ModelInfo[] }>(response).models;
512
529
  }
513
530
 
531
+ /**
532
+ * Get list of available slash commands.
533
+ */
534
+ async getAvailableCommands(): Promise<RpcAvailableSlashCommand[]> {
535
+ const response = await this.#send({ type: "get_available_commands" });
536
+ return this.#getData<{ commands: RpcAvailableSlashCommand[] }>(response).commands;
537
+ }
538
+
514
539
  /**
515
540
  * Set thinking level.
516
541
  */
@@ -825,6 +850,13 @@ export class RpcClient {
825
850
  return;
826
851
  }
827
852
 
853
+ if (isRpcAvailableCommandsUpdateFrame(data)) {
854
+ for (const listener of this.#availableCommandsUpdateListeners) {
855
+ listener(data.commands);
856
+ }
857
+ return;
858
+ }
859
+
828
860
  if (!isAgentSessionEvent(data)) return;
829
861
 
830
862
  for (const listener of this.#sessionEventListeners) {
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
+ import { reset as resetCapabilities } from "../../capability";
16
+ import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
15
17
  import {
16
18
  type ExtensionUIContext,
17
19
  type ExtensionUIDialogOptions,
@@ -19,8 +21,13 @@ import {
19
21
  type ExtensionWidgetOptions,
20
22
  getExtensionUISelectOptionLabel,
21
23
  } from "../../extensibility/extensions";
24
+ import { buildSkillPromptMessage } from "../../extensibility/skills";
25
+ import { loadSlashCommands } from "../../extensibility/slash-commands";
22
26
  import { type Theme, theme } from "../../modes/theme/theme";
23
27
  import type { AgentSession } from "../../session/agent-session";
28
+ import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
29
+ import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
30
+ import { buildAvailableSlashCommands } from "../../slash-commands/available-commands";
24
31
  import type { EventBus } from "../../utils/event-bus";
25
32
  import { initializeExtensions } from "../runtime-init";
26
33
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
@@ -70,6 +77,28 @@ export type RpcSessionChangeResult =
70
77
  | { type: "branch"; data: { text: string; cancelled: boolean } };
71
78
 
72
79
  export type RpcSessionChangeSession = Pick<AgentSession, "newSession" | "switchSession" | "branch">;
80
+
81
+ export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" | "skills" | "skillsSettings">;
82
+
83
+ export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise<boolean> {
84
+ if (!text.startsWith("/skill:")) return false;
85
+ if (!session.skillsSettings?.enableSkillCommands) return false;
86
+ const spaceIndex = text.indexOf(" ");
87
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
88
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
89
+ const skillName = commandName.slice("skill:".length);
90
+ const skill = session.skills.find(candidate => candidate.name === skillName);
91
+ if (!skill) return false;
92
+ const built = await buildSkillPromptMessage(skill, args);
93
+ await session.promptCustomMessage({
94
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
95
+ content: built.message,
96
+ display: true,
97
+ details: built.details,
98
+ attribution: "user",
99
+ });
100
+ return true;
101
+ }
73
102
  export type RpcSubagentResetRegistry = Pick<RpcSubagentRegistry, "clear">;
74
103
 
75
104
  export async function handleRpcSessionChange(
@@ -511,6 +540,24 @@ export async function runRpcMode(
511
540
  output(event);
512
541
  });
513
542
 
543
+ const getAvailableCommands = async () => buildAvailableSlashCommands(session);
544
+ const reloadPluginState = async () => {
545
+ const cwd = session.sessionManager.getCwd();
546
+ const projectPath = await resolveActiveProjectRegistryPath(cwd);
547
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
548
+ resetCapabilities();
549
+ session.setSlashCommands(await loadSlashCommands({ cwd }));
550
+ await session.refreshSshTool({ activateIfAvailable: true });
551
+ await emitAvailableCommandsUpdate();
552
+ };
553
+ const emitAvailableCommandsUpdate = async () => {
554
+ output({ type: "available_commands_update", commands: await getAvailableCommands() });
555
+ };
556
+ session.subscribeCommandMetadataChanged(() => {
557
+ void emitAvailableCommandsUpdate();
558
+ });
559
+ await emitAvailableCommandsUpdate();
560
+
514
561
  // Handle a single command
515
562
  const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
516
563
  const id = command.id;
@@ -521,6 +568,33 @@ export async function runRpcMode(
521
568
  // =================================================================
522
569
 
523
570
  case "prompt": {
571
+ if (await tryRunRpcSkillCommand(session, command.message)) {
572
+ return success(id, "prompt");
573
+ }
574
+ const builtinResult = await executeAcpBuiltinSlashCommand(command.message, {
575
+ session,
576
+ sessionManager: session.sessionManager,
577
+ settings: session.settings,
578
+ cwd: session.sessionManager.getCwd(),
579
+ output: text => output({ type: "command_output", text }),
580
+ refreshCommands: emitAvailableCommandsUpdate,
581
+ reloadPlugins: reloadPluginState,
582
+ notifyTitleChanged: async () => {
583
+ output({ type: "session_info_update", title: session.sessionName, sessionId: session.sessionId });
584
+ },
585
+ notifyConfigChanged: async () => {
586
+ output({ type: "config_update", model: session.model, thinkingLevel: session.thinkingLevel });
587
+ },
588
+ });
589
+ if (builtinResult !== false) {
590
+ if ("prompt" in builtinResult) {
591
+ session
592
+ .prompt(builtinResult.prompt, { images: command.images })
593
+ .catch(e => output(error(id, "prompt", e.message)));
594
+ }
595
+ return success(id, "prompt");
596
+ }
597
+
524
598
  // Don't await - events will stream
525
599
  // Extension commands are executed immediately, file prompt templates are expanded
526
600
  // If streaming and streamingBehavior specified, queues via steer/followUp
@@ -556,8 +630,11 @@ export async function runRpcMode(
556
630
  return success(id, "abort_and_prompt");
557
631
  }
558
632
 
559
- case "new_session": {
633
+ case "new_session":
634
+ case "switch_session":
635
+ case "branch": {
560
636
  const result = await handleRpcSessionChange(session, command, subagentRegistry);
637
+ if (!result.data.cancelled) await emitAvailableCommandsUpdate();
561
638
  return success(id, result.type, result.data);
562
639
  }
563
640
 
@@ -592,6 +669,10 @@ export async function runRpcMode(
592
669
  return success(id, "get_state", state);
593
670
  }
594
671
 
672
+ case "get_available_commands": {
673
+ return success(id, "get_available_commands", { commands: await getAvailableCommands() });
674
+ }
675
+
595
676
  case "set_todos": {
596
677
  session.setTodoPhases(command.phases);
597
678
  return success(id, "set_todos", { todoPhases: session.getTodoPhases() });
@@ -770,12 +851,6 @@ export async function runRpcMode(
770
851
  return success(id, "export_html", { path });
771
852
  }
772
853
 
773
- case "switch_session":
774
- case "branch": {
775
- const result = await handleRpcSessionChange(session, command, subagentRegistry);
776
- return success(id, result.type, result.data);
777
- }
778
-
779
854
  case "get_branch_messages": {
780
855
  const messages = session.getUserMessagesForBranching();
781
856
  return success(id, "get_branch_messages", { messages });
@@ -11,6 +11,7 @@ import type { BashResult } from "../../exec/bash-executor";
11
11
  import type { ContextUsage } from "../../extensibility/extensions/types";
12
12
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
13
13
  import type { FileEntry } from "../../session/session-manager";
14
+ import type { AvailableSlashCommandSource } from "../../slash-commands/available-commands";
14
15
  import type {
15
16
  AgentProgress,
16
17
  SubagentEventPayload,
@@ -34,6 +35,7 @@ export type RpcCommand =
34
35
 
35
36
  // State
36
37
  | { id?: string; type: "get_state" }
38
+ | { id?: string; type: "get_available_commands" }
37
39
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
38
40
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
39
41
  | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
@@ -110,6 +112,20 @@ export interface RpcSessionState {
110
112
  contextUsage?: ContextUsage;
111
113
  }
112
114
 
115
+ export interface RpcAvailableSlashCommand {
116
+ name: string;
117
+ aliases?: string[];
118
+ description?: string;
119
+ input?: { hint?: string };
120
+ subcommands?: Array<{ name: string; description?: string; usage?: string }>;
121
+ source: AvailableSlashCommandSource;
122
+ }
123
+
124
+ export interface RpcAvailableCommandsUpdateFrame {
125
+ type: "available_commands_update";
126
+ commands: RpcAvailableSlashCommand[];
127
+ }
128
+
113
129
  export interface RpcHandoffResult {
114
130
  savedPath?: string;
115
131
  }
@@ -156,6 +172,13 @@ export type RpcResponse =
156
172
 
157
173
  // State
158
174
  | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
175
+ | {
176
+ id?: string;
177
+ type: "response";
178
+ command: "get_available_commands";
179
+ success: true;
180
+ data: { commands: RpcAvailableSlashCommand[] };
181
+ }
159
182
  | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
160
183
  | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
161
184
  | { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }
@@ -108,6 +108,7 @@ export type SymbolKey =
108
108
  | "icon.time"
109
109
  | "icon.pi"
110
110
  | "icon.agents"
111
+ | "icon.job"
111
112
  | "icon.cache"
112
113
  | "icon.input"
113
114
  | "icon.output"
@@ -304,6 +305,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
304
305
  "icon.time": "⏱",
305
306
  "icon.pi": "π",
306
307
  "icon.agents": "👥",
308
+ "icon.job": "⚙",
307
309
  "icon.cache": "💾",
308
310
  "icon.input": "⤵",
309
311
  "icon.output": "⤴",
@@ -567,6 +569,8 @@ const NERD_SYMBOLS: SymbolMap = {
567
569
  "icon.pi": "\ue22c",
568
570
  // pick:  | alt: 
569
571
  "icon.agents": "\uf0c0",
572
+ // pick: (nf-fa-gear) | alt: ⚙
573
+ "icon.job": "\uf013",
570
574
  // pick:  | alt:  
571
575
  "icon.cache": "\uf1c0",
572
576
  // pick:  | alt:  →
@@ -796,6 +800,7 @@ const ASCII_SYMBOLS: SymbolMap = {
796
800
  "icon.time": "t:",
797
801
  "icon.pi": "pi",
798
802
  "icon.agents": "AG",
803
+ "icon.job": "bg",
799
804
  "icon.cache": "cache",
800
805
  "icon.input": "in:",
801
806
  "icon.output": "out:",
@@ -1678,6 +1683,7 @@ export class Theme {
1678
1683
  time: this.#symbols["icon.time"],
1679
1684
  pi: this.#symbols["icon.pi"],
1680
1685
  agents: this.#symbols["icon.agents"],
1686
+ job: this.#symbols["icon.job"],
1681
1687
  cache: this.#symbols["icon.cache"],
1682
1688
  input: this.#symbols["icon.input"],
1683
1689
  output: this.#symbols["icon.output"],
@@ -2559,10 +2565,10 @@ const HIGHLIGHT_CACHE_MAX = 256;
2559
2565
  const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
2560
2566
  let highlightCacheTheme: Theme | undefined;
2561
2567
 
2562
- function highlightCached(code: string, validLang: string | undefined): string | null {
2563
- if (highlightCacheTheme !== theme) {
2568
+ function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {
2569
+ if (highlightCacheTheme !== highlightTheme) {
2564
2570
  highlightCache.clear();
2565
- highlightCacheTheme = theme;
2571
+ highlightCacheTheme = highlightTheme;
2566
2572
  }
2567
2573
  const key = `${validLang ?? ""}\x00${code}`;
2568
2574
  const hit = highlightCache.get(key);
@@ -2571,7 +2577,7 @@ function highlightCached(code: string, validLang: string | undefined): string |
2571
2577
  }
2572
2578
  let highlighted: string;
2573
2579
  try {
2574
- highlighted = nativeHighlightCode(code, validLang, getHighlightColors(theme));
2580
+ highlighted = nativeHighlightCode(code, validLang, getHighlightColors(highlightTheme));
2575
2581
  } catch {
2576
2582
  return null;
2577
2583
  }
@@ -2583,9 +2589,9 @@ function highlightCached(code: string, validLang: string | undefined): string |
2583
2589
  * Highlight code with syntax coloring based on file extension or language.
2584
2590
  * Returns array of highlighted lines.
2585
2591
  */
2586
- export function highlightCode(code: string, lang?: string): string[] {
2592
+ export function highlightCode(code: string, lang?: string, highlightTheme: Theme = theme): string[] {
2587
2593
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2588
- const highlighted = highlightCached(code, validLang);
2594
+ const highlighted = highlightCached(code, validLang, highlightTheme);
2589
2595
  // Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
2590
2596
  // onto the result, which would corrupt the cached string otherwise.
2591
2597
  return (highlighted ?? code).split("\n");
@@ -2633,7 +2639,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2633
2639
  resolveMermaidAscii,
2634
2640
  highlightCode: (code: string, lang?: string): string[] => {
2635
2641
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2636
- const highlighted = highlightCached(code, validLang);
2642
+ const highlighted = highlightCached(code, validLang, theme);
2637
2643
  if (highlighted !== null) return highlighted.split("\n");
2638
2644
  return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2639
2645
  },