@oh-my-pi/pi-coding-agent 3.15.1 → 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 (127) hide show
  1. package/CHANGELOG.md +51 -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-selector.ts +3 -3
  110. package/src/modes/interactive/components/model-selector.ts +7 -6
  111. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  112. package/src/modes/interactive/components/settings-defs.ts +55 -6
  113. package/src/modes/interactive/components/status-line.ts +45 -37
  114. package/src/modes/interactive/components/tool-execution.ts +95 -23
  115. package/src/modes/interactive/interactive-mode.ts +643 -113
  116. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  117. package/src/modes/print-mode.ts +14 -72
  118. package/src/modes/rpc/rpc-client.ts +23 -9
  119. package/src/modes/rpc/rpc-mode.ts +137 -125
  120. package/src/modes/rpc/rpc-types.ts +46 -24
  121. package/src/prompts/task.md +1 -0
  122. package/src/prompts/tools/gemini-image.md +4 -0
  123. package/src/prompts/tools/git.md +9 -0
  124. package/src/prompts/voice-summary.md +12 -0
  125. package/src/utils/image-convert.ts +26 -0
  126. package/src/utils/image-resize.ts +215 -0
  127. 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,7 +187,6 @@ 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();
@@ -185,6 +197,26 @@ export class InteractiveMode {
185
197
  this.editorContainer.addChild(this.editor);
186
198
  this.statusLine = new StatusLineComponent(session);
187
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
+ });
188
220
 
189
221
  // Define slash commands for autocomplete
190
222
  const slashCommands: SlashCommand[] = [
@@ -204,6 +236,8 @@ export class InteractiveMode {
204
236
  { name: "logout", description: "Logout from OAuth provider" },
205
237
  { name: "new", description: "Start a new session" },
206
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" },
207
241
  { name: "resume", description: "Resume a different session" },
208
242
  { name: "exit", description: "Exit the application" },
209
243
  ];
@@ -211,14 +245,15 @@ export class InteractiveMode {
211
245
  // Load hide thinking block setting
212
246
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
213
247
 
214
- // Convert file commands to SlashCommand format
215
- 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) => ({
216
251
  name: cmd.name,
217
252
  description: cmd.description,
218
253
  }));
219
254
 
220
255
  // Convert hook commands to SlashCommand format
221
- const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
256
+ const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
222
257
  name: cmd.name,
223
258
  description: cmd.description ?? "(hook command)",
224
259
  }));
@@ -308,6 +343,10 @@ export class InteractiveMode {
308
343
  this.ui.start();
309
344
  this.isInitialized = true;
310
345
 
346
+ // Set terminal title
347
+ const cwdBasename = path.basename(process.cwd());
348
+ this.ui.terminal.setTitle(`pi - ${cwdBasename}`);
349
+
311
350
  // Initialize hooks with TUI-based UI context
312
351
  await this.initHooksAndCustomTools();
313
352
 
@@ -340,12 +379,14 @@ export class InteractiveMode {
340
379
  */
341
380
  private async initHooksAndCustomTools(): Promise<void> {
342
381
  // Create and set hook & tool UI context
343
- const uiContext: HookUIContext = {
382
+ const uiContext: ExtensionUIContext = {
344
383
  select: (title, options) => this.showHookSelector(title, options),
345
384
  confirm: (title, message) => this.showHookConfirm(title, message),
346
385
  input: (title, placeholder) => this.showHookInput(title, placeholder),
347
386
  notify: (message, type) => this.showHookNotify(message, type),
348
387
  setStatus: (key, text) => this.setHookStatus(key, text),
388
+ setWidget: (key, content) => this.setHookWidget(key, content),
389
+ setTitle: (title) => setTerminalTitle(title),
349
390
  custom: (factory) => this.showHookCustom(factory),
350
391
  setEditorText: (text) => this.editor.setText(text),
351
392
  getEditorText: () => this.editor.getText(),
@@ -356,37 +397,34 @@ export class InteractiveMode {
356
397
  };
357
398
  this.setToolUIContext(uiContext, true);
358
399
 
359
- // Notify custom tools of session start
360
- await this.emitCustomToolSessionEvent({
361
- reason: "start",
362
- previousSessionFile: undefined,
363
- });
364
-
365
- const hookRunner = this.session.hookRunner;
366
- if (!hookRunner) {
400
+ const extensionRunner = this.session.extensionRunner;
401
+ if (!extensionRunner) {
367
402
  return; // No hooks loaded
368
403
  }
369
404
 
370
- hookRunner.initialize({
405
+ extensionRunner.initialize({
371
406
  getModel: () => this.session.model,
372
- sendMessageHandler: (message, triggerTurn) => {
407
+ sendMessageHandler: (message, options) => {
373
408
  const wasStreaming = this.session.isStreaming;
374
409
  this.session
375
- .sendHookMessage(message, triggerTurn)
410
+ .sendCustomMessage(message, options)
376
411
  .then(() => {
377
412
  // For non-streaming cases with display=true, update UI
378
413
  // (streaming cases update via message_end event)
379
- if (!wasStreaming && message.display) {
414
+ if (!this.isBackgrounded && !wasStreaming && message.display) {
380
415
  this.rebuildChatFromMessages();
381
416
  }
382
417
  })
383
418
  .catch((err) => {
384
- 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)}`);
385
420
  });
386
421
  },
387
422
  appendEntryHandler: (customType, data) => {
388
423
  this.sessionManager.appendCustomEntry(customType, data);
389
424
  },
425
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
426
+ getAllToolsHandler: () => this.session.getAllToolNames(),
427
+ setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames),
390
428
  newSessionHandler: async (options) => {
391
429
  // Stop any loading animation
392
430
  if (this.loadingAnimation) {
@@ -456,41 +494,204 @@ export class InteractiveMode {
456
494
  abort: () => {
457
495
  this.session.abort();
458
496
  },
459
- hasQueuedMessages: () => this.session.queuedMessageCount > 0,
497
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
460
498
  uiContext,
461
499
  hasUI: true,
462
500
  });
463
501
 
464
- // Subscribe to hook errors
465
- hookRunner.onError((error) => {
466
- this.showHookError(error.hookPath, error.error);
502
+ // Subscribe to extension errors
503
+ extensionRunner.onError((error) => {
504
+ this.showExtensionError(error.extensionPath, error.error);
467
505
  });
468
506
 
469
507
  // Emit session_start event
470
- await hookRunner.emit({
508
+ await extensionRunner.emit({
471
509
  type: "session_start",
472
510
  });
473
511
  }
474
512
 
475
513
  /**
476
- * 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.
477
666
  */
478
- private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise<void> {
479
- for (const { tool } of this.customTools.values()) {
480
- 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) {
481
678
  try {
482
- await tool.onSession(event, {
679
+ await registeredTool.definition.onSession(event, {
680
+ ui: uiContext,
681
+ hasUI: !this.isBackgrounded,
682
+ cwd: this.sessionManager.getCwd(),
483
683
  sessionManager: this.session.sessionManager,
484
684
  modelRegistry: this.session.modelRegistry,
485
685
  model: this.session.model,
486
686
  isIdle: () => !this.session.isStreaming,
687
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
487
688
  hasQueuedMessages: () => this.session.queuedMessageCount > 0,
488
689
  abort: () => {
489
690
  this.session.abort();
490
691
  },
491
692
  });
492
693
  } catch (err) {
493
- this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
694
+ this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
494
695
  }
495
696
  }
496
697
  }
@@ -500,6 +701,10 @@ export class InteractiveMode {
500
701
  * Show a tool error in the chat.
501
702
  */
502
703
  private showToolError(toolName: string, error: string): void {
704
+ if (this.isBackgrounded) {
705
+ console.error(`Tool "${toolName}" error: ${error}`);
706
+ return;
707
+ }
503
708
  const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
504
709
  this.chatContainer.addChild(errorText);
505
710
  this.ui.requestRender();
@@ -509,6 +714,9 @@ export class InteractiveMode {
509
714
  * Set hook status text in the footer.
510
715
  */
511
716
  private setHookStatus(key: string, text: string | undefined): void {
717
+ if (this.isBackgrounded) {
718
+ return;
719
+ }
512
720
  this.statusLine.setHookStatus(key, text);
513
721
  this.ui.requestRender();
514
722
  }
@@ -679,10 +887,10 @@ export class InteractiveMode {
679
887
  }
680
888
 
681
889
  /**
682
- * Show a hook error in the UI.
890
+ * Show an extension error in the UI.
683
891
  */
684
- private showHookError(hookPath: string, error: string): void {
685
- 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);
686
894
  this.chatContainer.addChild(errorText);
687
895
  this.ui.requestRender();
688
896
  }
@@ -700,7 +908,7 @@ export class InteractiveMode {
700
908
  if (this.loadingAnimation) {
701
909
  // Abort and restore queued messages to editor
702
910
  const queuedMessages = this.session.clearQueue();
703
- const queuedText = queuedMessages.join("\n\n");
911
+ const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
704
912
  const currentText = this.editor.getText();
705
913
  const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
706
914
  this.editor.setText(combinedText);
@@ -713,10 +921,14 @@ export class InteractiveMode {
713
921
  this.isBashMode = false;
714
922
  this.updateEditorBorderColor();
715
923
  } else if (!this.editor.getText().trim()) {
716
- // Double-escape with empty editor triggers /branch
924
+ // Double-escape with empty editor triggers /tree or /branch based on setting
717
925
  const now = Date.now();
718
926
  if (now - this.lastEscapeTime < 500) {
719
- this.showUserMessageSelector();
927
+ if (this.settingsManager.getDoubleEscapeAction() === "tree") {
928
+ this.showTreeSelector();
929
+ } else {
930
+ this.showUserMessageSelector();
931
+ }
720
932
  this.lastEscapeTime = 0;
721
933
  } else {
722
934
  this.lastEscapeTime = now;
@@ -730,6 +942,7 @@ export class InteractiveMode {
730
942
  this.editor.onShiftTab = () => this.cycleThinkingLevel();
731
943
  this.editor.onCtrlP = () => this.cycleModel("forward");
732
944
  this.editor.onShiftCtrlP = () => this.cycleModel("backward");
945
+ this.editor.onCtrlY = () => this.cycleRoleModel();
733
946
 
734
947
  // Global debug handler on TUI (works regardless of focus)
735
948
  this.ui.onDebug = () => this.handleDebugCommand();
@@ -737,9 +950,15 @@ export class InteractiveMode {
737
950
  this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
738
951
  this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
739
952
  this.editor.onCtrlG = () => this.openExternalEditor();
953
+ this.editor.onCtrlY = () => {
954
+ void this.toggleVoiceListening();
955
+ };
740
956
  this.editor.onQuestionMark = () => this.handleHotkeysCommand();
741
957
  this.editor.onCtrlV = () => this.handleImagePaste();
742
958
 
959
+ // Wire up extension shortcuts
960
+ this.registerExtensionShortcuts();
961
+
743
962
  this.editor.onChange = (text: string) => {
744
963
  const wasBashMode = this.isBashMode;
745
964
  this.isBashMode = text.trimStart().startsWith("!");
@@ -747,6 +966,25 @@ export class InteractiveMode {
747
966
  this.updateEditorBorderColor();
748
967
  }
749
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
+ };
750
988
  }
751
989
 
752
990
  private setupEditorSubmitHandler(): void {
@@ -801,7 +1039,11 @@ export class InteractiveMode {
801
1039
  return;
802
1040
  }
803
1041
  if (text === "/branch") {
804
- this.showUserMessageSelector();
1042
+ if (this.settingsManager.getDoubleEscapeAction() === "tree") {
1043
+ this.showTreeSelector();
1044
+ } else {
1045
+ this.showUserMessageSelector();
1046
+ }
805
1047
  this.editor.setText("");
806
1048
  return;
807
1049
  }
@@ -836,6 +1078,11 @@ export class InteractiveMode {
836
1078
  }
837
1079
  return;
838
1080
  }
1081
+ if (text === "/background" || text === "/bg") {
1082
+ this.editor.setText("");
1083
+ this.handleBackgroundCommand();
1084
+ return;
1085
+ }
839
1086
  if (text === "/debug") {
840
1087
  this.handleDebugCommand();
841
1088
  this.editor.setText("");
@@ -857,9 +1104,10 @@ export class InteractiveMode {
857
1104
  return;
858
1105
  }
859
1106
 
860
- // Handle bash command
1107
+ // Handle bash command (! for normal, !! for excluded from context)
861
1108
  if (text.startsWith("!")) {
862
- const command = text.slice(1).trim();
1109
+ const isExcluded = text.startsWith("!!");
1110
+ const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
863
1111
  if (command) {
864
1112
  if (this.session.isBashRunning) {
865
1113
  this.showWarning("A bash command is already running. Press Esc to cancel it first.");
@@ -867,7 +1115,7 @@ export class InteractiveMode {
867
1115
  return;
868
1116
  }
869
1117
  this.editor.addToHistory(text);
870
- await this.handleBashCommand(command);
1118
+ await this.handleBashCommand(command, isExcluded);
871
1119
  this.isBashMode = false;
872
1120
  this.updateEditorBorderColor();
873
1121
  return;
@@ -879,39 +1127,13 @@ export class InteractiveMode {
879
1127
  return;
880
1128
  }
881
1129
 
882
- // Hook commands always run immediately, even during streaming
883
- // (if they need to interact with LLM, they use pi.sendMessage which handles queueing)
884
- if (text.startsWith("/") && this.session.hookRunner) {
885
- const spaceIndex = text.indexOf(" ");
886
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
887
- const command = this.session.hookRunner.getCommand(commandName);
888
- if (command) {
889
- this.editor.addToHistory(text);
890
- this.editor.setText("");
891
- await this.session.prompt(text);
892
- return;
893
- }
894
- }
895
-
896
- // Custom commands (TypeScript slash commands) - route through session.prompt()
897
- if (text.startsWith("/") && this.session.customCommands.length > 0) {
898
- const spaceIndex = text.indexOf(" ");
899
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
900
- const hasCustomCommand = this.session.customCommands.some((c) => c.command.name === commandName);
901
- if (hasCustomCommand) {
902
- this.editor.addToHistory(text);
903
- this.editor.setText("");
904
- await this.session.prompt(text);
905
- return;
906
- }
907
- }
908
-
909
- // 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
910
1132
  if (this.session.isStreaming) {
911
- await this.session.queueMessage(text);
912
- this.updatePendingMessagesDisplay();
913
1133
  this.editor.addToHistory(text);
914
1134
  this.editor.setText("");
1135
+ await this.session.prompt(text, { streamingBehavior: "steer" });
1136
+ this.updatePendingMessagesDisplay();
915
1137
  this.ui.requestRender();
916
1138
  return;
917
1139
  }
@@ -973,11 +1195,12 @@ export class InteractiveMode {
973
1195
  getSymbolTheme().spinnerFrames,
974
1196
  );
975
1197
  this.statusContainer.addChild(this.loadingAnimation);
1198
+ this.startVoiceProgressTimer();
976
1199
  this.ui.requestRender();
977
1200
  break;
978
1201
 
979
1202
  case "message_start":
980
- if (event.message.role === "hookMessage") {
1203
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
981
1204
  this.addMessageToChat(event.message);
982
1205
  this.ui.requestRender();
983
1206
  } else if (event.message.role === "user") {
@@ -1003,14 +1226,16 @@ export class InteractiveMode {
1003
1226
  if (content.type === "toolCall") {
1004
1227
  if (!this.pendingTools.has(content.id)) {
1005
1228
  this.chatContainer.addChild(new Text("", 0, 0));
1229
+ const tool = this.session.getToolByName(content.name);
1006
1230
  const component = new ToolExecutionComponent(
1007
1231
  content.name,
1008
1232
  content.arguments,
1009
1233
  {
1010
1234
  showImages: this.settingsManager.getShowImages(),
1011
1235
  },
1012
- this.customTools.get(content.name)?.tool,
1236
+ tool,
1013
1237
  this.ui,
1238
+ this.sessionManager.getCwd(),
1014
1239
  );
1015
1240
  component.setExpanded(this.toolOutputExpanded);
1016
1241
  this.chatContainer.addChild(component);
@@ -1071,14 +1296,16 @@ export class InteractiveMode {
1071
1296
 
1072
1297
  case "tool_execution_start": {
1073
1298
  if (!this.pendingTools.has(event.toolCallId)) {
1299
+ const tool = this.session.getToolByName(event.toolName);
1074
1300
  const component = new ToolExecutionComponent(
1075
1301
  event.toolName,
1076
1302
  event.args,
1077
1303
  {
1078
1304
  showImages: this.settingsManager.getShowImages(),
1079
1305
  },
1080
- this.customTools.get(event.toolName)?.tool,
1306
+ tool,
1081
1307
  this.ui,
1308
+ this.sessionManager.getCwd(),
1082
1309
  );
1083
1310
  component.setExpanded(this.toolOutputExpanded);
1084
1311
  this.chatContainer.addChild(component);
@@ -1108,6 +1335,7 @@ export class InteractiveMode {
1108
1335
  }
1109
1336
 
1110
1337
  case "agent_end":
1338
+ this.stopVoiceProgressTimer();
1111
1339
  if (this.loadingAnimation) {
1112
1340
  this.loadingAnimation.stop();
1113
1341
  this.loadingAnimation = undefined;
@@ -1119,7 +1347,17 @@ export class InteractiveMode {
1119
1347
  this.streamingMessage = undefined;
1120
1348
  }
1121
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
+ }
1122
1359
  this.ui.requestRender();
1360
+ this.sendCompletionNotification();
1123
1361
  break;
1124
1362
 
1125
1363
  case "auto_compaction_start": {
@@ -1232,6 +1470,14 @@ export class InteractiveMode {
1232
1470
  }
1233
1471
  }
1234
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
+
1235
1481
  /** Extract text content from a user message */
1236
1482
  private getUserMessageText(message: Message): string {
1237
1483
  if (message.role !== "user") return "";
@@ -1249,6 +1495,9 @@ export class InteractiveMode {
1249
1495
  * we update the previous status line instead of appending new ones to avoid log spam.
1250
1496
  */
1251
1497
  private showStatus(message: string): void {
1498
+ if (this.isBackgrounded) {
1499
+ return;
1500
+ }
1252
1501
  const children = this.chatContainer.children;
1253
1502
  const last = children.length > 0 ? children[children.length - 1] : undefined;
1254
1503
  const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
@@ -1271,7 +1520,7 @@ export class InteractiveMode {
1271
1520
  private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
1272
1521
  switch (message.role) {
1273
1522
  case "bashExecution": {
1274
- const component = new BashExecutionComponent(message.command, this.ui);
1523
+ const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);
1275
1524
  if (message.output) {
1276
1525
  component.appendOutput(message.output);
1277
1526
  }
@@ -1284,10 +1533,12 @@ export class InteractiveMode {
1284
1533
  this.chatContainer.addChild(component);
1285
1534
  break;
1286
1535
  }
1287
- case "hookMessage": {
1536
+ case "hookMessage":
1537
+ case "custom": {
1288
1538
  if (message.display) {
1289
- const renderer = this.session.hookRunner?.getMessageRenderer(message.customType);
1290
- 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));
1291
1542
  }
1292
1543
  break;
1293
1544
  }
@@ -1363,12 +1614,14 @@ export class InteractiveMode {
1363
1614
  // Render tool call components
1364
1615
  for (const content of message.content) {
1365
1616
  if (content.type === "toolCall") {
1617
+ const tool = this.session.getToolByName(content.name);
1366
1618
  const component = new ToolExecutionComponent(
1367
1619
  content.name,
1368
1620
  content.arguments,
1369
1621
  { showImages: this.settingsManager.getShowImages() },
1370
- this.customTools.get(content.name)?.tool,
1622
+ tool,
1371
1623
  this.ui,
1624
+ this.sessionManager.getCwd(),
1372
1625
  );
1373
1626
  component.setExpanded(this.toolOutputExpanded);
1374
1627
  this.chatContainer.addChild(component);
@@ -1455,18 +1708,13 @@ export class InteractiveMode {
1455
1708
  * Emits shutdown event to hooks and tools, then exits.
1456
1709
  */
1457
1710
  private async shutdown(): Promise<void> {
1711
+ this.voiceAutoModeEnabled = false;
1712
+ await this.voiceSupervisor.stop();
1713
+
1458
1714
  // Flush pending session writes before shutdown
1459
1715
  await this.sessionManager.flush();
1460
1716
 
1461
1717
  // Emit shutdown event to hooks
1462
- const hookRunner = this.session.hookRunner;
1463
- if (hookRunner?.hasHandlers("session_shutdown")) {
1464
- await hookRunner.emit({
1465
- type: "session_shutdown",
1466
- });
1467
- }
1468
-
1469
- // Emit shutdown event to custom tools
1470
1718
  await this.session.emitCustomToolSessionEvent("shutdown");
1471
1719
 
1472
1720
  this.stop();
@@ -1487,6 +1735,72 @@ export class InteractiveMode {
1487
1735
  process.kill(0, "SIGTSTP");
1488
1736
  }
1489
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
+
1490
1804
  /**
1491
1805
  * Handle Ctrl+V for image paste from clipboard.
1492
1806
  * Returns true if an image was found and added, false otherwise.
@@ -1516,6 +1830,138 @@ export class InteractiveMode {
1516
1830
  }
1517
1831
  }
1518
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
+
1519
1965
  private updateEditorBorderColor(): void {
1520
1966
  if (this.isBashMode) {
1521
1967
  this.editor.borderColor = theme.getBashModeBorderColor();
@@ -1562,6 +2008,25 @@ export class InteractiveMode {
1562
2008
  }
1563
2009
  }
1564
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
+
1565
2030
  private toggleToolOutputExpansion(): void {
1566
2031
  this.toolOutputExpanded = !this.toolOutputExpanded;
1567
2032
  for (const child of this.chatContainer.children) {
@@ -1643,18 +2108,29 @@ export class InteractiveMode {
1643
2108
  // =========================================================================
1644
2109
 
1645
2110
  clearEditor(): void {
2111
+ if (this.isBackgrounded) {
2112
+ return;
2113
+ }
1646
2114
  this.editor.setText("");
1647
2115
  this.pendingImages = [];
1648
2116
  this.ui.requestRender();
1649
2117
  }
1650
2118
 
1651
2119
  showError(errorMessage: string): void {
2120
+ if (this.isBackgrounded) {
2121
+ console.error(`Error: ${errorMessage}`);
2122
+ return;
2123
+ }
1652
2124
  this.chatContainer.addChild(new Spacer(1));
1653
2125
  this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
1654
2126
  this.ui.requestRender();
1655
2127
  }
1656
2128
 
1657
2129
  showWarning(warningMessage: string): void {
2130
+ if (this.isBackgrounded) {
2131
+ console.error(`Warning: ${warningMessage}`);
2132
+ return;
2133
+ }
1658
2134
  this.chatContainer.addChild(new Spacer(1));
1659
2135
  this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
1660
2136
  this.ui.requestRender();
@@ -1680,10 +2156,13 @@ export class InteractiveMode {
1680
2156
  private updatePendingMessagesDisplay(): void {
1681
2157
  this.pendingMessagesContainer.clear();
1682
2158
  const queuedMessages = this.session.getQueuedMessages();
1683
- 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) {
1684
2163
  this.pendingMessagesContainer.addChild(new Spacer(1));
1685
- for (const message of queuedMessages) {
1686
- const queuedText = theme.fg("dim", `Queued: ${message}`);
2164
+ for (const entry of allMessages) {
2165
+ const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
1687
2166
  this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
1688
2167
  }
1689
2168
  }
@@ -1804,8 +2283,11 @@ export class InteractiveMode {
1804
2283
  this.session.setAutoCompactionEnabled(value as boolean);
1805
2284
  this.statusLine.setAutoCompactEnabled(value as boolean);
1806
2285
  break;
1807
- case "queueMode":
1808
- 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");
1809
2291
  break;
1810
2292
  case "interruptMode":
1811
2293
  this.session.setInterruptMode(value as "immediate" | "wait");
@@ -1851,6 +2333,15 @@ export class InteractiveMode {
1851
2333
  this.ui.invalidate();
1852
2334
  break;
1853
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
+ }
1854
2345
  case "statusLinePreset":
1855
2346
  case "statusLineSeparator":
1856
2347
  case "statusLineShowHooks":
@@ -2157,7 +2648,7 @@ export class InteractiveMode {
2157
2648
  },
2158
2649
  });
2159
2650
  // Refresh models to pick up new baseUrl (e.g., github-copilot)
2160
- this.session.modelRegistry.refresh();
2651
+ await this.session.modelRegistry.refresh();
2161
2652
  this.chatContainer.addChild(new Spacer(1));
2162
2653
  this.chatContainer.addChild(
2163
2654
  new Text(
@@ -2175,9 +2666,9 @@ export class InteractiveMode {
2175
2666
  }
2176
2667
  } else {
2177
2668
  try {
2178
- this.session.modelRegistry.authStorage.logout(providerId);
2669
+ await this.session.modelRegistry.authStorage.logout(providerId);
2179
2670
  // Refresh models to reset baseUrl
2180
- this.session.modelRegistry.refresh();
2671
+ await this.session.modelRegistry.refresh();
2181
2672
  this.chatContainer.addChild(new Spacer(1));
2182
2673
  this.chatContainer.addChild(
2183
2674
  new Text(
@@ -2209,8 +2700,17 @@ export class InteractiveMode {
2209
2700
  // =========================================================================
2210
2701
 
2211
2702
  private openInBrowser(urlOrPath: string): void {
2212
- const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2213
- 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
+ }
2214
2714
  }
2215
2715
 
2216
2716
  private async handleExportCommand(text: string): Promise<void> {
@@ -2379,7 +2879,7 @@ export class InteractiveMode {
2379
2879
  const stats = this.session.getSessionStats();
2380
2880
 
2381
2881
  let info = `${theme.bold("Session Info")}\n\n`;
2382
- info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`;
2882
+ info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
2383
2883
  info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
2384
2884
  info += `${theme.bold("Messages")}\n`;
2385
2885
  info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
@@ -2429,6 +2929,31 @@ export class InteractiveMode {
2429
2929
  this.ui.requestRender();
2430
2930
  }
2431
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
+
2432
2957
  private handleHotkeysCommand(): void {
2433
2958
  const hotkeys = `
2434
2959
  **Navigation**
@@ -2458,6 +2983,7 @@ export class InteractiveMode {
2458
2983
  | \`Ctrl+Z\` | Suspend to background |
2459
2984
  | \`Shift+Tab\` | Cycle thinking level |
2460
2985
  | \`Ctrl+P\` | Cycle models |
2986
+ | \`Ctrl+Y\` | Cycle role models (slow/default/smol) |
2461
2987
  | \`Ctrl+O\` | Toggle tool output expansion |
2462
2988
  | \`Ctrl+T\` | Toggle thinking block visibility |
2463
2989
  | \`Ctrl+G\` | Edit message in external editor |
@@ -2544,9 +3070,9 @@ export class InteractiveMode {
2544
3070
  this.ui.requestRender();
2545
3071
  }
2546
3072
 
2547
- private async handleBashCommand(command: string): Promise<void> {
3073
+ private async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
2548
3074
  const isDeferred = this.session.isStreaming;
2549
- this.bashComponent = new BashExecutionComponent(command, this.ui);
3075
+ this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
2550
3076
 
2551
3077
  if (isDeferred) {
2552
3078
  // Show in pending area when agent is streaming
@@ -2559,12 +3085,16 @@ export class InteractiveMode {
2559
3085
  this.ui.requestRender();
2560
3086
 
2561
3087
  try {
2562
- const result = await this.session.executeBash(command, (chunk) => {
2563
- if (this.bashComponent) {
2564
- this.bashComponent.appendOutput(chunk);
2565
- this.ui.requestRender();
2566
- }
2567
- });
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
+ );
2568
3098
 
2569
3099
  if (this.bashComponent) {
2570
3100
  this.bashComponent.setComplete(