@oh-my-pi/pi-coding-agent 3.37.0 → 4.0.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 (70) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +193 -47
  9. package/src/core/auth-storage.ts +16 -3
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +1 -1
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
@@ -27,6 +27,7 @@ import { nanoid } from "nanoid";
27
27
  import { getAuthPath, getDebugLogPath } from "../../config";
28
28
  import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
29
29
  import type { ExtensionUIContext } from "../../core/extensions/index";
30
+ import { KeybindingsManager } from "../../core/keybindings";
30
31
  import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages";
31
32
  import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager";
32
33
  import { loadSlashCommands } from "../../core/slash-commands";
@@ -66,9 +67,11 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector
66
67
  import { WelcomeComponent } from "./components/welcome";
67
68
  import {
68
69
  getAvailableThemes,
70
+ getAvailableThemesWithPaths,
69
71
  getEditorTheme,
70
72
  getMarkdownTheme,
71
73
  getSymbolTheme,
74
+ getThemeByName,
72
75
  onThemeChange,
73
76
  setSymbolPreset,
74
77
  setTheme,
@@ -76,6 +79,20 @@ import {
76
79
  theme,
77
80
  } from "./theme/theme";
78
81
 
82
+ /** Options for creating an InteractiveMode instance (for future API use) */
83
+ export interface InteractiveModeOptions {
84
+ /** Providers that were migrated during startup */
85
+ migratedProviders?: string[];
86
+ /** Warning message if model fallback occurred */
87
+ modelFallbackMessage?: string;
88
+ /** Initial message to send */
89
+ initialMessage?: string;
90
+ /** Initial images to include with the message */
91
+ initialImages?: ImageContent[];
92
+ /** Additional initial messages to queue */
93
+ initialMessages?: string[];
94
+ }
95
+
79
96
  /** Interface for components that can be expanded/collapsed */
80
97
  interface Expandable {
81
98
  setExpanded(expanded: boolean): void;
@@ -85,6 +102,11 @@ function isExpandable(obj: unknown): obj is Expandable {
85
102
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
86
103
  }
87
104
 
105
+ type CompactionQueuedMessage = {
106
+ text: string;
107
+ mode: "steer" | "followUp";
108
+ };
109
+
88
110
  const VOICE_PROGRESS_DELAY_MS = 15000;
89
111
  const VOICE_PROGRESS_MIN_CHARS = 160;
90
112
  const VOICE_PROGRESS_DELTA_CHARS = 120;
@@ -145,6 +167,9 @@ export class InteractiveMode {
145
167
  // Track pending images from clipboard paste (attached to next message)
146
168
  private pendingImages: ImageContent[] = [];
147
169
 
170
+ // Slash commands loaded from files (for compaction queue handling)
171
+ private fileSlashCommands = new Set<string>();
172
+
148
173
  // Voice mode state
149
174
  private voiceSupervisor: VoiceSupervisor;
150
175
  private voiceAutoModeEnabled = false;
@@ -157,6 +182,9 @@ export class InteractiveMode {
157
182
  private autoCompactionLoader: Loader | undefined = undefined;
158
183
  private autoCompactionEscapeHandler?: () => void;
159
184
 
185
+ // Messages queued while compaction is running
186
+ private compactionQueuedMessages: CompactionQueuedMessage[] = [];
187
+
160
188
  // Auto-retry state
161
189
  private retryLoader: Loader | undefined = undefined;
162
190
  private retryEscapeHandler?: () => void;
@@ -185,7 +213,6 @@ export class InteractiveMode {
185
213
  private lspServers:
186
214
  | Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>
187
215
  | undefined = undefined,
188
- fdPath: string | undefined = undefined,
189
216
  ) {
190
217
  this.session = session;
191
218
  this.version = version;
@@ -250,6 +277,7 @@ export class InteractiveMode {
250
277
 
251
278
  // Load and convert file commands to SlashCommand format
252
279
  const fileCommands = loadSlashCommands({ cwd: process.cwd() });
280
+ this.fileSlashCommands = new Set(fileCommands.map((cmd) => cmd.name));
253
281
  const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({
254
282
  name: cmd.name,
255
283
  description: cmd.description,
@@ -271,7 +299,6 @@ export class InteractiveMode {
271
299
  const autocompleteProvider = new CombinedAutocompleteProvider(
272
300
  [...slashCommands, ...fileSlashCommands, ...hookCommands, ...customCommands],
273
301
  process.cwd(),
274
- fdPath,
275
302
  );
276
303
  this.editor.setAutocompleteProvider(autocompleteProvider);
277
304
  }
@@ -383,20 +410,32 @@ export class InteractiveMode {
383
410
  private async initHooksAndCustomTools(): Promise<void> {
384
411
  // Create and set hook & tool UI context
385
412
  const uiContext: ExtensionUIContext = {
386
- select: (title, options) => this.showHookSelector(title, options),
387
- confirm: (title, message) => this.showHookConfirm(title, message),
388
- input: (title, placeholder) => this.showHookInput(title, placeholder),
413
+ select: (title, options, _dialogOptions) => this.showHookSelector(title, options),
414
+ confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
415
+ input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder),
389
416
  notify: (message, type) => this.showHookNotify(message, type),
390
417
  setStatus: (key, text) => this.setHookStatus(key, text),
391
418
  setWidget: (key, content) => this.setHookWidget(key, content),
392
419
  setTitle: (title) => setTerminalTitle(title),
393
- custom: (factory) => this.showHookCustom(factory),
420
+ custom: (factory, _options) => this.showHookCustom(factory),
394
421
  setEditorText: (text) => this.editor.setText(text),
395
422
  getEditorText: () => this.editor.getText(),
396
423
  editor: (title, prefill) => this.showHookEditor(title, prefill),
397
424
  get theme() {
398
425
  return theme;
399
426
  },
427
+ getAllThemes: () => getAvailableThemesWithPaths().map((t) => ({ name: t.name, path: t.path })),
428
+ getTheme: (name) => getThemeByName(name),
429
+ setTheme: (themeArg) => {
430
+ if (typeof themeArg === "string") {
431
+ return setTheme(themeArg, true);
432
+ }
433
+ // Theme object passed directly - not supported in current implementation
434
+ return { success: false, error: "Direct theme object not supported" };
435
+ },
436
+ setFooter: () => {},
437
+ setHeader: () => {},
438
+ setEditorComponent: () => {},
400
439
  };
401
440
  this.setToolUIContext(uiContext, true);
402
441
 
@@ -405,102 +444,130 @@ export class InteractiveMode {
405
444
  return; // No hooks loaded
406
445
  }
407
446
 
408
- extensionRunner.initialize({
409
- getModel: () => this.session.model,
410
- sendMessageHandler: (message, options) => {
411
- const wasStreaming = this.session.isStreaming;
412
- this.session
413
- .sendCustomMessage(message, options)
414
- .then(() => {
415
- // For non-streaming cases with display=true, update UI
416
- // (streaming cases update via message_end event)
417
- if (!this.isBackgrounded && !wasStreaming && message.display) {
418
- this.rebuildChatFromMessages();
419
- }
420
- })
421
- .catch((err) => {
422
- this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
447
+ extensionRunner.initialize(
448
+ // ExtensionActions - for pi.* API
449
+ {
450
+ sendMessage: (message, options) => {
451
+ const wasStreaming = this.session.isStreaming;
452
+ this.session
453
+ .sendCustomMessage(message, options)
454
+ .then(() => {
455
+ // For non-streaming cases with display=true, update UI
456
+ // (streaming cases update via message_end event)
457
+ if (!this.isBackgrounded && !wasStreaming && message.display) {
458
+ this.rebuildChatFromMessages();
459
+ }
460
+ })
461
+ .catch((err) => {
462
+ this.showError(
463
+ `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
464
+ );
465
+ });
466
+ },
467
+ sendUserMessage: (content, options) => {
468
+ this.session.sendUserMessage(content, options).catch((err) => {
469
+ this.showError(
470
+ `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
471
+ );
423
472
  });
473
+ },
474
+ appendEntry: (customType, data) => {
475
+ this.sessionManager.appendCustomEntry(customType, data);
476
+ },
477
+ getActiveTools: () => this.session.getActiveToolNames(),
478
+ getAllTools: () => this.session.getAllToolNames(),
479
+ setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
480
+ setModel: async (model) => {
481
+ const key = await this.session.modelRegistry.getApiKey(model);
482
+ if (!key) return false;
483
+ await this.session.setModel(model);
484
+ return true;
485
+ },
486
+ getThinkingLevel: () => this.session.thinkingLevel,
487
+ setThinkingLevel: (level) => this.session.setThinkingLevel(level),
424
488
  },
425
- appendEntryHandler: (customType, data) => {
426
- this.sessionManager.appendCustomEntry(customType, data);
489
+ // ExtensionContextActions - for ctx.* in event handlers
490
+ {
491
+ getModel: () => this.session.model,
492
+ isIdle: () => !this.session.isStreaming,
493
+ abort: () => this.session.abort(),
494
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
495
+ shutdown: () => {
496
+ // Signal shutdown request (will be handled by main loop)
497
+ },
427
498
  },
428
- getActiveToolsHandler: () => this.session.getActiveToolNames(),
429
- getAllToolsHandler: () => this.session.getAllToolNames(),
430
- setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames),
431
- newSessionHandler: async (options) => {
432
- // Stop any loading animation
433
- if (this.loadingAnimation) {
434
- this.loadingAnimation.stop();
435
- this.loadingAnimation = undefined;
436
- }
437
- this.statusContainer.clear();
499
+ // ExtensionCommandContextActions - for ctx.* in command handlers
500
+ {
501
+ waitForIdle: () => this.session.agent.waitForIdle(),
502
+ newSession: async (options) => {
503
+ // Stop any loading animation
504
+ if (this.loadingAnimation) {
505
+ this.loadingAnimation.stop();
506
+ this.loadingAnimation = undefined;
507
+ }
508
+ this.statusContainer.clear();
438
509
 
439
- // Create new session
440
- const success = await this.session.newSession({ parentSession: options?.parentSession });
441
- if (!success) {
442
- return { cancelled: true };
443
- }
510
+ // Create new session
511
+ const success = await this.session.newSession({ parentSession: options?.parentSession });
512
+ if (!success) {
513
+ return { cancelled: true };
514
+ }
444
515
 
445
- // Call setup callback if provided
446
- if (options?.setup) {
447
- await options.setup(this.sessionManager);
448
- }
516
+ // Call setup callback if provided
517
+ if (options?.setup) {
518
+ await options.setup(this.sessionManager);
519
+ }
449
520
 
450
- // Clear UI state
451
- this.chatContainer.clear();
452
- this.pendingMessagesContainer.clear();
453
- this.streamingComponent = undefined;
454
- this.streamingMessage = undefined;
455
- this.pendingTools.clear();
521
+ // Clear UI state
522
+ this.chatContainer.clear();
523
+ this.pendingMessagesContainer.clear();
524
+ this.compactionQueuedMessages = [];
525
+ this.streamingComponent = undefined;
526
+ this.streamingMessage = undefined;
527
+ this.pendingTools.clear();
456
528
 
457
- this.chatContainer.addChild(new Spacer(1));
458
- this.chatContainer.addChild(
459
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
460
- );
461
- this.ui.requestRender();
529
+ this.chatContainer.addChild(new Spacer(1));
530
+ this.chatContainer.addChild(
531
+ new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
532
+ );
533
+ this.ui.requestRender();
462
534
 
463
- return { cancelled: false };
464
- },
465
- branchHandler: async (entryId) => {
466
- const result = await this.session.branch(entryId);
467
- if (result.cancelled) {
468
- return { cancelled: true };
469
- }
535
+ return { cancelled: false };
536
+ },
537
+ branch: async (entryId) => {
538
+ const result = await this.session.branch(entryId);
539
+ if (result.cancelled) {
540
+ return { cancelled: true };
541
+ }
470
542
 
471
- // Update UI
472
- this.chatContainer.clear();
473
- this.renderInitialMessages();
474
- this.editor.setText(result.selectedText);
475
- this.showStatus("Branched to new session");
543
+ // Update UI
544
+ this.chatContainer.clear();
545
+ this.renderInitialMessages();
546
+ this.editor.setText(result.selectedText);
547
+ this.showStatus("Branched to new session");
476
548
 
477
- return { cancelled: false };
478
- },
479
- navigateTreeHandler: async (targetId, options) => {
480
- const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
481
- if (result.cancelled) {
482
- return { cancelled: true };
483
- }
549
+ return { cancelled: false };
550
+ },
551
+ navigateTree: async (targetId, options) => {
552
+ const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
553
+ if (result.cancelled) {
554
+ return { cancelled: true };
555
+ }
484
556
 
485
- // Update UI
486
- this.chatContainer.clear();
487
- this.renderInitialMessages();
488
- if (result.editorText) {
489
- this.editor.setText(result.editorText);
490
- }
491
- this.showStatus("Navigated to selected point");
557
+ // Update UI
558
+ this.chatContainer.clear();
559
+ this.renderInitialMessages();
560
+ if (result.editorText) {
561
+ this.editor.setText(result.editorText);
562
+ }
563
+ this.showStatus("Navigated to selected point");
492
564
 
493
- return { cancelled: false };
494
- },
495
- isIdle: () => !this.session.isStreaming,
496
- waitForIdle: () => this.session.agent.waitForIdle(),
497
- abort: () => {
498
- this.session.abort();
565
+ return { cancelled: false };
566
+ },
499
567
  },
500
- hasPendingMessages: () => this.session.queuedMessageCount > 0,
568
+ // ExtensionUIContext
501
569
  uiContext,
502
- hasUI: true,
503
- });
570
+ );
504
571
 
505
572
  // Subscribe to extension errors
506
573
  extensionRunner.onError((error) => {
@@ -521,146 +588,171 @@ export class InteractiveMode {
521
588
  this.ui.requestRender();
522
589
  }
523
590
 
524
- private initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void {
591
+ private initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
525
592
  const extensionRunner = this.session.extensionRunner;
526
593
  if (!extensionRunner) {
527
594
  return;
528
595
  }
529
596
 
530
- extensionRunner.initialize({
531
- getModel: () => this.session.model,
532
- sendMessageHandler: (message, options) => {
533
- const wasStreaming = this.session.isStreaming;
534
- this.session
535
- .sendCustomMessage(message, options)
536
- .then(() => {
537
- // For non-streaming cases with display=true, update UI
538
- // (streaming cases update via message_end event)
539
- if (!this.isBackgrounded && !wasStreaming && message.display) {
540
- this.rebuildChatFromMessages();
541
- }
542
- })
543
- .catch((err: Error) => {
544
- const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
545
- if (this.isBackgrounded) {
546
- console.error(errorText);
547
- return;
548
- }
549
- this.showError(errorText);
597
+ extensionRunner.initialize(
598
+ // ExtensionActions - for pi.* API
599
+ {
600
+ sendMessage: (message, options) => {
601
+ const wasStreaming = this.session.isStreaming;
602
+ this.session
603
+ .sendCustomMessage(message, options)
604
+ .then(() => {
605
+ // For non-streaming cases with display=true, update UI
606
+ // (streaming cases update via message_end event)
607
+ if (!this.isBackgrounded && !wasStreaming && message.display) {
608
+ this.rebuildChatFromMessages();
609
+ }
610
+ })
611
+ .catch((err: Error) => {
612
+ const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`;
613
+ if (this.isBackgrounded) {
614
+ console.error(errorText);
615
+ return;
616
+ }
617
+ this.showError(errorText);
618
+ });
619
+ },
620
+ sendUserMessage: (content, options) => {
621
+ this.session.sendUserMessage(content, options).catch((err) => {
622
+ this.showError(
623
+ `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
624
+ );
550
625
  });
626
+ },
627
+ appendEntry: (customType, data) => {
628
+ this.sessionManager.appendCustomEntry(customType, data);
629
+ },
630
+ getActiveTools: () => this.session.getActiveToolNames(),
631
+ getAllTools: () => this.session.getAllToolNames(),
632
+ setActiveTools: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames),
633
+ setModel: async (model) => {
634
+ const key = await this.session.modelRegistry.getApiKey(model);
635
+ if (!key) return false;
636
+ await this.session.setModel(model);
637
+ return true;
638
+ },
639
+ getThinkingLevel: () => this.session.thinkingLevel,
640
+ setThinkingLevel: (level) => this.session.setThinkingLevel(level),
551
641
  },
552
- appendEntryHandler: (customType, data) => {
553
- this.sessionManager.appendCustomEntry(customType, data);
642
+ // ExtensionContextActions - for ctx.* in event handlers
643
+ {
644
+ getModel: () => this.session.model,
645
+ isIdle: () => !this.session.isStreaming,
646
+ abort: () => this.session.abort(),
647
+ hasPendingMessages: () => this.session.queuedMessageCount > 0,
648
+ shutdown: () => {
649
+ // Signal shutdown request (will be handled by main loop)
650
+ },
554
651
  },
555
- getActiveToolsHandler: () => this.session.getActiveToolNames(),
556
- getAllToolsHandler: () => this.session.getAllToolNames(),
557
- setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
558
- newSessionHandler: async (options) => {
559
- if (this.isBackgrounded) {
560
- return { cancelled: true };
561
- }
562
- // Stop any loading animation
563
- if (this.loadingAnimation) {
564
- this.loadingAnimation.stop();
565
- this.loadingAnimation = undefined;
566
- }
567
- this.statusContainer.clear();
652
+ // ExtensionCommandContextActions - for ctx.* in command handlers
653
+ {
654
+ waitForIdle: () => this.session.agent.waitForIdle(),
655
+ newSession: async (options) => {
656
+ if (this.isBackgrounded) {
657
+ return { cancelled: true };
658
+ }
659
+ // Stop any loading animation
660
+ if (this.loadingAnimation) {
661
+ this.loadingAnimation.stop();
662
+ this.loadingAnimation = undefined;
663
+ }
664
+ this.statusContainer.clear();
568
665
 
569
- // Create new session
570
- const success = await this.session.newSession({ parentSession: options?.parentSession });
571
- if (!success) {
572
- return { cancelled: true };
573
- }
666
+ // Create new session
667
+ const success = await this.session.newSession({ parentSession: options?.parentSession });
668
+ if (!success) {
669
+ return { cancelled: true };
670
+ }
574
671
 
575
- // Call setup callback if provided
576
- if (options?.setup) {
577
- await options.setup(this.sessionManager);
578
- }
672
+ // Call setup callback if provided
673
+ if (options?.setup) {
674
+ await options.setup(this.sessionManager);
675
+ }
579
676
 
580
- // Clear UI state
581
- this.chatContainer.clear();
582
- this.pendingMessagesContainer.clear();
583
- this.streamingComponent = undefined;
584
- this.streamingMessage = undefined;
585
- this.pendingTools.clear();
677
+ // Clear UI state
678
+ this.chatContainer.clear();
679
+ this.pendingMessagesContainer.clear();
680
+ this.compactionQueuedMessages = [];
681
+ this.streamingComponent = undefined;
682
+ this.streamingMessage = undefined;
683
+ this.pendingTools.clear();
586
684
 
587
- this.chatContainer.addChild(new Spacer(1));
588
- this.chatContainer.addChild(
589
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
590
- );
591
- this.ui.requestRender();
685
+ this.chatContainer.addChild(new Spacer(1));
686
+ this.chatContainer.addChild(
687
+ new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
688
+ );
689
+ this.ui.requestRender();
592
690
 
593
- return { cancelled: false };
594
- },
595
- branchHandler: async (entryId) => {
596
- if (this.isBackgrounded) {
597
- return { cancelled: true };
598
- }
599
- const result = await this.session.branch(entryId);
600
- if (result.cancelled) {
601
- return { cancelled: true };
602
- }
691
+ return { cancelled: false };
692
+ },
693
+ branch: async (entryId) => {
694
+ if (this.isBackgrounded) {
695
+ return { cancelled: true };
696
+ }
697
+ const result = await this.session.branch(entryId);
698
+ if (result.cancelled) {
699
+ return { cancelled: true };
700
+ }
603
701
 
604
- // Update UI
605
- this.chatContainer.clear();
606
- this.renderInitialMessages();
607
- this.editor.setText(result.selectedText);
608
- this.showStatus("Branched to new session");
702
+ // Update UI
703
+ this.chatContainer.clear();
704
+ this.renderInitialMessages();
705
+ this.editor.setText(result.selectedText);
706
+ this.showStatus("Branched to new session");
609
707
 
610
- return { cancelled: false };
611
- },
612
- navigateTreeHandler: async (targetId, options) => {
613
- if (this.isBackgrounded) {
614
- return { cancelled: true };
615
- }
616
- const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
617
- if (result.cancelled) {
618
- return { cancelled: true };
619
- }
708
+ return { cancelled: false };
709
+ },
710
+ navigateTree: async (targetId, options) => {
711
+ if (this.isBackgrounded) {
712
+ return { cancelled: true };
713
+ }
714
+ const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
715
+ if (result.cancelled) {
716
+ return { cancelled: true };
717
+ }
620
718
 
621
- // Update UI
622
- this.chatContainer.clear();
623
- this.renderInitialMessages();
624
- if (result.editorText) {
625
- this.editor.setText(result.editorText);
626
- }
627
- this.showStatus("Navigated to selected point");
719
+ // Update UI
720
+ this.chatContainer.clear();
721
+ this.renderInitialMessages();
722
+ if (result.editorText) {
723
+ this.editor.setText(result.editorText);
724
+ }
725
+ this.showStatus("Navigated to selected point");
628
726
 
629
- return { cancelled: false };
630
- },
631
- isIdle: () => !this.session.isStreaming,
632
- waitForIdle: () => this.session.agent.waitForIdle(),
633
- abort: () => {
634
- this.session.abort();
727
+ return { cancelled: false };
728
+ },
635
729
  },
636
- hasPendingMessages: () => this.session.queuedMessageCount > 0,
637
730
  uiContext,
638
- hasUI,
639
- });
731
+ );
640
732
  }
641
733
 
642
734
  private createBackgroundUiContext(): ExtensionUIContext {
643
735
  return {
644
- select: async (_title: string, _options: string[]) => undefined,
645
- confirm: async (_title: string, _message: string) => false,
646
- input: async (_title: string, _placeholder?: string) => undefined,
736
+ select: async (_title: string, _options: string[], _dialogOptions) => undefined,
737
+ confirm: async (_title: string, _message: string, _dialogOptions) => false,
738
+ input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
647
739
  notify: () => {},
648
740
  setStatus: () => {},
649
741
  setWidget: () => {},
650
742
  setTitle: () => {},
651
- custom: async <T>(
652
- _factory: (
653
- tui: TUI,
654
- theme: Theme,
655
- done: (result: T) => void,
656
- ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
657
- ) => undefined as T,
743
+ custom: async () => undefined as never,
658
744
  setEditorText: () => {},
659
745
  getEditorText: () => "",
660
746
  editor: async () => undefined,
661
747
  get theme() {
662
748
  return theme;
663
749
  },
750
+ getAllThemes: () => [],
751
+ getTheme: () => undefined,
752
+ setTheme: () => ({ success: false, error: "Background mode" }),
753
+ setFooter: () => {},
754
+ setHeader: () => {},
755
+ setEditorComponent: () => {},
664
756
  };
665
757
  }
666
758
 
@@ -692,6 +784,9 @@ export class InteractiveMode {
692
784
  abort: () => {
693
785
  this.session.abort();
694
786
  },
787
+ shutdown: () => {
788
+ // Signal shutdown request
789
+ },
695
790
  });
696
791
  } catch (err) {
697
792
  this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
@@ -861,10 +956,12 @@ export class InteractiveMode {
861
956
  factory: (
862
957
  tui: TUI,
863
958
  theme: Theme,
959
+ keybindings: KeybindingsManager,
864
960
  done: (result: T) => void,
865
961
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
866
962
  ): Promise<T> {
867
963
  const savedText = this.editor.getText();
964
+ const keybindings = KeybindingsManager.inMemory();
868
965
 
869
966
  return new Promise((resolve) => {
870
967
  let component: Component & { dispose?(): void };
@@ -879,7 +976,7 @@ export class InteractiveMode {
879
976
  resolve(result);
880
977
  };
881
978
 
882
- Promise.resolve(factory(this.ui, theme, close)).then((c) => {
979
+ Promise.resolve(factory(this.ui, theme, keybindings, close)).then((c) => {
883
980
  component = c;
884
981
  this.editorContainer.clear();
885
982
  this.editorContainer.addChild(component);
@@ -955,6 +1052,7 @@ export class InteractiveMode {
955
1052
  this.editor.onCtrlG = () => this.openExternalEditor();
956
1053
  this.editor.onQuestionMark = () => this.handleHotkeysCommand();
957
1054
  this.editor.onCtrlV = () => this.handleImagePaste();
1055
+ this.editor.onAltUp = () => this.handleDequeue();
958
1056
 
959
1057
  // Wire up extension shortcuts
960
1058
  this.registerExtensionShortcuts();
@@ -971,6 +1069,12 @@ export class InteractiveMode {
971
1069
  text = text.trim();
972
1070
  if (!text) return;
973
1071
 
1072
+ // Queue follow-up messages while compaction is running
1073
+ if (this.session.isCompacting) {
1074
+ this.queueCompactionMessage(text, "followUp");
1075
+ return;
1076
+ }
1077
+
974
1078
  // Alt+Enter queues a follow-up message (waits until agent finishes)
975
1079
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
976
1080
  if (this.session.isStreaming) {
@@ -1078,12 +1182,7 @@ export class InteractiveMode {
1078
1182
  if (text === "/compact" || text.startsWith("/compact ")) {
1079
1183
  const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
1080
1184
  this.editor.setText("");
1081
- this.editor.disableSubmit = true;
1082
- try {
1083
- await this.handleCompactCommand(customInstructions);
1084
- } finally {
1085
- this.editor.disableSubmit = false;
1086
- }
1185
+ await this.handleCompactCommand(customInstructions);
1087
1186
  return;
1088
1187
  }
1089
1188
  if (text === "/background" || text === "/bg") {
@@ -1130,8 +1229,13 @@ export class InteractiveMode {
1130
1229
  }
1131
1230
  }
1132
1231
 
1133
- // Block input during compaction
1232
+ // Queue input during compaction
1134
1233
  if (this.session.isCompacting) {
1234
+ if (this.pendingImages.length > 0) {
1235
+ this.showStatus("Compaction in progress. Retry after it completes to send images.");
1236
+ return;
1237
+ }
1238
+ this.queueCompactionMessage(text, "steer");
1135
1239
  return;
1136
1240
  }
1137
1241
 
@@ -1193,6 +1297,16 @@ export class InteractiveMode {
1193
1297
 
1194
1298
  switch (event.type) {
1195
1299
  case "agent_start":
1300
+ // Restore escape handler if retry UI is still active
1301
+ if (this.retryEscapeHandler) {
1302
+ this.editor.onEscape = this.retryEscapeHandler;
1303
+ this.retryEscapeHandler = undefined;
1304
+ }
1305
+ if (this.retryLoader) {
1306
+ this.retryLoader.stop();
1307
+ this.retryLoader = undefined;
1308
+ this.statusContainer.clear();
1309
+ }
1196
1310
  if (this.loadingAnimation) {
1197
1311
  this.loadingAnimation.stop();
1198
1312
  }
@@ -1281,10 +1395,16 @@ export class InteractiveMode {
1281
1395
  if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
1282
1396
  // Skip error handling for TTSR aborts
1283
1397
  if (!this.session.isTtsrAbortPending) {
1284
- const errorMessage =
1285
- this.streamingMessage.stopReason === "aborted"
1286
- ? "Operation aborted"
1287
- : this.streamingMessage.errorMessage || "Error";
1398
+ let errorMessage: string;
1399
+ if (this.streamingMessage.stopReason === "aborted") {
1400
+ const retryAttempt = this.session.retryAttempt;
1401
+ errorMessage =
1402
+ retryAttempt > 0
1403
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1404
+ : "Operation aborted";
1405
+ } else {
1406
+ errorMessage = this.streamingMessage.errorMessage || "Error";
1407
+ }
1288
1408
  for (const [, component] of this.pendingTools.entries()) {
1289
1409
  component.updateResult({
1290
1410
  content: [{ type: "text", text: errorMessage }],
@@ -1374,8 +1494,7 @@ export class InteractiveMode {
1374
1494
  break;
1375
1495
 
1376
1496
  case "auto_compaction_start": {
1377
- // Disable submit to preserve editor text during compaction
1378
- this.editor.disableSubmit = true;
1497
+ // Allow input during compaction; submissions are queued
1379
1498
  // Set up escape to abort auto-compaction
1380
1499
  this.autoCompactionEscapeHandler = this.editor.onEscape;
1381
1500
  this.editor.onEscape = () => {
@@ -1397,8 +1516,6 @@ export class InteractiveMode {
1397
1516
  }
1398
1517
 
1399
1518
  case "auto_compaction_end": {
1400
- // Re-enable submit
1401
- this.editor.disableSubmit = false;
1402
1519
  // Restore escape handler
1403
1520
  if (this.autoCompactionEscapeHandler) {
1404
1521
  this.editor.onEscape = this.autoCompactionEscapeHandler;
@@ -1427,6 +1544,7 @@ export class InteractiveMode {
1427
1544
  this.statusLine.invalidate();
1428
1545
  this.updateEditorTopBorder();
1429
1546
  }
1547
+ await this.flushCompactionQueue({ willRetry: event.willRetry });
1430
1548
  this.ui.requestRender();
1431
1549
  break;
1432
1550
  }
@@ -1648,8 +1766,16 @@ export class InteractiveMode {
1648
1766
  this.chatContainer.addChild(component);
1649
1767
 
1650
1768
  if (message.stopReason === "aborted" || message.stopReason === "error") {
1651
- const errorMessage =
1652
- message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
1769
+ let errorMessage: string;
1770
+ if (message.stopReason === "aborted") {
1771
+ const retryAttempt = this.session.retryAttempt;
1772
+ errorMessage =
1773
+ retryAttempt > 0
1774
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1775
+ : "Operation aborted";
1776
+ } else {
1777
+ errorMessage = message.errorMessage || "Error";
1778
+ }
1653
1779
  component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
1654
1780
  } else {
1655
1781
  this.pendingTools.set(content.id, component);
@@ -1756,6 +1882,21 @@ export class InteractiveMode {
1756
1882
  process.kill(0, "SIGTSTP");
1757
1883
  }
1758
1884
 
1885
+ /**
1886
+ * Handle Alt+Up: pop the last queued message and restore it to the editor.
1887
+ */
1888
+ private handleDequeue(): void {
1889
+ const message = this.session.popLastQueuedMessage();
1890
+ if (!message) return;
1891
+
1892
+ // Prepend to existing editor text (if any)
1893
+ const currentText = this.editor.getText();
1894
+ const newText = currentText ? `${message}\n\n${currentText}` : message;
1895
+ this.editor.setText(newText);
1896
+ this.updatePendingMessagesDisplay();
1897
+ this.ui.requestRender();
1898
+ }
1899
+
1759
1900
  private handleBackgroundCommand(): void {
1760
1901
  if (this.isBackgrounded) {
1761
1902
  this.showStatus("Background mode already enabled");
@@ -2164,8 +2305,18 @@ export class InteractiveMode {
2164
2305
  private updatePendingMessagesDisplay(): void {
2165
2306
  this.pendingMessagesContainer.clear();
2166
2307
  const queuedMessages = this.session.getQueuedMessages();
2167
- const steeringMessages = queuedMessages.steering.map((message) => ({ message, label: "Steer" }));
2168
- const followUpMessages = queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" }));
2308
+ const steeringMessages = [
2309
+ ...queuedMessages.steering.map((message) => ({ message, label: "Steer" })),
2310
+ ...this.compactionQueuedMessages
2311
+ .filter((entry) => entry.mode === "steer")
2312
+ .map((entry) => ({ message: entry.text, label: "Steer" })),
2313
+ ];
2314
+ const followUpMessages = [
2315
+ ...queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })),
2316
+ ...this.compactionQueuedMessages
2317
+ .filter((entry) => entry.mode === "followUp")
2318
+ .map((entry) => ({ message: entry.text, label: "Follow-up" })),
2319
+ ];
2169
2320
  const allMessages = [...steeringMessages, ...followUpMessages];
2170
2321
  if (allMessages.length > 0) {
2171
2322
  this.pendingMessagesContainer.addChild(new Spacer(1));
@@ -2176,6 +2327,102 @@ export class InteractiveMode {
2176
2327
  }
2177
2328
  }
2178
2329
 
2330
+ private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
2331
+ this.compactionQueuedMessages.push({ text, mode });
2332
+ this.editor.addToHistory(text);
2333
+ this.editor.setText("");
2334
+ this.updatePendingMessagesDisplay();
2335
+ this.showStatus("Queued message for after compaction");
2336
+ }
2337
+
2338
+ private isKnownSlashCommand(text: string): boolean {
2339
+ if (!text.startsWith("/")) return false;
2340
+ const spaceIndex = text.indexOf(" ");
2341
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
2342
+ if (!commandName) return false;
2343
+
2344
+ if (this.session.extensionRunner?.getCommand(commandName)) {
2345
+ return true;
2346
+ }
2347
+
2348
+ if (this.session.customCommands.some((cmd) => cmd.command.name === commandName)) {
2349
+ return true;
2350
+ }
2351
+
2352
+ return this.fileSlashCommands.has(commandName);
2353
+ }
2354
+
2355
+ private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
2356
+ if (this.compactionQueuedMessages.length === 0) {
2357
+ return;
2358
+ }
2359
+
2360
+ const queuedMessages = [...this.compactionQueuedMessages];
2361
+ this.compactionQueuedMessages = [];
2362
+ this.updatePendingMessagesDisplay();
2363
+
2364
+ const restoreQueue = (error: unknown) => {
2365
+ this.session.clearQueue();
2366
+ this.compactionQueuedMessages = queuedMessages;
2367
+ this.updatePendingMessagesDisplay();
2368
+ this.showError(
2369
+ `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${
2370
+ error instanceof Error ? error.message : String(error)
2371
+ }`,
2372
+ );
2373
+ };
2374
+
2375
+ try {
2376
+ if (options?.willRetry) {
2377
+ for (const message of queuedMessages) {
2378
+ if (this.isKnownSlashCommand(message.text)) {
2379
+ await this.session.prompt(message.text);
2380
+ } else if (message.mode === "followUp") {
2381
+ await this.session.followUp(message.text);
2382
+ } else {
2383
+ await this.session.steer(message.text);
2384
+ }
2385
+ }
2386
+ this.updatePendingMessagesDisplay();
2387
+ return;
2388
+ }
2389
+
2390
+ const firstPromptIndex = queuedMessages.findIndex((message) => !this.isKnownSlashCommand(message.text));
2391
+ if (firstPromptIndex === -1) {
2392
+ for (const message of queuedMessages) {
2393
+ await this.session.prompt(message.text);
2394
+ }
2395
+ return;
2396
+ }
2397
+
2398
+ const preCommands = queuedMessages.slice(0, firstPromptIndex);
2399
+ const firstPrompt = queuedMessages[firstPromptIndex];
2400
+ const rest = queuedMessages.slice(firstPromptIndex + 1);
2401
+
2402
+ for (const message of preCommands) {
2403
+ await this.session.prompt(message.text);
2404
+ }
2405
+
2406
+ const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => {
2407
+ restoreQueue(error);
2408
+ });
2409
+
2410
+ for (const message of rest) {
2411
+ if (this.isKnownSlashCommand(message.text)) {
2412
+ await this.session.prompt(message.text);
2413
+ } else if (message.mode === "followUp") {
2414
+ await this.session.followUp(message.text);
2415
+ } else {
2416
+ await this.session.steer(message.text);
2417
+ }
2418
+ }
2419
+ this.updatePendingMessagesDisplay();
2420
+ void promptPromise;
2421
+ } catch (error) {
2422
+ restoreQueue(error);
2423
+ }
2424
+ }
2425
+
2179
2426
  /** Move pending bash components from pending area to chat */
2180
2427
  private flushPendingBashComponents(): void {
2181
2428
  for (const component of this.pendingBashComponents) {
@@ -2597,6 +2844,7 @@ export class InteractiveMode {
2597
2844
 
2598
2845
  // Clear UI state
2599
2846
  this.pendingMessagesContainer.clear();
2847
+ this.compactionQueuedMessages = [];
2600
2848
  this.streamingComponent = undefined;
2601
2849
  this.streamingMessage = undefined;
2602
2850
  this.pendingTools.clear();
@@ -3018,6 +3266,7 @@ export class InteractiveMode {
3018
3266
  | \`Ctrl+G\` | Edit message in external editor |
3019
3267
  | \`/\` | Slash commands |
3020
3268
  | \`!\` | Run bash command |
3269
+ | \`!!\` | Run bash command (excluded from context) |
3021
3270
  `;
3022
3271
  this.chatContainer.addChild(new Spacer(1));
3023
3272
  this.chatContainer.addChild(new DynamicBorder());
@@ -3046,6 +3295,7 @@ export class InteractiveMode {
3046
3295
  // Clear UI state
3047
3296
  this.chatContainer.clear();
3048
3297
  this.pendingMessagesContainer.clear();
3298
+ this.compactionQueuedMessages = [];
3049
3299
  this.streamingComponent = undefined;
3050
3300
  this.streamingMessage = undefined;
3051
3301
  this.pendingTools.clear();
@@ -3207,6 +3457,7 @@ export class InteractiveMode {
3207
3457
  this.statusContainer.clear();
3208
3458
  this.editor.onEscape = originalOnEscape;
3209
3459
  }
3460
+ await this.flushCompactionQueue({ willRetry: false });
3210
3461
  }
3211
3462
 
3212
3463
  stop(): void {