@mariozechner/pi-coding-agent 0.37.8 → 0.38.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 (102) hide show
  1. package/CHANGELOG.md +84 -4
  2. package/README.md +10 -0
  3. package/dist/cli/args.d.ts +1 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +4 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +13 -1
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/extensions/index.d.ts +3 -3
  11. package/dist/core/extensions/index.d.ts.map +1 -1
  12. package/dist/core/extensions/index.js +1 -1
  13. package/dist/core/extensions/index.js.map +1 -1
  14. package/dist/core/extensions/loader.d.ts +8 -6
  15. package/dist/core/extensions/loader.d.ts.map +1 -1
  16. package/dist/core/extensions/loader.js +94 -211
  17. package/dist/core/extensions/loader.js.map +1 -1
  18. package/dist/core/extensions/runner.d.ts +24 -28
  19. package/dist/core/extensions/runner.d.ts.map +1 -1
  20. package/dist/core/extensions/runner.js +58 -38
  21. package/dist/core/extensions/runner.js.map +1 -1
  22. package/dist/core/extensions/types.d.ts +116 -27
  23. package/dist/core/extensions/types.d.ts.map +1 -1
  24. package/dist/core/extensions/types.js.map +1 -1
  25. package/dist/core/extensions/wrapper.d.ts +5 -3
  26. package/dist/core/extensions/wrapper.d.ts.map +1 -1
  27. package/dist/core/extensions/wrapper.js +6 -4
  28. package/dist/core/extensions/wrapper.js.map +1 -1
  29. package/dist/core/index.d.ts +1 -1
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js.map +1 -1
  32. package/dist/core/model-resolver.d.ts +4 -2
  33. package/dist/core/model-resolver.d.ts.map +1 -1
  34. package/dist/core/model-resolver.js +8 -9
  35. package/dist/core/model-resolver.js.map +1 -1
  36. package/dist/core/sdk.d.ts +3 -3
  37. package/dist/core/sdk.d.ts.map +1 -1
  38. package/dist/core/sdk.js +19 -75
  39. package/dist/core/sdk.js.map +1 -1
  40. package/dist/core/settings-manager.d.ts +8 -0
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +9 -1
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/main.d.ts.map +1 -1
  49. package/dist/main.js +47 -115
  50. package/dist/main.js.map +1 -1
  51. package/dist/modes/index.d.ts +2 -2
  52. package/dist/modes/index.d.ts.map +1 -1
  53. package/dist/modes/index.js.map +1 -1
  54. package/dist/modes/interactive/components/countdown-timer.d.ts +14 -0
  55. package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -0
  56. package/dist/modes/interactive/components/countdown-timer.js +33 -0
  57. package/dist/modes/interactive/components/countdown-timer.js.map +1 -0
  58. package/dist/modes/interactive/components/custom-editor.d.ts +1 -1
  59. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  60. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  61. package/dist/modes/interactive/components/extension-input.d.ts +10 -2
  62. package/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  63. package/dist/modes/interactive/components/extension-input.js +18 -14
  64. package/dist/modes/interactive/components/extension-input.js.map +1 -1
  65. package/dist/modes/interactive/components/extension-selector.d.ts +10 -2
  66. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  67. package/dist/modes/interactive/components/extension-selector.js +18 -22
  68. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  69. package/dist/modes/interactive/interactive-mode.d.ts +44 -3
  70. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  71. package/dist/modes/interactive/interactive-mode.js +289 -95
  72. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  73. package/dist/modes/print-mode.d.ts +14 -7
  74. package/dist/modes/print-mode.d.ts.map +1 -1
  75. package/dist/modes/print-mode.js +45 -21
  76. package/dist/modes/print-mode.js.map +1 -1
  77. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  78. package/dist/modes/rpc/rpc-mode.js +101 -101
  79. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  80. package/dist/modes/rpc/rpc-types.d.ts +3 -0
  81. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  82. package/dist/modes/rpc/rpc-types.js.map +1 -1
  83. package/dist/utils/clipboard-image.d.ts.map +1 -1
  84. package/dist/utils/clipboard-image.js +1 -1
  85. package/dist/utils/clipboard-image.js.map +1 -1
  86. package/docs/extensions.md +110 -9
  87. package/docs/sdk.md +65 -6
  88. package/docs/tui.md +81 -4
  89. package/examples/extensions/README.md +1 -0
  90. package/examples/extensions/handoff.ts +1 -1
  91. package/examples/extensions/modal-editor.ts +85 -0
  92. package/examples/extensions/preset.ts +1 -1
  93. package/examples/extensions/qna.ts +1 -1
  94. package/examples/extensions/rainbow-editor.ts +95 -0
  95. package/examples/extensions/shutdown-command.ts +63 -0
  96. package/examples/extensions/snake.ts +1 -1
  97. package/examples/extensions/timed-confirm.ts +32 -25
  98. package/examples/extensions/todo.ts +1 -1
  99. package/examples/extensions/tools.ts +1 -1
  100. package/examples/extensions/with-deps/package-lock.json +2 -2
  101. package/examples/extensions/with-deps/package.json +1 -1
  102. package/package.json +5 -5
@@ -6,18 +6,20 @@ import * as crypto from "node:crypto";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { getOAuthProviders } from "@mariozechner/pi-ai";
9
+ import { getOAuthProviders, } from "@mariozechner/pi-ai";
10
10
  import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
11
11
  import { spawn, spawnSync } from "child_process";
12
- import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
12
+ import { APP_NAME, getAuthPath, getDebugLogPath, VERSION } from "../../config.js";
13
13
  import { KeybindingsManager } from "../../core/keybindings.js";
14
14
  import { createCompactionSummaryMessage } from "../../core/messages.js";
15
15
  import { SessionManager } from "../../core/session-manager.js";
16
16
  import { loadSkills } from "../../core/skills.js";
17
17
  import { loadProjectContextFiles } from "../../core/system-prompt.js";
18
- import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
18
+ import { allTools } from "../../core/tools/index.js";
19
+ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
19
20
  import { copyToClipboard } from "../../utils/clipboard.js";
20
21
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
22
+ import { ensureTool } from "../../utils/tools-manager.js";
21
23
  import { ArminComponent } from "./components/armin.js";
22
24
  import { AssistantMessageComponent } from "./components/assistant-message.js";
23
25
  import { BashExecutionComponent } from "./components/bash-execution.js";
@@ -45,13 +47,15 @@ function isExpandable(obj) {
45
47
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
46
48
  }
47
49
  export class InteractiveMode {
48
- setExtensionUIContext;
50
+ options;
49
51
  session;
50
52
  ui;
51
53
  chatContainer;
52
54
  pendingMessagesContainer;
53
55
  statusContainer;
56
+ defaultEditor;
54
57
  editor;
58
+ autocompleteProvider;
55
59
  editorContainer;
56
60
  footer;
57
61
  keybindings;
@@ -90,6 +94,8 @@ export class InteractiveMode {
90
94
  retryEscapeHandler;
91
95
  // Messages queued while compaction is running
92
96
  compactionQueuedMessages = [];
97
+ // Shutdown state
98
+ shutdownRequested = false;
93
99
  // Extension UI state
94
100
  extensionSelector = undefined;
95
101
  extensionInput = undefined;
@@ -113,22 +119,26 @@ export class InteractiveMode {
113
119
  get settingsManager() {
114
120
  return this.session.settingsManager;
115
121
  }
116
- constructor(session, version, changelogMarkdown = undefined, _extensions = [], setExtensionUIContext = () => { }, fdPath = undefined) {
117
- this.setExtensionUIContext = setExtensionUIContext;
122
+ constructor(session, options = {}) {
123
+ this.options = options;
118
124
  this.session = session;
119
- this.version = version;
120
- this.changelogMarkdown = changelogMarkdown;
125
+ this.version = VERSION;
121
126
  this.ui = new TUI(new ProcessTerminal());
122
127
  this.chatContainer = new Container();
123
128
  this.pendingMessagesContainer = new Container();
124
129
  this.statusContainer = new Container();
125
130
  this.widgetContainer = new Container();
126
131
  this.keybindings = KeybindingsManager.create();
127
- this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
132
+ this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);
133
+ this.editor = this.defaultEditor;
128
134
  this.editorContainer = new Container();
129
135
  this.editorContainer.addChild(this.editor);
130
136
  this.footer = new FooterComponent(session);
131
137
  this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
138
+ // Load hide thinking block setting
139
+ this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
140
+ }
141
+ setupAutocomplete(fdPath) {
132
142
  // Define commands for autocomplete
133
143
  const slashCommands = [
134
144
  { name: "settings", description: "Open settings menu" },
@@ -147,8 +157,6 @@ export class InteractiveMode {
147
157
  { name: "compact", description: "Manually compact the session context" },
148
158
  { name: "resume", description: "Resume a different session" },
149
159
  ];
150
- // Load hide thinking block setting
151
- this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
152
160
  // Convert prompt templates to SlashCommand format for autocomplete
153
161
  const templateCommands = this.session.promptTemplates.map((cmd) => ({
154
162
  name: cmd.name,
@@ -160,12 +168,17 @@ export class InteractiveMode {
160
168
  description: cmd.description ?? "(extension command)",
161
169
  }));
162
170
  // Setup autocomplete
163
- const autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath);
164
- this.editor.setAutocompleteProvider(autocompleteProvider);
171
+ this.autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath);
172
+ this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
165
173
  }
166
174
  async init() {
167
175
  if (this.isInitialized)
168
176
  return;
177
+ // Load changelog (only show new entries, skip for resumed sessions)
178
+ this.changelogMarkdown = this.getChangelogForDisplay();
179
+ // Setup autocomplete with fd tool for file path completion
180
+ const fdPath = await ensureTool("fd");
181
+ this.setupAutocomplete(fdPath);
169
182
  // Add header with keybindings from config
170
183
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
171
184
  // Format keybinding for startup display (lowercase, compact)
@@ -293,6 +306,112 @@ export class InteractiveMode {
293
306
  this.ui.requestRender();
294
307
  });
295
308
  }
309
+ /**
310
+ * Run the interactive mode. This is the main entry point.
311
+ * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.
312
+ */
313
+ async run() {
314
+ await this.init();
315
+ // Start version check asynchronously
316
+ this.checkForNewVersion().then((newVersion) => {
317
+ if (newVersion) {
318
+ this.showNewVersionNotification(newVersion);
319
+ }
320
+ });
321
+ this.renderInitialMessages();
322
+ // Show startup warnings
323
+ const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
324
+ if (migratedProviders && migratedProviders.length > 0) {
325
+ this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
326
+ }
327
+ const modelsJsonError = this.session.modelRegistry.getError();
328
+ if (modelsJsonError) {
329
+ this.showError(`models.json error: ${modelsJsonError}`);
330
+ }
331
+ if (modelFallbackMessage) {
332
+ this.showWarning(modelFallbackMessage);
333
+ }
334
+ // Process initial messages
335
+ if (initialMessage) {
336
+ try {
337
+ await this.session.prompt(initialMessage, { images: initialImages });
338
+ }
339
+ catch (error) {
340
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
341
+ this.showError(errorMessage);
342
+ }
343
+ }
344
+ if (initialMessages) {
345
+ for (const message of initialMessages) {
346
+ try {
347
+ await this.session.prompt(message);
348
+ }
349
+ catch (error) {
350
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
351
+ this.showError(errorMessage);
352
+ }
353
+ }
354
+ }
355
+ // Main interactive loop
356
+ while (true) {
357
+ const userInput = await this.getUserInput();
358
+ try {
359
+ await this.session.prompt(userInput);
360
+ }
361
+ catch (error) {
362
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
363
+ this.showError(errorMessage);
364
+ }
365
+ }
366
+ }
367
+ /**
368
+ * Check npm registry for a newer version.
369
+ */
370
+ async checkForNewVersion() {
371
+ if (process.env.PI_SKIP_VERSION_CHECK)
372
+ return undefined;
373
+ try {
374
+ const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
375
+ if (!response.ok)
376
+ return undefined;
377
+ const data = (await response.json());
378
+ const latestVersion = data.version;
379
+ if (latestVersion && latestVersion !== this.version) {
380
+ return latestVersion;
381
+ }
382
+ return undefined;
383
+ }
384
+ catch {
385
+ return undefined;
386
+ }
387
+ }
388
+ /**
389
+ * Get changelog entries to display on startup.
390
+ * Only shows new entries since last seen version, skips for resumed sessions.
391
+ */
392
+ getChangelogForDisplay() {
393
+ // Skip changelog for resumed/continued sessions (already have messages)
394
+ if (this.session.state.messages.length > 0) {
395
+ return undefined;
396
+ }
397
+ const lastVersion = this.settingsManager.getLastChangelogVersion();
398
+ const changelogPath = getChangelogPath();
399
+ const entries = parseChangelog(changelogPath);
400
+ if (!lastVersion) {
401
+ if (entries.length > 0) {
402
+ this.settingsManager.setLastChangelogVersion(VERSION);
403
+ return entries.map((e) => e.content).join("\n\n");
404
+ }
405
+ }
406
+ else {
407
+ const newEntries = getNewEntries(entries, lastVersion);
408
+ if (newEntries.length > 0) {
409
+ this.settingsManager.setLastChangelogVersion(VERSION);
410
+ return newEntries.map((e) => e.content).join("\n\n");
411
+ }
412
+ }
413
+ return undefined;
414
+ }
296
415
  // =========================================================================
297
416
  // Extension System
298
417
  // =========================================================================
@@ -325,22 +444,20 @@ export class InteractiveMode {
325
444
  this.chatContainer.addChild(new Spacer(1));
326
445
  }
327
446
  }
328
- // Create and set extension UI context
329
- const uiContext = this.createExtensionUIContext();
330
- this.setExtensionUIContext(uiContext, true);
331
447
  const extensionRunner = this.session.extensionRunner;
332
448
  if (!extensionRunner) {
333
449
  return; // No extensions loaded
334
450
  }
335
- extensionRunner.initialize({
336
- getModel: () => this.session.model,
337
- sendMessageHandler: (message, options) => {
451
+ // Create extension UI context
452
+ const uiContext = this.createExtensionUIContext();
453
+ extensionRunner.initialize(
454
+ // ExtensionActions - for pi.* API
455
+ {
456
+ sendMessage: (message, options) => {
338
457
  const wasStreaming = this.session.isStreaming;
339
458
  this.session
340
459
  .sendCustomMessage(message, options)
341
460
  .then(() => {
342
- // For non-streaming cases with display=true, update UI
343
- // (streaming cases update via message_end event)
344
461
  if (!wasStreaming && message.display) {
345
462
  this.rebuildChatFromMessages();
346
463
  }
@@ -349,34 +466,53 @@ export class InteractiveMode {
349
466
  this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
350
467
  });
351
468
  },
352
- sendUserMessageHandler: (content, options) => {
469
+ sendUserMessage: (content, options) => {
353
470
  this.session.sendUserMessage(content, options).catch((err) => {
354
471
  this.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
355
472
  });
356
473
  },
357
- appendEntryHandler: (customType, data) => {
474
+ appendEntry: (customType, data) => {
358
475
  this.sessionManager.appendCustomEntry(customType, data);
359
476
  },
360
- getActiveToolsHandler: () => this.session.getActiveToolNames(),
361
- getAllToolsHandler: () => this.session.getAllToolNames(),
362
- setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
363
- newSessionHandler: async (options) => {
364
- // Stop any loading animation
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)
483
+ return false;
484
+ await this.session.setModel(model);
485
+ return true;
486
+ },
487
+ getThinkingLevel: () => this.session.thinkingLevel,
488
+ setThinkingLevel: (level) => this.session.setThinkingLevel(level),
489
+ },
490
+ // ExtensionContextActions - for ctx.* in event handlers
491
+ {
492
+ getModel: () => this.session.model,
493
+ isIdle: () => !this.session.isStreaming,
494
+ abort: () => this.session.abort(),
495
+ hasPendingMessages: () => this.session.pendingMessageCount > 0,
496
+ shutdown: () => {
497
+ this.shutdownRequested = true;
498
+ },
499
+ },
500
+ // ExtensionCommandContextActions - for ctx.* in command handlers
501
+ {
502
+ waitForIdle: () => this.session.agent.waitForIdle(),
503
+ newSession: async (options) => {
365
504
  if (this.loadingAnimation) {
366
505
  this.loadingAnimation.stop();
367
506
  this.loadingAnimation = undefined;
368
507
  }
369
508
  this.statusContainer.clear();
370
- // Create new session
371
509
  const success = await this.session.newSession({ parentSession: options?.parentSession });
372
510
  if (!success) {
373
511
  return { cancelled: true };
374
512
  }
375
- // Call setup callback if provided
376
513
  if (options?.setup) {
377
514
  await options.setup(this.sessionManager);
378
515
  }
379
- // Clear UI state
380
516
  this.chatContainer.clear();
381
517
  this.pendingMessagesContainer.clear();
382
518
  this.compactionQueuedMessages = [];
@@ -388,24 +524,22 @@ export class InteractiveMode {
388
524
  this.ui.requestRender();
389
525
  return { cancelled: false };
390
526
  },
391
- branchHandler: async (entryId) => {
527
+ branch: async (entryId) => {
392
528
  const result = await this.session.branch(entryId);
393
529
  if (result.cancelled) {
394
530
  return { cancelled: true };
395
531
  }
396
- // Update UI
397
532
  this.chatContainer.clear();
398
533
  this.renderInitialMessages();
399
534
  this.editor.setText(result.selectedText);
400
535
  this.showStatus("Branched to new session");
401
536
  return { cancelled: false };
402
537
  },
403
- navigateTreeHandler: async (targetId, options) => {
538
+ navigateTree: async (targetId, options) => {
404
539
  const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
405
540
  if (result.cancelled) {
406
541
  return { cancelled: true };
407
542
  }
408
- // Update UI
409
543
  this.chatContainer.clear();
410
544
  this.renderInitialMessages();
411
545
  if (result.editorText) {
@@ -414,24 +548,7 @@ export class InteractiveMode {
414
548
  this.showStatus("Navigated to selected point");
415
549
  return { cancelled: false };
416
550
  },
417
- setModelHandler: async (model) => {
418
- const key = await this.session.modelRegistry.getApiKey(model);
419
- if (!key)
420
- return false;
421
- await this.session.setModel(model);
422
- return true;
423
- },
424
- getThinkingLevelHandler: () => this.session.thinkingLevel,
425
- setThinkingLevelHandler: (level) => this.session.setThinkingLevel(level),
426
- isIdle: () => !this.session.isStreaming,
427
- waitForIdle: () => this.session.agent.waitForIdle(),
428
- abort: () => {
429
- this.session.abort();
430
- },
431
- hasPendingMessages: () => this.session.pendingMessageCount > 0,
432
- uiContext,
433
- hasUI: true,
434
- });
551
+ }, uiContext);
435
552
  // Subscribe to extension errors
436
553
  extensionRunner.onError((error) => {
437
554
  this.showExtensionError(error.extensionPath, error.error, error.stack);
@@ -445,6 +562,14 @@ export class InteractiveMode {
445
562
  this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
446
563
  this.chatContainer.addChild(new Spacer(1));
447
564
  }
565
+ // Warn about built-in tool overrides
566
+ const builtInToolNames = new Set(Object.keys(allTools));
567
+ const registeredTools = extensionRunner.getAllRegisteredTools();
568
+ for (const tool of registeredTools) {
569
+ if (builtInToolNames.has(tool.definition.name)) {
570
+ this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: Extension "${tool.extensionPath}" overrides built-in tool "${tool.definition.name}"`), 0, 0));
571
+ }
572
+ }
448
573
  // Emit session_start event
449
574
  await extensionRunner.emit({
450
575
  type: "session_start",
@@ -476,9 +601,12 @@ export class InteractiveMode {
476
601
  isIdle: () => !this.session.isStreaming,
477
602
  abort: () => this.session.abort(),
478
603
  hasPendingMessages: () => this.session.pendingMessageCount > 0,
604
+ shutdown: () => {
605
+ this.shutdownRequested = true;
606
+ },
479
607
  });
480
- // Set up the extension shortcut handler on the editor
481
- this.editor.onExtensionShortcut = (data) => {
608
+ // Set up the extension shortcut handler on the default editor
609
+ this.defaultEditor.onExtensionShortcut = (data) => {
482
610
  for (const [shortcutStr, shortcut] of shortcuts) {
483
611
  // Cast to KeyId - extension shortcuts use the same format
484
612
  if (matchesKey(data, shortcutStr)) {
@@ -622,6 +750,7 @@ export class InteractiveMode {
622
750
  setEditorText: (text) => this.editor.setText(text),
623
751
  getEditorText: () => this.editor.getText(),
624
752
  editor: (title, prefill) => this.showExtensionEditor(title, prefill),
753
+ setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
625
754
  get theme() {
626
755
  return theme;
627
756
  },
@@ -649,7 +778,7 @@ export class InteractiveMode {
649
778
  opts?.signal?.removeEventListener("abort", onAbort);
650
779
  this.hideExtensionSelector();
651
780
  resolve(undefined);
652
- });
781
+ }, { tui: this.ui, timeout: opts?.timeout });
653
782
  this.editorContainer.clear();
654
783
  this.editorContainer.addChild(this.extensionSelector);
655
784
  this.ui.setFocus(this.extensionSelector);
@@ -660,6 +789,7 @@ export class InteractiveMode {
660
789
  * Hide the extension selector.
661
790
  */
662
791
  hideExtensionSelector() {
792
+ this.extensionSelector?.dispose();
663
793
  this.editorContainer.clear();
664
794
  this.editorContainer.addChild(this.editor);
665
795
  this.extensionSelector = undefined;
@@ -695,7 +825,7 @@ export class InteractiveMode {
695
825
  opts?.signal?.removeEventListener("abort", onAbort);
696
826
  this.hideExtensionInput();
697
827
  resolve(undefined);
698
- });
828
+ }, { tui: this.ui, timeout: opts?.timeout });
699
829
  this.editorContainer.clear();
700
830
  this.editorContainer.addChild(this.extensionInput);
701
831
  this.ui.setFocus(this.extensionInput);
@@ -706,6 +836,7 @@ export class InteractiveMode {
706
836
  * Hide the extension input.
707
837
  */
708
838
  hideExtensionInput() {
839
+ this.extensionInput?.dispose();
709
840
  this.editorContainer.clear();
710
841
  this.editorContainer.addChild(this.editor);
711
842
  this.extensionInput = undefined;
@@ -740,6 +871,54 @@ export class InteractiveMode {
740
871
  this.ui.setFocus(this.editor);
741
872
  this.ui.requestRender();
742
873
  }
874
+ /**
875
+ * Set a custom editor component from an extension.
876
+ * Pass undefined to restore the default editor.
877
+ */
878
+ setCustomEditorComponent(factory) {
879
+ // Save text from current editor before switching
880
+ const currentText = this.editor.getText();
881
+ this.editorContainer.clear();
882
+ if (factory) {
883
+ // Create the custom editor with tui, theme, and keybindings
884
+ const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
885
+ // Wire up callbacks from the default editor
886
+ newEditor.onSubmit = this.defaultEditor.onSubmit;
887
+ newEditor.onChange = this.defaultEditor.onChange;
888
+ // Copy text from previous editor
889
+ newEditor.setText(currentText);
890
+ // Copy appearance settings if supported
891
+ if (newEditor.borderColor !== undefined) {
892
+ newEditor.borderColor = this.defaultEditor.borderColor;
893
+ }
894
+ // Set autocomplete if supported
895
+ if (newEditor.setAutocompleteProvider && this.autocompleteProvider) {
896
+ newEditor.setAutocompleteProvider(this.autocompleteProvider);
897
+ }
898
+ // If extending CustomEditor, copy app-level handlers
899
+ // Use duck typing since instanceof fails across jiti module boundaries
900
+ const customEditor = newEditor;
901
+ if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) {
902
+ customEditor.onEscape = this.defaultEditor.onEscape;
903
+ customEditor.onCtrlD = this.defaultEditor.onCtrlD;
904
+ customEditor.onPasteImage = this.defaultEditor.onPasteImage;
905
+ customEditor.onExtensionShortcut = this.defaultEditor.onExtensionShortcut;
906
+ // Copy action handlers (clear, suspend, model switching, etc.)
907
+ for (const [action, handler] of this.defaultEditor.actionHandlers) {
908
+ customEditor.actionHandlers.set(action, handler);
909
+ }
910
+ }
911
+ this.editor = newEditor;
912
+ }
913
+ else {
914
+ // Restore default editor with text from custom editor
915
+ this.defaultEditor.setText(currentText);
916
+ this.editor = this.defaultEditor;
917
+ }
918
+ this.editorContainer.addChild(this.editor);
919
+ this.ui.setFocus(this.editor);
920
+ this.ui.requestRender();
921
+ }
743
922
  /**
744
923
  * Show a notification for extensions.
745
924
  */
@@ -770,7 +949,7 @@ export class InteractiveMode {
770
949
  this.ui.requestRender();
771
950
  resolve(result);
772
951
  };
773
- Promise.resolve(factory(this.ui, theme, close)).then((c) => {
952
+ Promise.resolve(factory(this.ui, theme, this.keybindings, close)).then((c) => {
774
953
  component = c;
775
954
  this.editorContainer.clear();
776
955
  this.editorContainer.addChild(component);
@@ -803,7 +982,9 @@ export class InteractiveMode {
803
982
  // Key Handlers
804
983
  // =========================================================================
805
984
  setupKeyHandlers() {
806
- this.editor.onEscape = () => {
985
+ // Set up handlers on defaultEditor - they use this.editor for text access
986
+ // so they work correctly regardless of which editor is active
987
+ this.defaultEditor.onEscape = () => {
807
988
  if (this.loadingAnimation) {
808
989
  // Abort and restore queued messages to editor
809
990
  const { steering, followUp } = this.session.clearQueue();
@@ -841,20 +1022,20 @@ export class InteractiveMode {
841
1022
  }
842
1023
  };
843
1024
  // Register app action handlers
844
- this.editor.onAction("clear", () => this.handleCtrlC());
845
- this.editor.onCtrlD = () => this.handleCtrlD();
846
- this.editor.onAction("suspend", () => this.handleCtrlZ());
847
- this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
848
- this.editor.onAction("cycleModelForward", () => this.cycleModel("forward"));
849
- this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
1025
+ this.defaultEditor.onAction("clear", () => this.handleCtrlC());
1026
+ this.defaultEditor.onCtrlD = () => this.handleCtrlD();
1027
+ this.defaultEditor.onAction("suspend", () => this.handleCtrlZ());
1028
+ this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
1029
+ this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward"));
1030
+ this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
850
1031
  // Global debug handler on TUI (works regardless of focus)
851
1032
  this.ui.onDebug = () => this.handleDebugCommand();
852
- this.editor.onAction("selectModel", () => this.showModelSelector());
853
- this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion());
854
- this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
855
- this.editor.onAction("externalEditor", () => this.openExternalEditor());
856
- this.editor.onAction("followUp", () => this.handleFollowUp());
857
- this.editor.onChange = (text) => {
1033
+ this.defaultEditor.onAction("selectModel", () => this.showModelSelector());
1034
+ this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion());
1035
+ this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
1036
+ this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor());
1037
+ this.defaultEditor.onAction("followUp", () => this.handleFollowUp());
1038
+ this.defaultEditor.onChange = (text) => {
858
1039
  const wasBashMode = this.isBashMode;
859
1040
  this.isBashMode = text.trimStart().startsWith("!");
860
1041
  if (wasBashMode !== this.isBashMode) {
@@ -862,7 +1043,7 @@ export class InteractiveMode {
862
1043
  }
863
1044
  };
864
1045
  // Handle clipboard image paste (triggered on Ctrl+V)
865
- this.editor.onPasteImage = () => {
1046
+ this.defaultEditor.onPasteImage = () => {
866
1047
  this.handleClipboardImagePaste();
867
1048
  };
868
1049
  }
@@ -879,7 +1060,7 @@ export class InteractiveMode {
879
1060
  const filePath = path.join(tmpDir, fileName);
880
1061
  fs.writeFileSync(filePath, Buffer.from(image.bytes));
881
1062
  // Insert file path directly
882
- this.editor.insertTextAtCursor(filePath);
1063
+ this.editor.insertTextAtCursor?.(filePath);
883
1064
  this.ui.requestRender();
884
1065
  }
885
1066
  catch {
@@ -887,7 +1068,7 @@ export class InteractiveMode {
887
1068
  }
888
1069
  }
889
1070
  setupEditorSubmitHandler() {
890
- this.editor.onSubmit = async (text) => {
1071
+ this.defaultEditor.onSubmit = async (text) => {
891
1072
  text = text.trim();
892
1073
  if (!text)
893
1074
  return;
@@ -993,7 +1174,7 @@ export class InteractiveMode {
993
1174
  this.editor.setText(text);
994
1175
  return;
995
1176
  }
996
- this.editor.addToHistory(text);
1177
+ this.editor.addToHistory?.(text);
997
1178
  await this.handleBashCommand(command, isExcluded);
998
1179
  this.isBashMode = false;
999
1180
  this.updateEditorBorderColor();
@@ -1003,7 +1184,7 @@ export class InteractiveMode {
1003
1184
  // Queue input during compaction (extension commands execute immediately)
1004
1185
  if (this.session.isCompacting) {
1005
1186
  if (this.isExtensionCommand(text)) {
1006
- this.editor.addToHistory(text);
1187
+ this.editor.addToHistory?.(text);
1007
1188
  this.editor.setText("");
1008
1189
  await this.session.prompt(text);
1009
1190
  }
@@ -1015,7 +1196,7 @@ export class InteractiveMode {
1015
1196
  // If streaming, use prompt() with steer behavior
1016
1197
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
1017
1198
  if (this.session.isStreaming) {
1018
- this.editor.addToHistory(text);
1199
+ this.editor.addToHistory?.(text);
1019
1200
  this.editor.setText("");
1020
1201
  await this.session.prompt(text, { streamingBehavior: "steer" });
1021
1202
  this.updatePendingMessagesDisplay();
@@ -1028,7 +1209,7 @@ export class InteractiveMode {
1028
1209
  if (this.onInputCallback) {
1029
1210
  this.onInputCallback(text);
1030
1211
  }
1031
- this.editor.addToHistory(text);
1212
+ this.editor.addToHistory?.(text);
1032
1213
  };
1033
1214
  }
1034
1215
  subscribeToAgent() {
@@ -1166,13 +1347,14 @@ export class InteractiveMode {
1166
1347
  this.streamingMessage = undefined;
1167
1348
  }
1168
1349
  this.pendingTools.clear();
1350
+ await this.checkShutdownRequested();
1169
1351
  this.ui.requestRender();
1170
1352
  break;
1171
1353
  case "auto_compaction_start": {
1172
1354
  // Keep editor active; submissions are queued during compaction.
1173
1355
  // Set up escape to abort auto-compaction
1174
- this.autoCompactionEscapeHandler = this.editor.onEscape;
1175
- this.editor.onEscape = () => {
1356
+ this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
1357
+ this.defaultEditor.onEscape = () => {
1176
1358
  this.session.abortCompaction();
1177
1359
  };
1178
1360
  // Show compacting indicator with reason
@@ -1186,7 +1368,7 @@ export class InteractiveMode {
1186
1368
  case "auto_compaction_end": {
1187
1369
  // Restore escape handler
1188
1370
  if (this.autoCompactionEscapeHandler) {
1189
- this.editor.onEscape = this.autoCompactionEscapeHandler;
1371
+ this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
1190
1372
  this.autoCompactionEscapeHandler = undefined;
1191
1373
  }
1192
1374
  // Stop loader
@@ -1218,8 +1400,8 @@ export class InteractiveMode {
1218
1400
  }
1219
1401
  case "auto_retry_start": {
1220
1402
  // Set up escape to abort retry
1221
- this.retryEscapeHandler = this.editor.onEscape;
1222
- this.editor.onEscape = () => {
1403
+ this.retryEscapeHandler = this.defaultEditor.onEscape;
1404
+ this.defaultEditor.onEscape = () => {
1223
1405
  this.session.abortRetry();
1224
1406
  };
1225
1407
  // Show retry indicator
@@ -1233,7 +1415,7 @@ export class InteractiveMode {
1233
1415
  case "auto_retry_end": {
1234
1416
  // Restore escape handler
1235
1417
  if (this.retryEscapeHandler) {
1236
- this.editor.onEscape = this.retryEscapeHandler;
1418
+ this.defaultEditor.onEscape = this.retryEscapeHandler;
1237
1419
  this.retryEscapeHandler = undefined;
1238
1420
  }
1239
1421
  // Stop loader
@@ -1321,7 +1503,7 @@ export class InteractiveMode {
1321
1503
  const userComponent = new UserMessageComponent(textContent);
1322
1504
  this.chatContainer.addChild(userComponent);
1323
1505
  if (options?.populateHistory) {
1324
- this.editor.addToHistory(textContent);
1506
+ this.editor.addToHistory?.(textContent);
1325
1507
  }
1326
1508
  }
1327
1509
  break;
@@ -1437,7 +1619,11 @@ export class InteractiveMode {
1437
1619
  * Gracefully shutdown the agent.
1438
1620
  * Emits shutdown event to extensions, then exits.
1439
1621
  */
1622
+ isShuttingDown = false;
1440
1623
  async shutdown() {
1624
+ if (this.isShuttingDown)
1625
+ return;
1626
+ this.isShuttingDown = true;
1441
1627
  // Emit shutdown event to extensions
1442
1628
  const extensionRunner = this.session.extensionRunner;
1443
1629
  if (extensionRunner?.hasHandlers("session_shutdown")) {
@@ -1448,6 +1634,14 @@ export class InteractiveMode {
1448
1634
  this.stop();
1449
1635
  process.exit(0);
1450
1636
  }
1637
+ /**
1638
+ * Check if shutdown was requested and perform shutdown if so.
1639
+ */
1640
+ async checkShutdownRequested() {
1641
+ if (!this.shutdownRequested)
1642
+ return;
1643
+ await this.shutdown();
1644
+ }
1451
1645
  handleCtrlZ() {
1452
1646
  // Set up handler to restore TUI when resumed
1453
1647
  process.once("SIGCONT", () => {
@@ -1466,7 +1660,7 @@ export class InteractiveMode {
1466
1660
  // Queue input during compaction (extension commands execute immediately)
1467
1661
  if (this.session.isCompacting) {
1468
1662
  if (this.isExtensionCommand(text)) {
1469
- this.editor.addToHistory(text);
1663
+ this.editor.addToHistory?.(text);
1470
1664
  this.editor.setText("");
1471
1665
  await this.session.prompt(text);
1472
1666
  }
@@ -1478,7 +1672,7 @@ export class InteractiveMode {
1478
1672
  // Alt+Enter queues a follow-up message (waits until agent finishes)
1479
1673
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
1480
1674
  if (this.session.isStreaming) {
1481
- this.editor.addToHistory(text);
1675
+ this.editor.addToHistory?.(text);
1482
1676
  this.editor.setText("");
1483
1677
  await this.session.prompt(text, { streamingBehavior: "followUp" });
1484
1678
  this.updatePendingMessagesDisplay();
@@ -1558,7 +1752,7 @@ export class InteractiveMode {
1558
1752
  this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1559
1753
  return;
1560
1754
  }
1561
- const currentText = this.editor.getExpandedText();
1755
+ const currentText = this.editor.getExpandedText?.() ?? this.editor.getText();
1562
1756
  const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
1563
1757
  try {
1564
1758
  // Write current content to temp file
@@ -1642,7 +1836,7 @@ export class InteractiveMode {
1642
1836
  }
1643
1837
  queueCompactionMessage(text, mode) {
1644
1838
  this.compactionQueuedMessages.push({ text, mode });
1645
- this.editor.addToHistory(text);
1839
+ this.editor.addToHistory?.(text);
1646
1840
  this.editor.setText("");
1647
1841
  this.updatePendingMessagesDisplay();
1648
1842
  this.showStatus("Queued message for after compaction");
@@ -1917,9 +2111,9 @@ export class InteractiveMode {
1917
2111
  const wantsSummary = await this.showExtensionConfirm("Summarize branch?", "Create a summary of the branch you're leaving?");
1918
2112
  // Set up escape handler and loader if summarizing
1919
2113
  let summaryLoader;
1920
- const originalOnEscape = this.editor.onEscape;
2114
+ const originalOnEscape = this.defaultEditor.onEscape;
1921
2115
  if (wantsSummary) {
1922
- this.editor.onEscape = () => {
2116
+ this.defaultEditor.onEscape = () => {
1923
2117
  this.session.abortBranchSummary();
1924
2118
  };
1925
2119
  this.chatContainer.addChild(new Spacer(1));
@@ -1955,7 +2149,7 @@ export class InteractiveMode {
1955
2149
  summaryLoader.stop();
1956
2150
  this.statusContainer.clear();
1957
2151
  }
1958
- this.editor.onEscape = originalOnEscape;
2152
+ this.defaultEditor.onEscape = originalOnEscape;
1959
2153
  }
1960
2154
  }, () => {
1961
2155
  done();
@@ -2487,8 +2681,8 @@ export class InteractiveMode {
2487
2681
  }
2488
2682
  this.statusContainer.clear();
2489
2683
  // Set up escape handler during compaction
2490
- const originalOnEscape = this.editor.onEscape;
2491
- this.editor.onEscape = () => {
2684
+ const originalOnEscape = this.defaultEditor.onEscape;
2685
+ this.defaultEditor.onEscape = () => {
2492
2686
  this.session.abortCompaction();
2493
2687
  };
2494
2688
  // Show compacting status
@@ -2518,7 +2712,7 @@ export class InteractiveMode {
2518
2712
  finally {
2519
2713
  compactingLoader.stop();
2520
2714
  this.statusContainer.clear();
2521
- this.editor.onEscape = originalOnEscape;
2715
+ this.defaultEditor.onEscape = originalOnEscape;
2522
2716
  }
2523
2717
  void this.flushCompactionQueue({ willRetry: false });
2524
2718
  }