@mariozechner/pi-coding-agent 0.37.8 → 0.39.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 (165) hide show
  1. package/CHANGELOG.md +115 -4
  2. package/README.md +11 -0
  3. package/dist/cli/args.d.ts +2 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +8 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +23 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +75 -35
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/bash-executor.d.ts +6 -0
  12. package/dist/core/bash-executor.d.ts.map +1 -1
  13. package/dist/core/bash-executor.js +77 -0
  14. package/dist/core/bash-executor.js.map +1 -1
  15. package/dist/core/extensions/index.d.ts +3 -3
  16. package/dist/core/extensions/index.d.ts.map +1 -1
  17. package/dist/core/extensions/index.js +1 -1
  18. package/dist/core/extensions/index.js.map +1 -1
  19. package/dist/core/extensions/loader.d.ts +8 -6
  20. package/dist/core/extensions/loader.d.ts.map +1 -1
  21. package/dist/core/extensions/loader.js +94 -211
  22. package/dist/core/extensions/loader.js.map +1 -1
  23. package/dist/core/extensions/runner.d.ts +27 -30
  24. package/dist/core/extensions/runner.d.ts.map +1 -1
  25. package/dist/core/extensions/runner.js +102 -45
  26. package/dist/core/extensions/runner.js.map +1 -1
  27. package/dist/core/extensions/types.d.ts +155 -30
  28. package/dist/core/extensions/types.d.ts.map +1 -1
  29. package/dist/core/extensions/types.js.map +1 -1
  30. package/dist/core/extensions/wrapper.d.ts +5 -3
  31. package/dist/core/extensions/wrapper.d.ts.map +1 -1
  32. package/dist/core/extensions/wrapper.js +6 -4
  33. package/dist/core/extensions/wrapper.js.map +1 -1
  34. package/dist/core/index.d.ts +2 -2
  35. package/dist/core/index.d.ts.map +1 -1
  36. package/dist/core/index.js +1 -1
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/model-resolver.d.ts +4 -2
  39. package/dist/core/model-resolver.d.ts.map +1 -1
  40. package/dist/core/model-resolver.js +8 -9
  41. package/dist/core/model-resolver.js.map +1 -1
  42. package/dist/core/sdk.d.ts +8 -5
  43. package/dist/core/sdk.d.ts.map +1 -1
  44. package/dist/core/sdk.js +39 -87
  45. package/dist/core/sdk.js.map +1 -1
  46. package/dist/core/settings-manager.d.ts +8 -0
  47. package/dist/core/settings-manager.d.ts.map +1 -1
  48. package/dist/core/settings-manager.js +9 -1
  49. package/dist/core/settings-manager.js.map +1 -1
  50. package/dist/core/system-prompt.d.ts.map +1 -1
  51. package/dist/core/system-prompt.js +1 -5
  52. package/dist/core/system-prompt.js.map +1 -1
  53. package/dist/core/tools/bash.d.ts +25 -1
  54. package/dist/core/tools/bash.d.ts.map +1 -1
  55. package/dist/core/tools/bash.js +103 -73
  56. package/dist/core/tools/bash.js.map +1 -1
  57. package/dist/core/tools/edit.d.ts +17 -1
  58. package/dist/core/tools/edit.d.ts.map +1 -1
  59. package/dist/core/tools/edit.js +12 -5
  60. package/dist/core/tools/edit.js.map +1 -1
  61. package/dist/core/tools/find.d.ts +18 -1
  62. package/dist/core/tools/find.d.ts.map +1 -1
  63. package/dist/core/tools/find.js +68 -18
  64. package/dist/core/tools/find.js.map +1 -1
  65. package/dist/core/tools/grep.d.ts +15 -1
  66. package/dist/core/tools/grep.d.ts.map +1 -1
  67. package/dist/core/tools/grep.js +22 -10
  68. package/dist/core/tools/grep.js.map +1 -1
  69. package/dist/core/tools/index.d.ts +7 -7
  70. package/dist/core/tools/index.d.ts.map +1 -1
  71. package/dist/core/tools/index.js +1 -1
  72. package/dist/core/tools/index.js.map +1 -1
  73. package/dist/core/tools/ls.d.ts +21 -1
  74. package/dist/core/tools/ls.d.ts.map +1 -1
  75. package/dist/core/tools/ls.js +80 -72
  76. package/dist/core/tools/ls.js.map +1 -1
  77. package/dist/core/tools/read.d.ts +14 -0
  78. package/dist/core/tools/read.d.ts.map +1 -1
  79. package/dist/core/tools/read.js +12 -5
  80. package/dist/core/tools/read.js.map +1 -1
  81. package/dist/core/tools/write.d.ts +15 -1
  82. package/dist/core/tools/write.d.ts.map +1 -1
  83. package/dist/core/tools/write.js +9 -4
  84. package/dist/core/tools/write.js.map +1 -1
  85. package/dist/index.d.ts +5 -4
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +4 -2
  88. package/dist/index.js.map +1 -1
  89. package/dist/main.d.ts.map +1 -1
  90. package/dist/main.js +58 -116
  91. package/dist/main.js.map +1 -1
  92. package/dist/modes/index.d.ts +2 -2
  93. package/dist/modes/index.d.ts.map +1 -1
  94. package/dist/modes/index.js.map +1 -1
  95. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  96. package/dist/modes/interactive/components/assistant-message.js +7 -3
  97. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  98. package/dist/modes/interactive/components/countdown-timer.d.ts +14 -0
  99. package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -0
  100. package/dist/modes/interactive/components/countdown-timer.js +33 -0
  101. package/dist/modes/interactive/components/countdown-timer.js.map +1 -0
  102. package/dist/modes/interactive/components/custom-editor.d.ts +1 -1
  103. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  104. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  105. package/dist/modes/interactive/components/extension-input.d.ts +10 -2
  106. package/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  107. package/dist/modes/interactive/components/extension-input.js +18 -14
  108. package/dist/modes/interactive/components/extension-input.js.map +1 -1
  109. package/dist/modes/interactive/components/extension-selector.d.ts +10 -2
  110. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  111. package/dist/modes/interactive/components/extension-selector.js +18 -22
  112. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  113. package/dist/modes/interactive/components/tool-execution.d.ts +6 -0
  114. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  115. package/dist/modes/interactive/components/tool-execution.js +50 -23
  116. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  117. package/dist/modes/interactive/interactive-mode.d.ts +44 -3
  118. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  119. package/dist/modes/interactive/interactive-mode.js +440 -139
  120. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  121. package/dist/modes/interactive/theme/theme.d.ts +7 -0
  122. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  123. package/dist/modes/interactive/theme/theme.js +34 -0
  124. package/dist/modes/interactive/theme/theme.js.map +1 -1
  125. package/dist/modes/print-mode.d.ts +14 -7
  126. package/dist/modes/print-mode.d.ts.map +1 -1
  127. package/dist/modes/print-mode.js +45 -21
  128. package/dist/modes/print-mode.js.map +1 -1
  129. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  130. package/dist/modes/rpc/rpc-mode.js +111 -101
  131. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  132. package/dist/modes/rpc/rpc-types.d.ts +3 -0
  133. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  134. package/dist/modes/rpc/rpc-types.js.map +1 -1
  135. package/dist/utils/clipboard-image.d.ts.map +1 -1
  136. package/dist/utils/clipboard-image.js +1 -1
  137. package/dist/utils/clipboard-image.js.map +1 -1
  138. package/dist/utils/clipboard.d.ts.map +1 -1
  139. package/dist/utils/clipboard.js +35 -7
  140. package/dist/utils/clipboard.js.map +1 -1
  141. package/docs/extensions.md +211 -15
  142. package/docs/sdk.md +68 -9
  143. package/docs/tui.md +81 -4
  144. package/examples/extensions/README.md +3 -0
  145. package/examples/extensions/claude-rules.ts +5 -2
  146. package/examples/extensions/handoff.ts +1 -1
  147. package/examples/extensions/interactive-shell.ts +196 -0
  148. package/examples/extensions/mac-system-theme.ts +25 -0
  149. package/examples/extensions/modal-editor.ts +85 -0
  150. package/examples/extensions/overlay-test.ts +145 -0
  151. package/examples/extensions/pirate.ts +7 -4
  152. package/examples/extensions/preset.ts +3 -3
  153. package/examples/extensions/qna.ts +1 -1
  154. package/examples/extensions/rainbow-editor.ts +95 -0
  155. package/examples/extensions/shutdown-command.ts +63 -0
  156. package/examples/extensions/snake.ts +1 -1
  157. package/examples/extensions/ssh.ts +220 -0
  158. package/examples/extensions/timed-confirm.ts +32 -25
  159. package/examples/extensions/todo.ts +1 -1
  160. package/examples/extensions/tool-override.ts +143 -0
  161. package/examples/extensions/tools.ts +1 -1
  162. package/examples/extensions/with-deps/package-lock.json +2 -2
  163. package/examples/extensions/with-deps/package.json +1 -1
  164. package/examples/sdk/04-skills.ts +4 -1
  165. package/package.json +6 -6
@@ -6,18 +6,19 @@ 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, isBunBinary, 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
- import { loadSkills } from "../../core/skills.js";
17
16
  import { loadProjectContextFiles } from "../../core/system-prompt.js";
18
- import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
17
+ import { allTools } from "../../core/tools/index.js";
18
+ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
19
19
  import { copyToClipboard } from "../../utils/clipboard.js";
20
20
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
21
+ import { ensureTool } from "../../utils/tools-manager.js";
21
22
  import { ArminComponent } from "./components/armin.js";
22
23
  import { AssistantMessageComponent } from "./components/assistant-message.js";
23
24
  import { BashExecutionComponent } from "./components/bash-execution.js";
@@ -40,18 +41,20 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
40
41
  import { TreeSelectorComponent } from "./components/tree-selector.js";
41
42
  import { UserMessageComponent } from "./components/user-message.js";
42
43
  import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
43
- import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme, } from "./theme/theme.js";
44
+ import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setTheme, setThemeInstance, Theme, theme, } from "./theme/theme.js";
44
45
  function isExpandable(obj) {
45
46
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
46
47
  }
47
48
  export class InteractiveMode {
48
- setExtensionUIContext;
49
+ options;
49
50
  session;
50
51
  ui;
51
52
  chatContainer;
52
53
  pendingMessagesContainer;
53
54
  statusContainer;
55
+ defaultEditor;
54
56
  editor;
57
+ autocompleteProvider;
55
58
  editorContainer;
56
59
  footer;
57
60
  keybindings;
@@ -90,6 +93,8 @@ export class InteractiveMode {
90
93
  retryEscapeHandler;
91
94
  // Messages queued while compaction is running
92
95
  compactionQueuedMessages = [];
96
+ // Shutdown state
97
+ shutdownRequested = false;
93
98
  // Extension UI state
94
99
  extensionSelector = undefined;
95
100
  extensionInput = undefined;
@@ -113,22 +118,28 @@ export class InteractiveMode {
113
118
  get settingsManager() {
114
119
  return this.session.settingsManager;
115
120
  }
116
- constructor(session, version, changelogMarkdown = undefined, _extensions = [], setExtensionUIContext = () => { }, fdPath = undefined) {
117
- this.setExtensionUIContext = setExtensionUIContext;
121
+ constructor(session, options = {}) {
122
+ this.options = options;
118
123
  this.session = session;
119
- this.version = version;
120
- this.changelogMarkdown = changelogMarkdown;
124
+ this.version = VERSION;
121
125
  this.ui = new TUI(new ProcessTerminal());
122
126
  this.chatContainer = new Container();
123
127
  this.pendingMessagesContainer = new Container();
124
128
  this.statusContainer = new Container();
125
129
  this.widgetContainer = new Container();
126
130
  this.keybindings = KeybindingsManager.create();
127
- this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
131
+ this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings);
132
+ this.editor = this.defaultEditor;
128
133
  this.editorContainer = new Container();
129
134
  this.editorContainer.addChild(this.editor);
130
135
  this.footer = new FooterComponent(session);
131
136
  this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
137
+ // Load hide thinking block setting
138
+ this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
139
+ // Initialize theme with watcher for interactive mode
140
+ initTheme(this.settingsManager.getTheme(), true);
141
+ }
142
+ setupAutocomplete(fdPath) {
132
143
  // Define commands for autocomplete
133
144
  const slashCommands = [
134
145
  { name: "settings", description: "Open settings menu" },
@@ -147,8 +158,6 @@ export class InteractiveMode {
147
158
  { name: "compact", description: "Manually compact the session context" },
148
159
  { name: "resume", description: "Resume a different session" },
149
160
  ];
150
- // Load hide thinking block setting
151
- this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
152
161
  // Convert prompt templates to SlashCommand format for autocomplete
153
162
  const templateCommands = this.session.promptTemplates.map((cmd) => ({
154
163
  name: cmd.name,
@@ -160,12 +169,17 @@ export class InteractiveMode {
160
169
  description: cmd.description ?? "(extension command)",
161
170
  }));
162
171
  // Setup autocomplete
163
- const autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath);
164
- this.editor.setAutocompleteProvider(autocompleteProvider);
172
+ this.autocompleteProvider = new CombinedAutocompleteProvider([...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath);
173
+ this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
165
174
  }
166
175
  async init() {
167
176
  if (this.isInitialized)
168
177
  return;
178
+ // Load changelog (only show new entries, skip for resumed sessions)
179
+ this.changelogMarkdown = this.getChangelogForDisplay();
180
+ // Setup autocomplete with fd tool for file path completion
181
+ const fdPath = await ensureTool("fd");
182
+ this.setupAutocomplete(fdPath);
169
183
  // Add header with keybindings from config
170
184
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
171
185
  // Format keybinding for startup display (lowercase, compact)
@@ -293,6 +307,112 @@ export class InteractiveMode {
293
307
  this.ui.requestRender();
294
308
  });
295
309
  }
310
+ /**
311
+ * Run the interactive mode. This is the main entry point.
312
+ * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.
313
+ */
314
+ async run() {
315
+ await this.init();
316
+ // Start version check asynchronously
317
+ this.checkForNewVersion().then((newVersion) => {
318
+ if (newVersion) {
319
+ this.showNewVersionNotification(newVersion);
320
+ }
321
+ });
322
+ this.renderInitialMessages();
323
+ // Show startup warnings
324
+ const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options;
325
+ if (migratedProviders && migratedProviders.length > 0) {
326
+ this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
327
+ }
328
+ const modelsJsonError = this.session.modelRegistry.getError();
329
+ if (modelsJsonError) {
330
+ this.showError(`models.json error: ${modelsJsonError}`);
331
+ }
332
+ if (modelFallbackMessage) {
333
+ this.showWarning(modelFallbackMessage);
334
+ }
335
+ // Process initial messages
336
+ if (initialMessage) {
337
+ try {
338
+ await this.session.prompt(initialMessage, { images: initialImages });
339
+ }
340
+ catch (error) {
341
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
342
+ this.showError(errorMessage);
343
+ }
344
+ }
345
+ if (initialMessages) {
346
+ for (const message of initialMessages) {
347
+ try {
348
+ await this.session.prompt(message);
349
+ }
350
+ catch (error) {
351
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
352
+ this.showError(errorMessage);
353
+ }
354
+ }
355
+ }
356
+ // Main interactive loop
357
+ while (true) {
358
+ const userInput = await this.getUserInput();
359
+ try {
360
+ await this.session.prompt(userInput);
361
+ }
362
+ catch (error) {
363
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
364
+ this.showError(errorMessage);
365
+ }
366
+ }
367
+ }
368
+ /**
369
+ * Check npm registry for a newer version.
370
+ */
371
+ async checkForNewVersion() {
372
+ if (process.env.PI_SKIP_VERSION_CHECK)
373
+ return undefined;
374
+ try {
375
+ const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
376
+ if (!response.ok)
377
+ return undefined;
378
+ const data = (await response.json());
379
+ const latestVersion = data.version;
380
+ if (latestVersion && latestVersion !== this.version) {
381
+ return latestVersion;
382
+ }
383
+ return undefined;
384
+ }
385
+ catch {
386
+ return undefined;
387
+ }
388
+ }
389
+ /**
390
+ * Get changelog entries to display on startup.
391
+ * Only shows new entries since last seen version, skips for resumed sessions.
392
+ */
393
+ getChangelogForDisplay() {
394
+ // Skip changelog for resumed/continued sessions (already have messages)
395
+ if (this.session.state.messages.length > 0) {
396
+ return undefined;
397
+ }
398
+ const lastVersion = this.settingsManager.getLastChangelogVersion();
399
+ const changelogPath = getChangelogPath();
400
+ const entries = parseChangelog(changelogPath);
401
+ if (!lastVersion) {
402
+ if (entries.length > 0) {
403
+ this.settingsManager.setLastChangelogVersion(VERSION);
404
+ return entries.map((e) => e.content).join("\n\n");
405
+ }
406
+ }
407
+ else {
408
+ const newEntries = getNewEntries(entries, lastVersion);
409
+ if (newEntries.length > 0) {
410
+ this.settingsManager.setLastChangelogVersion(VERSION);
411
+ return newEntries.map((e) => e.content).join("\n\n");
412
+ }
413
+ }
414
+ return undefined;
415
+ }
296
416
  // =========================================================================
297
417
  // Extension System
298
418
  // =========================================================================
@@ -307,40 +427,34 @@ export class InteractiveMode {
307
427
  this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
308
428
  this.chatContainer.addChild(new Spacer(1));
309
429
  }
310
- // Show loaded skills
311
- const skillsSettings = this.session.skillsSettings;
312
- if (skillsSettings?.enabled !== false) {
313
- const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {});
314
- if (skills.length > 0) {
315
- const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
316
- this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
317
- this.chatContainer.addChild(new Spacer(1));
318
- }
319
- // Show skill warnings if any
320
- if (skillWarnings.length > 0) {
321
- const warningList = skillWarnings
322
- .map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
323
- .join("\n");
324
- this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
325
- this.chatContainer.addChild(new Spacer(1));
326
- }
430
+ // Show loaded skills (already discovered by SDK)
431
+ const skills = this.session.skills;
432
+ if (skills.length > 0) {
433
+ const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
434
+ this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
435
+ this.chatContainer.addChild(new Spacer(1));
436
+ }
437
+ // Show skill warnings if any
438
+ const skillWarnings = this.session.skillWarnings;
439
+ if (skillWarnings.length > 0) {
440
+ const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
441
+ this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
442
+ this.chatContainer.addChild(new Spacer(1));
327
443
  }
328
- // Create and set extension UI context
329
- const uiContext = this.createExtensionUIContext();
330
- this.setExtensionUIContext(uiContext, true);
331
444
  const extensionRunner = this.session.extensionRunner;
332
445
  if (!extensionRunner) {
333
446
  return; // No extensions loaded
334
447
  }
335
- extensionRunner.initialize({
336
- getModel: () => this.session.model,
337
- sendMessageHandler: (message, options) => {
448
+ // Create extension UI context
449
+ const uiContext = this.createExtensionUIContext();
450
+ extensionRunner.initialize(
451
+ // ExtensionActions - for pi.* API
452
+ {
453
+ sendMessage: (message, options) => {
338
454
  const wasStreaming = this.session.isStreaming;
339
455
  this.session
340
456
  .sendCustomMessage(message, options)
341
457
  .then(() => {
342
- // For non-streaming cases with display=true, update UI
343
- // (streaming cases update via message_end event)
344
458
  if (!wasStreaming && message.display) {
345
459
  this.rebuildChatFromMessages();
346
460
  }
@@ -349,34 +463,53 @@ export class InteractiveMode {
349
463
  this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
350
464
  });
351
465
  },
352
- sendUserMessageHandler: (content, options) => {
466
+ sendUserMessage: (content, options) => {
353
467
  this.session.sendUserMessage(content, options).catch((err) => {
354
468
  this.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
355
469
  });
356
470
  },
357
- appendEntryHandler: (customType, data) => {
471
+ appendEntry: (customType, data) => {
358
472
  this.sessionManager.appendCustomEntry(customType, data);
359
473
  },
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
474
+ getActiveTools: () => this.session.getActiveToolNames(),
475
+ getAllTools: () => this.session.getAllToolNames(),
476
+ setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
477
+ setModel: async (model) => {
478
+ const key = await this.session.modelRegistry.getApiKey(model);
479
+ if (!key)
480
+ return false;
481
+ await this.session.setModel(model);
482
+ return true;
483
+ },
484
+ getThinkingLevel: () => this.session.thinkingLevel,
485
+ setThinkingLevel: (level) => this.session.setThinkingLevel(level),
486
+ },
487
+ // ExtensionContextActions - for ctx.* in event handlers
488
+ {
489
+ getModel: () => this.session.model,
490
+ isIdle: () => !this.session.isStreaming,
491
+ abort: () => this.session.abort(),
492
+ hasPendingMessages: () => this.session.pendingMessageCount > 0,
493
+ shutdown: () => {
494
+ this.shutdownRequested = true;
495
+ },
496
+ },
497
+ // ExtensionCommandContextActions - for ctx.* in command handlers
498
+ {
499
+ waitForIdle: () => this.session.agent.waitForIdle(),
500
+ newSession: async (options) => {
365
501
  if (this.loadingAnimation) {
366
502
  this.loadingAnimation.stop();
367
503
  this.loadingAnimation = undefined;
368
504
  }
369
505
  this.statusContainer.clear();
370
- // Create new session
371
506
  const success = await this.session.newSession({ parentSession: options?.parentSession });
372
507
  if (!success) {
373
508
  return { cancelled: true };
374
509
  }
375
- // Call setup callback if provided
376
510
  if (options?.setup) {
377
511
  await options.setup(this.sessionManager);
378
512
  }
379
- // Clear UI state
380
513
  this.chatContainer.clear();
381
514
  this.pendingMessagesContainer.clear();
382
515
  this.compactionQueuedMessages = [];
@@ -388,24 +521,22 @@ export class InteractiveMode {
388
521
  this.ui.requestRender();
389
522
  return { cancelled: false };
390
523
  },
391
- branchHandler: async (entryId) => {
524
+ branch: async (entryId) => {
392
525
  const result = await this.session.branch(entryId);
393
526
  if (result.cancelled) {
394
527
  return { cancelled: true };
395
528
  }
396
- // Update UI
397
529
  this.chatContainer.clear();
398
530
  this.renderInitialMessages();
399
531
  this.editor.setText(result.selectedText);
400
532
  this.showStatus("Branched to new session");
401
533
  return { cancelled: false };
402
534
  },
403
- navigateTreeHandler: async (targetId, options) => {
535
+ navigateTree: async (targetId, options) => {
404
536
  const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
405
537
  if (result.cancelled) {
406
538
  return { cancelled: true };
407
539
  }
408
- // Update UI
409
540
  this.chatContainer.clear();
410
541
  this.renderInitialMessages();
411
542
  if (result.editorText) {
@@ -414,24 +545,7 @@ export class InteractiveMode {
414
545
  this.showStatus("Navigated to selected point");
415
546
  return { cancelled: false };
416
547
  },
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
- });
548
+ }, uiContext);
435
549
  // Subscribe to extension errors
436
550
  extensionRunner.onError((error) => {
437
551
  this.showExtensionError(error.extensionPath, error.error, error.stack);
@@ -445,6 +559,14 @@ export class InteractiveMode {
445
559
  this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
446
560
  this.chatContainer.addChild(new Spacer(1));
447
561
  }
562
+ // Warn about built-in tool overrides
563
+ const builtInToolNames = new Set(Object.keys(allTools));
564
+ const registeredTools = extensionRunner.getAllRegisteredTools();
565
+ for (const tool of registeredTools) {
566
+ if (builtInToolNames.has(tool.definition.name)) {
567
+ this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: Extension "${tool.extensionPath}" overrides built-in tool "${tool.definition.name}"`), 0, 0));
568
+ }
569
+ }
448
570
  // Emit session_start event
449
571
  await extensionRunner.emit({
450
572
  type: "session_start",
@@ -476,9 +598,12 @@ export class InteractiveMode {
476
598
  isIdle: () => !this.session.isStreaming,
477
599
  abort: () => this.session.abort(),
478
600
  hasPendingMessages: () => this.session.pendingMessageCount > 0,
601
+ shutdown: () => {
602
+ this.shutdownRequested = true;
603
+ },
479
604
  });
480
- // Set up the extension shortcut handler on the editor
481
- this.editor.onExtensionShortcut = (data) => {
605
+ // Set up the extension shortcut handler on the default editor
606
+ this.defaultEditor.onExtensionShortcut = (data) => {
482
607
  for (const [shortcutStr, shortcut] of shortcuts) {
483
608
  // Cast to KeyId - extension shortcuts use the same format
484
609
  if (matchesKey(data, shortcutStr)) {
@@ -618,13 +743,28 @@ export class InteractiveMode {
618
743
  setFooter: (factory) => this.setExtensionFooter(factory),
619
744
  setHeader: (factory) => this.setExtensionHeader(factory),
620
745
  setTitle: (title) => this.ui.terminal.setTitle(title),
621
- custom: (factory) => this.showExtensionCustom(factory),
746
+ custom: (factory, options) => this.showExtensionCustom(factory, options),
622
747
  setEditorText: (text) => this.editor.setText(text),
623
748
  getEditorText: () => this.editor.getText(),
624
749
  editor: (title, prefill) => this.showExtensionEditor(title, prefill),
750
+ setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
625
751
  get theme() {
626
752
  return theme;
627
753
  },
754
+ getAllThemes: () => getAvailableThemesWithPaths(),
755
+ getTheme: (name) => getThemeByName(name),
756
+ setTheme: (themeOrName) => {
757
+ if (themeOrName instanceof Theme) {
758
+ setThemeInstance(themeOrName);
759
+ this.ui.requestRender();
760
+ return { success: true };
761
+ }
762
+ const result = setTheme(themeOrName, true);
763
+ if (result.success) {
764
+ this.ui.requestRender();
765
+ }
766
+ return result;
767
+ },
628
768
  };
629
769
  }
630
770
  /**
@@ -649,7 +789,7 @@ export class InteractiveMode {
649
789
  opts?.signal?.removeEventListener("abort", onAbort);
650
790
  this.hideExtensionSelector();
651
791
  resolve(undefined);
652
- });
792
+ }, { tui: this.ui, timeout: opts?.timeout });
653
793
  this.editorContainer.clear();
654
794
  this.editorContainer.addChild(this.extensionSelector);
655
795
  this.ui.setFocus(this.extensionSelector);
@@ -660,6 +800,7 @@ export class InteractiveMode {
660
800
  * Hide the extension selector.
661
801
  */
662
802
  hideExtensionSelector() {
803
+ this.extensionSelector?.dispose();
663
804
  this.editorContainer.clear();
664
805
  this.editorContainer.addChild(this.editor);
665
806
  this.extensionSelector = undefined;
@@ -695,7 +836,7 @@ export class InteractiveMode {
695
836
  opts?.signal?.removeEventListener("abort", onAbort);
696
837
  this.hideExtensionInput();
697
838
  resolve(undefined);
698
- });
839
+ }, { tui: this.ui, timeout: opts?.timeout });
699
840
  this.editorContainer.clear();
700
841
  this.editorContainer.addChild(this.extensionInput);
701
842
  this.ui.setFocus(this.extensionInput);
@@ -706,6 +847,7 @@ export class InteractiveMode {
706
847
  * Hide the extension input.
707
848
  */
708
849
  hideExtensionInput() {
850
+ this.extensionInput?.dispose();
709
851
  this.editorContainer.clear();
710
852
  this.editorContainer.addChild(this.editor);
711
853
  this.extensionInput = undefined;
@@ -740,6 +882,54 @@ export class InteractiveMode {
740
882
  this.ui.setFocus(this.editor);
741
883
  this.ui.requestRender();
742
884
  }
885
+ /**
886
+ * Set a custom editor component from an extension.
887
+ * Pass undefined to restore the default editor.
888
+ */
889
+ setCustomEditorComponent(factory) {
890
+ // Save text from current editor before switching
891
+ const currentText = this.editor.getText();
892
+ this.editorContainer.clear();
893
+ if (factory) {
894
+ // Create the custom editor with tui, theme, and keybindings
895
+ const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
896
+ // Wire up callbacks from the default editor
897
+ newEditor.onSubmit = this.defaultEditor.onSubmit;
898
+ newEditor.onChange = this.defaultEditor.onChange;
899
+ // Copy text from previous editor
900
+ newEditor.setText(currentText);
901
+ // Copy appearance settings if supported
902
+ if (newEditor.borderColor !== undefined) {
903
+ newEditor.borderColor = this.defaultEditor.borderColor;
904
+ }
905
+ // Set autocomplete if supported
906
+ if (newEditor.setAutocompleteProvider && this.autocompleteProvider) {
907
+ newEditor.setAutocompleteProvider(this.autocompleteProvider);
908
+ }
909
+ // If extending CustomEditor, copy app-level handlers
910
+ // Use duck typing since instanceof fails across jiti module boundaries
911
+ const customEditor = newEditor;
912
+ if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) {
913
+ customEditor.onEscape = this.defaultEditor.onEscape;
914
+ customEditor.onCtrlD = this.defaultEditor.onCtrlD;
915
+ customEditor.onPasteImage = this.defaultEditor.onPasteImage;
916
+ customEditor.onExtensionShortcut = this.defaultEditor.onExtensionShortcut;
917
+ // Copy action handlers (clear, suspend, model switching, etc.)
918
+ for (const [action, handler] of this.defaultEditor.actionHandlers) {
919
+ customEditor.actionHandlers.set(action, handler);
920
+ }
921
+ }
922
+ this.editor = newEditor;
923
+ }
924
+ else {
925
+ // Restore default editor with text from custom editor
926
+ this.defaultEditor.setText(currentText);
927
+ this.editor = this.defaultEditor;
928
+ }
929
+ this.editorContainer.addChild(this.editor);
930
+ this.ui.setFocus(this.editor);
931
+ this.ui.requestRender();
932
+ }
743
933
  /**
744
934
  * Show a notification for extensions.
745
935
  */
@@ -754,28 +944,59 @@ export class InteractiveMode {
754
944
  this.showStatus(message);
755
945
  }
756
946
  }
757
- /**
758
- * Show a custom component with keyboard focus.
759
- */
760
- async showExtensionCustom(factory) {
947
+ /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
948
+ async showExtensionCustom(factory, options) {
761
949
  const savedText = this.editor.getText();
762
- return new Promise((resolve) => {
950
+ const isOverlay = options?.overlay ?? false;
951
+ const restoreEditor = () => {
952
+ this.editorContainer.clear();
953
+ this.editorContainer.addChild(this.editor);
954
+ this.editor.setText(savedText);
955
+ this.ui.setFocus(this.editor);
956
+ this.ui.requestRender();
957
+ };
958
+ return new Promise((resolve, reject) => {
763
959
  let component;
960
+ let closed = false;
764
961
  const close = (result) => {
765
- component.dispose?.();
766
- this.editorContainer.clear();
767
- this.editorContainer.addChild(this.editor);
768
- this.editor.setText(savedText);
769
- this.ui.setFocus(this.editor);
770
- this.ui.requestRender();
962
+ if (closed)
963
+ return;
964
+ closed = true;
965
+ if (isOverlay)
966
+ this.ui.hideOverlay();
967
+ else
968
+ restoreEditor();
969
+ // Note: both branches above already call requestRender
771
970
  resolve(result);
971
+ try {
972
+ component?.dispose?.();
973
+ }
974
+ catch {
975
+ /* ignore dispose errors */
976
+ }
772
977
  };
773
- Promise.resolve(factory(this.ui, theme, close)).then((c) => {
978
+ Promise.resolve(factory(this.ui, theme, this.keybindings, close))
979
+ .then((c) => {
980
+ if (closed)
981
+ return;
774
982
  component = c;
775
- this.editorContainer.clear();
776
- this.editorContainer.addChild(component);
777
- this.ui.setFocus(component);
778
- this.ui.requestRender();
983
+ if (isOverlay) {
984
+ const w = component.width;
985
+ this.ui.showOverlay(component, w ? { width: w } : undefined);
986
+ }
987
+ else {
988
+ this.editorContainer.clear();
989
+ this.editorContainer.addChild(component);
990
+ this.ui.setFocus(component);
991
+ this.ui.requestRender();
992
+ }
993
+ })
994
+ .catch((err) => {
995
+ if (closed)
996
+ return;
997
+ if (!isOverlay)
998
+ restoreEditor();
999
+ reject(err);
779
1000
  });
780
1001
  });
781
1002
  }
@@ -803,7 +1024,9 @@ export class InteractiveMode {
803
1024
  // Key Handlers
804
1025
  // =========================================================================
805
1026
  setupKeyHandlers() {
806
- this.editor.onEscape = () => {
1027
+ // Set up handlers on defaultEditor - they use this.editor for text access
1028
+ // so they work correctly regardless of which editor is active
1029
+ this.defaultEditor.onEscape = () => {
807
1030
  if (this.loadingAnimation) {
808
1031
  // Abort and restore queued messages to editor
809
1032
  const { steering, followUp } = this.session.clearQueue();
@@ -841,20 +1064,20 @@ export class InteractiveMode {
841
1064
  }
842
1065
  };
843
1066
  // 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"));
1067
+ this.defaultEditor.onAction("clear", () => this.handleCtrlC());
1068
+ this.defaultEditor.onCtrlD = () => this.handleCtrlD();
1069
+ this.defaultEditor.onAction("suspend", () => this.handleCtrlZ());
1070
+ this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
1071
+ this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward"));
1072
+ this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
850
1073
  // Global debug handler on TUI (works regardless of focus)
851
1074
  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) => {
1075
+ this.defaultEditor.onAction("selectModel", () => this.showModelSelector());
1076
+ this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion());
1077
+ this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
1078
+ this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor());
1079
+ this.defaultEditor.onAction("followUp", () => this.handleFollowUp());
1080
+ this.defaultEditor.onChange = (text) => {
858
1081
  const wasBashMode = this.isBashMode;
859
1082
  this.isBashMode = text.trimStart().startsWith("!");
860
1083
  if (wasBashMode !== this.isBashMode) {
@@ -862,7 +1085,7 @@ export class InteractiveMode {
862
1085
  }
863
1086
  };
864
1087
  // Handle clipboard image paste (triggered on Ctrl+V)
865
- this.editor.onPasteImage = () => {
1088
+ this.defaultEditor.onPasteImage = () => {
866
1089
  this.handleClipboardImagePaste();
867
1090
  };
868
1091
  }
@@ -879,7 +1102,7 @@ export class InteractiveMode {
879
1102
  const filePath = path.join(tmpDir, fileName);
880
1103
  fs.writeFileSync(filePath, Buffer.from(image.bytes));
881
1104
  // Insert file path directly
882
- this.editor.insertTextAtCursor(filePath);
1105
+ this.editor.insertTextAtCursor?.(filePath);
883
1106
  this.ui.requestRender();
884
1107
  }
885
1108
  catch {
@@ -887,7 +1110,7 @@ export class InteractiveMode {
887
1110
  }
888
1111
  }
889
1112
  setupEditorSubmitHandler() {
890
- this.editor.onSubmit = async (text) => {
1113
+ this.defaultEditor.onSubmit = async (text) => {
891
1114
  text = text.trim();
892
1115
  if (!text)
893
1116
  return;
@@ -993,7 +1216,7 @@ export class InteractiveMode {
993
1216
  this.editor.setText(text);
994
1217
  return;
995
1218
  }
996
- this.editor.addToHistory(text);
1219
+ this.editor.addToHistory?.(text);
997
1220
  await this.handleBashCommand(command, isExcluded);
998
1221
  this.isBashMode = false;
999
1222
  this.updateEditorBorderColor();
@@ -1003,7 +1226,7 @@ export class InteractiveMode {
1003
1226
  // Queue input during compaction (extension commands execute immediately)
1004
1227
  if (this.session.isCompacting) {
1005
1228
  if (this.isExtensionCommand(text)) {
1006
- this.editor.addToHistory(text);
1229
+ this.editor.addToHistory?.(text);
1007
1230
  this.editor.setText("");
1008
1231
  await this.session.prompt(text);
1009
1232
  }
@@ -1015,7 +1238,7 @@ export class InteractiveMode {
1015
1238
  // If streaming, use prompt() with steer behavior
1016
1239
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
1017
1240
  if (this.session.isStreaming) {
1018
- this.editor.addToHistory(text);
1241
+ this.editor.addToHistory?.(text);
1019
1242
  this.editor.setText("");
1020
1243
  await this.session.prompt(text, { streamingBehavior: "steer" });
1021
1244
  this.updatePendingMessagesDisplay();
@@ -1028,7 +1251,7 @@ export class InteractiveMode {
1028
1251
  if (this.onInputCallback) {
1029
1252
  this.onInputCallback(text);
1030
1253
  }
1031
- this.editor.addToHistory(text);
1254
+ this.editor.addToHistory?.(text);
1032
1255
  };
1033
1256
  }
1034
1257
  subscribeToAgent() {
@@ -1043,6 +1266,16 @@ export class InteractiveMode {
1043
1266
  this.footer.invalidate();
1044
1267
  switch (event.type) {
1045
1268
  case "agent_start":
1269
+ // Restore main escape handler if retry handler is still active
1270
+ // (retry success event fires later, but we need main handler now)
1271
+ if (this.retryEscapeHandler) {
1272
+ this.defaultEditor.onEscape = this.retryEscapeHandler;
1273
+ this.retryEscapeHandler = undefined;
1274
+ }
1275
+ if (this.retryLoader) {
1276
+ this.retryLoader.stop();
1277
+ this.retryLoader = undefined;
1278
+ }
1046
1279
  if (this.loadingAnimation) {
1047
1280
  this.loadingAnimation.stop();
1048
1281
  }
@@ -1100,11 +1333,20 @@ export class InteractiveMode {
1100
1333
  break;
1101
1334
  if (this.streamingComponent && event.message.role === "assistant") {
1102
1335
  this.streamingMessage = event.message;
1336
+ let errorMessage;
1337
+ if (this.streamingMessage.stopReason === "aborted") {
1338
+ const retryAttempt = this.session.retryAttempt;
1339
+ errorMessage =
1340
+ retryAttempt > 0
1341
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1342
+ : "Operation aborted";
1343
+ this.streamingMessage.errorMessage = errorMessage;
1344
+ }
1103
1345
  this.streamingComponent.updateContent(this.streamingMessage);
1104
1346
  if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
1105
- const errorMessage = this.streamingMessage.stopReason === "aborted"
1106
- ? "Operation aborted"
1107
- : this.streamingMessage.errorMessage || "Error";
1347
+ if (!errorMessage) {
1348
+ errorMessage = this.streamingMessage.errorMessage || "Error";
1349
+ }
1108
1350
  for (const [, component] of this.pendingTools.entries()) {
1109
1351
  component.updateResult({
1110
1352
  content: [{ type: "text", text: errorMessage }],
@@ -1166,13 +1408,14 @@ export class InteractiveMode {
1166
1408
  this.streamingMessage = undefined;
1167
1409
  }
1168
1410
  this.pendingTools.clear();
1411
+ await this.checkShutdownRequested();
1169
1412
  this.ui.requestRender();
1170
1413
  break;
1171
1414
  case "auto_compaction_start": {
1172
1415
  // Keep editor active; submissions are queued during compaction.
1173
1416
  // Set up escape to abort auto-compaction
1174
- this.autoCompactionEscapeHandler = this.editor.onEscape;
1175
- this.editor.onEscape = () => {
1417
+ this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
1418
+ this.defaultEditor.onEscape = () => {
1176
1419
  this.session.abortCompaction();
1177
1420
  };
1178
1421
  // Show compacting indicator with reason
@@ -1186,7 +1429,7 @@ export class InteractiveMode {
1186
1429
  case "auto_compaction_end": {
1187
1430
  // Restore escape handler
1188
1431
  if (this.autoCompactionEscapeHandler) {
1189
- this.editor.onEscape = this.autoCompactionEscapeHandler;
1432
+ this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
1190
1433
  this.autoCompactionEscapeHandler = undefined;
1191
1434
  }
1192
1435
  // Stop loader
@@ -1218,8 +1461,8 @@ export class InteractiveMode {
1218
1461
  }
1219
1462
  case "auto_retry_start": {
1220
1463
  // Set up escape to abort retry
1221
- this.retryEscapeHandler = this.editor.onEscape;
1222
- this.editor.onEscape = () => {
1464
+ this.retryEscapeHandler = this.defaultEditor.onEscape;
1465
+ this.defaultEditor.onEscape = () => {
1223
1466
  this.session.abortRetry();
1224
1467
  };
1225
1468
  // Show retry indicator
@@ -1233,7 +1476,7 @@ export class InteractiveMode {
1233
1476
  case "auto_retry_end": {
1234
1477
  // Restore escape handler
1235
1478
  if (this.retryEscapeHandler) {
1236
- this.editor.onEscape = this.retryEscapeHandler;
1479
+ this.defaultEditor.onEscape = this.retryEscapeHandler;
1237
1480
  this.retryEscapeHandler = undefined;
1238
1481
  }
1239
1482
  // Stop loader
@@ -1321,7 +1564,7 @@ export class InteractiveMode {
1321
1564
  const userComponent = new UserMessageComponent(textContent);
1322
1565
  this.chatContainer.addChild(userComponent);
1323
1566
  if (options?.populateHistory) {
1324
- this.editor.addToHistory(textContent);
1567
+ this.editor.addToHistory?.(textContent);
1325
1568
  }
1326
1569
  }
1327
1570
  break;
@@ -1363,7 +1606,17 @@ export class InteractiveMode {
1363
1606
  component.setExpanded(this.toolOutputExpanded);
1364
1607
  this.chatContainer.addChild(component);
1365
1608
  if (message.stopReason === "aborted" || message.stopReason === "error") {
1366
- const errorMessage = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
1609
+ let errorMessage;
1610
+ if (message.stopReason === "aborted") {
1611
+ const retryAttempt = this.session.retryAttempt;
1612
+ errorMessage =
1613
+ retryAttempt > 0
1614
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
1615
+ : "Operation aborted";
1616
+ }
1617
+ else {
1618
+ errorMessage = message.errorMessage || "Error";
1619
+ }
1367
1620
  component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
1368
1621
  }
1369
1622
  else {
@@ -1437,7 +1690,11 @@ export class InteractiveMode {
1437
1690
  * Gracefully shutdown the agent.
1438
1691
  * Emits shutdown event to extensions, then exits.
1439
1692
  */
1693
+ isShuttingDown = false;
1440
1694
  async shutdown() {
1695
+ if (this.isShuttingDown)
1696
+ return;
1697
+ this.isShuttingDown = true;
1441
1698
  // Emit shutdown event to extensions
1442
1699
  const extensionRunner = this.session.extensionRunner;
1443
1700
  if (extensionRunner?.hasHandlers("session_shutdown")) {
@@ -1448,6 +1705,14 @@ export class InteractiveMode {
1448
1705
  this.stop();
1449
1706
  process.exit(0);
1450
1707
  }
1708
+ /**
1709
+ * Check if shutdown was requested and perform shutdown if so.
1710
+ */
1711
+ async checkShutdownRequested() {
1712
+ if (!this.shutdownRequested)
1713
+ return;
1714
+ await this.shutdown();
1715
+ }
1451
1716
  handleCtrlZ() {
1452
1717
  // Set up handler to restore TUI when resumed
1453
1718
  process.once("SIGCONT", () => {
@@ -1466,7 +1731,7 @@ export class InteractiveMode {
1466
1731
  // Queue input during compaction (extension commands execute immediately)
1467
1732
  if (this.session.isCompacting) {
1468
1733
  if (this.isExtensionCommand(text)) {
1469
- this.editor.addToHistory(text);
1734
+ this.editor.addToHistory?.(text);
1470
1735
  this.editor.setText("");
1471
1736
  await this.session.prompt(text);
1472
1737
  }
@@ -1478,7 +1743,7 @@ export class InteractiveMode {
1478
1743
  // Alt+Enter queues a follow-up message (waits until agent finishes)
1479
1744
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
1480
1745
  if (this.session.isStreaming) {
1481
- this.editor.addToHistory(text);
1746
+ this.editor.addToHistory?.(text);
1482
1747
  this.editor.setText("");
1483
1748
  await this.session.prompt(text, { streamingBehavior: "followUp" });
1484
1749
  this.updatePendingMessagesDisplay();
@@ -1558,7 +1823,7 @@ export class InteractiveMode {
1558
1823
  this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1559
1824
  return;
1560
1825
  }
1561
- const currentText = this.editor.getExpandedText();
1826
+ const currentText = this.editor.getExpandedText?.() ?? this.editor.getText();
1562
1827
  const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
1563
1828
  try {
1564
1829
  // Write current content to temp file
@@ -1609,12 +1874,14 @@ export class InteractiveMode {
1609
1874
  this.ui.requestRender();
1610
1875
  }
1611
1876
  showNewVersionNotification(newVersion) {
1877
+ const updateInstruction = isBunBinary
1878
+ ? theme.fg("muted", `New version ${newVersion} is available. Download from: `) +
1879
+ theme.fg("accent", "https://github.com/badlogic/pi-mono/releases/latest")
1880
+ : theme.fg("muted", `New version ${newVersion} is available. Run: `) +
1881
+ theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent");
1612
1882
  this.chatContainer.addChild(new Spacer(1));
1613
1883
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
1614
- this.chatContainer.addChild(new Text(theme.bold(theme.fg("warning", "Update Available")) +
1615
- "\n" +
1616
- theme.fg("muted", `New version ${newVersion} is available. Run: `) +
1617
- theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"), 1, 0));
1884
+ this.chatContainer.addChild(new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0));
1618
1885
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
1619
1886
  this.ui.requestRender();
1620
1887
  }
@@ -1642,7 +1909,7 @@ export class InteractiveMode {
1642
1909
  }
1643
1910
  queueCompactionMessage(text, mode) {
1644
1911
  this.compactionQueuedMessages.push({ text, mode });
1645
- this.editor.addToHistory(text);
1912
+ this.editor.addToHistory?.(text);
1646
1913
  this.editor.setText("");
1647
1914
  this.updatePendingMessagesDisplay();
1648
1915
  this.showStatus("Queued message for after compaction");
@@ -1917,9 +2184,9 @@ export class InteractiveMode {
1917
2184
  const wantsSummary = await this.showExtensionConfirm("Summarize branch?", "Create a summary of the branch you're leaving?");
1918
2185
  // Set up escape handler and loader if summarizing
1919
2186
  let summaryLoader;
1920
- const originalOnEscape = this.editor.onEscape;
2187
+ const originalOnEscape = this.defaultEditor.onEscape;
1921
2188
  if (wantsSummary) {
1922
- this.editor.onEscape = () => {
2189
+ this.defaultEditor.onEscape = () => {
1923
2190
  this.session.abortBranchSummary();
1924
2191
  };
1925
2192
  this.chatContainer.addChild(new Spacer(1));
@@ -1955,7 +2222,7 @@ export class InteractiveMode {
1955
2222
  summaryLoader.stop();
1956
2223
  this.statusContainer.clear();
1957
2224
  }
1958
- this.editor.onEscape = originalOnEscape;
2225
+ this.defaultEditor.onEscape = originalOnEscape;
1959
2226
  }
1960
2227
  }, () => {
1961
2228
  done();
@@ -2438,6 +2705,40 @@ export class InteractiveMode {
2438
2705
  this.ui.requestRender();
2439
2706
  }
2440
2707
  async handleBashCommand(command, excludeFromContext = false) {
2708
+ const extensionRunner = this.session.extensionRunner;
2709
+ // Emit user_bash event to let extensions intercept
2710
+ const eventResult = extensionRunner
2711
+ ? await extensionRunner.emitUserBash({
2712
+ type: "user_bash",
2713
+ command,
2714
+ excludeFromContext,
2715
+ cwd: process.cwd(),
2716
+ })
2717
+ : undefined;
2718
+ // If extension returned a full result, use it directly
2719
+ if (eventResult?.result) {
2720
+ const result = eventResult.result;
2721
+ // Create UI component for display
2722
+ this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
2723
+ if (this.session.isStreaming) {
2724
+ this.pendingMessagesContainer.addChild(this.bashComponent);
2725
+ this.pendingBashComponents.push(this.bashComponent);
2726
+ }
2727
+ else {
2728
+ this.chatContainer.addChild(this.bashComponent);
2729
+ }
2730
+ // Show output and complete
2731
+ if (result.output) {
2732
+ this.bashComponent.appendOutput(result.output);
2733
+ }
2734
+ this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true, content: result.output } : undefined, result.fullOutputPath);
2735
+ // Record the result in session
2736
+ this.session.recordBashResult(command, result, { excludeFromContext });
2737
+ this.bashComponent = undefined;
2738
+ this.ui.requestRender();
2739
+ return;
2740
+ }
2741
+ // Normal execution path (possibly with custom operations)
2441
2742
  const isDeferred = this.session.isStreaming;
2442
2743
  this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
2443
2744
  if (isDeferred) {
@@ -2456,7 +2757,7 @@ export class InteractiveMode {
2456
2757
  this.bashComponent.appendOutput(chunk);
2457
2758
  this.ui.requestRender();
2458
2759
  }
2459
- }, { excludeFromContext });
2760
+ }, { excludeFromContext, operations: eventResult?.operations });
2460
2761
  if (this.bashComponent) {
2461
2762
  this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true, content: result.output } : undefined, result.fullOutputPath);
2462
2763
  }
@@ -2487,8 +2788,8 @@ export class InteractiveMode {
2487
2788
  }
2488
2789
  this.statusContainer.clear();
2489
2790
  // Set up escape handler during compaction
2490
- const originalOnEscape = this.editor.onEscape;
2491
- this.editor.onEscape = () => {
2791
+ const originalOnEscape = this.defaultEditor.onEscape;
2792
+ this.defaultEditor.onEscape = () => {
2492
2793
  this.session.abortCompaction();
2493
2794
  };
2494
2795
  // Show compacting status
@@ -2518,7 +2819,7 @@ export class InteractiveMode {
2518
2819
  finally {
2519
2820
  compactingLoader.stop();
2520
2821
  this.statusContainer.clear();
2521
- this.editor.onEscape = originalOnEscape;
2822
+ this.defaultEditor.onEscape = originalOnEscape;
2522
2823
  }
2523
2824
  void this.flushCompactionQueue({ willRetry: false });
2524
2825
  }