@apholdings/jensen-code 0.0.3 → 0.0.5

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 (166) hide show
  1. package/dist/cli/args.d.ts.map +1 -1
  2. package/dist/cli/args.js +6 -6
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/config.d.ts +6 -5
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +32 -25
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/agent-session.d.ts +1 -0
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +25 -0
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/extensions/loader.d.ts.map +1 -1
  13. package/dist/core/extensions/loader.js +1 -1
  14. package/dist/core/extensions/loader.js.map +1 -1
  15. package/dist/core/footer-data-provider.d.ts +4 -1
  16. package/dist/core/footer-data-provider.d.ts.map +1 -1
  17. package/dist/core/footer-data-provider.js +25 -11
  18. package/dist/core/footer-data-provider.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/modes/interactive/components/custom-editor.d.ts +1 -0
  24. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  25. package/dist/modes/interactive/components/custom-editor.js +5 -0
  26. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  27. package/dist/modes/interactive/components/footer.d.ts +0 -2
  28. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  29. package/dist/modes/interactive/components/footer.js +8 -146
  30. package/dist/modes/interactive/components/footer.js.map +1 -1
  31. package/dist/modes/interactive/components/header.d.ts +9 -3
  32. package/dist/modes/interactive/components/header.d.ts.map +1 -1
  33. package/dist/modes/interactive/components/header.js +125 -196
  34. package/dist/modes/interactive/components/header.js.map +1 -1
  35. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  36. package/dist/modes/interactive/components/tool-execution.js +1 -2
  37. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  38. package/dist/modes/interactive/interactive-mode.d.ts +23 -4
  39. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  40. package/dist/modes/interactive/interactive-mode.js +657 -243
  41. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  42. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  43. package/dist/modes/interactive/theme/theme.js +2 -0
  44. package/dist/modes/interactive/theme/theme.js.map +1 -1
  45. package/dist/utils/frontmatter.d.ts.map +1 -1
  46. package/dist/utils/frontmatter.js +8 -4
  47. package/dist/utils/frontmatter.js.map +1 -1
  48. package/dist/utils/tools-manager.d.ts.map +1 -1
  49. package/dist/utils/tools-manager.js +2 -2
  50. package/dist/utils/tools-manager.js.map +1 -1
  51. package/examples/extensions/osgrep.ts +643 -0
  52. package/examples/extensions/subagent/agents.ts +150 -38
  53. package/examples/extensions/subagent/index.ts +634 -514
  54. package/package.json +4 -3
  55. package/examples/README.md +0 -25
  56. package/examples/extensions/README.md +0 -206
  57. package/examples/extensions/antigravity-image-gen.ts +0 -416
  58. package/examples/extensions/auto-commit-on-exit.ts +0 -50
  59. package/examples/extensions/bash-spawn-hook.ts +0 -31
  60. package/examples/extensions/bookmark.ts +0 -51
  61. package/examples/extensions/built-in-tool-renderer.ts +0 -247
  62. package/examples/extensions/claude-rules.ts +0 -87
  63. package/examples/extensions/commands.ts +0 -73
  64. package/examples/extensions/confirm-destructive.ts +0 -60
  65. package/examples/extensions/custom-compaction.ts +0 -115
  66. package/examples/extensions/custom-footer.ts +0 -65
  67. package/examples/extensions/custom-header.ts +0 -74
  68. package/examples/extensions/custom-provider-anthropic/index.ts +0 -605
  69. package/examples/extensions/custom-provider-anthropic/package-lock.json +0 -24
  70. package/examples/extensions/custom-provider-anthropic/package.json +0 -19
  71. package/examples/extensions/custom-provider-gitlab-duo/index.ts +0 -350
  72. package/examples/extensions/custom-provider-gitlab-duo/package.json +0 -16
  73. package/examples/extensions/custom-provider-gitlab-duo/test.ts +0 -82
  74. package/examples/extensions/custom-provider-qwen-cli/index.ts +0 -346
  75. package/examples/extensions/custom-provider-qwen-cli/package.json +0 -16
  76. package/examples/extensions/dirty-repo-guard.ts +0 -57
  77. package/examples/extensions/doom-overlay/README.md +0 -46
  78. package/examples/extensions/doom-overlay/doom/build/doom.js +0 -21
  79. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  80. package/examples/extensions/doom-overlay/doom/build.sh +0 -152
  81. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +0 -72
  82. package/examples/extensions/doom-overlay/doom-component.ts +0 -132
  83. package/examples/extensions/doom-overlay/doom-engine.ts +0 -173
  84. package/examples/extensions/doom-overlay/doom-keys.ts +0 -104
  85. package/examples/extensions/doom-overlay/index.ts +0 -75
  86. package/examples/extensions/doom-overlay/wad-finder.ts +0 -51
  87. package/examples/extensions/dynamic-resources/SKILL.md +0 -8
  88. package/examples/extensions/dynamic-resources/dynamic.json +0 -79
  89. package/examples/extensions/dynamic-resources/dynamic.md +0 -5
  90. package/examples/extensions/dynamic-resources/index.ts +0 -16
  91. package/examples/extensions/dynamic-tools.ts +0 -75
  92. package/examples/extensions/event-bus.ts +0 -44
  93. package/examples/extensions/file-trigger.ts +0 -42
  94. package/examples/extensions/git-checkpoint.ts +0 -54
  95. package/examples/extensions/handoff.ts +0 -151
  96. package/examples/extensions/hello.ts +0 -26
  97. package/examples/extensions/inline-bash.ts +0 -95
  98. package/examples/extensions/input-transform.ts +0 -44
  99. package/examples/extensions/interactive-shell.ts +0 -197
  100. package/examples/extensions/mac-system-theme.ts +0 -48
  101. package/examples/extensions/message-renderer.ts +0 -60
  102. package/examples/extensions/minimal-mode.ts +0 -427
  103. package/examples/extensions/modal-editor.ts +0 -86
  104. package/examples/extensions/model-status.ts +0 -32
  105. package/examples/extensions/notify.ts +0 -56
  106. package/examples/extensions/overlay-qa-tests.ts +0 -1349
  107. package/examples/extensions/overlay-test.ts +0 -151
  108. package/examples/extensions/permission-gate.ts +0 -35
  109. package/examples/extensions/pirate.ts +0 -48
  110. package/examples/extensions/plan-mode/README.md +0 -65
  111. package/examples/extensions/plan-mode/index.ts +0 -341
  112. package/examples/extensions/plan-mode/utils.ts +0 -168
  113. package/examples/extensions/preset.ts +0 -399
  114. package/examples/extensions/protected-paths.ts +0 -31
  115. package/examples/extensions/provider-payload.ts +0 -15
  116. package/examples/extensions/qna.ts +0 -120
  117. package/examples/extensions/question.ts +0 -265
  118. package/examples/extensions/questionnaire.ts +0 -428
  119. package/examples/extensions/rainbow-editor.ts +0 -89
  120. package/examples/extensions/reload-runtime.ts +0 -38
  121. package/examples/extensions/rpc-demo.ts +0 -125
  122. package/examples/extensions/sandbox/index.ts +0 -319
  123. package/examples/extensions/sandbox/package-lock.json +0 -92
  124. package/examples/extensions/sandbox/package.json +0 -19
  125. package/examples/extensions/send-user-message.ts +0 -98
  126. package/examples/extensions/session-name.ts +0 -28
  127. package/examples/extensions/shutdown-command.ts +0 -64
  128. package/examples/extensions/snake.ts +0 -344
  129. package/examples/extensions/space-invaders.ts +0 -561
  130. package/examples/extensions/ssh.ts +0 -221
  131. package/examples/extensions/status-line.ts +0 -41
  132. package/examples/extensions/subagent/README.md +0 -172
  133. package/examples/extensions/subagent/agents/planner.md +0 -37
  134. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  135. package/examples/extensions/subagent/agents/scout.md +0 -50
  136. package/examples/extensions/subagent/agents/worker.md +0 -24
  137. package/examples/extensions/subagent/prompts/implement-and-review.md +0 -10
  138. package/examples/extensions/subagent/prompts/implement.md +0 -10
  139. package/examples/extensions/subagent/prompts/scout-and-plan.md +0 -9
  140. package/examples/extensions/summarize.ts +0 -196
  141. package/examples/extensions/system-prompt-header.ts +0 -18
  142. package/examples/extensions/timed-confirm.ts +0 -71
  143. package/examples/extensions/titlebar-spinner.ts +0 -59
  144. package/examples/extensions/todo.ts +0 -300
  145. package/examples/extensions/tool-override.ts +0 -144
  146. package/examples/extensions/tools.ts +0 -147
  147. package/examples/extensions/trigger-compact.ts +0 -41
  148. package/examples/extensions/truncated-tool.ts +0 -193
  149. package/examples/extensions/widget-placement.ts +0 -18
  150. package/examples/extensions/with-deps/index.ts +0 -33
  151. package/examples/extensions/with-deps/package-lock.json +0 -31
  152. package/examples/extensions/with-deps/package.json +0 -22
  153. package/examples/rpc-extension-ui.ts +0 -632
  154. package/examples/sdk/01-minimal.ts +0 -23
  155. package/examples/sdk/02-custom-model.ts +0 -50
  156. package/examples/sdk/03-custom-prompt.ts +0 -56
  157. package/examples/sdk/04-skills.ts +0 -47
  158. package/examples/sdk/05-tools.ts +0 -57
  159. package/examples/sdk/06-extensions.ts +0 -89
  160. package/examples/sdk/07-context-files.ts +0 -41
  161. package/examples/sdk/08-prompt-templates.ts +0 -48
  162. package/examples/sdk/09-api-keys-and-oauth.ts +0 -49
  163. package/examples/sdk/10-settings.ts +0 -52
  164. package/examples/sdk/11-sessions.ts +0 -49
  165. package/examples/sdk/12-full-control.ts +0 -83
  166. package/examples/sdk/README.md +0 -145
@@ -67,21 +67,27 @@ export class InteractiveMode {
67
67
  header;
68
68
  /** Container that normally hosts the prompt/editor, but may be replaced by selectors or extension UI. */
69
69
  editorContainer;
70
+ promptAreaComponent;
71
+ promptWidgetsVisible = true;
70
72
  footer;
71
73
  footerDataProvider;
72
74
  keybindings;
73
75
  version;
74
76
  isInitialized = false;
77
+ initializing = null;
75
78
  onInputCallback;
76
79
  loadingAnimation = undefined;
77
80
  pendingWorkingMessage = undefined;
78
81
  defaultWorkingMessage = "Working...";
82
+ startupUiGateActive = true;
83
+ deferredStartupNotices = [];
79
84
  lastSigintTime = 0;
80
85
  lastEscapeTime = 0;
81
86
  changelogMarkdown = undefined;
82
87
  // Status line tracking (for mutating immediately-sequential status updates)
83
88
  lastStatusSpacer = undefined;
84
89
  lastStatusText = undefined;
90
+ statusComponent = undefined;
85
91
  // Streaming message tracking
86
92
  streamingComponent = undefined;
87
93
  streamingMessage = undefined;
@@ -113,6 +119,13 @@ export class InteractiveMode {
113
119
  compactionQueuedMessages = [];
114
120
  // Shutdown state
115
121
  shutdownRequested = false;
122
+ // Epoch hardening
123
+ uiEpoch = 0;
124
+ sessionEpoch = 0;
125
+ isInitialMessagesRendered = false;
126
+ // Visual region ownership
127
+ statusOwner = undefined;
128
+ promptOwner = undefined;
116
129
  // Extension UI state
117
130
  extensionSelector = undefined;
118
131
  extensionInput = undefined;
@@ -163,7 +176,8 @@ export class InteractiveMode {
163
176
  getThinkingLevel: () => this.session.thinkingLevel || "off",
164
177
  keybindings: this.keybindings,
165
178
  });
166
- this.header = new Header();
179
+ this.footerDataProvider = new FooterDataProvider();
180
+ this.header = new Header(this.session, this.footerDataProvider);
167
181
  const editorPaddingX = this.settingsManager.getEditorPaddingX();
168
182
  const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
169
183
  this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, {
@@ -177,7 +191,7 @@ export class InteractiveMode {
177
191
  this.editor = this.defaultEditor;
178
192
  this.editorContainer = new Container();
179
193
  this.editorContainer.addChild(this.editor);
180
- this.footerDataProvider = new FooterDataProvider();
194
+ this.promptAreaComponent = this.editor;
181
195
  this.footer = new FooterComponent(session, this.footerDataProvider);
182
196
  this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
183
197
  // Load hide thinking block setting
@@ -343,10 +357,44 @@ export class InteractiveMode {
343
357
  this.chatContainer.addChild(helpContainer);
344
358
  this.ui.requestRender();
345
359
  }
346
- resetSessionUiState(renderInitialMessages = false) {
360
+ resetInteractiveSessionUI(renderInitialMessages = false) {
361
+ // Invalidate all stale async callbacks
362
+ this.uiEpoch++;
363
+ this.sessionEpoch++;
364
+ this.isInitialMessagesRendered = false;
365
+ // Stop all loaders
366
+ if (this.loadingAnimation) {
367
+ this.loadingAnimation.dispose();
368
+ this.loadingAnimation = undefined;
369
+ }
370
+ if (this.autoCompactionLoader) {
371
+ this.autoCompactionLoader.dispose();
372
+ this.autoCompactionLoader = undefined;
373
+ }
374
+ if (this.retryLoader) {
375
+ this.retryLoader.dispose();
376
+ this.retryLoader = undefined;
377
+ }
378
+ // Clean up any transient prompt UI
379
+ if (this.extensionSelector) {
380
+ this.extensionSelector.dispose();
381
+ this.extensionSelector = undefined;
382
+ }
383
+ if (this.extensionInput) {
384
+ this.extensionInput.dispose();
385
+ this.extensionInput = undefined;
386
+ }
387
+ if (this.extensionEditor) {
388
+ this.extensionEditor = undefined;
389
+ }
390
+ this.ui.hideOverlay();
347
391
  this.clearChatContainer();
348
392
  this.pendingMessagesContainer.clear();
393
+ this.clearStatusOwner({ requestRender: false });
394
+ // Force empty the status container directly in case there are other elements left behind
349
395
  this.statusContainer.clear();
396
+ this.statusComponent = undefined;
397
+ this.restoreCanonicalEditor({ requestRender: false, text: "" });
350
398
  this.compactionQueuedMessages = [];
351
399
  this.streamingComponent = undefined;
352
400
  this.streamingMessage = undefined;
@@ -355,7 +403,8 @@ export class InteractiveMode {
355
403
  this.bashComponent = undefined;
356
404
  this.pendingWorkingMessage = undefined;
357
405
  this.isBashMode = false;
358
- this.editor.setText("");
406
+ // Ensure clean widget state
407
+ this.renderWidgets(false);
359
408
  this.updatePromptChrome();
360
409
  if (renderInitialMessages) {
361
410
  this.renderInitialMessages();
@@ -365,96 +414,114 @@ export class InteractiveMode {
365
414
  async init() {
366
415
  if (this.isInitialized)
367
416
  return;
368
- // Load changelog (only show new entries, skip for resumed sessions)
369
- this.changelogMarkdown = this.getChangelogForDisplay();
370
- // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
371
- // Both are needed: fd for autocomplete, rg for grep tool and bash commands
372
- const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
373
- this.fdPath = fdPath;
374
- // Add header container as first child
375
- this.ui.addChild(this.headerContainer);
376
- // Always show the branded header/logo, even when quietStartup is enabled
377
- this.headerContainer.addChild(this.header);
378
- this.headerContainer.addChild(new Spacer(1));
379
- // Add startup instructions only when not silenced
380
- if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
381
- const instructions = this.buildStartupInstructionsText();
382
- this.builtInHeader = new Text(instructions, 1, 0);
383
- this.headerContainer.addChild(this.builtInHeader);
384
- this.headerContainer.addChild(new Spacer(1));
385
- // Add changelog if provided
386
- if (this.changelogMarkdown) {
387
- this.headerContainer.addChild(new DynamicBorder());
388
- if (this.settingsManager.getCollapseChangelog()) {
389
- const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
390
- const latestVersion = versionMatch ? versionMatch[1] : this.version;
391
- const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
392
- this.headerContainer.addChild(new Text(condensedText, 1, 0));
417
+ if (this.initializing)
418
+ return this.initializing;
419
+ this.initializing = (async () => {
420
+ try {
421
+ if (this.isInitialized)
422
+ return;
423
+ // Load changelog (only show new entries, skip for resumed sessions)
424
+ this.changelogMarkdown = this.getChangelogForDisplay();
425
+ // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
426
+ // Both are needed: fd for autocomplete, rg for grep tool and bash commands
427
+ const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
428
+ this.fdPath = fdPath;
429
+ // Add containers only if they're not already children
430
+ const addIfMissing = (parent, child) => {
431
+ if (!parent.children.includes(child)) {
432
+ parent.addChild(child);
433
+ }
434
+ };
435
+ // Add header container as first child
436
+ addIfMissing(this.ui, this.headerContainer);
437
+ // Always show the branded header/logo, even when quietStartup is enabled
438
+ addIfMissing(this.headerContainer, this.header);
439
+ addIfMissing(this.headerContainer, new Spacer(1));
440
+ // Add startup instructions only when not silenced
441
+ if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
442
+ const instructions = this.buildStartupInstructionsText();
443
+ this.builtInHeader = new Text(instructions, 1, 0);
444
+ addIfMissing(this.headerContainer, this.builtInHeader);
445
+ addIfMissing(this.headerContainer, new Spacer(1));
446
+ // Add changelog if provided
447
+ if (this.changelogMarkdown) {
448
+ addIfMissing(this.headerContainer, new DynamicBorder());
449
+ if (this.settingsManager.getCollapseChangelog()) {
450
+ const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
451
+ const latestVersion = versionMatch ? versionMatch[1] : this.version;
452
+ const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
453
+ addIfMissing(this.headerContainer, new Text(condensedText, 1, 0));
454
+ }
455
+ else {
456
+ addIfMissing(this.headerContainer, new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
457
+ addIfMissing(this.headerContainer, new Spacer(1));
458
+ addIfMissing(this.headerContainer, new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()));
459
+ addIfMissing(this.headerContainer, new Spacer(1));
460
+ }
461
+ addIfMissing(this.headerContainer, new DynamicBorder());
462
+ }
393
463
  }
394
464
  else {
395
- this.headerContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
396
- this.headerContainer.addChild(new Spacer(1));
397
- this.headerContainer.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()));
398
- this.headerContainer.addChild(new Spacer(1));
465
+ // Quiet startup: keep the logo visible, but suppress instruction text
466
+ this.builtInHeader = new Text("", 0, 0);
467
+ addIfMissing(this.headerContainer, this.builtInHeader);
468
+ if (this.changelogMarkdown) {
469
+ addIfMissing(this.headerContainer, new Spacer(1));
470
+ const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
471
+ const latestVersion = versionMatch ? versionMatch[1] : this.version;
472
+ const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
473
+ addIfMissing(this.headerContainer, new Text(condensedText, 1, 0));
474
+ }
399
475
  }
400
- this.headerContainer.addChild(new DynamicBorder());
476
+ addIfMissing(this.ui, this.chatContainer);
477
+ addIfMissing(this.ui, this.pendingMessagesContainer);
478
+ addIfMissing(this.ui, this.statusContainer);
479
+ this.renderWidgets(); // Initialize with default spacer
480
+ // The prompt area consists of an optional widget strip, the editor, and a bottom widget strip.
481
+ // Extensions should use ctx.ui.setWidget(..., { placement: "aboveEditor" }) for the top strip.
482
+ addIfMissing(this.ui, this.widgetContainerAbove);
483
+ addIfMissing(this.ui, this.editorContainer);
484
+ addIfMissing(this.ui, this.widgetContainerBelow);
485
+ addIfMissing(this.ui, this.footer);
486
+ this.ui.setFocus(this.editor);
487
+ this.updatePromptChrome();
488
+ this.setupKeyHandlers();
489
+ this.setupEditorSubmitHandler();
490
+ // Initialize extensions first so resources are shown before messages
491
+ await this.initExtensions();
492
+ // Render initial messages AFTER showing loaded resources
493
+ this.renderInitialMessages();
494
+ // Start the UI
495
+ this.ui.start();
496
+ this.isInitialized = true;
497
+ // Set terminal title
498
+ this.updateTerminalTitle();
499
+ // Subscribe to agent events
500
+ this.subscribeToAgent();
501
+ this.windowFocusChangeUnsubscribe = this.ui.onWindowFocusChange(() => {
502
+ this.updatePromptChrome();
503
+ });
504
+ this.focusTargetChangeUnsubscribe = this.ui.onFocusTargetChange(() => {
505
+ this.updatePromptChrome();
506
+ });
507
+ // Set up theme file watcher
508
+ onThemeChange(() => {
509
+ this.ui.invalidate();
510
+ this.updatePromptChrome();
511
+ this.ui.requestRender();
512
+ });
513
+ // Set up git branch watcher (uses provider instead of footer)
514
+ this.footerDataProvider.onBranchChange(() => {
515
+ this.ui.requestRender();
516
+ });
517
+ // Initialize available provider count for footer display
518
+ await this.updateAvailableProviderCount();
401
519
  }
402
- }
403
- else {
404
- // Quiet startup: keep the logo visible, but suppress instruction text
405
- this.builtInHeader = new Text("", 0, 0);
406
- this.headerContainer.addChild(this.builtInHeader);
407
- if (this.changelogMarkdown) {
408
- this.headerContainer.addChild(new Spacer(1));
409
- const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
410
- const latestVersion = versionMatch ? versionMatch[1] : this.version;
411
- const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
412
- this.headerContainer.addChild(new Text(condensedText, 1, 0));
413
- }
414
- }
415
- this.ui.addChild(this.chatContainer);
416
- this.ui.addChild(this.pendingMessagesContainer);
417
- this.ui.addChild(this.statusContainer);
418
- this.renderWidgets(); // Initialize with default spacer
419
- // The prompt area consists of an optional widget strip, the editor, and a bottom widget strip.
420
- // Extensions should use ctx.ui.setWidget(..., { placement: "aboveEditor" }) for the top strip.
421
- this.ui.addChild(this.widgetContainerAbove);
422
- this.ui.addChild(this.editorContainer);
423
- this.ui.addChild(this.widgetContainerBelow);
424
- this.ui.addChild(this.footer);
425
- this.ui.setFocus(this.editor);
426
- this.updatePromptChrome();
427
- this.setupKeyHandlers();
428
- this.setupEditorSubmitHandler();
429
- // Initialize extensions first so resources are shown before messages
430
- await this.initExtensions();
431
- // Render initial messages AFTER showing loaded resources
432
- this.renderInitialMessages();
433
- // Start the UI
434
- this.ui.start();
435
- this.isInitialized = true;
436
- // Set terminal title
437
- this.updateTerminalTitle();
438
- // Subscribe to agent events
439
- this.subscribeToAgent();
440
- this.windowFocusChangeUnsubscribe = this.ui.onWindowFocusChange(() => {
441
- this.updatePromptChrome();
442
- });
443
- this.focusTargetChangeUnsubscribe = this.ui.onFocusTargetChange(() => {
444
- this.updatePromptChrome();
445
- });
446
- // Set up theme file watcher
447
- onThemeChange(() => {
448
- this.ui.invalidate();
449
- this.updatePromptChrome();
450
- this.ui.requestRender();
451
- });
452
- // Set up git branch watcher (uses provider instead of footer)
453
- this.footerDataProvider.onBranchChange(() => {
454
- this.ui.requestRender();
455
- });
456
- // Initialize available provider count for footer display
457
- await this.updateAvailableProviderCount();
520
+ finally {
521
+ this.initializing = null;
522
+ }
523
+ })();
524
+ return this.initializing;
458
525
  }
459
526
  /**
460
527
  * Update terminal title with session name and cwd.
@@ -929,22 +996,23 @@ export class InteractiveMode {
929
996
  waitForIdle: () => this.session.agent.waitForIdle(),
930
997
  newSession: async (options) => {
931
998
  if (this.loadingAnimation) {
932
- this.loadingAnimation.stop();
999
+ this.loadingAnimation.dispose();
933
1000
  this.loadingAnimation = undefined;
934
1001
  }
935
- this.statusContainer.clear();
1002
+ this.clearStatusOwner({ requestRender: false });
936
1003
  // Delegate to AgentSession (handles setup + agent state sync)
937
1004
  const success = await this.session.newSession(options);
938
1005
  if (!success) {
939
1006
  return { cancelled: true };
940
1007
  }
941
- this.resetSessionUiState(true);
1008
+ this.resetInteractiveSessionUI(true);
942
1009
  this.ui.requestRender();
943
1010
  return { cancelled: false };
944
1011
  },
945
1012
  fork: async (entryId) => {
1013
+ const capturedEpoch = this.sessionEpoch;
946
1014
  const result = await this.session.fork(entryId);
947
- if (result.cancelled) {
1015
+ if (result.cancelled || this.sessionEpoch !== capturedEpoch) {
948
1016
  return { cancelled: true };
949
1017
  }
950
1018
  this.clearChatContainer();
@@ -954,13 +1022,14 @@ export class InteractiveMode {
954
1022
  return { cancelled: false };
955
1023
  },
956
1024
  navigateTree: async (targetId, options) => {
1025
+ const capturedEpoch = this.sessionEpoch;
957
1026
  const result = await this.session.navigateTree(targetId, {
958
1027
  summarize: options?.summarize,
959
1028
  customInstructions: options?.customInstructions,
960
1029
  replaceInstructions: options?.replaceInstructions,
961
1030
  label: options?.label,
962
1031
  });
963
- if (result.cancelled) {
1032
+ if (result.cancelled || this.sessionEpoch !== capturedEpoch) {
964
1033
  return { cancelled: true };
965
1034
  }
966
1035
  this.clearChatContainer();
@@ -1140,17 +1209,143 @@ export class InteractiveMode {
1140
1209
  this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`);
1141
1210
  }
1142
1211
  }
1212
+ setPromptWidgetsVisible(visible) {
1213
+ if (this.promptWidgetsVisible === visible) {
1214
+ return;
1215
+ }
1216
+ this.promptWidgetsVisible = visible;
1217
+ if (visible) {
1218
+ this.renderWidgets(false);
1219
+ return;
1220
+ }
1221
+ this.widgetContainerAbove.clear();
1222
+ this.widgetContainerBelow.clear();
1223
+ }
1224
+ mountPromptOwner(component, owner, options) {
1225
+ const focus = options?.focus ?? component;
1226
+ const showWidgets = options?.showWidgets ?? false;
1227
+ const requestRender = options?.requestRender ?? true;
1228
+ const isMounted = this.promptAreaComponent === component &&
1229
+ this.editorContainer.children.length === 1 &&
1230
+ this.editorContainer.children[0] === component;
1231
+ if (!isMounted) {
1232
+ this.promptOwner = owner;
1233
+ this.editorContainer.clear();
1234
+ this.editorContainer.addChild(component);
1235
+ this.promptAreaComponent = component;
1236
+ }
1237
+ if (process.env.NODE_ENV !== "production") {
1238
+ if (this.editorContainer.children.length > 1) {
1239
+ throw new Error("Invariant violation: Multiple prompt owners mounted");
1240
+ }
1241
+ }
1242
+ this.setPromptWidgetsVisible(showWidgets);
1243
+ if (this.ui.getFocusedComponent() !== focus) {
1244
+ this.ui.setFocus(focus);
1245
+ }
1246
+ if (requestRender) {
1247
+ this.ui.requestRender();
1248
+ }
1249
+ }
1250
+ restoreCanonicalEditor(options) {
1251
+ if (options?.owner !== undefined && this.promptOwner !== options.owner) {
1252
+ return;
1253
+ }
1254
+ if (options?.text !== undefined) {
1255
+ this.editor.setText(options.text);
1256
+ }
1257
+ const editorComponent = this.editor;
1258
+ const isMounted = this.promptAreaComponent === editorComponent &&
1259
+ this.editorContainer.children.length === 1 &&
1260
+ this.editorContainer.children[0] === editorComponent;
1261
+ if (!isMounted) {
1262
+ this.promptOwner = undefined;
1263
+ this.editorContainer.clear();
1264
+ this.editorContainer.addChild(editorComponent);
1265
+ this.promptAreaComponent = editorComponent;
1266
+ }
1267
+ if (process.env.NODE_ENV !== "production") {
1268
+ if (this.editorContainer.children.length > 1) {
1269
+ throw new Error("Invariant violation: Multiple prompt owners mounted");
1270
+ }
1271
+ if (this.promptAreaComponent !== editorComponent) {
1272
+ throw new Error("Invariant violation: restoreCanonicalEditor mounted non-canonical editor");
1273
+ }
1274
+ }
1275
+ this.setPromptWidgetsVisible(true);
1276
+ if (options?.focus ?? true) {
1277
+ this.ui.setFocus(editorComponent);
1278
+ }
1279
+ if (options?.requestRender ?? true) {
1280
+ this.ui.requestRender();
1281
+ }
1282
+ }
1283
+ mountStatusOwner(component, owner, options) {
1284
+ const requestRender = options?.requestRender ?? true;
1285
+ const isMounted = this.statusComponent === component &&
1286
+ this.statusContainer.children.length === 1 &&
1287
+ this.statusContainer.children[0] === component;
1288
+ if (!isMounted) {
1289
+ this.statusOwner = owner;
1290
+ this.statusContainer.clear();
1291
+ this.statusContainer.addChild(component);
1292
+ this.statusComponent = component;
1293
+ }
1294
+ if (process.env.NODE_ENV !== "production") {
1295
+ if (this.statusContainer.children.length > 1) {
1296
+ throw new Error("Invariant violation: Multiple status owners mounted");
1297
+ }
1298
+ }
1299
+ if (requestRender) {
1300
+ this.ui.requestRender();
1301
+ }
1302
+ }
1303
+ mountStatusLoader(loader, options) {
1304
+ const row = new Container();
1305
+ row.addChild(new Spacer(1));
1306
+ row.addChild(loader);
1307
+ this.mountStatusOwner(row, loader, options);
1308
+ }
1309
+ clearStatusOwner(options) {
1310
+ if (options?.owner !== undefined && this.statusOwner !== options.owner) {
1311
+ return;
1312
+ }
1313
+ const requestRender = options?.requestRender ?? true;
1314
+ if (this.statusComponent !== undefined || this.statusContainer.children.length > 0) {
1315
+ this.statusOwner = undefined;
1316
+ this.statusContainer.clear();
1317
+ this.statusComponent = undefined;
1318
+ }
1319
+ if (process.env.NODE_ENV !== "production") {
1320
+ if (this.statusContainer.children.length > 0) {
1321
+ throw new Error("Invariant violation: Status container not empty after clear");
1322
+ }
1323
+ }
1324
+ if (requestRender) {
1325
+ this.ui.requestRender();
1326
+ }
1327
+ }
1143
1328
  // Maximum total widget lines to prevent viewport overflow
1144
1329
  static MAX_WIDGET_LINES = 10;
1145
1330
  /**
1146
1331
  * Render all extension widgets to the widget container.
1147
1332
  */
1148
- renderWidgets() {
1333
+ renderWidgets(requestRender = true) {
1149
1334
  if (!this.widgetContainerAbove || !this.widgetContainerBelow)
1150
1335
  return;
1336
+ if (!this.promptWidgetsVisible) {
1337
+ this.widgetContainerAbove.clear();
1338
+ this.widgetContainerBelow.clear();
1339
+ if (requestRender) {
1340
+ this.ui.requestRender();
1341
+ }
1342
+ return;
1343
+ }
1151
1344
  this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true, this.topBar);
1152
1345
  this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false);
1153
- this.ui.requestRender();
1346
+ if (requestRender) {
1347
+ this.ui.requestRender();
1348
+ }
1154
1349
  }
1155
1350
  renderWidgetContainer(container, widgets, spacerWhenEmpty, leadingSpacer, builtInComponent) {
1156
1351
  container.clear();
@@ -1163,7 +1358,7 @@ export class InteractiveMode {
1163
1358
  return;
1164
1359
  }
1165
1360
  if (leadingSpacer) {
1166
- container.addChild(new Spacer(0));
1361
+ container.addChild(new Spacer(1));
1167
1362
  }
1168
1363
  if (builtInComponent) {
1169
1364
  container.addChild(builtInComponent);
@@ -1252,14 +1447,44 @@ export class InteractiveMode {
1252
1447
  * Create the ExtensionUIContext for extensions.
1253
1448
  */
1254
1449
  createExtensionUIContext() {
1450
+ const capturedUiEpoch = this.uiEpoch;
1451
+ const capturedSessionEpoch = this.sessionEpoch;
1452
+ const guard = (fn) => {
1453
+ return ((...args) => {
1454
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1455
+ return undefined;
1456
+ }
1457
+ return fn(...args);
1458
+ });
1459
+ };
1255
1460
  return {
1256
- select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
1257
- confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
1258
- input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
1259
- notify: (message, type) => this.showExtensionNotify(message, type),
1260
- onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler),
1261
- setStatus: (key, text) => this.setExtensionStatus(key, text),
1262
- setWorkingMessage: (message) => {
1461
+ select: (title, options, opts) => {
1462
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1463
+ return Promise.resolve(undefined);
1464
+ }
1465
+ return this.showExtensionSelector(title, options, opts);
1466
+ },
1467
+ confirm: (title, message, opts) => {
1468
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1469
+ return Promise.resolve(false);
1470
+ }
1471
+ return this.showExtensionConfirm(title, message, opts);
1472
+ },
1473
+ input: (title, placeholder, opts) => {
1474
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1475
+ return Promise.resolve(undefined);
1476
+ }
1477
+ return this.showExtensionInput(title, placeholder, opts);
1478
+ },
1479
+ notify: guard((message, type) => this.showExtensionNotify(message, type)),
1480
+ onTerminalInput: (handler) => {
1481
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1482
+ return () => { };
1483
+ }
1484
+ return this.addExtensionTerminalInputListener(handler);
1485
+ },
1486
+ setStatus: guard((key, text) => this.setExtensionStatus(key, text)),
1487
+ setWorkingMessage: guard((message) => {
1263
1488
  if (this.loadingAnimation) {
1264
1489
  if (message) {
1265
1490
  this.loadingAnimation.setMessage(message);
@@ -1272,23 +1497,41 @@ export class InteractiveMode {
1272
1497
  // Queue message for when loadingAnimation is created (handles agent_start race)
1273
1498
  this.pendingWorkingMessage = message;
1274
1499
  }
1500
+ }),
1501
+ setWidget: guard((key, content, options) => this.setExtensionWidget(key, content, options)),
1502
+ setFooter: guard((factory) => this.setExtensionFooter(factory)),
1503
+ setHeader: guard((factory) => this.setExtensionHeader(factory)),
1504
+ setTitle: guard((title) => this.ui.terminal.setTitle(title)),
1505
+ custom: (factory, options) => {
1506
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1507
+ return Promise.resolve(undefined);
1508
+ }
1509
+ return this.showExtensionCustom(factory, options);
1510
+ },
1511
+ pasteToEditor: guard((text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`)),
1512
+ setEditorText: guard((text) => this.editor.setText(text)),
1513
+ getEditorText: () => {
1514
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1515
+ return "";
1516
+ }
1517
+ return this.editor.getText();
1275
1518
  },
1276
- setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
1277
- setFooter: (factory) => this.setExtensionFooter(factory),
1278
- setHeader: (factory) => this.setExtensionHeader(factory),
1279
- setTitle: (title) => this.ui.terminal.setTitle(title),
1280
- custom: (factory, options) => this.showExtensionCustom(factory, options),
1281
- pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`),
1282
- setEditorText: (text) => this.editor.setText(text),
1283
- getEditorText: () => this.editor.getText(),
1284
- editor: (title, prefill) => this.showExtensionEditor(title, prefill),
1285
- setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
1519
+ editor: (title, prefill) => {
1520
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1521
+ return Promise.resolve(undefined);
1522
+ }
1523
+ return this.showExtensionEditor(title, prefill);
1524
+ },
1525
+ setEditorComponent: guard((factory) => this.setCustomEditorComponent(factory)),
1286
1526
  get theme() {
1287
1527
  return theme;
1288
1528
  },
1289
1529
  getAllThemes: () => getAvailableThemesWithPaths(),
1290
1530
  getTheme: (name) => getThemeByName(name),
1291
1531
  setTheme: (themeOrName) => {
1532
+ if (this.uiEpoch !== capturedUiEpoch || this.sessionEpoch !== capturedSessionEpoch) {
1533
+ return { success: false, error: "Stale context" };
1534
+ }
1292
1535
  if (themeOrName instanceof Theme) {
1293
1536
  setThemeInstance(themeOrName);
1294
1537
  this.ui.requestRender();
@@ -1304,48 +1547,55 @@ export class InteractiveMode {
1304
1547
  return result;
1305
1548
  },
1306
1549
  getToolsExpanded: () => this.toolOutputExpanded,
1307
- setToolsExpanded: (expanded) => this.setToolsExpanded(expanded),
1550
+ setToolsExpanded: guard((expanded) => this.setToolsExpanded(expanded)),
1308
1551
  };
1309
1552
  }
1310
1553
  /**
1311
1554
  * Show a selector for extensions.
1312
1555
  */
1313
1556
  showExtensionSelector(title, options, opts) {
1557
+ const capturedEpoch = this.uiEpoch;
1314
1558
  return new Promise((resolve) => {
1315
1559
  if (opts?.signal?.aborted) {
1316
1560
  resolve(undefined);
1317
1561
  return;
1318
1562
  }
1319
1563
  const onAbort = () => {
1564
+ if (this.uiEpoch !== capturedEpoch) {
1565
+ resolve(undefined);
1566
+ return;
1567
+ }
1320
1568
  this.hideExtensionSelector();
1321
1569
  resolve(undefined);
1322
1570
  };
1323
1571
  opts?.signal?.addEventListener("abort", onAbort, { once: true });
1324
1572
  this.extensionSelector = new ExtensionSelectorComponent(title, options, (option) => {
1325
1573
  opts?.signal?.removeEventListener("abort", onAbort);
1326
- this.hideExtensionSelector();
1574
+ if (this.uiEpoch !== capturedEpoch) {
1575
+ resolve(undefined);
1576
+ return;
1577
+ }
1578
+ this.hideExtensionSelector(this.extensionSelector);
1327
1579
  resolve(option);
1328
1580
  }, () => {
1329
1581
  opts?.signal?.removeEventListener("abort", onAbort);
1330
- this.hideExtensionSelector();
1582
+ if (this.uiEpoch !== capturedEpoch) {
1583
+ resolve(undefined);
1584
+ return;
1585
+ }
1586
+ this.hideExtensionSelector(this.extensionSelector);
1331
1587
  resolve(undefined);
1332
1588
  }, { tui: this.ui, timeout: opts?.timeout });
1333
- this.editorContainer.clear();
1334
- this.editorContainer.addChild(this.extensionSelector);
1335
- this.ui.setFocus(this.extensionSelector);
1336
- this.ui.requestRender();
1589
+ this.mountPromptOwner(this.extensionSelector, this.extensionSelector);
1337
1590
  });
1338
1591
  }
1339
1592
  /**
1340
1593
  * Hide the extension selector.
1341
1594
  */
1342
- hideExtensionSelector() {
1595
+ hideExtensionSelector(owner) {
1343
1596
  this.extensionSelector?.dispose();
1344
- this.editorContainer.clear();
1345
- this.editorContainer.addChild(this.editor);
1346
1597
  this.extensionSelector = undefined;
1347
- this.ui.setFocus(this.editor);
1348
- this.ui.requestRender();
1598
+ this.restoreCanonicalEditor({ owner });
1349
1599
  }
1350
1600
  /**
1351
1601
  * Show a confirmation dialog for extensions.
@@ -1358,69 +1608,79 @@ export class InteractiveMode {
1358
1608
  * Show a text input for extensions.
1359
1609
  */
1360
1610
  showExtensionInput(title, placeholder, opts) {
1611
+ const capturedEpoch = this.uiEpoch;
1361
1612
  return new Promise((resolve) => {
1362
1613
  if (opts?.signal?.aborted) {
1363
1614
  resolve(undefined);
1364
1615
  return;
1365
1616
  }
1366
1617
  const onAbort = () => {
1367
- this.hideExtensionInput();
1618
+ if (this.uiEpoch !== capturedEpoch) {
1619
+ resolve(undefined);
1620
+ return;
1621
+ }
1622
+ this.hideExtensionInput(this.extensionInput);
1368
1623
  resolve(undefined);
1369
1624
  };
1370
1625
  opts?.signal?.addEventListener("abort", onAbort, { once: true });
1371
1626
  this.extensionInput = new ExtensionInputComponent(title, placeholder, (value) => {
1372
1627
  opts?.signal?.removeEventListener("abort", onAbort);
1373
- this.hideExtensionInput();
1628
+ if (this.uiEpoch !== capturedEpoch) {
1629
+ resolve(undefined);
1630
+ return;
1631
+ }
1632
+ this.hideExtensionInput(this.extensionInput);
1374
1633
  resolve(value);
1375
1634
  }, () => {
1376
1635
  opts?.signal?.removeEventListener("abort", onAbort);
1377
- this.hideExtensionInput();
1636
+ if (this.uiEpoch !== capturedEpoch) {
1637
+ resolve(undefined);
1638
+ return;
1639
+ }
1640
+ this.hideExtensionInput(this.extensionInput);
1378
1641
  resolve(undefined);
1379
1642
  }, { tui: this.ui, timeout: opts?.timeout });
1380
- this.editorContainer.clear();
1381
- this.editorContainer.addChild(this.extensionInput);
1382
- this.ui.setFocus(this.extensionInput);
1383
- this.ui.requestRender();
1643
+ this.mountPromptOwner(this.extensionInput, this.extensionInput);
1384
1644
  });
1385
1645
  }
1386
1646
  /**
1387
1647
  * Hide the extension input.
1388
1648
  */
1389
- hideExtensionInput() {
1649
+ hideExtensionInput(owner) {
1390
1650
  this.extensionInput?.dispose();
1391
- this.editorContainer.clear();
1392
- this.editorContainer.addChild(this.editor);
1393
1651
  this.extensionInput = undefined;
1394
- this.ui.setFocus(this.editor);
1395
- this.ui.requestRender();
1652
+ this.restoreCanonicalEditor({ owner });
1396
1653
  }
1397
1654
  /**
1398
1655
  * Show a multi-line editor for extensions (with Ctrl+G support).
1399
1656
  */
1400
1657
  showExtensionEditor(title, prefill) {
1658
+ const capturedEpoch = this.uiEpoch;
1401
1659
  return new Promise((resolve) => {
1402
1660
  this.extensionEditor = new ExtensionEditorComponent(this.ui, this.keybindings, title, prefill, (value) => {
1403
- this.hideExtensionEditor();
1661
+ if (this.uiEpoch !== capturedEpoch) {
1662
+ resolve(undefined);
1663
+ return;
1664
+ }
1665
+ this.hideExtensionEditor(this.extensionEditor);
1404
1666
  resolve(value);
1405
1667
  }, () => {
1406
- this.hideExtensionEditor();
1668
+ if (this.uiEpoch !== capturedEpoch) {
1669
+ resolve(undefined);
1670
+ return;
1671
+ }
1672
+ this.hideExtensionEditor(this.extensionEditor);
1407
1673
  resolve(undefined);
1408
1674
  });
1409
- this.editorContainer.clear();
1410
- this.editorContainer.addChild(this.extensionEditor);
1411
- this.ui.setFocus(this.extensionEditor);
1412
- this.ui.requestRender();
1675
+ this.mountPromptOwner(this.extensionEditor, this.extensionEditor);
1413
1676
  });
1414
1677
  }
1415
1678
  /**
1416
1679
  * Hide the extension editor.
1417
1680
  */
1418
- hideExtensionEditor() {
1419
- this.editorContainer.clear();
1420
- this.editorContainer.addChild(this.editor);
1681
+ hideExtensionEditor(owner) {
1421
1682
  this.extensionEditor = undefined;
1422
- this.ui.setFocus(this.editor);
1423
- this.ui.requestRender();
1683
+ this.restoreCanonicalEditor({ owner });
1424
1684
  }
1425
1685
  /**
1426
1686
  * Set a custom editor component from an extension.
@@ -1429,7 +1689,6 @@ export class InteractiveMode {
1429
1689
  setCustomEditorComponent(factory) {
1430
1690
  // Save text from current editor before switching
1431
1691
  const currentText = this.editor.getText();
1432
- this.editorContainer.clear();
1433
1692
  if (factory) {
1434
1693
  // Create the custom editor with tui, theme, and keybindings
1435
1694
  const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);
@@ -1473,8 +1732,7 @@ export class InteractiveMode {
1473
1732
  this.defaultEditor.setText(currentText);
1474
1733
  this.editor = this.defaultEditor;
1475
1734
  }
1476
- this.editorContainer.addChild(this.editor);
1477
- this.ui.setFocus(this.editor);
1735
+ this.restoreCanonicalEditor({ requestRender: false });
1478
1736
  this.updatePromptChrome();
1479
1737
  }
1480
1738
  /**
@@ -1493,22 +1751,24 @@ export class InteractiveMode {
1493
1751
  }
1494
1752
  /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
1495
1753
  async showExtensionCustom(factory, options) {
1754
+ const capturedEpoch = this.uiEpoch;
1496
1755
  const savedText = this.editor.getText();
1497
1756
  const isOverlay = options?.overlay ?? false;
1757
+ let component;
1498
1758
  const restoreEditor = () => {
1499
- this.editorContainer.clear();
1500
- this.editorContainer.addChild(this.editor);
1501
- this.editor.setText(savedText);
1502
- this.ui.setFocus(this.editor);
1503
- this.ui.requestRender();
1759
+ this.restoreCanonicalEditor({ text: savedText, owner: component });
1504
1760
  };
1505
1761
  return new Promise((resolve, reject) => {
1506
- let component;
1507
1762
  let closed = false;
1508
1763
  const close = (result) => {
1509
1764
  if (closed)
1510
1765
  return;
1511
1766
  closed = true;
1767
+ if (this.uiEpoch !== capturedEpoch) {
1768
+ // We're stale, just resolve the promise to avoid leaks, but don't mutate UI
1769
+ resolve(result);
1770
+ return;
1771
+ }
1512
1772
  if (isOverlay)
1513
1773
  this.ui.hideOverlay();
1514
1774
  else
@@ -1526,6 +1786,14 @@ export class InteractiveMode {
1526
1786
  .then((c) => {
1527
1787
  if (closed)
1528
1788
  return;
1789
+ if (this.uiEpoch !== capturedEpoch) {
1790
+ closed = true;
1791
+ try {
1792
+ c?.dispose?.();
1793
+ }
1794
+ catch { }
1795
+ return; // Do not mount if epoch changed
1796
+ }
1529
1797
  component = c;
1530
1798
  if (isOverlay) {
1531
1799
  // Resolve overlay options - can be static or dynamic function
@@ -1545,10 +1813,7 @@ export class InteractiveMode {
1545
1813
  options?.onHandle?.(handle);
1546
1814
  }
1547
1815
  else {
1548
- this.editorContainer.clear();
1549
- this.editorContainer.addChild(component);
1550
- this.ui.setFocus(component);
1551
- this.ui.requestRender();
1816
+ this.mountPromptOwner(component, component);
1552
1817
  }
1553
1818
  })
1554
1819
  .catch((err) => {
@@ -1841,9 +2106,13 @@ export class InteractiveMode {
1841
2106
  });
1842
2107
  }
1843
2108
  async handleEvent(event) {
2109
+ const capturedEpoch = this.sessionEpoch;
1844
2110
  if (!this.isInitialized) {
1845
2111
  await this.init();
1846
2112
  }
2113
+ if (this.sessionEpoch !== capturedEpoch) {
2114
+ return; // Stale event from previous session/epoch
2115
+ }
1847
2116
  this.footer.invalidate();
1848
2117
  switch (event.type) {
1849
2118
  case "agent_start":
@@ -1854,15 +2123,20 @@ export class InteractiveMode {
1854
2123
  this.retryEscapeHandler = undefined;
1855
2124
  }
1856
2125
  if (this.retryLoader) {
1857
- this.retryLoader.stop();
2126
+ this.retryLoader.dispose();
1858
2127
  this.retryLoader = undefined;
1859
2128
  }
1860
2129
  if (this.loadingAnimation) {
1861
- this.loadingAnimation.stop();
2130
+ this.loadingAnimation.dispose();
2131
+ const oldLoader = this.loadingAnimation;
2132
+ this.loadingAnimation = undefined;
2133
+ this.clearStatusOwner({ requestRender: false, owner: oldLoader });
2134
+ }
2135
+ else {
2136
+ this.clearStatusOwner({ requestRender: false });
1862
2137
  }
1863
- this.statusContainer.clear();
1864
2138
  this.loadingAnimation = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), this.defaultWorkingMessage);
1865
- this.statusContainer.addChild(this.loadingAnimation);
2139
+ this.mountStatusLoader(this.loadingAnimation, { requestRender: false });
1866
2140
  // Apply any pending working message queued before loader existed
1867
2141
  if (this.pendingWorkingMessage !== undefined) {
1868
2142
  if (this.pendingWorkingMessage) {
@@ -1875,14 +2149,29 @@ export class InteractiveMode {
1875
2149
  case "message_start":
1876
2150
  if (event.message.role === "custom") {
1877
2151
  this.addMessageToChat(event.message);
2152
+ if (this.loadingAnimation || this.statusContainer.children.length > 0) {
2153
+ this.ui.invalidate();
2154
+ }
1878
2155
  this.ui.requestRender();
1879
2156
  }
1880
2157
  else if (event.message.role === "user") {
1881
2158
  this.addMessageToChat(event.message);
1882
2159
  this.updatePendingMessagesDisplay();
2160
+ if (this.loadingAnimation || this.statusContainer.children.length > 0) {
2161
+ this.ui.invalidate();
2162
+ }
1883
2163
  this.ui.requestRender();
1884
2164
  }
1885
2165
  else if (event.message.role === "assistant") {
2166
+ // Check if we already have a streaming component for this message to prevent duplicates
2167
+ if (this.streamingComponent && this.streamingMessage === event.message) {
2168
+ return;
2169
+ }
2170
+ // If we have a different streaming component, something is wrong, but let's at least not leak it
2171
+ if (this.streamingComponent) {
2172
+ this.chatContainer.removeChild(this.streamingComponent);
2173
+ this.streamingComponent = undefined;
2174
+ }
1886
2175
  this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings());
1887
2176
  this.streamingMessage = event.message;
1888
2177
  this.chatContainer.addChild(this.streamingComponent);
@@ -1985,9 +2274,10 @@ export class InteractiveMode {
1985
2274
  }
1986
2275
  case "agent_end":
1987
2276
  if (this.loadingAnimation) {
1988
- this.loadingAnimation.stop();
2277
+ this.loadingAnimation.dispose();
2278
+ const oldLoader = this.loadingAnimation;
1989
2279
  this.loadingAnimation = undefined;
1990
- this.statusContainer.clear();
2280
+ this.clearStatusOwner({ requestRender: false, owner: oldLoader });
1991
2281
  }
1992
2282
  if (this.streamingComponent) {
1993
2283
  this.chatContainer.removeChild(this.streamingComponent);
@@ -1996,7 +2286,13 @@ export class InteractiveMode {
1996
2286
  }
1997
2287
  this.pendingTools.clear();
1998
2288
  await this.checkShutdownRequested();
1999
- this.ui.requestRender();
2289
+ if (this.startupUiGateActive) {
2290
+ this.startupUiGateActive = false;
2291
+ this.flushDeferredStartupNotices();
2292
+ }
2293
+ else {
2294
+ this.ui.requestRender();
2295
+ }
2000
2296
  break;
2001
2297
  case "auto_compaction_start": {
2002
2298
  // Keep editor active; submissions are queued during compaction.
@@ -2005,11 +2301,16 @@ export class InteractiveMode {
2005
2301
  this.defaultEditor.onEscape = () => {
2006
2302
  this.session.abortCompaction();
2007
2303
  };
2304
+ // Stop loading animation
2305
+ if (this.loadingAnimation) {
2306
+ this.loadingAnimation.dispose();
2307
+ this.loadingAnimation = undefined;
2308
+ }
2309
+ this.clearStatusOwner({ requestRender: false });
2008
2310
  // Show compacting indicator with reason
2009
- this.statusContainer.clear();
2010
2311
  const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
2011
2312
  this.autoCompactionLoader = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), `${reasonText}Auto-compacting... (${appKey(this.keybindings, "interrupt")} to cancel)`);
2012
- this.statusContainer.addChild(this.autoCompactionLoader);
2313
+ this.mountStatusLoader(this.autoCompactionLoader, { requestRender: false });
2013
2314
  this.ui.requestRender();
2014
2315
  break;
2015
2316
  }
@@ -2021,9 +2322,10 @@ export class InteractiveMode {
2021
2322
  }
2022
2323
  // Stop loader
2023
2324
  if (this.autoCompactionLoader) {
2024
- this.autoCompactionLoader.stop();
2325
+ this.autoCompactionLoader.dispose();
2326
+ const oldLoader = this.autoCompactionLoader;
2025
2327
  this.autoCompactionLoader = undefined;
2026
- this.statusContainer.clear();
2328
+ this.clearStatusOwner({ requestRender: false, owner: oldLoader });
2027
2329
  }
2028
2330
  // Handle result
2029
2331
  if (event.aborted) {
@@ -2057,11 +2359,16 @@ export class InteractiveMode {
2057
2359
  this.defaultEditor.onEscape = () => {
2058
2360
  this.session.abortRetry();
2059
2361
  };
2362
+ // Stop loading animation
2363
+ if (this.loadingAnimation) {
2364
+ this.loadingAnimation.dispose();
2365
+ this.loadingAnimation = undefined;
2366
+ }
2367
+ this.clearStatusOwner({ requestRender: false });
2060
2368
  // Show retry indicator
2061
- this.statusContainer.clear();
2062
2369
  const delaySeconds = Math.round(event.delayMs / 1000);
2063
2370
  this.retryLoader = new Loader(this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${appKey(this.keybindings, "interrupt")} to cancel)`);
2064
- this.statusContainer.addChild(this.retryLoader);
2371
+ this.mountStatusLoader(this.retryLoader, { requestRender: false });
2065
2372
  this.ui.requestRender();
2066
2373
  break;
2067
2374
  }
@@ -2073,9 +2380,10 @@ export class InteractiveMode {
2073
2380
  }
2074
2381
  // Stop loader
2075
2382
  if (this.retryLoader) {
2076
- this.retryLoader.stop();
2383
+ this.retryLoader.dispose();
2384
+ const oldLoader = this.retryLoader;
2077
2385
  this.retryLoader = undefined;
2078
- this.statusContainer.clear();
2386
+ this.clearStatusOwner({ requestRender: false, owner: oldLoader });
2079
2387
  }
2080
2388
  // Show error only on final failure (success shows normal response)
2081
2389
  if (!event.success) {
@@ -2101,7 +2409,9 @@ export class InteractiveMode {
2101
2409
  * If multiple status messages are emitted back-to-back (without anything else being added to the chat),
2102
2410
  * we update the previous status line instead of appending new ones to avoid log spam.
2103
2411
  */
2104
- showStatus(message) {
2412
+ showStatus(message, ownerEpoch) {
2413
+ if (ownerEpoch !== undefined && this.sessionEpoch !== ownerEpoch)
2414
+ return;
2105
2415
  const children = this.chatContainer.children;
2106
2416
  const last = children.length > 0 ? children[children.length - 1] : undefined;
2107
2417
  const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
@@ -2251,6 +2561,9 @@ export class InteractiveMode {
2251
2561
  this.ui.requestRender();
2252
2562
  }
2253
2563
  renderInitialMessages() {
2564
+ if (this.isInitialMessagesRendered)
2565
+ return;
2566
+ this.isInitialMessagesRendered = true;
2254
2567
  // Get aligned messages and entries from session context
2255
2568
  const context = this.sessionManager.buildSessionContext();
2256
2569
  this.renderSessionContext(context, {
@@ -2354,6 +2667,7 @@ export class InteractiveMode {
2354
2667
  const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
2355
2668
  if (!text)
2356
2669
  return;
2670
+ const capturedEpoch = this.sessionEpoch;
2357
2671
  // Queue input during compaction (extension commands execute immediately)
2358
2672
  if (this.session.isCompacting) {
2359
2673
  if (this.isExtensionCommand(text)) {
@@ -2372,6 +2686,8 @@ export class InteractiveMode {
2372
2686
  this.editor.addToHistory?.(text);
2373
2687
  this.editor.setText("");
2374
2688
  await this.session.prompt(text, { streamingBehavior: "followUp" });
2689
+ if (this.sessionEpoch !== capturedEpoch)
2690
+ return;
2375
2691
  this.updatePendingMessagesDisplay();
2376
2692
  this.ui.requestRender();
2377
2693
  }
@@ -2435,8 +2751,11 @@ export class InteractiveMode {
2435
2751
  }
2436
2752
  }
2437
2753
  async cycleModel(direction) {
2754
+ const capturedEpoch = this.sessionEpoch;
2438
2755
  try {
2439
2756
  const result = await this.session.cycleModel(direction);
2757
+ if (this.sessionEpoch !== capturedEpoch)
2758
+ return;
2440
2759
  if (result === undefined) {
2441
2760
  const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
2442
2761
  this.showStatus(msg);
@@ -2449,6 +2768,8 @@ export class InteractiveMode {
2449
2768
  }
2450
2769
  }
2451
2770
  catch (error) {
2771
+ if (this.sessionEpoch !== capturedEpoch)
2772
+ return;
2452
2773
  this.showError(error instanceof Error ? error.message : String(error));
2453
2774
  }
2454
2775
  }
@@ -2527,26 +2848,54 @@ export class InteractiveMode {
2527
2848
  this.editor.setText("");
2528
2849
  this.ui.requestRender();
2529
2850
  }
2530
- showError(errorMessage) {
2851
+ showError(errorMessage, ownerEpoch) {
2852
+ if (ownerEpoch !== undefined && this.sessionEpoch !== ownerEpoch)
2853
+ return;
2531
2854
  this.chatContainer.addChild(new Spacer(1));
2532
2855
  this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
2533
2856
  this.ui.requestRender();
2534
2857
  }
2535
- showWarning(warningMessage) {
2536
- this.chatContainer.addChild(new Spacer(1));
2537
- this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
2858
+ enqueueStartupNotice(notice) {
2859
+ if (this.startupUiGateActive) {
2860
+ this.deferredStartupNotices.push(notice);
2861
+ return;
2862
+ }
2863
+ notice();
2864
+ this.ui.requestRender();
2865
+ }
2866
+ flushDeferredStartupNotices() {
2867
+ if (this.deferredStartupNotices.length === 0) {
2868
+ return;
2869
+ }
2870
+ const notices = [...this.deferredStartupNotices];
2871
+ this.deferredStartupNotices = [];
2872
+ for (const notice of notices) {
2873
+ notice();
2874
+ }
2875
+ this.ui.invalidate();
2538
2876
  this.ui.requestRender();
2539
2877
  }
2540
- showNewVersionNotification(newVersion) {
2878
+ showWarning(warningMessage, ownerEpoch) {
2879
+ if (ownerEpoch !== undefined && this.sessionEpoch !== ownerEpoch)
2880
+ return;
2881
+ this.enqueueStartupNotice(() => {
2882
+ this.chatContainer.addChild(new Spacer(1));
2883
+ this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
2884
+ });
2885
+ }
2886
+ showNewVersionNotification(newVersion, ownerEpoch) {
2887
+ if (ownerEpoch !== undefined && this.sessionEpoch !== ownerEpoch)
2888
+ return;
2541
2889
  const action = theme.fg("accent", getUpdateInstruction(PACKAGE_NAME));
2542
2890
  const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. `) + action;
2543
2891
  const changelogUrl = theme.fg("accent", "https://github.com/apholdings/jensen-code/blob/main/packages/coding-agent/CHANGELOG.md");
2544
2892
  const changelogLine = theme.fg("muted", "Changelog: ") + changelogUrl;
2545
- this.chatContainer.addChild(new Spacer(1));
2546
- this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2547
- this.chatContainer.addChild(new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`, 1, 0));
2548
- this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2549
- this.ui.requestRender();
2893
+ this.enqueueStartupNotice(() => {
2894
+ this.chatContainer.addChild(new Spacer(1));
2895
+ this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2896
+ this.chatContainer.addChild(new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`, 1, 0));
2897
+ this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2898
+ });
2550
2899
  }
2551
2900
  /**
2552
2901
  * Get all queued messages (read-only).
@@ -2641,10 +2990,13 @@ export class InteractiveMode {
2641
2990
  if (this.compactionQueuedMessages.length === 0) {
2642
2991
  return;
2643
2992
  }
2993
+ const capturedEpoch = this.sessionEpoch;
2644
2994
  const queuedMessages = [...this.compactionQueuedMessages];
2645
2995
  this.compactionQueuedMessages = [];
2646
2996
  this.updatePendingMessagesDisplay();
2647
2997
  const restoreQueue = (error) => {
2998
+ if (this.sessionEpoch !== capturedEpoch)
2999
+ return;
2648
3000
  this.session.clearQueue();
2649
3001
  this.compactionQueuedMessages = queuedMessages;
2650
3002
  this.updatePendingMessagesDisplay();
@@ -2654,6 +3006,8 @@ export class InteractiveMode {
2654
3006
  if (options?.willRetry) {
2655
3007
  // When retry is pending, queue messages for the retry turn
2656
3008
  for (const message of queuedMessages) {
3009
+ if (this.sessionEpoch !== capturedEpoch)
3010
+ return;
2657
3011
  if (this.isExtensionCommand(message.text)) {
2658
3012
  await this.session.prompt(message.text);
2659
3013
  }
@@ -2664,7 +3018,9 @@ export class InteractiveMode {
2664
3018
  await this.session.steer(message.text);
2665
3019
  }
2666
3020
  }
2667
- this.updatePendingMessagesDisplay();
3021
+ if (this.sessionEpoch === capturedEpoch) {
3022
+ this.updatePendingMessagesDisplay();
3023
+ }
2668
3024
  return;
2669
3025
  }
2670
3026
  // Find first non-extension-command message to use as prompt
@@ -2672,6 +3028,8 @@ export class InteractiveMode {
2672
3028
  if (firstPromptIndex === -1) {
2673
3029
  // All extension commands - execute them all
2674
3030
  for (const message of queuedMessages) {
3031
+ if (this.sessionEpoch !== capturedEpoch)
3032
+ return;
2675
3033
  await this.session.prompt(message.text);
2676
3034
  }
2677
3035
  return;
@@ -2681,14 +3039,20 @@ export class InteractiveMode {
2681
3039
  const firstPrompt = queuedMessages[firstPromptIndex];
2682
3040
  const rest = queuedMessages.slice(firstPromptIndex + 1);
2683
3041
  for (const message of preCommands) {
3042
+ if (this.sessionEpoch !== capturedEpoch)
3043
+ return;
2684
3044
  await this.session.prompt(message.text);
2685
3045
  }
3046
+ if (this.sessionEpoch !== capturedEpoch)
3047
+ return;
2686
3048
  // Send first prompt (starts streaming)
2687
3049
  const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => {
2688
3050
  restoreQueue(error);
2689
3051
  });
2690
3052
  // Queue remaining messages
2691
3053
  for (const message of rest) {
3054
+ if (this.sessionEpoch !== capturedEpoch)
3055
+ return;
2692
3056
  if (this.isExtensionCommand(message.text)) {
2693
3057
  await this.session.prompt(message.text);
2694
3058
  }
@@ -2699,7 +3063,9 @@ export class InteractiveMode {
2699
3063
  await this.session.steer(message.text);
2700
3064
  }
2701
3065
  }
2702
- this.updatePendingMessagesDisplay();
3066
+ if (this.sessionEpoch === capturedEpoch) {
3067
+ this.updatePendingMessagesDisplay();
3068
+ }
2703
3069
  void promptPromise;
2704
3070
  }
2705
3071
  catch (error) {
@@ -2722,16 +3088,14 @@ export class InteractiveMode {
2722
3088
  * @param create Factory that receives a `done` callback and returns the component and focus target
2723
3089
  */
2724
3090
  showSelector(create) {
3091
+ const capturedEpoch = this.uiEpoch;
2725
3092
  const done = () => {
2726
- this.editorContainer.clear();
2727
- this.editorContainer.addChild(this.editor);
2728
- this.ui.setFocus(this.editor);
3093
+ if (this.uiEpoch !== capturedEpoch)
3094
+ return;
3095
+ this.restoreCanonicalEditor({ requestRender: false, owner: component });
2729
3096
  };
2730
3097
  const { component, focus } = create(done);
2731
- this.editorContainer.clear();
2732
- this.editorContainer.addChild(component);
2733
- this.ui.setFocus(focus);
2734
- this.ui.requestRender();
3098
+ this.mountPromptOwner(component, component, { focus });
2735
3099
  }
2736
3100
  showSettingsSelector() {
2737
3101
  this.showSelector((done) => {
@@ -2870,16 +3234,23 @@ export class InteractiveMode {
2870
3234
  this.showModelSelector();
2871
3235
  return;
2872
3236
  }
3237
+ const capturedEpoch = this.sessionEpoch;
2873
3238
  const model = await this.findExactModelMatch(searchTerm);
3239
+ if (this.sessionEpoch !== capturedEpoch)
3240
+ return;
2874
3241
  if (model) {
2875
3242
  try {
2876
3243
  await this.session.setModel(model);
3244
+ if (this.sessionEpoch !== capturedEpoch)
3245
+ return;
2877
3246
  this.footer.invalidate();
2878
3247
  this.updatePromptChrome();
2879
3248
  // this.showStatus(`Model: ${model.id}`);
2880
3249
  this.checkDaxnutsEasterEgg(model);
2881
3250
  }
2882
3251
  catch (error) {
3252
+ if (this.sessionEpoch !== capturedEpoch)
3253
+ return;
2883
3254
  this.showError(error instanceof Error ? error.message : String(error));
2884
3255
  }
2885
3256
  return;
@@ -3140,7 +3511,7 @@ export class InteractiveMode {
3140
3511
  };
3141
3512
  this.chatContainer.addChild(new Spacer(1));
3142
3513
  summaryLoader = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), `Summarizing branch... (${appKey(this.keybindings, "interrupt")} to cancel)`);
3143
- this.statusContainer.addChild(summaryLoader);
3514
+ this.mountStatusLoader(summaryLoader, { requestRender: false });
3144
3515
  this.ui.requestRender();
3145
3516
  }
3146
3517
  try {
@@ -3171,8 +3542,8 @@ export class InteractiveMode {
3171
3542
  }
3172
3543
  finally {
3173
3544
  if (summaryLoader) {
3174
- summaryLoader.stop();
3175
- this.statusContainer.clear();
3545
+ summaryLoader.dispose();
3546
+ this.clearStatusOwner({ requestRender: false, owner: summaryLoader });
3176
3547
  }
3177
3548
  this.defaultEditor.onEscape = originalOnEscape;
3178
3549
  }
@@ -3213,16 +3584,16 @@ export class InteractiveMode {
3213
3584
  async handleResumeSession(sessionPath) {
3214
3585
  // Stop loading animation
3215
3586
  if (this.loadingAnimation) {
3216
- this.loadingAnimation.stop();
3587
+ this.loadingAnimation.dispose();
3217
3588
  this.loadingAnimation = undefined;
3218
3589
  }
3219
- this.statusContainer.clear();
3590
+ this.clearStatusOwner({ requestRender: false });
3220
3591
  // Clear UI state
3221
3592
  this.pendingMessagesContainer.clear();
3222
3593
  this.compactionQueuedMessages = [];
3223
3594
  // Switch session via AgentSession (emits extension session events)
3224
3595
  await this.session.switchSession(sessionPath);
3225
- this.resetSessionUiState(true);
3596
+ this.resetInteractiveSessionUI(true);
3226
3597
  this.showStatus("Resumed session");
3227
3598
  }
3228
3599
  async showOAuthSelector(mode) {
@@ -3273,10 +3644,7 @@ export class InteractiveMode {
3273
3644
  // Completion handled below
3274
3645
  });
3275
3646
  // Show dialog in editor container
3276
- this.editorContainer.clear();
3277
- this.editorContainer.addChild(dialog);
3278
- this.ui.setFocus(dialog);
3279
- this.ui.requestRender();
3647
+ this.mountPromptOwner(dialog, dialog);
3280
3648
  // Promise for manual code input (racing with callback server)
3281
3649
  let manualCodeResolve;
3282
3650
  let manualCodeReject;
@@ -3285,11 +3653,11 @@ export class InteractiveMode {
3285
3653
  manualCodeReject = reject;
3286
3654
  });
3287
3655
  // Restore editor helper
3656
+ const capturedEpoch = this.uiEpoch;
3288
3657
  const restoreEditor = () => {
3289
- this.editorContainer.clear();
3290
- this.editorContainer.addChild(this.editor);
3291
- this.ui.setFocus(this.editor);
3292
- this.ui.requestRender();
3658
+ if (this.uiEpoch !== capturedEpoch)
3659
+ return;
3660
+ this.restoreCanonicalEditor({ owner: dialog });
3293
3661
  };
3294
3662
  try {
3295
3663
  await this.session.modelRegistry.authStorage.login(providerId, {
@@ -3331,10 +3699,14 @@ export class InteractiveMode {
3331
3699
  restoreEditor();
3332
3700
  this.session.modelRegistry.refresh();
3333
3701
  await this.updateAvailableProviderCount();
3702
+ if (this.uiEpoch !== capturedEpoch)
3703
+ return;
3334
3704
  this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
3335
3705
  }
3336
3706
  catch (error) {
3337
3707
  restoreEditor();
3708
+ if (this.uiEpoch !== capturedEpoch)
3709
+ return;
3338
3710
  const errorMsg = error instanceof Error ? error.message : String(error);
3339
3711
  if (errorMsg !== "Login cancelled") {
3340
3712
  this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
@@ -3358,19 +3730,20 @@ export class InteractiveMode {
3358
3730
  cancellable: false,
3359
3731
  });
3360
3732
  const previousEditor = this.editor;
3361
- this.editorContainer.clear();
3362
- this.editorContainer.addChild(loader);
3363
- this.ui.setFocus(loader);
3364
- this.ui.requestRender();
3365
- const dismissLoader = (editor) => {
3733
+ this.mountPromptOwner(loader, loader);
3734
+ const capturedEpoch = this.uiEpoch;
3735
+ const dismissLoader = (_editor) => {
3366
3736
  loader.dispose();
3367
- this.editorContainer.clear();
3368
- this.editorContainer.addChild(editor);
3369
- this.ui.setFocus(editor);
3370
- this.ui.requestRender();
3737
+ if (this.uiEpoch !== capturedEpoch)
3738
+ return;
3739
+ this.restoreCanonicalEditor({ owner: loader });
3371
3740
  };
3372
3741
  try {
3373
3742
  await this.session.reload();
3743
+ if (this.uiEpoch !== capturedEpoch) {
3744
+ loader.dispose();
3745
+ return;
3746
+ }
3374
3747
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
3375
3748
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
3376
3749
  const themeName = this.settingsManager.getTheme();
@@ -3408,6 +3781,10 @@ export class InteractiveMode {
3408
3781
  this.showStatus("Reloaded extensions, skills, prompts, themes");
3409
3782
  }
3410
3783
  catch (error) {
3784
+ if (this.uiEpoch !== capturedEpoch) {
3785
+ loader.dispose();
3786
+ return;
3787
+ }
3411
3788
  dismissLoader(previousEditor);
3412
3789
  this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
3413
3790
  }
@@ -3415,11 +3792,16 @@ export class InteractiveMode {
3415
3792
  async handleExportCommand(text) {
3416
3793
  const parts = text.split(/\s+/);
3417
3794
  const outputPath = parts.length > 1 ? parts[1] : undefined;
3795
+ const capturedEpoch = this.uiEpoch;
3418
3796
  try {
3419
3797
  const filePath = await this.session.exportToHtml(outputPath);
3798
+ if (this.uiEpoch !== capturedEpoch)
3799
+ return;
3420
3800
  this.showStatus(`Session exported to: ${filePath}`);
3421
3801
  }
3422
3802
  catch (error) {
3803
+ if (this.uiEpoch !== capturedEpoch)
3804
+ return;
3423
3805
  this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
3424
3806
  }
3425
3807
  }
@@ -3436,26 +3818,28 @@ export class InteractiveMode {
3436
3818
  this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
3437
3819
  return;
3438
3820
  }
3821
+ const capturedEpoch = this.uiEpoch;
3439
3822
  // Export to a temp file
3440
3823
  const tmpFile = path.join(os.tmpdir(), "session.html");
3441
3824
  try {
3442
3825
  await this.session.exportToHtml(tmpFile);
3826
+ if (this.uiEpoch !== capturedEpoch)
3827
+ return;
3443
3828
  }
3444
3829
  catch (error) {
3830
+ if (this.uiEpoch !== capturedEpoch)
3831
+ return;
3445
3832
  this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
3446
3833
  return;
3447
3834
  }
3448
3835
  // Show cancellable loader, replacing the editor
3449
3836
  const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
3450
- this.editorContainer.clear();
3451
- this.editorContainer.addChild(loader);
3452
- this.ui.setFocus(loader);
3453
- this.ui.requestRender();
3837
+ this.mountPromptOwner(loader, loader);
3454
3838
  const restoreEditor = () => {
3455
3839
  loader.dispose();
3456
- this.editorContainer.clear();
3457
- this.editorContainer.addChild(this.editor);
3458
- this.ui.setFocus(this.editor);
3840
+ if (this.uiEpoch !== capturedEpoch)
3841
+ return;
3842
+ this.restoreCanonicalEditor({ requestRender: false, owner: loader });
3459
3843
  try {
3460
3844
  fs.unlinkSync(tmpFile);
3461
3845
  }
@@ -3468,6 +3852,8 @@ export class InteractiveMode {
3468
3852
  loader.onAbort = () => {
3469
3853
  proc?.kill();
3470
3854
  restoreEditor();
3855
+ if (this.uiEpoch !== capturedEpoch)
3856
+ return;
3471
3857
  this.showStatus("Share cancelled");
3472
3858
  };
3473
3859
  try {
@@ -3485,6 +3871,8 @@ export class InteractiveMode {
3485
3871
  });
3486
3872
  if (loader.signal.aborted)
3487
3873
  return;
3874
+ if (this.uiEpoch !== capturedEpoch)
3875
+ return;
3488
3876
  restoreEditor();
3489
3877
  if (result.code !== 0) {
3490
3878
  const errorMsg = result.stderr?.trim() || "Unknown error";
@@ -3506,6 +3894,8 @@ export class InteractiveMode {
3506
3894
  catch (error) {
3507
3895
  if (!loader.signal.aborted) {
3508
3896
  restoreEditor();
3897
+ if (this.uiEpoch !== capturedEpoch)
3898
+ return;
3509
3899
  this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
3510
3900
  }
3511
3901
  }
@@ -3724,14 +4114,12 @@ export class InteractiveMode {
3724
4114
  this.ui.requestRender();
3725
4115
  }
3726
4116
  async handleClearCommand() {
3727
- // Stop loading animation
3728
- if (this.loadingAnimation) {
3729
- this.loadingAnimation.stop();
3730
- this.loadingAnimation = undefined;
3731
- }
3732
4117
  // New session via session (emits extension session events)
3733
- await this.session.newSession();
3734
- this.resetSessionUiState();
4118
+ const success = await this.session.newSession();
4119
+ if (!success) {
4120
+ return;
4121
+ }
4122
+ this.resetInteractiveSessionUI(true);
3735
4123
  this.ui.requestRender();
3736
4124
  }
3737
4125
  handleDebugCommand() {
@@ -3777,6 +4165,7 @@ export class InteractiveMode {
3777
4165
  }
3778
4166
  }
3779
4167
  async handleBashCommand(command, excludeFromContext = false) {
4168
+ const capturedEpoch = this.sessionEpoch;
3780
4169
  const extensionRunner = this.session.extensionRunner;
3781
4170
  // Emit user_bash event to let extensions intercept
3782
4171
  const eventResult = extensionRunner
@@ -3787,6 +4176,8 @@ export class InteractiveMode {
3787
4176
  cwd: process.cwd(),
3788
4177
  })
3789
4178
  : undefined;
4179
+ if (this.sessionEpoch !== capturedEpoch)
4180
+ return;
3790
4181
  // If extension returned a full result, use it directly
3791
4182
  if (eventResult?.result) {
3792
4183
  const result = eventResult.result;
@@ -3825,16 +4216,22 @@ export class InteractiveMode {
3825
4216
  this.ui.requestRender();
3826
4217
  try {
3827
4218
  const result = await this.session.executeBash(command, (chunk) => {
4219
+ if (this.sessionEpoch !== capturedEpoch)
4220
+ return;
3828
4221
  if (this.bashComponent) {
3829
4222
  this.bashComponent.appendOutput(chunk);
3830
4223
  this.ui.requestRender();
3831
4224
  }
3832
4225
  }, { excludeFromContext, operations: eventResult?.operations });
4226
+ if (this.sessionEpoch !== capturedEpoch)
4227
+ return;
3833
4228
  if (this.bashComponent) {
3834
4229
  this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true, content: result.output } : undefined, result.fullOutputPath);
3835
4230
  }
3836
4231
  }
3837
4232
  catch (error) {
4233
+ if (this.sessionEpoch !== capturedEpoch)
4234
+ return;
3838
4235
  if (this.bashComponent) {
3839
4236
  this.bashComponent.setComplete(undefined, false);
3840
4237
  }
@@ -3853,12 +4250,13 @@ export class InteractiveMode {
3853
4250
  await this.executeCompaction(customInstructions, false);
3854
4251
  }
3855
4252
  async executeCompaction(customInstructions, isAuto = false) {
4253
+ const capturedEpoch = this.sessionEpoch;
3856
4254
  // Stop loading animation
3857
4255
  if (this.loadingAnimation) {
3858
- this.loadingAnimation.stop();
4256
+ this.loadingAnimation.dispose();
3859
4257
  this.loadingAnimation = undefined;
3860
4258
  }
3861
- this.statusContainer.clear();
4259
+ this.clearStatusOwner({ requestRender: false });
3862
4260
  // Set up escape handler during compaction
3863
4261
  const originalOnEscape = this.defaultEditor.onEscape;
3864
4262
  this.defaultEditor.onEscape = () => {
@@ -3869,11 +4267,13 @@ export class InteractiveMode {
3869
4267
  const cancelHint = `(${appKey(this.keybindings, "interrupt")} to cancel)`;
3870
4268
  const label = isAuto ? `Auto-compacting context... ${cancelHint}` : `Compacting context... ${cancelHint}`;
3871
4269
  const compactingLoader = new Loader(this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), label);
3872
- this.statusContainer.addChild(compactingLoader);
4270
+ this.mountStatusLoader(compactingLoader, { requestRender: false });
3873
4271
  this.ui.requestRender();
3874
4272
  let result;
3875
4273
  try {
3876
4274
  result = await this.session.compact(customInstructions);
4275
+ if (this.sessionEpoch !== capturedEpoch)
4276
+ return undefined;
3877
4277
  // Rebuild UI
3878
4278
  this.rebuildChatFromMessages();
3879
4279
  // Add compaction component at bottom so user sees it without scrolling
@@ -3882,6 +4282,8 @@ export class InteractiveMode {
3882
4282
  this.footer.invalidate();
3883
4283
  }
3884
4284
  catch (error) {
4285
+ if (this.sessionEpoch !== capturedEpoch)
4286
+ return undefined;
3885
4287
  const message = error instanceof Error ? error.message : String(error);
3886
4288
  if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
3887
4289
  this.showError("Compaction cancelled");
@@ -3891,18 +4293,30 @@ export class InteractiveMode {
3891
4293
  }
3892
4294
  }
3893
4295
  finally {
3894
- compactingLoader.stop();
3895
- this.statusContainer.clear();
3896
- this.defaultEditor.onEscape = originalOnEscape;
4296
+ if (this.sessionEpoch === capturedEpoch) {
4297
+ compactingLoader.dispose();
4298
+ this.clearStatusOwner({ requestRender: false, owner: compactingLoader });
4299
+ this.defaultEditor.onEscape = originalOnEscape;
4300
+ }
4301
+ }
4302
+ if (this.sessionEpoch === capturedEpoch) {
4303
+ void this.flushCompactionQueue({ willRetry: false });
3897
4304
  }
3898
- void this.flushCompactionQueue({ willRetry: false });
3899
4305
  return result;
3900
4306
  }
3901
4307
  stop() {
3902
4308
  if (this.loadingAnimation) {
3903
- this.loadingAnimation.stop();
4309
+ this.loadingAnimation.dispose();
3904
4310
  this.loadingAnimation = undefined;
3905
4311
  }
4312
+ if (this.autoCompactionLoader) {
4313
+ this.autoCompactionLoader.dispose();
4314
+ this.autoCompactionLoader = undefined;
4315
+ }
4316
+ if (this.retryLoader) {
4317
+ this.retryLoader.dispose();
4318
+ this.retryLoader = undefined;
4319
+ }
3906
4320
  this.clearExtensionTerminalInputListeners();
3907
4321
  this.footer.dispose();
3908
4322
  this.footerDataProvider.dispose();