@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
@@ -25,12 +25,14 @@ import {
25
25
  } from "@oh-my-pi/pi-tui";
26
26
  import { getAuthPath, getDebugLogPath } from "../../config";
27
27
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
28
- import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index";
29
- import type { HookUIContext } from "../../core/hooks/index";
30
- import { createCompactionSummaryMessage } from "../../core/messages";
28
+ import type { ExtensionUIContext } from "../../core/extensions/index";
29
+ import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages";
31
30
  import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
31
+ import { loadSlashCommands } from "../../core/slash-commands";
32
+ import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify";
32
33
  import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator";
33
34
  import type { TruncationResult } from "../../core/tools/truncate";
35
+ import { VoiceSupervisor } from "../../core/voice-supervisor";
34
36
  import { disableProvider, enableProvider } from "../../discovery";
35
37
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
36
38
  import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
@@ -42,11 +44,11 @@ import { BorderedLoader } from "./components/bordered-loader";
42
44
  import { BranchSummaryMessageComponent } from "./components/branch-summary-message";
43
45
  import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message";
44
46
  import { CustomEditor } from "./components/custom-editor";
47
+ import { CustomMessageComponent } from "./components/custom-message";
45
48
  import { DynamicBorder } from "./components/dynamic-border";
46
49
  import { ExtensionDashboard } from "./components/extensions";
47
50
  import { HookEditorComponent } from "./components/hook-editor";
48
51
  import { HookInputComponent } from "./components/hook-input";
49
- import { HookMessageComponent } from "./components/hook-message";
50
52
  import { HookSelectorComponent } from "./components/hook-selector";
51
53
  import { ModelSelectorComponent } from "./components/model-selector";
52
54
  import { OAuthSelectorComponent } from "./components/oauth-selector";
@@ -80,6 +82,10 @@ function isExpandable(obj: unknown): obj is Expandable {
80
82
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
81
83
  }
82
84
 
85
+ const VOICE_PROGRESS_DELAY_MS = 15000;
86
+ const VOICE_PROGRESS_MIN_CHARS = 160;
87
+ const VOICE_PROGRESS_DELTA_CHARS = 120;
88
+
83
89
  export class InteractiveMode {
84
90
  private session: AgentSession;
85
91
  private ui: TUI;
@@ -115,6 +121,9 @@ export class InteractiveMode {
115
121
  // Thinking block visibility state
116
122
  private hideThinkingBlock = false;
117
123
 
124
+ // Background mode flag (no UI, no interactive prompts)
125
+ private isBackgrounded = false;
126
+
118
127
  // Agent subscription unsubscribe function
119
128
  private unsubscribe?: () => void;
120
129
 
@@ -133,6 +142,14 @@ export class InteractiveMode {
133
142
  // Track pending images from clipboard paste (attached to next message)
134
143
  private pendingImages: ImageContent[] = [];
135
144
 
145
+ // Voice mode state
146
+ private voiceSupervisor: VoiceSupervisor;
147
+ private voiceAutoModeEnabled = false;
148
+ private voiceProgressTimer: ReturnType<typeof setTimeout> | undefined = undefined;
149
+ private voiceProgressSpoken = false;
150
+ private voiceProgressLastLength = 0;
151
+ private lastVoiceInterruptAt = 0;
152
+
136
153
  // Auto-compaction state
137
154
  private autoCompactionLoader: Loader | undefined = undefined;
138
155
  private autoCompactionEscapeHandler?: () => void;
@@ -146,9 +163,6 @@ export class InteractiveMode {
146
163
  private hookInput: HookInputComponent | undefined = undefined;
147
164
  private hookEditor: HookEditorComponent | undefined = undefined;
148
165
 
149
- // Custom tools for custom rendering
150
- private customTools: Map<string, LoadedCustomTool>;
151
-
152
166
  // Convenience accessors
153
167
  private get agent() {
154
168
  return this.session.agent;
@@ -164,8 +178,7 @@ export class InteractiveMode {
164
178
  session: AgentSession,
165
179
  version: string,
166
180
  changelogMarkdown: string | undefined = undefined,
167
- customTools: LoadedCustomTool[] = [],
168
- private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
181
+ private setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
169
182
  private lspServers:
170
183
  | Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>
171
184
  | undefined = undefined,
@@ -174,16 +187,36 @@ export class InteractiveMode {
174
187
  this.session = session;
175
188
  this.version = version;
176
189
  this.changelogMarkdown = changelogMarkdown;
177
- this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct]));
178
190
  this.ui = new TUI(new ProcessTerminal());
179
191
  this.chatContainer = new Container();
180
192
  this.pendingMessagesContainer = new Container();
181
193
  this.statusContainer = new Container();
182
194
  this.editor = new CustomEditor(getEditorTheme());
195
+ this.editor.setUseTerminalCursor(true);
183
196
  this.editorContainer = new Container();
184
197
  this.editorContainer.addChild(this.editor);
185
198
  this.statusLine = new StatusLineComponent(session);
186
199
  this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
200
+ this.voiceSupervisor = new VoiceSupervisor(this.session.modelRegistry, {
201
+ onSendToAgent: async (text) => {
202
+ await this.submitVoiceText(text);
203
+ },
204
+ onInterruptAgent: async (reason) => {
205
+ await this.handleVoiceInterrupt(reason);
206
+ },
207
+ onStatus: (status) => {
208
+ this.setVoiceStatus(status);
209
+ },
210
+ onError: (error) => {
211
+ this.showError(error.message);
212
+ this.voiceAutoModeEnabled = false;
213
+ void this.voiceSupervisor.stop();
214
+ this.setVoiceStatus(undefined);
215
+ },
216
+ onWarning: (message) => {
217
+ this.showWarning(message);
218
+ },
219
+ });
187
220
 
188
221
  // Define slash commands for autocomplete
189
222
  const slashCommands: SlashCommand[] = [
@@ -203,6 +236,8 @@ export class InteractiveMode {
203
236
  { name: "logout", description: "Logout from OAuth provider" },
204
237
  { name: "new", description: "Start a new session" },
205
238
  { name: "compact", description: "Manually compact the session context" },
239
+ { name: "background", description: "Detach UI and continue running in background" },
240
+ { name: "bg", description: "Alias for /background" },
206
241
  { name: "resume", description: "Resume a different session" },
207
242
  { name: "exit", description: "Exit the application" },
208
243
  ];
@@ -210,14 +245,15 @@ export class InteractiveMode {
210
245
  // Load hide thinking block setting
211
246
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
212
247
 
213
- // Convert file commands to SlashCommand format
214
- const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({
248
+ // Load and convert file commands to SlashCommand format
249
+ const fileCommands = loadSlashCommands({ cwd: process.cwd() });
250
+ const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({
215
251
  name: cmd.name,
216
252
  description: cmd.description,
217
253
  }));
218
254
 
219
255
  // Convert hook commands to SlashCommand format
220
- const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
256
+ const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
221
257
  name: cmd.name,
222
258
  description: cmd.description ?? "(hook command)",
223
259
  }));
@@ -307,6 +343,10 @@ export class InteractiveMode {
307
343
  this.ui.start();
308
344
  this.isInitialized = true;
309
345
 
346
+ // Set terminal title
347
+ const cwdBasename = path.basename(process.cwd());
348
+ this.ui.terminal.setTitle(`pi - ${cwdBasename}`);
349
+
310
350
  // Initialize hooks with TUI-based UI context
311
351
  await this.initHooksAndCustomTools();
312
352
 
@@ -339,12 +379,14 @@ export class InteractiveMode {
339
379
  */
340
380
  private async initHooksAndCustomTools(): Promise<void> {
341
381
  // Create and set hook & tool UI context
342
- const uiContext: HookUIContext = {
382
+ const uiContext: ExtensionUIContext = {
343
383
  select: (title, options) => this.showHookSelector(title, options),
344
384
  confirm: (title, message) => this.showHookConfirm(title, message),
345
385
  input: (title, placeholder) => this.showHookInput(title, placeholder),
346
386
  notify: (message, type) => this.showHookNotify(message, type),
347
387
  setStatus: (key, text) => this.setHookStatus(key, text),
388
+ setWidget: (key, content) => this.setHookWidget(key, content),
389
+ setTitle: (title) => setTerminalTitle(title),
348
390
  custom: (factory) => this.showHookCustom(factory),
349
391
  setEditorText: (text) => this.editor.setText(text),
350
392
  getEditorText: () => this.editor.getText(),
@@ -355,37 +397,34 @@ export class InteractiveMode {
355
397
  };
356
398
  this.setToolUIContext(uiContext, true);
357
399
 
358
- // Notify custom tools of session start
359
- await this.emitCustomToolSessionEvent({
360
- reason: "start",
361
- previousSessionFile: undefined,
362
- });
363
-
364
- const hookRunner = this.session.hookRunner;
365
- if (!hookRunner) {
400
+ const extensionRunner = this.session.extensionRunner;
401
+ if (!extensionRunner) {
366
402
  return; // No hooks loaded
367
403
  }
368
404
 
369
- hookRunner.initialize({
405
+ extensionRunner.initialize({
370
406
  getModel: () => this.session.model,
371
- sendMessageHandler: (message, triggerTurn) => {
407
+ sendMessageHandler: (message, options) => {
372
408
  const wasStreaming = this.session.isStreaming;
373
409
  this.session
374
- .sendHookMessage(message, triggerTurn)
410
+ .sendCustomMessage(message, options)
375
411
  .then(() => {
376
412
  // For non-streaming cases with display=true, update UI
377
413
  // (streaming cases update via message_end event)
378
- if (!wasStreaming && message.display) {
414
+ if (!this.isBackgrounded && !wasStreaming && message.display) {
379
415
  this.rebuildChatFromMessages();
380
416
  }
381
417
  })
382
418
  .catch((err) => {
383
- this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
419
+ this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
384
420
  });
385
421
  },
386
422
  appendEntryHandler: (customType, data) => {
387
423
  this.sessionManager.appendCustomEntry(customType, data);
388
424
  },
425
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
426
+ getAllToolsHandler: () => this.session.getAllToolNames(),
427
+ setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames),
389
428
  newSessionHandler: async (options) => {
390
429
  // Stop any loading animation
391
430
  if (this.loadingAnimation) {
@@ -455,41 +494,204 @@ export class InteractiveMode {
455
494
  abort: () => {
456
495
  this.session.abort();
457
496
  },
458
- hasQueuedMessages: () => this.session.queuedMessageCount > 0,
497
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
459
498
  uiContext,
460
499
  hasUI: true,
461
500
  });
462
501
 
463
- // Subscribe to hook errors
464
- hookRunner.onError((error) => {
465
- this.showHookError(error.hookPath, error.error);
502
+ // Subscribe to extension errors
503
+ extensionRunner.onError((error) => {
504
+ this.showExtensionError(error.extensionPath, error.error);
466
505
  });
467
506
 
468
507
  // Emit session_start event
469
- await hookRunner.emit({
508
+ await extensionRunner.emit({
470
509
  type: "session_start",
471
510
  });
472
511
  }
473
512
 
474
513
  /**
475
- * Emit session event to all custom tools.
514
+ * Set extension widget content.
515
+ */
516
+ private setHookWidget(key: string, content: unknown): void {
517
+ this.statusLine.setHookStatus(key, String(content));
518
+ this.ui.requestRender();
519
+ }
520
+
521
+ private initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void {
522
+ const extensionRunner = this.session.extensionRunner;
523
+ if (!extensionRunner) {
524
+ return;
525
+ }
526
+
527
+ extensionRunner.initialize({
528
+ getModel: () => this.session.model,
529
+ sendMessageHandler: (message, options) => {
530
+ const wasStreaming = this.session.isStreaming;
531
+ this.session
532
+ .sendCustomMessage(message, options)
533
+ .then(() => {
534
+ // For non-streaming cases with display=true, update UI
535
+ // (streaming cases update via message_end event)
536
+ if (!this.isBackgrounded && !wasStreaming && message.display) {
537
+ this.rebuildChatFromMessages();
538
+ }
539
+ })
540
+ .catch((err: Error) => {
541
+ const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
542
+ if (this.isBackgrounded) {
543
+ console.error(errorText);
544
+ return;
545
+ }
546
+ this.showError(errorText);
547
+ });
548
+ },
549
+ appendEntryHandler: (customType, data) => {
550
+ this.sessionManager.appendCustomEntry(customType, data);
551
+ },
552
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
553
+ getAllToolsHandler: () => this.session.getAllToolNames(),
554
+ setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
555
+ newSessionHandler: async (options) => {
556
+ if (this.isBackgrounded) {
557
+ return { cancelled: true };
558
+ }
559
+ // Stop any loading animation
560
+ if (this.loadingAnimation) {
561
+ this.loadingAnimation.stop();
562
+ this.loadingAnimation = undefined;
563
+ }
564
+ this.statusContainer.clear();
565
+
566
+ // Create new session
567
+ const success = await this.session.newSession({ parentSession: options?.parentSession });
568
+ if (!success) {
569
+ return { cancelled: true };
570
+ }
571
+
572
+ // Call setup callback if provided
573
+ if (options?.setup) {
574
+ await options.setup(this.sessionManager);
575
+ }
576
+
577
+ // Clear UI state
578
+ this.chatContainer.clear();
579
+ this.pendingMessagesContainer.clear();
580
+ this.streamingComponent = undefined;
581
+ this.streamingMessage = undefined;
582
+ this.pendingTools.clear();
583
+
584
+ this.chatContainer.addChild(new Spacer(1));
585
+ this.chatContainer.addChild(
586
+ new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
587
+ );
588
+ this.ui.requestRender();
589
+
590
+ return { cancelled: false };
591
+ },
592
+ branchHandler: async (entryId) => {
593
+ if (this.isBackgrounded) {
594
+ return { cancelled: true };
595
+ }
596
+ const result = await this.session.branch(entryId);
597
+ if (result.cancelled) {
598
+ return { cancelled: true };
599
+ }
600
+
601
+ // Update UI
602
+ this.chatContainer.clear();
603
+ this.renderInitialMessages();
604
+ this.editor.setText(result.selectedText);
605
+ this.showStatus("Branched to new session");
606
+
607
+ return { cancelled: false };
608
+ },
609
+ navigateTreeHandler: async (targetId, options) => {
610
+ if (this.isBackgrounded) {
611
+ return { cancelled: true };
612
+ }
613
+ const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
614
+ if (result.cancelled) {
615
+ return { cancelled: true };
616
+ }
617
+
618
+ // Update UI
619
+ this.chatContainer.clear();
620
+ this.renderInitialMessages();
621
+ if (result.editorText) {
622
+ this.editor.setText(result.editorText);
623
+ }
624
+ this.showStatus("Navigated to selected point");
625
+
626
+ return { cancelled: false };
627
+ },
628
+ isIdle: () => !this.session.isStreaming,
629
+ waitForIdle: () => this.session.agent.waitForIdle(),
630
+ abort: () => {
631
+ this.session.abort();
632
+ },
633
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
634
+ uiContext,
635
+ hasUI,
636
+ });
637
+ }
638
+
639
+ private createBackgroundUiContext(): ExtensionUIContext {
640
+ return {
641
+ select: async (_title: string, _options: string[]) => undefined,
642
+ confirm: async (_title: string, _message: string) => false,
643
+ input: async (_title: string, _placeholder?: string) => undefined,
644
+ notify: () => {},
645
+ setStatus: () => {},
646
+ setWidget: () => {},
647
+ setTitle: () => {},
648
+ custom: async <T>(
649
+ _factory: (
650
+ tui: TUI,
651
+ theme: Theme,
652
+ done: (result: T) => void,
653
+ ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
654
+ ) => undefined as T,
655
+ setEditorText: () => {},
656
+ getEditorText: () => "",
657
+ editor: async () => undefined,
658
+ get theme() {
659
+ return theme;
660
+ },
661
+ };
662
+ }
663
+
664
+ /**
665
+ * Emit session event to all extension tools.
476
666
  */
477
- private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise<void> {
478
- for (const { tool } of this.customTools.values()) {
479
- if (tool.onSession) {
667
+ private async emitCustomToolSessionEvent(
668
+ reason: "start" | "switch" | "branch" | "tree" | "shutdown",
669
+ previousSessionFile?: string,
670
+ ): Promise<void> {
671
+ const event = { reason, previousSessionFile };
672
+ const uiContext = this.session.extensionRunner?.getUIContext();
673
+ if (!uiContext) {
674
+ return;
675
+ }
676
+ for (const registeredTool of this.session.extensionRunner?.getAllRegisteredTools() ?? []) {
677
+ if (registeredTool.definition.onSession) {
480
678
  try {
481
- await tool.onSession(event, {
679
+ await registeredTool.definition.onSession(event, {
680
+ ui: uiContext,
681
+ hasUI: !this.isBackgrounded,
682
+ cwd: this.sessionManager.getCwd(),
482
683
  sessionManager: this.session.sessionManager,
483
684
  modelRegistry: this.session.modelRegistry,
484
685
  model: this.session.model,
485
686
  isIdle: () => !this.session.isStreaming,
687
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
486
688
  hasQueuedMessages: () => this.session.queuedMessageCount > 0,
487
689
  abort: () => {
488
690
  this.session.abort();
489
691
  },
490
692
  });
491
693
  } catch (err) {
492
- this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
694
+ this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
493
695
  }
494
696
  }
495
697
  }
@@ -499,6 +701,10 @@ export class InteractiveMode {
499
701
  * Show a tool error in the chat.
500
702
  */
501
703
  private showToolError(toolName: string, error: string): void {
704
+ if (this.isBackgrounded) {
705
+ console.error(`Tool "${toolName}" error: ${error}`);
706
+ return;
707
+ }
502
708
  const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
503
709
  this.chatContainer.addChild(errorText);
504
710
  this.ui.requestRender();
@@ -508,6 +714,9 @@ export class InteractiveMode {
508
714
  * Set hook status text in the footer.
509
715
  */
510
716
  private setHookStatus(key: string, text: string | undefined): void {
717
+ if (this.isBackgrounded) {
718
+ return;
719
+ }
511
720
  this.statusLine.setHookStatus(key, text);
512
721
  this.ui.requestRender();
513
722
  }
@@ -678,10 +887,10 @@ export class InteractiveMode {
678
887
  }
679
888
 
680
889
  /**
681
- * Show a hook error in the UI.
890
+ * Show an extension error in the UI.
682
891
  */
683
- private showHookError(hookPath: string, error: string): void {
684
- const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
892
+ private showExtensionError(extensionPath: string, error: string): void {
893
+ const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
685
894
  this.chatContainer.addChild(errorText);
686
895
  this.ui.requestRender();
687
896
  }
@@ -699,7 +908,7 @@ export class InteractiveMode {
699
908
  if (this.loadingAnimation) {
700
909
  // Abort and restore queued messages to editor
701
910
  const queuedMessages = this.session.clearQueue();
702
- const queuedText = queuedMessages.join("\n\n");
911
+ const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
703
912
  const currentText = this.editor.getText();
704
913
  const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
705
914
  this.editor.setText(combinedText);
@@ -712,10 +921,14 @@ export class InteractiveMode {
712
921
  this.isBashMode = false;
713
922
  this.updateEditorBorderColor();
714
923
  } else if (!this.editor.getText().trim()) {
715
- // Double-escape with empty editor triggers /branch
924
+ // Double-escape with empty editor triggers /tree or /branch based on setting
716
925
  const now = Date.now();
717
926
  if (now - this.lastEscapeTime < 500) {
718
- this.showUserMessageSelector();
927
+ if (this.settingsManager.getDoubleEscapeAction() === "tree") {
928
+ this.showTreeSelector();
929
+ } else {
930
+ this.showUserMessageSelector();
931
+ }
719
932
  this.lastEscapeTime = 0;
720
933
  } else {
721
934
  this.lastEscapeTime = now;
@@ -729,6 +942,7 @@ export class InteractiveMode {
729
942
  this.editor.onShiftTab = () => this.cycleThinkingLevel();
730
943
  this.editor.onCtrlP = () => this.cycleModel("forward");
731
944
  this.editor.onShiftCtrlP = () => this.cycleModel("backward");
945
+ this.editor.onCtrlY = () => this.cycleRoleModel();
732
946
 
733
947
  // Global debug handler on TUI (works regardless of focus)
734
948
  this.ui.onDebug = () => this.handleDebugCommand();
@@ -736,9 +950,15 @@ export class InteractiveMode {
736
950
  this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
737
951
  this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
738
952
  this.editor.onCtrlG = () => this.openExternalEditor();
953
+ this.editor.onCtrlY = () => {
954
+ void this.toggleVoiceListening();
955
+ };
739
956
  this.editor.onQuestionMark = () => this.handleHotkeysCommand();
740
957
  this.editor.onCtrlV = () => this.handleImagePaste();
741
958
 
959
+ // Wire up extension shortcuts
960
+ this.registerExtensionShortcuts();
961
+
742
962
  this.editor.onChange = (text: string) => {
743
963
  const wasBashMode = this.isBashMode;
744
964
  this.isBashMode = text.trimStart().startsWith("!");
@@ -746,6 +966,25 @@ export class InteractiveMode {
746
966
  this.updateEditorBorderColor();
747
967
  }
748
968
  };
969
+
970
+ this.editor.onAltEnter = async (text: string) => {
971
+ text = text.trim();
972
+ if (!text) return;
973
+
974
+ // Alt+Enter queues a follow-up message (waits until agent finishes)
975
+ // This handles extension commands (execute immediately), prompt template expansion, and queueing
976
+ if (this.session.isStreaming) {
977
+ this.editor.addToHistory(text);
978
+ this.editor.setText("");
979
+ await this.session.prompt(text, { streamingBehavior: "followUp" });
980
+ this.updatePendingMessagesDisplay();
981
+ this.ui.requestRender();
982
+ }
983
+ // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
984
+ else if (this.editor.onSubmit) {
985
+ this.editor.onSubmit(text);
986
+ }
987
+ };
749
988
  }
750
989
 
751
990
  private setupEditorSubmitHandler(): void {
@@ -800,7 +1039,11 @@ export class InteractiveMode {
800
1039
  return;
801
1040
  }
802
1041
  if (text === "/branch") {
803
- this.showUserMessageSelector();
1042
+ if (this.settingsManager.getDoubleEscapeAction() === "tree") {
1043
+ this.showTreeSelector();
1044
+ } else {
1045
+ this.showUserMessageSelector();
1046
+ }
804
1047
  this.editor.setText("");
805
1048
  return;
806
1049
  }
@@ -835,6 +1078,11 @@ export class InteractiveMode {
835
1078
  }
836
1079
  return;
837
1080
  }
1081
+ if (text === "/background" || text === "/bg") {
1082
+ this.editor.setText("");
1083
+ this.handleBackgroundCommand();
1084
+ return;
1085
+ }
838
1086
  if (text === "/debug") {
839
1087
  this.handleDebugCommand();
840
1088
  this.editor.setText("");
@@ -856,9 +1104,10 @@ export class InteractiveMode {
856
1104
  return;
857
1105
  }
858
1106
 
859
- // Handle bash command
1107
+ // Handle bash command (! for normal, !! for excluded from context)
860
1108
  if (text.startsWith("!")) {
861
- const command = text.slice(1).trim();
1109
+ const isExcluded = text.startsWith("!!");
1110
+ const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
862
1111
  if (command) {
863
1112
  if (this.session.isBashRunning) {
864
1113
  this.showWarning("A bash command is already running. Press Esc to cancel it first.");
@@ -866,7 +1115,7 @@ export class InteractiveMode {
866
1115
  return;
867
1116
  }
868
1117
  this.editor.addToHistory(text);
869
- await this.handleBashCommand(command);
1118
+ await this.handleBashCommand(command, isExcluded);
870
1119
  this.isBashMode = false;
871
1120
  this.updateEditorBorderColor();
872
1121
  return;
@@ -878,39 +1127,13 @@ export class InteractiveMode {
878
1127
  return;
879
1128
  }
880
1129
 
881
- // Hook commands always run immediately, even during streaming
882
- // (if they need to interact with LLM, they use pi.sendMessage which handles queueing)
883
- if (text.startsWith("/") && this.session.hookRunner) {
884
- const spaceIndex = text.indexOf(" ");
885
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
886
- const command = this.session.hookRunner.getCommand(commandName);
887
- if (command) {
888
- this.editor.addToHistory(text);
889
- this.editor.setText("");
890
- await this.session.prompt(text);
891
- return;
892
- }
893
- }
894
-
895
- // Custom commands (TypeScript slash commands) - route through session.prompt()
896
- if (text.startsWith("/") && this.session.customCommands.length > 0) {
897
- const spaceIndex = text.indexOf(" ");
898
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
899
- const hasCustomCommand = this.session.customCommands.some((c) => c.command.name === commandName);
900
- if (hasCustomCommand) {
901
- this.editor.addToHistory(text);
902
- this.editor.setText("");
903
- await this.session.prompt(text);
904
- return;
905
- }
906
- }
907
-
908
- // Queue regular messages if agent is streaming
1130
+ // If streaming, use prompt() with steer behavior
1131
+ // This handles extension commands (execute immediately), prompt template expansion, and queueing
909
1132
  if (this.session.isStreaming) {
910
- await this.session.queueMessage(text);
911
- this.updatePendingMessagesDisplay();
912
1133
  this.editor.addToHistory(text);
913
1134
  this.editor.setText("");
1135
+ await this.session.prompt(text, { streamingBehavior: "steer" });
1136
+ this.updatePendingMessagesDisplay();
914
1137
  this.ui.requestRender();
915
1138
  return;
916
1139
  }
@@ -972,11 +1195,12 @@ export class InteractiveMode {
972
1195
  getSymbolTheme().spinnerFrames,
973
1196
  );
974
1197
  this.statusContainer.addChild(this.loadingAnimation);
1198
+ this.startVoiceProgressTimer();
975
1199
  this.ui.requestRender();
976
1200
  break;
977
1201
 
978
1202
  case "message_start":
979
- if (event.message.role === "hookMessage") {
1203
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
980
1204
  this.addMessageToChat(event.message);
981
1205
  this.ui.requestRender();
982
1206
  } else if (event.message.role === "user") {
@@ -1002,14 +1226,16 @@ export class InteractiveMode {
1002
1226
  if (content.type === "toolCall") {
1003
1227
  if (!this.pendingTools.has(content.id)) {
1004
1228
  this.chatContainer.addChild(new Text("", 0, 0));
1229
+ const tool = this.session.getToolByName(content.name);
1005
1230
  const component = new ToolExecutionComponent(
1006
1231
  content.name,
1007
1232
  content.arguments,
1008
1233
  {
1009
1234
  showImages: this.settingsManager.getShowImages(),
1010
1235
  },
1011
- this.customTools.get(content.name)?.tool,
1236
+ tool,
1012
1237
  this.ui,
1238
+ this.sessionManager.getCwd(),
1013
1239
  );
1014
1240
  component.setExpanded(this.toolOutputExpanded);
1015
1241
  this.chatContainer.addChild(component);
@@ -1070,14 +1296,16 @@ export class InteractiveMode {
1070
1296
 
1071
1297
  case "tool_execution_start": {
1072
1298
  if (!this.pendingTools.has(event.toolCallId)) {
1299
+ const tool = this.session.getToolByName(event.toolName);
1073
1300
  const component = new ToolExecutionComponent(
1074
1301
  event.toolName,
1075
1302
  event.args,
1076
1303
  {
1077
1304
  showImages: this.settingsManager.getShowImages(),
1078
1305
  },
1079
- this.customTools.get(event.toolName)?.tool,
1306
+ tool,
1080
1307
  this.ui,
1308
+ this.sessionManager.getCwd(),
1081
1309
  );
1082
1310
  component.setExpanded(this.toolOutputExpanded);
1083
1311
  this.chatContainer.addChild(component);
@@ -1107,6 +1335,7 @@ export class InteractiveMode {
1107
1335
  }
1108
1336
 
1109
1337
  case "agent_end":
1338
+ this.stopVoiceProgressTimer();
1110
1339
  if (this.loadingAnimation) {
1111
1340
  this.loadingAnimation.stop();
1112
1341
  this.loadingAnimation = undefined;
@@ -1118,7 +1347,17 @@ export class InteractiveMode {
1118
1347
  this.streamingMessage = undefined;
1119
1348
  }
1120
1349
  this.pendingTools.clear();
1350
+ if (this.settingsManager.getVoiceEnabled() && this.voiceAutoModeEnabled) {
1351
+ const lastAssistant = this.findLastAssistantMessage();
1352
+ if (lastAssistant && lastAssistant.stopReason !== "aborted" && lastAssistant.stopReason !== "error") {
1353
+ const text = this.extractAssistantText(lastAssistant);
1354
+ if (text) {
1355
+ this.voiceSupervisor.notifyResult(text);
1356
+ }
1357
+ }
1358
+ }
1121
1359
  this.ui.requestRender();
1360
+ this.sendCompletionNotification();
1122
1361
  break;
1123
1362
 
1124
1363
  case "auto_compaction_start": {
@@ -1231,6 +1470,14 @@ export class InteractiveMode {
1231
1470
  }
1232
1471
  }
1233
1472
 
1473
+ private sendCompletionNotification(): void {
1474
+ if (isNotificationSuppressed()) return;
1475
+ const method = this.settingsManager.getNotificationOnComplete();
1476
+ if (method === "off") return;
1477
+ const protocol = method === "auto" ? detectNotificationProtocol() : method;
1478
+ sendNotification(protocol, "Agent complete");
1479
+ }
1480
+
1234
1481
  /** Extract text content from a user message */
1235
1482
  private getUserMessageText(message: Message): string {
1236
1483
  if (message.role !== "user") return "";
@@ -1248,6 +1495,9 @@ export class InteractiveMode {
1248
1495
  * we update the previous status line instead of appending new ones to avoid log spam.
1249
1496
  */
1250
1497
  private showStatus(message: string): void {
1498
+ if (this.isBackgrounded) {
1499
+ return;
1500
+ }
1251
1501
  const children = this.chatContainer.children;
1252
1502
  const last = children.length > 0 ? children[children.length - 1] : undefined;
1253
1503
  const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
@@ -1270,7 +1520,7 @@ export class InteractiveMode {
1270
1520
  private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
1271
1521
  switch (message.role) {
1272
1522
  case "bashExecution": {
1273
- const component = new BashExecutionComponent(message.command, this.ui);
1523
+ const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);
1274
1524
  if (message.output) {
1275
1525
  component.appendOutput(message.output);
1276
1526
  }
@@ -1283,10 +1533,12 @@ export class InteractiveMode {
1283
1533
  this.chatContainer.addChild(component);
1284
1534
  break;
1285
1535
  }
1286
- case "hookMessage": {
1536
+ case "hookMessage":
1537
+ case "custom": {
1287
1538
  if (message.display) {
1288
- const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
1289
- this.chatContainer.addChild(new HookMessageComponent(message, renderer));
1539
+ const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType);
1540
+ // Both HookMessage and CustomMessage have the same structure, cast for compatibility
1541
+ this.chatContainer.addChild(new CustomMessageComponent(message as CustomMessage<unknown>, renderer));
1290
1542
  }
1291
1543
  break;
1292
1544
  }
@@ -1362,12 +1614,14 @@ export class InteractiveMode {
1362
1614
  // Render tool call components
1363
1615
  for (const content of message.content) {
1364
1616
  if (content.type === "toolCall") {
1617
+ const tool = this.session.getToolByName(content.name);
1365
1618
  const component = new ToolExecutionComponent(
1366
1619
  content.name,
1367
1620
  content.arguments,
1368
1621
  { showImages: this.settingsManager.getShowImages() },
1369
- this.customTools.get(content.name)?.tool,
1622
+ tool,
1370
1623
  this.ui,
1624
+ this.sessionManager.getCwd(),
1371
1625
  );
1372
1626
  component.setExpanded(this.toolOutputExpanded);
1373
1627
  this.chatContainer.addChild(component);
@@ -1454,18 +1708,13 @@ export class InteractiveMode {
1454
1708
  * Emits shutdown event to hooks and tools, then exits.
1455
1709
  */
1456
1710
  private async shutdown(): Promise<void> {
1711
+ this.voiceAutoModeEnabled = false;
1712
+ await this.voiceSupervisor.stop();
1713
+
1457
1714
  // Flush pending session writes before shutdown
1458
1715
  await this.sessionManager.flush();
1459
1716
 
1460
1717
  // Emit shutdown event to hooks
1461
- const hookRunner = this.session.hookRunner;
1462
- if (hookRunner?.hasHandlers("session_shutdown")) {
1463
- await hookRunner.emit({
1464
- type: "session_shutdown",
1465
- });
1466
- }
1467
-
1468
- // Emit shutdown event to custom tools
1469
1718
  await this.session.emitCustomToolSessionEvent("shutdown");
1470
1719
 
1471
1720
  this.stop();
@@ -1486,6 +1735,72 @@ export class InteractiveMode {
1486
1735
  process.kill(0, "SIGTSTP");
1487
1736
  }
1488
1737
 
1738
+ private handleBackgroundCommand(): void {
1739
+ if (this.isBackgrounded) {
1740
+ this.showStatus("Background mode already enabled");
1741
+ return;
1742
+ }
1743
+ if (!this.session.isStreaming && this.session.queuedMessageCount === 0) {
1744
+ this.showWarning("Agent is idle; nothing to background");
1745
+ return;
1746
+ }
1747
+
1748
+ this.isBackgrounded = true;
1749
+ const backgroundUiContext = this.createBackgroundUiContext();
1750
+
1751
+ // Background mode disables interactive UI so tools like ask fail fast.
1752
+ this.setToolUIContext(backgroundUiContext, false);
1753
+ this.initializeHookRunner(backgroundUiContext, false);
1754
+
1755
+ if (this.loadingAnimation) {
1756
+ this.loadingAnimation.stop();
1757
+ this.loadingAnimation = undefined;
1758
+ }
1759
+ if (this.autoCompactionLoader) {
1760
+ this.autoCompactionLoader.stop();
1761
+ this.autoCompactionLoader = undefined;
1762
+ }
1763
+ if (this.retryLoader) {
1764
+ this.retryLoader.stop();
1765
+ this.retryLoader = undefined;
1766
+ }
1767
+ this.statusContainer.clear();
1768
+ this.statusLine.dispose();
1769
+
1770
+ if (this.unsubscribe) {
1771
+ this.unsubscribe();
1772
+ }
1773
+ this.unsubscribe = this.session.subscribe(async (event) => {
1774
+ await this.handleBackgroundEvent(event);
1775
+ });
1776
+
1777
+ // Backgrounding keeps the current process to preserve in-flight agent state.
1778
+ if (this.isInitialized) {
1779
+ this.ui.stop();
1780
+ this.isInitialized = false;
1781
+ }
1782
+
1783
+ process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n");
1784
+
1785
+ if (process.platform === "win32" || !process.stdout.isTTY) {
1786
+ process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n");
1787
+ return;
1788
+ }
1789
+
1790
+ process.kill(0, "SIGTSTP");
1791
+ }
1792
+
1793
+ private async handleBackgroundEvent(event: AgentSessionEvent): Promise<void> {
1794
+ if (event.type !== "agent_end") {
1795
+ return;
1796
+ }
1797
+ if (this.session.queuedMessageCount > 0 || this.session.isStreaming) {
1798
+ return;
1799
+ }
1800
+ this.sendCompletionNotification();
1801
+ await this.shutdown();
1802
+ }
1803
+
1489
1804
  /**
1490
1805
  * Handle Ctrl+V for image paste from clipboard.
1491
1806
  * Returns true if an image was found and added, false otherwise.
@@ -1515,6 +1830,138 @@ export class InteractiveMode {
1515
1830
  }
1516
1831
  }
1517
1832
 
1833
+ private setVoiceStatus(text: string | undefined): void {
1834
+ this.statusLine.setHookStatus("voice", text);
1835
+ this.ui.requestRender();
1836
+ }
1837
+
1838
+ private async handleVoiceInterrupt(reason?: string): Promise<void> {
1839
+ const now = Date.now();
1840
+ if (now - this.lastVoiceInterruptAt < 200) return;
1841
+ this.lastVoiceInterruptAt = now;
1842
+ if (this.session.isBashRunning) {
1843
+ this.session.abortBash();
1844
+ }
1845
+ if (this.session.isStreaming) {
1846
+ await this.session.abort();
1847
+ }
1848
+ if (reason) {
1849
+ this.showStatus(reason);
1850
+ }
1851
+ }
1852
+
1853
+ private stopVoiceProgressTimer(): void {
1854
+ if (this.voiceProgressTimer) {
1855
+ clearTimeout(this.voiceProgressTimer);
1856
+ this.voiceProgressTimer = undefined;
1857
+ }
1858
+ }
1859
+
1860
+ private startVoiceProgressTimer(): void {
1861
+ this.stopVoiceProgressTimer();
1862
+ if (!this.settingsManager.getVoiceEnabled() || !this.voiceAutoModeEnabled) return;
1863
+ this.voiceProgressSpoken = false;
1864
+ this.voiceProgressLastLength = 0;
1865
+ this.voiceProgressTimer = setTimeout(() => {
1866
+ void this.maybeSpeakProgress();
1867
+ }, VOICE_PROGRESS_DELAY_MS);
1868
+ }
1869
+
1870
+ private async maybeSpeakProgress(): Promise<void> {
1871
+ if (!this.session.isStreaming || this.voiceProgressSpoken || !this.voiceAutoModeEnabled) return;
1872
+ const streaming = this.streamingMessage;
1873
+ if (!streaming) return;
1874
+ const text = this.extractAssistantText(streaming);
1875
+ if (!text || text.length < VOICE_PROGRESS_MIN_CHARS) {
1876
+ if (this.session.isStreaming) {
1877
+ this.voiceProgressTimer = setTimeout(() => {
1878
+ void this.maybeSpeakProgress();
1879
+ }, VOICE_PROGRESS_DELAY_MS);
1880
+ }
1881
+ return;
1882
+ }
1883
+
1884
+ const delta = text.length - this.voiceProgressLastLength;
1885
+ if (delta < VOICE_PROGRESS_DELTA_CHARS) {
1886
+ if (this.session.isStreaming) {
1887
+ this.voiceProgressTimer = setTimeout(() => {
1888
+ void this.maybeSpeakProgress();
1889
+ }, VOICE_PROGRESS_DELAY_MS);
1890
+ }
1891
+ return;
1892
+ }
1893
+
1894
+ this.voiceProgressLastLength = text.length;
1895
+ this.voiceProgressSpoken = true;
1896
+ this.voiceSupervisor.notifyProgress(text);
1897
+ }
1898
+
1899
+ private async toggleVoiceListening(): Promise<void> {
1900
+ if (!this.settingsManager.getVoiceEnabled()) {
1901
+ this.settingsManager.setVoiceEnabled(true);
1902
+ this.showStatus("Voice mode enabled.");
1903
+ }
1904
+
1905
+ if (this.voiceAutoModeEnabled) {
1906
+ this.voiceAutoModeEnabled = false;
1907
+ this.stopVoiceProgressTimer();
1908
+ await this.voiceSupervisor.stop();
1909
+ this.setVoiceStatus(undefined);
1910
+ this.showStatus("Voice mode disabled.");
1911
+ return;
1912
+ }
1913
+
1914
+ this.voiceAutoModeEnabled = true;
1915
+ try {
1916
+ await this.voiceSupervisor.start();
1917
+ } catch (error) {
1918
+ this.voiceAutoModeEnabled = false;
1919
+ this.setVoiceStatus(undefined);
1920
+ this.showError(error instanceof Error ? error.message : String(error));
1921
+ }
1922
+ }
1923
+
1924
+ private async submitVoiceText(text: string): Promise<void> {
1925
+ const cleaned = text.trim();
1926
+ if (!cleaned) {
1927
+ this.showWarning("No speech detected. Try again.");
1928
+ return;
1929
+ }
1930
+ const toSend = cleaned;
1931
+ this.editor.addToHistory(toSend);
1932
+
1933
+ if (this.session.isStreaming) {
1934
+ await this.session.abort();
1935
+ await this.session.steer(toSend);
1936
+ this.updatePendingMessagesDisplay();
1937
+ return;
1938
+ }
1939
+
1940
+ if (this.onInputCallback) {
1941
+ this.onInputCallback({ text: toSend });
1942
+ }
1943
+ }
1944
+
1945
+ private findLastAssistantMessage(): AssistantMessage | undefined {
1946
+ for (let i = this.session.messages.length - 1; i >= 0; i--) {
1947
+ const message = this.session.messages[i];
1948
+ if (message?.role === "assistant") {
1949
+ return message as AssistantMessage;
1950
+ }
1951
+ }
1952
+ return undefined;
1953
+ }
1954
+
1955
+ private extractAssistantText(message: AssistantMessage): string {
1956
+ let text = "";
1957
+ for (const content of message.content) {
1958
+ if (content.type === "text") {
1959
+ text += content.text;
1960
+ }
1961
+ }
1962
+ return text.trim();
1963
+ }
1964
+
1518
1965
  private updateEditorBorderColor(): void {
1519
1966
  if (this.isBashMode) {
1520
1967
  this.editor.borderColor = theme.getBashModeBorderColor();
@@ -1561,6 +2008,25 @@ export class InteractiveMode {
1561
2008
  }
1562
2009
  }
1563
2010
 
2011
+ private async cycleRoleModel(): Promise<void> {
2012
+ try {
2013
+ const result = await this.session.cycleRoleModels(["slow", "default", "smol"]);
2014
+ if (!result) {
2015
+ this.showStatus("Only one role model available");
2016
+ return;
2017
+ }
2018
+
2019
+ this.statusLine.invalidate();
2020
+ this.updateEditorBorderColor();
2021
+ const roleLabel = result.role === "default" ? "default" : result.role;
2022
+ const thinkingStr =
2023
+ result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
2024
+ this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}`);
2025
+ } catch (error) {
2026
+ this.showError(error instanceof Error ? error.message : String(error));
2027
+ }
2028
+ }
2029
+
1564
2030
  private toggleToolOutputExpansion(): void {
1565
2031
  this.toolOutputExpanded = !this.toolOutputExpanded;
1566
2032
  for (const child of this.chatContainer.children) {
@@ -1642,18 +2108,29 @@ export class InteractiveMode {
1642
2108
  // =========================================================================
1643
2109
 
1644
2110
  clearEditor(): void {
2111
+ if (this.isBackgrounded) {
2112
+ return;
2113
+ }
1645
2114
  this.editor.setText("");
1646
2115
  this.pendingImages = [];
1647
2116
  this.ui.requestRender();
1648
2117
  }
1649
2118
 
1650
2119
  showError(errorMessage: string): void {
2120
+ if (this.isBackgrounded) {
2121
+ console.error(`Error: ${errorMessage}`);
2122
+ return;
2123
+ }
1651
2124
  this.chatContainer.addChild(new Spacer(1));
1652
2125
  this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
1653
2126
  this.ui.requestRender();
1654
2127
  }
1655
2128
 
1656
2129
  showWarning(warningMessage: string): void {
2130
+ if (this.isBackgrounded) {
2131
+ console.error(`Warning: ${warningMessage}`);
2132
+ return;
2133
+ }
1657
2134
  this.chatContainer.addChild(new Spacer(1));
1658
2135
  this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
1659
2136
  this.ui.requestRender();
@@ -1679,10 +2156,13 @@ export class InteractiveMode {
1679
2156
  private updatePendingMessagesDisplay(): void {
1680
2157
  this.pendingMessagesContainer.clear();
1681
2158
  const queuedMessages = this.session.getQueuedMessages();
1682
- if (queuedMessages.length > 0) {
2159
+ const steeringMessages = queuedMessages.steering.map((message) => ({ message, label: "Steer" }));
2160
+ const followUpMessages = queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" }));
2161
+ const allMessages = [...steeringMessages, ...followUpMessages];
2162
+ if (allMessages.length > 0) {
1683
2163
  this.pendingMessagesContainer.addChild(new Spacer(1));
1684
- for (const message of queuedMessages) {
1685
- const queuedText = theme.fg("dim", `Queued: ${message}`);
2164
+ for (const entry of allMessages) {
2165
+ const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
1686
2166
  this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
1687
2167
  }
1688
2168
  }
@@ -1803,8 +2283,11 @@ export class InteractiveMode {
1803
2283
  this.session.setAutoCompactionEnabled(value as boolean);
1804
2284
  this.statusLine.setAutoCompactEnabled(value as boolean);
1805
2285
  break;
1806
- case "queueMode":
1807
- this.session.setQueueMode(value as "all" | "one-at-a-time");
2286
+ case "steeringMode":
2287
+ this.session.setSteeringMode(value as "all" | "one-at-a-time");
2288
+ break;
2289
+ case "followUpMode":
2290
+ this.session.setFollowUpMode(value as "all" | "one-at-a-time");
1808
2291
  break;
1809
2292
  case "interruptMode":
1810
2293
  this.session.setInterruptMode(value as "immediate" | "wait");
@@ -1850,6 +2333,15 @@ export class InteractiveMode {
1850
2333
  this.ui.invalidate();
1851
2334
  break;
1852
2335
  }
2336
+ case "voiceEnabled": {
2337
+ if (!value) {
2338
+ this.voiceAutoModeEnabled = false;
2339
+ this.stopVoiceProgressTimer();
2340
+ void this.voiceSupervisor.stop();
2341
+ this.setVoiceStatus(undefined);
2342
+ }
2343
+ break;
2344
+ }
1853
2345
  case "statusLinePreset":
1854
2346
  case "statusLineSeparator":
1855
2347
  case "statusLineShowHooks":
@@ -2156,7 +2648,7 @@ export class InteractiveMode {
2156
2648
  },
2157
2649
  });
2158
2650
  // Refresh models to pick up new baseUrl (e.g., github-copilot)
2159
- this.session.modelRegistry.refresh();
2651
+ await this.session.modelRegistry.refresh();
2160
2652
  this.chatContainer.addChild(new Spacer(1));
2161
2653
  this.chatContainer.addChild(
2162
2654
  new Text(
@@ -2174,9 +2666,9 @@ export class InteractiveMode {
2174
2666
  }
2175
2667
  } else {
2176
2668
  try {
2177
- this.session.modelRegistry.authStorage.logout(providerId);
2669
+ await this.session.modelRegistry.authStorage.logout(providerId);
2178
2670
  // Refresh models to reset baseUrl
2179
- this.session.modelRegistry.refresh();
2671
+ await this.session.modelRegistry.refresh();
2180
2672
  this.chatContainer.addChild(new Spacer(1));
2181
2673
  this.chatContainer.addChild(
2182
2674
  new Text(
@@ -2208,8 +2700,17 @@ export class InteractiveMode {
2208
2700
  // =========================================================================
2209
2701
 
2210
2702
  private openInBrowser(urlOrPath: string): void {
2211
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2212
- Bun.spawn([openCmd, urlOrPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
2703
+ try {
2704
+ const args =
2705
+ process.platform === "darwin"
2706
+ ? ["open", urlOrPath]
2707
+ : process.platform === "win32"
2708
+ ? ["cmd", "/c", "start", "", urlOrPath]
2709
+ : ["xdg-open", urlOrPath];
2710
+ Bun.spawn(args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
2711
+ } catch {
2712
+ // Best-effort: browser opening is non-critical
2713
+ }
2213
2714
  }
2214
2715
 
2215
2716
  private async handleExportCommand(text: string): Promise<void> {
@@ -2378,7 +2879,7 @@ export class InteractiveMode {
2378
2879
  const stats = this.session.getSessionStats();
2379
2880
 
2380
2881
  let info = `${theme.bold("Session Info")}\n\n`;
2381
- info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`;
2882
+ info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
2382
2883
  info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
2383
2884
  info += `${theme.bold("Messages")}\n`;
2384
2885
  info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
@@ -2428,6 +2929,31 @@ export class InteractiveMode {
2428
2929
  this.ui.requestRender();
2429
2930
  }
2430
2931
 
2932
+ /**
2933
+ * Register extension-defined keyboard shortcuts with the editor.
2934
+ */
2935
+ private registerExtensionShortcuts(): void {
2936
+ const runner = this.session.extensionRunner;
2937
+ if (!runner) return;
2938
+
2939
+ const shortcuts = runner.getShortcuts();
2940
+ for (const [keyId, shortcut] of shortcuts) {
2941
+ this.editor.setCustomKeyHandler(keyId, () => {
2942
+ const ctx = runner.createCommandContext();
2943
+ try {
2944
+ shortcut.handler(ctx);
2945
+ } catch (err) {
2946
+ runner.emitError({
2947
+ extensionPath: shortcut.extensionPath,
2948
+ event: "shortcut",
2949
+ error: err instanceof Error ? err.message : String(err),
2950
+ stack: err instanceof Error ? err.stack : undefined,
2951
+ });
2952
+ }
2953
+ });
2954
+ }
2955
+ }
2956
+
2431
2957
  private handleHotkeysCommand(): void {
2432
2958
  const hotkeys = `
2433
2959
  **Navigation**
@@ -2457,6 +2983,7 @@ export class InteractiveMode {
2457
2983
  | \`Ctrl+Z\` | Suspend to background |
2458
2984
  | \`Shift+Tab\` | Cycle thinking level |
2459
2985
  | \`Ctrl+P\` | Cycle models |
2986
+ | \`Ctrl+Y\` | Cycle role models (slow/default/smol) |
2460
2987
  | \`Ctrl+O\` | Toggle tool output expansion |
2461
2988
  | \`Ctrl+T\` | Toggle thinking block visibility |
2462
2989
  | \`Ctrl+G\` | Edit message in external editor |
@@ -2543,9 +3070,9 @@ export class InteractiveMode {
2543
3070
  this.ui.requestRender();
2544
3071
  }
2545
3072
 
2546
- private async handleBashCommand(command: string): Promise<void> {
3073
+ private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
2547
3074
  const isDeferred = this.session.isStreaming;
2548
- this.bashComponent = new BashExecutionComponent(command, this.ui);
3075
+ this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
2549
3076
 
2550
3077
  if (isDeferred) {
2551
3078
  // Show in pending area when agent is streaming
@@ -2558,12 +3085,16 @@ export class InteractiveMode {
2558
3085
  this.ui.requestRender();
2559
3086
 
2560
3087
  try {
2561
- const result = await this.session.executeBash(command, (chunk) => {
2562
- if (this.bashComponent) {
2563
- this.bashComponent.appendOutput(chunk);
2564
- this.ui.requestRender();
2565
- }
2566
- });
3088
+ const result = await this.session.executeBash(
3089
+ command,
3090
+ (chunk) => {
3091
+ if (this.bashComponent) {
3092
+ this.bashComponent.appendOutput(chunk);
3093
+ this.ui.requestRender();
3094
+ }
3095
+ },
3096
+ { excludeFromContext },
3097
+ );
2567
3098
 
2568
3099
  if (this.bashComponent) {
2569
3100
  this.bashComponent.setComplete(