@leanandmean/coding-agent 0.74.1-scramjet.6 → 0.74.1-scramjet.60

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 (227) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -9
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +1 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/initial-message.d.ts +1 -0
  8. package/dist/cli/initial-message.d.ts.map +1 -1
  9. package/dist/cli/initial-message.js +3 -0
  10. package/dist/cli/initial-message.js.map +1 -1
  11. package/dist/cli/list-models.d.ts +2 -0
  12. package/dist/cli/list-models.d.ts.map +1 -1
  13. package/dist/cli/list-models.js +4 -1
  14. package/dist/cli/list-models.js.map +1 -1
  15. package/dist/config.d.ts +9 -2
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +33 -22
  18. package/dist/config.js.map +1 -1
  19. package/dist/core/agent-session-runtime.d.ts +12 -3
  20. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  21. package/dist/core/agent-session-runtime.js +64 -38
  22. package/dist/core/agent-session-runtime.js.map +1 -1
  23. package/dist/core/agent-session-services.d.ts +1 -0
  24. package/dist/core/agent-session-services.d.ts.map +1 -1
  25. package/dist/core/agent-session-services.js +2 -0
  26. package/dist/core/agent-session-services.js.map +1 -1
  27. package/dist/core/agent-session.d.ts +86 -16
  28. package/dist/core/agent-session.d.ts.map +1 -1
  29. package/dist/core/agent-session.js +865 -181
  30. package/dist/core/agent-session.js.map +1 -1
  31. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  32. package/dist/core/compaction/branch-summarization.js +21 -17
  33. package/dist/core/compaction/branch-summarization.js.map +1 -1
  34. package/dist/core/compaction/compaction.d.ts +3 -5
  35. package/dist/core/compaction/compaction.d.ts.map +1 -1
  36. package/dist/core/compaction/compaction.js +43 -10
  37. package/dist/core/compaction/compaction.js.map +1 -1
  38. package/dist/core/exec.d.ts +12 -0
  39. package/dist/core/exec.d.ts.map +1 -1
  40. package/dist/core/exec.js +74 -28
  41. package/dist/core/exec.js.map +1 -1
  42. package/dist/core/export-html/template.js +4 -0
  43. package/dist/core/extensions/index.d.ts +1 -1
  44. package/dist/core/extensions/index.d.ts.map +1 -1
  45. package/dist/core/extensions/index.js.map +1 -1
  46. package/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/dist/core/extensions/loader.js +7 -0
  48. package/dist/core/extensions/loader.js.map +1 -1
  49. package/dist/core/extensions/runner.d.ts +9 -5
  50. package/dist/core/extensions/runner.d.ts.map +1 -1
  51. package/dist/core/extensions/runner.js +162 -3
  52. package/dist/core/extensions/runner.js.map +1 -1
  53. package/dist/core/extensions/types.d.ts +110 -6
  54. package/dist/core/extensions/types.d.ts.map +1 -1
  55. package/dist/core/extensions/types.js.map +1 -1
  56. package/dist/core/keybindings.d.ts +8 -0
  57. package/dist/core/keybindings.d.ts.map +1 -1
  58. package/dist/core/model-registry.d.ts +4 -0
  59. package/dist/core/model-registry.d.ts.map +1 -1
  60. package/dist/core/model-registry.js +179 -28
  61. package/dist/core/model-registry.js.map +1 -1
  62. package/dist/core/model-resolver.d.ts.map +1 -1
  63. package/dist/core/model-resolver.js +24 -6
  64. package/dist/core/model-resolver.js.map +1 -1
  65. package/dist/core/output-throughput-history.d.ts +25 -0
  66. package/dist/core/output-throughput-history.d.ts.map +1 -0
  67. package/dist/core/output-throughput-history.js +240 -0
  68. package/dist/core/output-throughput-history.js.map +1 -0
  69. package/dist/core/output-throughput.d.ts +43 -0
  70. package/dist/core/output-throughput.d.ts.map +1 -0
  71. package/dist/core/output-throughput.js +161 -0
  72. package/dist/core/output-throughput.js.map +1 -0
  73. package/dist/core/resource-loader.d.ts +6 -0
  74. package/dist/core/resource-loader.d.ts.map +1 -1
  75. package/dist/core/resource-loader.js +76 -21
  76. package/dist/core/resource-loader.js.map +1 -1
  77. package/dist/core/scramjet-command-parser.d.ts +4 -0
  78. package/dist/core/scramjet-command-parser.d.ts.map +1 -1
  79. package/dist/core/scramjet-command-parser.js +72 -1
  80. package/dist/core/scramjet-command-parser.js.map +1 -1
  81. package/dist/core/sdk.d.ts +2 -0
  82. package/dist/core/sdk.d.ts.map +1 -1
  83. package/dist/core/sdk.js +61 -10
  84. package/dist/core/sdk.js.map +1 -1
  85. package/dist/core/session-manager.d.ts +11 -3
  86. package/dist/core/session-manager.d.ts.map +1 -1
  87. package/dist/core/session-manager.js +46 -11
  88. package/dist/core/session-manager.js.map +1 -1
  89. package/dist/core/settings-manager.d.ts +18 -6
  90. package/dist/core/settings-manager.d.ts.map +1 -1
  91. package/dist/core/settings-manager.js +84 -16
  92. package/dist/core/settings-manager.js.map +1 -1
  93. package/dist/core/skills.d.ts.map +1 -1
  94. package/dist/core/skills.js +6 -2
  95. package/dist/core/skills.js.map +1 -1
  96. package/dist/core/system-prompt.d.ts +2 -0
  97. package/dist/core/system-prompt.d.ts.map +1 -1
  98. package/dist/core/system-prompt.js +5 -2
  99. package/dist/core/system-prompt.js.map +1 -1
  100. package/dist/core/tools/bash.d.ts.map +1 -1
  101. package/dist/core/tools/bash.js +4 -3
  102. package/dist/core/tools/bash.js.map +1 -1
  103. package/dist/index.d.ts +4 -3
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +3 -2
  106. package/dist/index.js.map +1 -1
  107. package/dist/main.d.ts +16 -0
  108. package/dist/main.d.ts.map +1 -1
  109. package/dist/main.js +38 -10
  110. package/dist/main.js.map +1 -1
  111. package/dist/modes/interactive/components/armin.d.ts +4 -2
  112. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  113. package/dist/modes/interactive/components/armin.js +14 -8
  114. package/dist/modes/interactive/components/armin.js.map +1 -1
  115. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  116. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  117. package/dist/modes/interactive/components/assistant-message.js +19 -8
  118. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  119. package/dist/modes/interactive/components/daxnuts.d.ts +4 -1
  120. package/dist/modes/interactive/components/daxnuts.d.ts.map +1 -1
  121. package/dist/modes/interactive/components/daxnuts.js +14 -6
  122. package/dist/modes/interactive/components/daxnuts.js.map +1 -1
  123. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  124. package/dist/modes/interactive/components/extension-editor.js +5 -3
  125. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  126. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  127. package/dist/modes/interactive/components/extension-selector.js +4 -3
  128. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  129. package/dist/modes/interactive/components/footer.d.ts +4 -0
  130. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  131. package/dist/modes/interactive/components/footer.js +22 -18
  132. package/dist/modes/interactive/components/footer.js.map +1 -1
  133. package/dist/modes/interactive/components/model-selector.d.ts +3 -1
  134. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  135. package/dist/modes/interactive/components/model-selector.js +9 -3
  136. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  137. package/dist/modes/interactive/components/scoped-models-selector.d.ts +3 -1
  138. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  139. package/dist/modes/interactive/components/scoped-models-selector.js +8 -2
  140. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  141. package/dist/modes/interactive/components/settings-selector.d.ts +17 -3
  142. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  143. package/dist/modes/interactive/components/settings-selector.js +87 -16
  144. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  145. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  146. package/dist/modes/interactive/components/thinking-selector.js +2 -1
  147. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  148. package/dist/modes/interactive/components/tool-execution.d.ts +19 -1
  149. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  150. package/dist/modes/interactive/components/tool-execution.js +129 -36
  151. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  152. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  153. package/dist/modes/interactive/components/user-message.js +3 -2
  154. package/dist/modes/interactive/components/user-message.js.map +1 -1
  155. package/dist/modes/interactive/components/visual-truncate.d.ts.map +1 -1
  156. package/dist/modes/interactive/components/visual-truncate.js +2 -2
  157. package/dist/modes/interactive/components/visual-truncate.js.map +1 -1
  158. package/dist/modes/interactive/interactive-mode.d.ts +37 -1
  159. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  160. package/dist/modes/interactive/interactive-mode.js +876 -175
  161. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  162. package/dist/modes/interactive/spellcheck.d.ts +26 -0
  163. package/dist/modes/interactive/spellcheck.d.ts.map +1 -0
  164. package/dist/modes/interactive/spellcheck.js +144 -0
  165. package/dist/modes/interactive/spellcheck.js.map +1 -0
  166. package/dist/modes/interactive/theme/{dark.json → pi-dark.json} +3 -2
  167. package/dist/modes/interactive/theme/{light.json → pi-light.json} +27 -26
  168. package/dist/modes/interactive/theme/theme-schema.json +6 -1
  169. package/dist/modes/interactive/theme/theme.d.ts +24 -4
  170. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  171. package/dist/modes/interactive/theme/theme.js +123 -22
  172. package/dist/modes/interactive/theme/theme.js.map +1 -1
  173. package/dist/modes/print-mode.d.ts.map +1 -1
  174. package/dist/modes/print-mode.js +5 -0
  175. package/dist/modes/print-mode.js.map +1 -1
  176. package/dist/modes/rpc/rpc-client.d.ts +3 -1
  177. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  178. package/dist/modes/rpc/rpc-client.js.map +1 -1
  179. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  180. package/dist/modes/rpc/rpc-mode.js +6 -1
  181. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  182. package/dist/package-manager-cli.d.ts.map +1 -1
  183. package/dist/package-manager-cli.js +33 -14
  184. package/dist/package-manager-cli.js.map +1 -1
  185. package/dist/utils/clipboard-native.d.ts +1 -0
  186. package/dist/utils/clipboard-native.d.ts.map +1 -1
  187. package/dist/utils/clipboard-native.js.map +1 -1
  188. package/dist/utils/clipboard.d.ts +6 -0
  189. package/dist/utils/clipboard.d.ts.map +1 -1
  190. package/dist/utils/clipboard.js +278 -10
  191. package/dist/utils/clipboard.js.map +1 -1
  192. package/dist/utils/tools-manager.d.ts +1 -1
  193. package/dist/utils/tools-manager.d.ts.map +1 -1
  194. package/dist/utils/tools-manager.js +7 -6
  195. package/dist/utils/tools-manager.js.map +1 -1
  196. package/dist/utils/version-check.d.ts +17 -5
  197. package/dist/utils/version-check.d.ts.map +1 -1
  198. package/dist/utils/version-check.js +88 -21
  199. package/dist/utils/version-check.js.map +1 -1
  200. package/docs/compaction.md +9 -3
  201. package/docs/custom-provider.md +13 -4
  202. package/docs/extensions.md +139 -21
  203. package/docs/keybindings.md +25 -1
  204. package/docs/models.md +34 -2
  205. package/docs/packages.md +2 -0
  206. package/docs/prompt-templates.md +2 -0
  207. package/docs/quickstart.md +1 -4
  208. package/docs/rpc.md +8 -6
  209. package/docs/sdk.md +22 -3
  210. package/docs/session-format.md +2 -1
  211. package/docs/settings.md +13 -3
  212. package/docs/skills.md +7 -5
  213. package/docs/terminal-setup.md +79 -2
  214. package/docs/terminal-verification.md +71 -0
  215. package/docs/themes.md +63 -10
  216. package/docs/tui.md +120 -25
  217. package/docs/usage.md +6 -4
  218. package/examples/extensions/border-status-editor.ts +2 -3
  219. package/examples/extensions/doom-overlay/doom-component.ts +3 -3
  220. package/examples/extensions/mac-system-theme.ts +3 -2
  221. package/examples/extensions/modal-editor.ts +13 -8
  222. package/examples/extensions/overlay-qa-tests.ts +22 -9
  223. package/examples/extensions/overlay-test.ts +5 -3
  224. package/examples/extensions/snake.ts +3 -1
  225. package/examples/extensions/space-invaders.ts +6 -3
  226. package/examples/extensions/subagent/index.ts +20 -17
  227. package/package.json +69 -58
@@ -6,8 +6,9 @@ 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 { stripVTControlCharacters } from "node:util";
9
10
  import { getProviders, } from "@leanandmean/ai";
10
- import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@leanandmean/tui";
11
+ import { CombinedAutocompleteProvider, Container, fuzzyFilter, isKeyRelease, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, TUI_KEYBINDINGS, visibleWidth, } from "@leanandmean/tui";
11
12
  import { spawn, spawnSync } from "child_process";
12
13
  import { APP_NAME, APP_TITLE, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, VERSION } from "../../config.js";
13
14
  import { parseSkillBlock } from "../../core/agent-session.js";
@@ -18,12 +19,13 @@ import { createCompactionSummaryMessage } from "../../core/messages.js";
18
19
  import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
19
20
  import { DefaultPackageManager } from "../../core/package-manager.js";
20
21
  import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.js";
21
- import { parseScramjetCommandBlock } from "../../core/scramjet-command-parser.js"; // SCRAMJET-DIVERGENCE: scramjet-command block parsing (issue 82)
22
+ import { describeRuntimeError } from "../../core/resource-loader.js";
23
+ import { indexScramjetCommandStarts, parseScramjetCommandBlock, restoreScramjetCommandInvocation, } from "../../core/scramjet-command-parser.js"; // SCRAMJET-DIVERGENCE: scramjet-command restoration (issues 82, 414)
22
24
  import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
23
25
  import { SessionManager } from "../../core/session-manager.js";
24
26
  import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
25
27
  import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
26
- import { copyToClipboard } from "../../utils/clipboard.js";
28
+ import { copyToClipboard, createWslClipboardReader, readClipboardText } from "../../utils/clipboard.js";
27
29
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
28
30
  import { parseGitUrl } from "../../utils/git.js";
29
31
  import { getCwdRelativePath } from "../../utils/paths.js";
@@ -57,7 +59,8 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
57
59
  import { TreeSelectorComponent } from "./components/tree-selector.js";
58
60
  import { UserMessageComponent } from "./components/user-message.js";
59
61
  import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
60
- import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
62
+ import { NspellProvider } from "./spellcheck.js";
63
+ import { classifyBackgroundColor, detectThemeFromEnvironment, getAvailableThemes, getAvailableThemesWithPaths, getCurrentThemeName, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, resolveThemeName, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
61
64
  function isExpandable(obj) {
62
65
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
63
66
  }
@@ -106,11 +109,15 @@ export class InteractiveMode {
106
109
  options;
107
110
  runtimeHost;
108
111
  ui;
112
+ tuiMode;
113
+ committedChatContainer;
109
114
  chatContainer;
115
+ mutableChatComponents = new Set();
110
116
  pendingMessagesContainer;
111
117
  statusContainer;
112
118
  defaultEditor;
113
119
  editor;
120
+ editorAvailableRows;
114
121
  editorComponentFactory;
115
122
  autocompleteProvider;
116
123
  autocompleteProviderWrappers = [];
@@ -126,6 +133,11 @@ export class InteractiveMode {
126
133
  loadingAnimation = undefined;
127
134
  workingMessage = undefined;
128
135
  workingVisible = true;
136
+ // SCRAMJET-DIVERGENCE: title provider for extension-owned terminal titles.
137
+ titleProvider = undefined;
138
+ // SCRAMJET-DIVERGENCE: captured terminal classification (env-seeded in the constructor, updated
139
+ // by the OSC 11 query) used to re-resolve the default theme once extension themes register.
140
+ detectedClassification = undefined;
129
141
  workingIndicatorOptions = undefined;
130
142
  defaultWorkingMessage = "Working...";
131
143
  defaultHiddenThinkingLabel = "Thinking...";
@@ -143,6 +155,11 @@ export class InteractiveMode {
143
155
  streamingMessage = undefined;
144
156
  // Tool execution tracking: toolCallId -> component
145
157
  pendingTools = new Map();
158
+ pendingToolFinalizations = new Set();
159
+ agentRunGeneration = 0;
160
+ selectorOpenGeneration = 0;
161
+ pendingSelectorOpenGeneration;
162
+ layoutSettingsSettlementGeneration = 0;
146
163
  // Tool output expansion state
147
164
  toolOutputExpanded = false;
148
165
  // Thinking block visibility state
@@ -173,6 +190,7 @@ export class InteractiveMode {
173
190
  extensionSelector = undefined;
174
191
  extensionInput = undefined;
175
192
  extensionEditor = undefined;
193
+ customHeightAllocation;
176
194
  extensionTerminalInputUnsubscribers = new Set();
177
195
  // Extension widgets (components rendered above/below the editor)
178
196
  extensionWidgetsAbove = new Map();
@@ -210,9 +228,30 @@ export class InteractiveMode {
210
228
  await this.rebindCurrentSession();
211
229
  });
212
230
  this.version = VERSION;
213
- this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
214
- this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
231
+ this.tuiMode = this.settingsManager.getTuiMode();
232
+ // SCRAMJET-DIVERGENCE: retained interactive rendering and injectable terminals.
233
+ this.ui = new TUI(options.terminal ?? new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
234
+ this.clipboardReader = this.tuiMode === "retained" ? createWslClipboardReader() : undefined;
235
+ this.ui.addLifecycleListener((event) => {
236
+ if (event === "start")
237
+ void this.clipboardReader?.start().catch(() => { });
238
+ else {
239
+ this.clipboardPasteGeneration++;
240
+ this.clipboardPastePending = undefined;
241
+ this.clipboardReader?.close();
242
+ }
243
+ });
244
+ this.ui.addInputListener((data) => {
245
+ if (!isKeyRelease(data) &&
246
+ data !== "\x1b[I" &&
247
+ !/^\x1b\[<34;\d{1,5};\d{1,5}M$/.test(data) &&
248
+ !/^\x1b\[6;\d+;\d+t$/.test(data) &&
249
+ !/^\x1b\[<\d+;\d+;\d+m$/.test(data))
250
+ this.clipboardPasteGeneration++;
251
+ return undefined;
252
+ });
215
253
  this.headerContainer = new Container();
254
+ this.committedChatContainer = new Container();
216
255
  this.chatContainer = new Container();
217
256
  this.pendingMessagesContainer = new Container();
218
257
  this.statusContainer = new Container();
@@ -227,6 +266,8 @@ export class InteractiveMode {
227
266
  autocompleteMaxVisible,
228
267
  });
229
268
  this.editor = this.defaultEditor;
269
+ this.configureEditorHeight(this.editor);
270
+ this.defaultEditor.setSpellcheckProvider(new NspellProvider());
230
271
  this.editorContainer = new Container();
231
272
  this.editorContainer.addChild(this.editor);
232
273
  this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
@@ -236,8 +277,287 @@ export class InteractiveMode {
236
277
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
237
278
  // Register themes from resource loader and initialize
238
279
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
280
+ // SCRAMJET-DIVERGENCE: capture the env classification so the post-extension-bind reapplication
281
+ // can honor COLORFGBG/Apple-Terminal-detected (never-OSC) terminals. Without this seed, those
282
+ // terminals would resolve the dark branch and clobber light terminals to scramjet-dark.
283
+ this.detectedClassification = detectThemeFromEnvironment()?.theme;
239
284
  initTheme(this.settingsManager.getTheme(), true);
240
285
  }
286
+ configureEditorHeight(editor) {
287
+ editor.setHeightLimit?.(() => ({
288
+ rows: Math.max(1, this.editorAvailableRows ?? this.ui.terminal.rows - 3),
289
+ text: Math.max(1, Math.floor((this.ui.terminal.rows * this.settingsManager.getEditorMaxHeightPercent()) / 100)),
290
+ }));
291
+ }
292
+ renderSessionGap(width, below) {
293
+ if (below === 0)
294
+ return "";
295
+ const forms = [
296
+ `Session: ${below} lines below · Ctrl+End: latest`,
297
+ `Session: ${below} lines below`,
298
+ `Session: ${below} below`,
299
+ `Session↓${below}`,
300
+ "Session ↓…",
301
+ ];
302
+ return theme.fg("muted", forms.find((text) => visibleWidth(text) <= width) ?? "");
303
+ }
304
+ // SCRAMJET-DIVERGENCE: preserve production ownership while making mutable overflow browseable.
305
+ clipboardReader;
306
+ clipboardPastePending;
307
+ clipboardPasteGeneration = 0;
308
+ clipboardPasteOwner;
309
+ async pasteFromClipboard() {
310
+ const editor = this.editor;
311
+ const session = this.session;
312
+ const draft = editor.getText();
313
+ const generation = this.clipboardPasteGeneration;
314
+ const current = () => generation === this.clipboardPasteGeneration &&
315
+ this.editor === editor &&
316
+ this.session === session &&
317
+ editor.getText() === draft &&
318
+ this.editorContainer.children.length === 1 &&
319
+ this.editorContainer.children[0] === editor &&
320
+ this.ui.isComponentFocused(editor) &&
321
+ this.ui.isComponentVisible(this.editorContainer) &&
322
+ this.ui.isComponentRenderComplete(this.editorContainer);
323
+ if (!current())
324
+ return;
325
+ let pending;
326
+ try {
327
+ while (current() && !this.ui.isViewportFrameFlushed()) {
328
+ await this.ui.renderNow({ requireFlush: true });
329
+ }
330
+ if (!current())
331
+ return;
332
+ if (this.clipboardReader) {
333
+ await this.clipboardReader.start();
334
+ if (!current())
335
+ return;
336
+ }
337
+ this.clipboardPastePending ??= this.clipboardReader ? this.clipboardReader.read() : readClipboardText();
338
+ pending = this.clipboardPastePending;
339
+ const text = await pending;
340
+ if (!current())
341
+ return;
342
+ const safeText = stripVTControlCharacters(text).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
343
+ editor.handleInput(`\x1b[200~${safeText}\x1b[201~`);
344
+ this.ui.requestRender();
345
+ }
346
+ catch (error) {
347
+ if (current())
348
+ this.showError(`Paste failed: ${error instanceof Error ? error.message : String(error)}`);
349
+ }
350
+ finally {
351
+ if (pending && this.clipboardPastePending === pending)
352
+ this.clipboardPastePending = undefined;
353
+ }
354
+ }
355
+ configureRetainedViewport() {
356
+ this.ui.configureViewport({
357
+ getBlocks: () => this.ui.children.flatMap((component) => {
358
+ if (component === this.editorContainer &&
359
+ this.clipboardPasteOwner !== this.editorContainer.children[0]) {
360
+ this.clipboardPasteOwner = this.editorContainer.children[0];
361
+ this.clipboardPasteGeneration++;
362
+ }
363
+ if (component instanceof Container &&
364
+ (component === this.committedChatContainer || component === this.chatContainer)) {
365
+ return component.children.map((child) => ({
366
+ component: child,
367
+ finalized: component === this.committedChatContainer,
368
+ }));
369
+ }
370
+ const dock = this.settingsManager.getDockEditor() &&
371
+ this.editorContainer.children.length > 0 &&
372
+ [
373
+ this.widgetContainerAbove,
374
+ this.editorContainer,
375
+ this.widgetContainerBelow,
376
+ this.customFooter ?? this.footer,
377
+ ].includes(component);
378
+ return [
379
+ {
380
+ component,
381
+ finalized: false,
382
+ dock,
383
+ renderDockGap: component === this.widgetContainerAbove
384
+ ? (width, below) => this.renderSessionGap(width, below)
385
+ : undefined,
386
+ fitHeight: component === this.editorContainer
387
+ ? (rows) => {
388
+ this.editorAvailableRows = rows;
389
+ const allocation = this.customHeightAllocation;
390
+ if (allocation) {
391
+ if (this.editorContainer.children.length === 1 &&
392
+ this.editorContainer.children[0] === allocation.component) {
393
+ try {
394
+ allocation.notify(rows);
395
+ }
396
+ catch (error) {
397
+ allocation.fail(error);
398
+ }
399
+ }
400
+ else
401
+ this.releaseCustomHeight();
402
+ }
403
+ }
404
+ : undefined,
405
+ },
406
+ ];
407
+ }),
408
+ keybindings: this.keybindings,
409
+ copy: copyToClipboard,
410
+ requestPaste: () => void this.pasteFromClipboard(),
411
+ minimumSize: { columns: 12, rows: 3 },
412
+ handleBlockedInput: (data) => {
413
+ const selector = this.editorContainer.children[0];
414
+ if (selector instanceof SettingsSelectorComponent &&
415
+ this.ui.isComponentFocused(selector.getSettingsList()) &&
416
+ this.keybindings.matches(data, "tui.select.cancel")) {
417
+ selector.cancel();
418
+ }
419
+ else if (this.keybindings.matches(data, "app.interrupt")) {
420
+ this.restoreQueuedMessagesToEditor();
421
+ this.session.abortBash();
422
+ this.session.abortCompaction();
423
+ this.session.abortBranchSummary();
424
+ void this.session.abort();
425
+ }
426
+ else if (this.keybindings.matches(data, "app.exit") && this.editor.getText().length === 0) {
427
+ this.handleCtrlD();
428
+ }
429
+ },
430
+ getScrollWheelStep: () => this.settingsManager.getScrollWheelStep(),
431
+ allowViewportKeys: (data) => this.ui.isComponentFocused(this.editor) ||
432
+ (!matchesKey(data, "space") &&
433
+ !Object.keys(TUI_KEYBINDINGS).some((action) => !action.startsWith("tui.viewport.") && this.keybindings.matches(data, action))),
434
+ keepReadingOnInput: () => {
435
+ const ownsFocus = (component) => this.ui.isComponentFocused(component) ||
436
+ (component instanceof Container && component.children.some(ownsFocus));
437
+ return this.editorContainer.children.some(ownsFocus);
438
+ },
439
+ handlePresentationInput: (data) => {
440
+ if (!this.ui.isComponentFocused(this.editor))
441
+ return false;
442
+ if (!this.keybindings.matches(data, "app.tools.expand") &&
443
+ !this.keybindings.matches(data, "app.thinking.toggle"))
444
+ return false;
445
+ this.editor.handleInput(data);
446
+ return true;
447
+ },
448
+ });
449
+ // SCRAMJET-DIVERGENCE: completion needs a painted slot even when its hardware cursor is hidden.
450
+ this.ui.addInputListener((data) => {
451
+ if (!this.ui.isComponentFocused(this.editor) ||
452
+ !this.editor.isShowingAutocomplete?.() ||
453
+ isKeyRelease(data) ||
454
+ /^\x1b\[\d+;\d+;\d+t$/.test(data) ||
455
+ this.keybindings.matches(data, "app.tools.expand") ||
456
+ this.keybindings.matches(data, "app.thinking.toggle"))
457
+ return undefined;
458
+ const visible = this.ui.isComponentVisible(this.editorContainer);
459
+ if (!visible && !this.ui.hasOverlay())
460
+ this.ui.revealComponent(this.editorContainer);
461
+ if ((this.keybindings.matches(data, "tui.input.tab") || this.keybindings.matches(data, "tui.select.confirm")) &&
462
+ (!visible || !this.ui.isViewportFrameFlushed()))
463
+ return { consume: true };
464
+ return undefined;
465
+ });
466
+ }
467
+ // SCRAMJET-DIVERGENCE: finalized transcript components are promoted atomically into append-only history (#389).
468
+ promoteFinalizedChatPrefix() {
469
+ let promoted = false;
470
+ while (this.chatContainer.children.length > 0) {
471
+ const child = this.chatContainer.children[0];
472
+ if (this.mutableChatComponents.has(child))
473
+ break;
474
+ this.chatContainer.removeChild(child);
475
+ if (child instanceof ToolExecutionComponent)
476
+ child.markCommitted();
477
+ this.committedChatContainer.addChild(child);
478
+ promoted = true;
479
+ }
480
+ if (promoted) {
481
+ if (this.ui.getViewportState())
482
+ this.ui.requestRender();
483
+ else
484
+ this.ui.commit();
485
+ }
486
+ }
487
+ setChatComponentMutable(component, mutable) {
488
+ if (mutable)
489
+ this.mutableChatComponents.add(component);
490
+ else
491
+ this.mutableChatComponents.delete(component);
492
+ }
493
+ sealStatus() {
494
+ if (this.lastStatusSpacer)
495
+ this.mutableChatComponents.delete(this.lastStatusSpacer);
496
+ if (this.lastStatusText)
497
+ this.mutableChatComponents.delete(this.lastStatusText);
498
+ this.lastStatusSpacer = undefined;
499
+ this.lastStatusText = undefined;
500
+ this.promoteFinalizedChatPrefix();
501
+ }
502
+ clearTranscript() {
503
+ this.clipboardPasteGeneration++;
504
+ if (this.ui.getViewportState())
505
+ this.ui.resetViewport();
506
+ this.pendingToolFinalizations = new Set();
507
+ const tools = new Set([
508
+ ...this.pendingTools.values(),
509
+ ...this.committedChatContainer.children.filter((component) => component instanceof ToolExecutionComponent),
510
+ ...this.chatContainer.children.filter((component) => component instanceof ToolExecutionComponent),
511
+ ]);
512
+ for (const tool of tools)
513
+ tool.detach();
514
+ this.committedChatContainer.clear();
515
+ this.chatContainer.clear();
516
+ this.mutableChatComponents.clear();
517
+ this.lastStatusSpacer = undefined;
518
+ this.lastStatusText = undefined;
519
+ this.ui.rebuild();
520
+ }
521
+ updateToolImagePresentation(update) {
522
+ for (const container of [this.committedChatContainer, this.chatContainer]) {
523
+ for (const child of container.children) {
524
+ if (child instanceof ToolExecutionComponent)
525
+ update(child);
526
+ }
527
+ }
528
+ this.ui.rebuild();
529
+ }
530
+ commitFinalizedChatOutput() {
531
+ this.sealStatus();
532
+ this.ui.requestRender();
533
+ }
534
+ bindThemeChangeHandler() {
535
+ onThemeChange(() => {
536
+ this.ui.invalidate();
537
+ this.updateEditorBorderColor();
538
+ this.ui.rebuild();
539
+ });
540
+ }
541
+ // SCRAMJET-DIVERGENCE: re-resolve the theme after extension themes register (or after a reload),
542
+ // so scramjet-dark can take effect once it becomes available. An explicit user setting still wins
543
+ // via the resolver; the captured classification (env-seeded + OSC-updated) drives auto-detection.
544
+ // Skips the apply when the resolved name already matches the active theme, unless `force` is set:
545
+ // the reload path forces a re-read from disk (registered extension themes have no file watcher, so
546
+ // /reload is their only refresh path even when the active theme's name is unchanged).
547
+ reapplyResolvedTheme(force = false) {
548
+ const name = resolveThemeName({
549
+ explicitSetting: this.settingsManager.getTheme(),
550
+ detectedClassification: this.detectedClassification,
551
+ hasScramjetDark: getAvailableThemes().includes("scramjet-dark"),
552
+ });
553
+ if (!force && name === getCurrentThemeName()) {
554
+ return;
555
+ }
556
+ const result = setTheme(name, true);
557
+ if (!result.success) {
558
+ this.showError(`Failed to load theme "${name}": ${result.error}\nFell back to pi-dark theme.`);
559
+ }
560
+ }
241
561
  getAutocompleteSourceTag(sourceInfo) {
242
562
  if (!sourceInfo) {
243
563
  return undefined;
@@ -376,6 +696,7 @@ export class InteractiveMode {
376
696
  this.chatContainer.addChild(new Spacer(1));
377
697
  }
378
698
  this.chatContainer.addChild(new DynamicBorder());
699
+ this.promoteFinalizedChatPrefix();
379
700
  }
380
701
  async init() {
381
702
  if (this.isInitialized)
@@ -385,27 +706,58 @@ export class InteractiveMode {
385
706
  this.changelogMarkdown = this.getChangelogForDisplay();
386
707
  // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
387
708
  // Both are needed: fd for autocomplete, rg for grep tool and bash commands
388
- const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
709
+ const startupDiagnostics = [];
710
+ const report = (message) => {
711
+ startupDiagnostics.push(message);
712
+ };
713
+ const [fdPath] = await Promise.all([ensureTool("fd", false, report), ensureTool("rg", false, report)]);
389
714
  this.fdPath = fdPath;
390
- if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
391
- const modelList = this.session.scopedModels
392
- .map((sm) => {
393
- const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
394
- return `${sm.model.id}${thinkingStr}`;
395
- })
396
- .join(", ");
397
- const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
398
- const cycleHint = cycleKeys.length > 0
399
- ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
400
- : "";
401
- console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
402
- }
403
- // Add header container as first child
715
+ // Add header container as first child (content built after theme detection below)
404
716
  this.ui.addChild(this.headerContainer);
405
- // Add header with keybindings from config (unless silenced)
717
+ this.ui.addChild(this.committedChatContainer);
718
+ this.ui.addChild(this.chatContainer);
719
+ if (this.tuiMode === "retained")
720
+ this.configureRetainedViewport();
721
+ else
722
+ this.ui.setLiveRegionStart(this.chatContainer);
723
+ this.ui.addChild(this.pendingMessagesContainer);
724
+ this.ui.addChild(this.statusContainer);
725
+ this.renderWidgets(); // Initialize with default spacer
726
+ this.ui.addChild(this.widgetContainerAbove);
727
+ this.ui.addChild(this.editorContainer);
728
+ this.ui.addChild(this.widgetContainerBelow);
729
+ this.ui.addChild(this.footer);
730
+ this.ui.setFocus(this.editor);
731
+ this.setupKeyHandlers();
732
+ this.setupEditorSubmitHandler();
733
+ // Start the UI before initializing extensions so session_start handlers can use interactive dialogs
734
+ this.ui.start();
735
+ this.isInitialized = true;
736
+ // Detect terminal background via OSC 11 if no stronger signal resolved synchronously.
737
+ // The constructor already initialized theme from explicit setting, COLORFGBG, or Apple Terminal
738
+ // heuristic (via getDefaultTheme). This step only fires when no explicit theme is set and
739
+ // detectThemeFromEnvironment() returned undefined (no env signal available).
740
+ if (!this.settingsManager.getTheme() && !detectThemeFromEnvironment()) {
741
+ const rgb = await this.ui.queryTerminalBackgroundColor({ timeoutMs: 100 });
742
+ if (rgb) {
743
+ // SCRAMJET-DIVERGENCE: capture the OSC 11 classification and resolve through the shared
744
+ // resolver so a dark terminal maps to scramjet-dark (when available) and a light one to
745
+ // pi-light. Skips the apply when the resolved name is already active.
746
+ this.detectedClassification = classifyBackgroundColor(rgb);
747
+ this.reapplyResolvedTheme();
748
+ }
749
+ }
750
+ // Build themed header content (after theme detection so colors are correct)
406
751
  if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
752
+ if (this.session.scopedModels.length > 0) {
753
+ const modelList = this.session.scopedModels
754
+ .map(({ model, thinkingLevel }) => `${model.id}${thinkingLevel ? `:${thinkingLevel}` : ""}`)
755
+ .join(", ");
756
+ const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
757
+ const cycleHint = cycleKeys.length > 0 ? ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)` : "";
758
+ this.headerContainer.addChild(new Text(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`), 1, 0));
759
+ }
407
760
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
408
- // Build startup instructions using keybinding hint helpers
409
761
  const hint = (keybinding, description) => keyHint(keybinding, description);
410
762
  const expandedInstructions = [
411
763
  hint("app.interrupt", "to interrupt"),
@@ -440,40 +792,22 @@ export class InteractiveMode {
440
792
  // SCRAMJET-DIVERGENCE: rebrand Pi → APP_NAME.
441
793
  `${APP_NAME} can explain its own features and look up its docs. Ask it how to use or extend ${APP_NAME}.`);
442
794
  this.builtInHeader = new ExpandableText(() => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`, () => `${logo}\n${expandedInstructions}\n\n${onboarding}`, this.getStartupExpansionState(), 1, 0);
443
- // Setup UI layout
444
795
  this.headerContainer.addChild(new Spacer(1));
445
796
  this.headerContainer.addChild(this.builtInHeader);
446
797
  this.headerContainer.addChild(new Spacer(1));
447
798
  }
448
799
  else {
449
- // Minimal header when silenced
450
800
  this.builtInHeader = new Text("", 0, 0);
451
801
  this.headerContainer.addChild(this.builtInHeader);
452
802
  }
453
- this.ui.addChild(this.chatContainer);
454
- this.ui.addChild(this.pendingMessagesContainer);
455
- this.ui.addChild(this.statusContainer);
456
- this.renderWidgets(); // Initialize with default spacer
457
- this.ui.addChild(this.widgetContainerAbove);
458
- this.ui.addChild(this.editorContainer);
459
- this.ui.addChild(this.widgetContainerBelow);
460
- this.ui.addChild(this.footer);
461
- this.ui.setFocus(this.editor);
462
- this.setupKeyHandlers();
463
- this.setupEditorSubmitHandler();
464
- // Start the UI before initializing extensions so session_start handlers can use interactive dialogs
465
- this.ui.start();
466
- this.isInitialized = true;
803
+ for (const diagnostic of startupDiagnostics)
804
+ this.headerContainer.addChild(new Text(diagnostic, 1, 0));
467
805
  // Initialize extensions first so resources are shown before messages
468
806
  await this.rebindCurrentSession();
469
807
  // Render initial messages AFTER showing loaded resources
470
808
  this.renderInitialMessages();
471
809
  // Set up theme file watcher
472
- onThemeChange(() => {
473
- this.ui.invalidate();
474
- this.updateEditorBorderColor();
475
- this.ui.requestRender();
476
- });
810
+ this.bindThemeChangeHandler();
477
811
  // Set up git branch watcher (uses provider instead of footer)
478
812
  this.footerDataProvider.onBranchChange(() => {
479
813
  this.ui.requestRender();
@@ -485,6 +819,20 @@ export class InteractiveMode {
485
819
  * Update terminal title with session name and cwd.
486
820
  */
487
821
  updateTerminalTitle() {
822
+ // SCRAMJET-DIVERGENCE: delegate to title provider when set.
823
+ if (this.titleProvider) {
824
+ let title;
825
+ try {
826
+ title = this.titleProvider();
827
+ }
828
+ catch {
829
+ // Provider failed; fall through to default title
830
+ }
831
+ if (title !== undefined) {
832
+ this.ui.terminal.setTitle(title);
833
+ return;
834
+ }
835
+ }
488
836
  const cwdBasename = path.basename(this.sessionManager.getCwd());
489
837
  const sessionName = this.sessionManager.getSessionName();
490
838
  if (sessionName) {
@@ -1112,7 +1460,7 @@ export class InteractiveMode {
1112
1460
  if (result.cancelled) {
1113
1461
  return { cancelled: true };
1114
1462
  }
1115
- this.chatContainer.clear();
1463
+ this.clearTranscript();
1116
1464
  this.renderInitialMessages();
1117
1465
  if (result.editorText && !this.editor.getText().trim()) {
1118
1466
  this.editor.setText(result.editorText);
@@ -1139,11 +1487,15 @@ export class InteractiveMode {
1139
1487
  },
1140
1488
  });
1141
1489
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
1490
+ // SCRAMJET-DIVERGENCE: extension themes (e.g. scramjet-dark) register after the constructor's
1491
+ // initTheme, so re-resolve now that they are available.
1492
+ this.reapplyResolvedTheme();
1142
1493
  this.setupAutocompleteProvider();
1143
1494
  const extensionRunner = this.session.extensionRunner;
1144
1495
  this.setupExtensionShortcuts(extensionRunner);
1145
1496
  this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true });
1146
1497
  this.showStartupNoticesIfNeeded();
1498
+ this.promoteFinalizedChatPrefix();
1147
1499
  }
1148
1500
  applyRuntimeSettings() {
1149
1501
  this.footer.setSession(this.session);
@@ -1151,7 +1503,6 @@ export class InteractiveMode {
1151
1503
  this.footerDataProvider.setCwd(this.sessionManager.getCwd());
1152
1504
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
1153
1505
  this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
1154
- this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
1155
1506
  const editorPaddingX = this.settingsManager.getEditorPaddingX();
1156
1507
  const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
1157
1508
  this.defaultEditor.setPaddingX(editorPaddingX);
@@ -1196,14 +1547,13 @@ export class InteractiveMode {
1196
1547
  }
1197
1548
  }
1198
1549
  async handleFatalRuntimeError(prefix, error) {
1199
- const message = error instanceof Error ? error.message : String(error);
1200
- this.showError(`${prefix}: ${message}`);
1550
+ this.showError(`${prefix}: ${describeRuntimeError(error)}`);
1201
1551
  stopThemeWatcher();
1202
1552
  this.stop();
1203
1553
  process.exit(1);
1204
1554
  }
1205
1555
  renderCurrentSessionState() {
1206
- this.chatContainer.clear();
1556
+ this.clearTranscript();
1207
1557
  this.pendingMessagesContainer.clear();
1208
1558
  this.compactionQueuedMessages = [];
1209
1559
  this.streamingComponent = undefined;
@@ -1232,6 +1582,7 @@ export class InteractiveMode {
1232
1582
  sessionManager: this.sessionManager,
1233
1583
  modelRegistry: this.session.modelRegistry,
1234
1584
  model: this.session.model,
1585
+ scopedModels: this.session.scopedModels,
1235
1586
  isIdle: () => !this.session.isStreaming,
1236
1587
  signal: this.session.agent.signal,
1237
1588
  abort: () => this.session.abort(),
@@ -1312,7 +1663,7 @@ export class InteractiveMode {
1312
1663
  }
1313
1664
  setHiddenThinkingLabel(label) {
1314
1665
  this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;
1315
- for (const child of this.chatContainer.children) {
1666
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
1316
1667
  if (child instanceof AssistantMessageComponent) {
1317
1668
  child.setHiddenThinkingLabel(this.hiddenThinkingLabel);
1318
1669
  }
@@ -1320,7 +1671,7 @@ export class InteractiveMode {
1320
1671
  if (this.streamingComponent) {
1321
1672
  this.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel);
1322
1673
  }
1323
- this.ui.requestRender();
1674
+ this.ui.rebuild();
1324
1675
  }
1325
1676
  /**
1326
1677
  * Set an extension widget (string array or custom component).
@@ -1370,7 +1721,19 @@ export class InteractiveMode {
1370
1721
  this.extensionWidgetsBelow.clear();
1371
1722
  this.renderWidgets();
1372
1723
  }
1724
+ // SCRAMJET-DIVERGENCE: height delivery belongs to one opted-in custom invocation, never an inferred component capability.
1725
+ releaseCustomHeight() {
1726
+ const allocation = this.customHeightAllocation;
1727
+ this.customHeightAllocation = undefined;
1728
+ try {
1729
+ allocation?.notify(undefined);
1730
+ }
1731
+ catch (error) {
1732
+ this.showError(`Custom input height release failed: ${error instanceof Error ? error.message : String(error)}`);
1733
+ }
1734
+ }
1373
1735
  resetExtensionUI() {
1736
+ this.releaseCustomHeight();
1374
1737
  if (this.extensionSelector) {
1375
1738
  this.hideExtensionSelector();
1376
1739
  }
@@ -1391,6 +1754,7 @@ export class InteractiveMode {
1391
1754
  this.setCustomEditorComponent(undefined);
1392
1755
  this.setupAutocompleteProvider();
1393
1756
  this.defaultEditor.onExtensionShortcut = undefined;
1757
+ this.titleProvider = undefined;
1394
1758
  this.updateTerminalTitle();
1395
1759
  this.workingMessage = undefined;
1396
1760
  this.workingVisible = true;
@@ -1493,7 +1857,7 @@ export class InteractiveMode {
1493
1857
  this.headerContainer.children[index] = this.builtInHeader;
1494
1858
  }
1495
1859
  }
1496
- this.ui.requestRender();
1860
+ this.ui.rebuild();
1497
1861
  }
1498
1862
  addExtensionTerminalInputListener(handler) {
1499
1863
  const unsubscribe = this.ui.addInputListener(handler);
@@ -1533,6 +1897,10 @@ export class InteractiveMode {
1533
1897
  setFooter: (factory) => this.setExtensionFooter(factory),
1534
1898
  setHeader: (factory) => this.setExtensionHeader(factory),
1535
1899
  setTitle: (title) => this.ui.terminal.setTitle(title),
1900
+ setTitleProvider: (provider) => {
1901
+ this.titleProvider = provider;
1902
+ this.updateTerminalTitle();
1903
+ },
1536
1904
  custom: (factory, options) => this.showExtensionCustom(factory, options),
1537
1905
  pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`),
1538
1906
  setEditorText: (text) => this.editor.setText(text),
@@ -1737,6 +2105,7 @@ export class InteractiveMode {
1737
2105
  }
1738
2106
  }
1739
2107
  this.editor = newEditor;
2108
+ this.configureEditorHeight(this.editor);
1740
2109
  }
1741
2110
  else {
1742
2111
  // Restore default editor with text from custom editor
@@ -1763,26 +2132,77 @@ export class InteractiveMode {
1763
2132
  }
1764
2133
  /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
1765
2134
  async showExtensionCustom(factory, options) {
2135
+ if (options?.onAvailableHeight && (options.overlay || options.toolAttachedContext))
2136
+ throw new Error("Available height is supported only for ordinary custom input controls");
1766
2137
  const savedText = this.editor.getText();
1767
2138
  const isOverlay = options?.overlay ?? false;
1768
- const restoreEditor = () => {
2139
+ const restoreEditor = (restoreFocus = true) => {
1769
2140
  this.editorContainer.clear();
1770
2141
  this.editorContainer.addChild(this.editor);
1771
2142
  this.editor.setText(savedText);
1772
- this.ui.setFocus(this.editor);
2143
+ if (restoreFocus)
2144
+ this.ui.setFocus(this.editor);
1773
2145
  this.ui.requestRender();
1774
2146
  };
1775
2147
  return new Promise((resolve, reject) => {
1776
2148
  let component;
2149
+ let attachedTool;
2150
+ let committedContext;
2151
+ let removeInputGuard;
2152
+ let releaseHeight = () => { };
2153
+ let revealingControls = false;
2154
+ let needsReveal = false;
2155
+ const retained = this.ui.getViewportState() !== undefined;
2156
+ const pendingFocus = { render: () => [], invalidate() { } };
2157
+ const restoreAttachedFocus = (current) => {
2158
+ if (!retained)
2159
+ return;
2160
+ this.ui.replaceFocus(pendingFocus, current ? this.editor : null);
2161
+ if (attachedTool)
2162
+ this.ui.replaceFocus(attachedTool, current ? this.editor : null);
2163
+ };
1777
2164
  let closed = false;
2165
+ const attachmentCurrent = () => attachedTool &&
2166
+ committedContext &&
2167
+ this.pendingTools.get(options.toolAttachedContext.toolCallId) === attachedTool &&
2168
+ this.chatContainer.children.includes(attachedTool) &&
2169
+ this.committedChatContainer.children.includes(committedContext);
2170
+ const fail = (error) => {
2171
+ if (closed)
2172
+ return;
2173
+ closed = true;
2174
+ removeInputGuard?.();
2175
+ releaseHeight();
2176
+ const current = !attachedTool || this.chatContainer.children.includes(attachedTool);
2177
+ attachedTool?.cancelCommittedContext();
2178
+ if (committedContext) {
2179
+ this.committedChatContainer.removeChild(committedContext);
2180
+ this.ui.rebuild();
2181
+ }
2182
+ try {
2183
+ component?.dispose?.();
2184
+ }
2185
+ catch { }
2186
+ if (attachedTool)
2187
+ restoreAttachedFocus(current);
2188
+ if (!isOverlay && current)
2189
+ restoreEditor(!attachedTool || !retained);
2190
+ reject(error);
2191
+ };
1778
2192
  const close = (result) => {
1779
2193
  if (closed)
1780
2194
  return;
1781
2195
  closed = true;
2196
+ removeInputGuard?.();
2197
+ releaseHeight();
2198
+ attachedTool?.detachCommittedContext();
2199
+ const current = !attachedTool || this.chatContainer.children.includes(attachedTool);
2200
+ if (attachedTool)
2201
+ restoreAttachedFocus(current);
1782
2202
  if (isOverlay)
1783
2203
  this.ui.hideOverlay();
1784
- else
1785
- restoreEditor();
2204
+ else if (current)
2205
+ restoreEditor(!attachedTool || !retained);
1786
2206
  // Note: both branches above already call requestRender
1787
2207
  resolve(result);
1788
2208
  try {
@@ -1793,10 +2213,90 @@ export class InteractiveMode {
1793
2213
  }
1794
2214
  };
1795
2215
  Promise.resolve(factory(this.ui, theme, this.keybindings, close))
1796
- .then((c) => {
2216
+ .then(async (c) => {
1797
2217
  if (closed)
1798
2218
  return;
1799
2219
  component = c;
2220
+ const attachment = options?.toolAttachedContext;
2221
+ if (attachment) {
2222
+ if (isOverlay)
2223
+ throw new Error("Tool-attached context cannot be shown as an overlay");
2224
+ const tool = this.pendingTools.get(attachment.toolCallId);
2225
+ const orderingBarrier = this.chatContainer.children.find((child) => this.mutableChatComponents.has(child));
2226
+ if (!tool || !this.chatContainer.children.includes(tool) || orderingBarrier !== tool) {
2227
+ throw new Error("Tool-attached context requires the current pending tool row");
2228
+ }
2229
+ attachedTool = tool;
2230
+ committedContext = attachment.render(this.ui, theme);
2231
+ this.committedChatContainer.addChild(committedContext);
2232
+ tool.attachCommittedContext(component);
2233
+ if (retained)
2234
+ this.ui.replaceFocus(this.editor, pendingFocus);
2235
+ else
2236
+ this.ui.setFocus(null);
2237
+ this.editorContainer.clear();
2238
+ const reveal = async () => {
2239
+ revealingControls = true;
2240
+ this.ui.replaceFocus(tool, pendingFocus);
2241
+ if (retained) {
2242
+ this.ui.revealComponent(tool);
2243
+ await this.ui.renderNow({ requireFlush: true });
2244
+ }
2245
+ else
2246
+ await this.ui.commitNow({ requireFlush: true });
2247
+ if (closed)
2248
+ return;
2249
+ if (!attachmentCurrent())
2250
+ throw new Error("Tool-attached context was replaced before flush settled");
2251
+ if (retained && !this.ui.isViewportFrameFlushed()) {
2252
+ needsReveal = true;
2253
+ revealingControls = false;
2254
+ return;
2255
+ }
2256
+ if (retained &&
2257
+ (!this.ui.isComponentRenderComplete(committedContext) ||
2258
+ !this.ui.isComponentRenderComplete(tool)))
2259
+ throw new Error("Approval context or controls could not be rendered completely");
2260
+ const visibility = retained ? this.ui.getComponentVisibility(tool) : "visible";
2261
+ if (visibility === "outside")
2262
+ throw new Error("Approval controls do not fit in the visible viewport");
2263
+ needsReveal = visibility === "occluded";
2264
+ revealingControls = false;
2265
+ if (retained)
2266
+ this.ui.replaceFocus(pendingFocus, tool);
2267
+ else
2268
+ this.ui.setFocus(tool);
2269
+ };
2270
+ if (retained) {
2271
+ // SCRAMJET-DIVERGENCE: occluded controls require a fresh reveal/flush, never overlay focus theft.
2272
+ removeInputGuard = this.ui.addInputListener((data) => {
2273
+ if (isKeyRelease(data) || /^\x1b\[\d+;\d+;\d+t$/.test(data))
2274
+ return undefined;
2275
+ if (!this.ui.isComponentFocused(tool) && !this.ui.isComponentFocused(pendingFocus))
2276
+ return undefined;
2277
+ if (!attachmentCurrent()) {
2278
+ fail(new Error("Tool-attached context is no longer current"));
2279
+ return { consume: true };
2280
+ }
2281
+ if (revealingControls)
2282
+ return { consume: true };
2283
+ if (!needsReveal &&
2284
+ this.ui.isViewportFrameFlushed() &&
2285
+ this.ui.isComponentVisible(tool) &&
2286
+ this.ui.isComponentRenderComplete(committedContext) &&
2287
+ this.ui.isComponentRenderComplete(tool))
2288
+ return undefined;
2289
+ if (!this.ui.hasOverlay())
2290
+ void reveal().catch(fail);
2291
+ return { consume: true };
2292
+ });
2293
+ }
2294
+ await reveal();
2295
+ if (closed)
2296
+ return;
2297
+ this.ui.requestRender();
2298
+ return;
2299
+ }
1800
2300
  if (isOverlay) {
1801
2301
  // Resolve overlay options - can be static or dynamic function
1802
2302
  const resolveOptions = () => {
@@ -1815,19 +2315,23 @@ export class InteractiveMode {
1815
2315
  options?.onHandle?.(handle);
1816
2316
  }
1817
2317
  else {
2318
+ this.releaseCustomHeight();
2319
+ if (options?.onAvailableHeight) {
2320
+ const allocation = { component, notify: options.onAvailableHeight, fail };
2321
+ this.customHeightAllocation = allocation;
2322
+ releaseHeight = () => {
2323
+ if (this.customHeightAllocation === allocation)
2324
+ this.releaseCustomHeight();
2325
+ };
2326
+ allocation.notify(undefined);
2327
+ }
1818
2328
  this.editorContainer.clear();
1819
2329
  this.editorContainer.addChild(component);
1820
2330
  this.ui.setFocus(component);
1821
2331
  this.ui.requestRender();
1822
2332
  }
1823
2333
  })
1824
- .catch((err) => {
1825
- if (closed)
1826
- return;
1827
- if (!isOverlay)
1828
- restoreEditor();
1829
- reject(err);
1830
- });
2334
+ .catch(fail);
1831
2335
  });
1832
2336
  }
1833
2337
  /**
@@ -1848,7 +2352,7 @@ export class InteractiveMode {
1848
2352
  this.chatContainer.addChild(new Text(stackLines, 1, 0));
1849
2353
  }
1850
2354
  }
1851
- this.ui.requestRender();
2355
+ this.commitFinalizedChatOutput();
1852
2356
  }
1853
2357
  // =========================================================================
1854
2358
  // Key Handlers
@@ -1857,6 +2361,11 @@ export class InteractiveMode {
1857
2361
  // Set up handlers on defaultEditor - they use this.editor for text access
1858
2362
  // so they work correctly regardless of which editor is active
1859
2363
  this.defaultEditor.onEscape = () => {
2364
+ if (this.pendingSelectorOpenGeneration !== undefined) {
2365
+ this.selectorOpenGeneration++;
2366
+ this.pendingSelectorOpenGeneration = undefined;
2367
+ return;
2368
+ }
1860
2369
  if (this.session.isStreaming) {
1861
2370
  this.restoreQueuedMessagesToEditor({ abort: true });
1862
2371
  }
@@ -1908,6 +2417,7 @@ export class InteractiveMode {
1908
2417
  this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector());
1909
2418
  this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector());
1910
2419
  this.defaultEditor.onChange = (text) => {
2420
+ this.clipboardPasteGeneration++;
1911
2421
  const wasBashMode = this.isBashMode;
1912
2422
  this.isBashMode = text.trimStart().startsWith("!");
1913
2423
  if (wasBashMode !== this.isBashMode) {
@@ -1944,6 +2454,8 @@ export class InteractiveMode {
1944
2454
  text = text.trim();
1945
2455
  if (!text)
1946
2456
  return;
2457
+ // SCRAMJET-DIVERGENCE: submission intent precedes command routing and queued-message delivery.
2458
+ this.ui.followViewport();
1947
2459
  // Handle commands
1948
2460
  if (text === "/settings") {
1949
2461
  this.showSettingsSelector();
@@ -2122,7 +2634,9 @@ export class InteractiveMode {
2122
2634
  this.footer.invalidate();
2123
2635
  switch (event.type) {
2124
2636
  case "agent_start":
2637
+ this.agentRunGeneration += 1;
2125
2638
  this.pendingTools.clear();
2639
+ this.pendingToolFinalizations = new Set();
2126
2640
  if (this.settingsManager.getShowTerminalProgress()) {
2127
2641
  this.ui.terminal.setProgress(true);
2128
2642
  }
@@ -2161,19 +2675,22 @@ export class InteractiveMode {
2161
2675
  this.updateEditorBorderColor();
2162
2676
  break;
2163
2677
  case "message_start":
2678
+ this.sealStatus();
2164
2679
  if (event.message.role === "custom") {
2165
2680
  this.addMessageToChat(event.message);
2166
2681
  this.ui.requestRender();
2167
2682
  }
2168
2683
  else if (event.message.role === "user") {
2169
2684
  this.addMessageToChat(event.message);
2685
+ this.ui.followViewport();
2170
2686
  this.updatePendingMessagesDisplay();
2171
2687
  this.ui.requestRender();
2172
2688
  }
2173
2689
  else if (event.message.role === "assistant") {
2174
- this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel);
2690
+ this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, false);
2175
2691
  this.streamingMessage = event.message;
2176
2692
  this.chatContainer.addChild(this.streamingComponent);
2693
+ this.setChatComponentMutable(this.streamingComponent, true);
2177
2694
  this.streamingComponent.updateContent(this.streamingMessage);
2178
2695
  this.ui.requestRender();
2179
2696
  }
@@ -2191,6 +2708,7 @@ export class InteractiveMode {
2191
2708
  }, this.getRegisteredToolDefinition(content.name), this.ui, this.sessionManager.getCwd());
2192
2709
  component.setExpanded(this.toolOutputExpanded);
2193
2710
  this.chatContainer.addChild(component);
2711
+ this.setChatComponentMutable(component, true);
2194
2712
  this.pendingTools.set(content.id, component);
2195
2713
  }
2196
2714
  else {
@@ -2219,6 +2737,8 @@ export class InteractiveMode {
2219
2737
  this.streamingMessage.errorMessage = errorMessage;
2220
2738
  }
2221
2739
  this.streamingComponent.updateContent(this.streamingMessage);
2740
+ this.streamingComponent.setFinalized(true);
2741
+ this.setChatComponentMutable(this.streamingComponent, false);
2222
2742
  if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
2223
2743
  if (!errorMessage) {
2224
2744
  errorMessage = this.streamingMessage.errorMessage || "Error";
@@ -2228,6 +2748,8 @@ export class InteractiveMode {
2228
2748
  content: [{ type: "text", text: errorMessage }],
2229
2749
  isError: true,
2230
2750
  });
2751
+ component.seal();
2752
+ this.setChatComponentMutable(component, false);
2231
2753
  }
2232
2754
  this.pendingTools.clear();
2233
2755
  }
@@ -2237,6 +2759,7 @@ export class InteractiveMode {
2237
2759
  component.setArgsComplete();
2238
2760
  }
2239
2761
  }
2762
+ this.promoteFinalizedChatPrefix();
2240
2763
  this.streamingComponent = undefined;
2241
2764
  this.streamingMessage = undefined;
2242
2765
  this.footer.invalidate();
@@ -2244,6 +2767,7 @@ export class InteractiveMode {
2244
2767
  this.ui.requestRender();
2245
2768
  break;
2246
2769
  case "tool_execution_start": {
2770
+ this.sealStatus();
2247
2771
  let component = this.pendingTools.get(event.toolCallId);
2248
2772
  if (!component) {
2249
2773
  component = new ToolExecutionComponent(event.toolName, event.toolCallId, event.args, {
@@ -2252,6 +2776,7 @@ export class InteractiveMode {
2252
2776
  }, this.getRegisteredToolDefinition(event.toolName), this.ui, this.sessionManager.getCwd());
2253
2777
  component.setExpanded(this.toolOutputExpanded);
2254
2778
  this.chatContainer.addChild(component);
2779
+ this.setChatComponentMutable(component, true);
2255
2780
  this.pendingTools.set(event.toolCallId, component);
2256
2781
  }
2257
2782
  component.markExecutionStarted();
@@ -2270,12 +2795,34 @@ export class InteractiveMode {
2270
2795
  const component = this.pendingTools.get(event.toolCallId);
2271
2796
  if (component) {
2272
2797
  component.updateResult({ ...event.result, isError: event.isError });
2273
- this.pendingTools.delete(event.toolCallId);
2274
- this.ui.requestRender();
2798
+ const pendingToolFinalizations = this.pendingToolFinalizations;
2799
+ const finalization = component.waitForImageConversions().then(() => {
2800
+ component.seal();
2801
+ if (this.pendingTools.get(event.toolCallId) === component) {
2802
+ this.pendingTools.delete(event.toolCallId);
2803
+ }
2804
+ if (this.mutableChatComponents.has(component) && this.chatContainer.children.includes(component)) {
2805
+ this.setChatComponentMutable(component, false);
2806
+ this.promoteFinalizedChatPrefix();
2807
+ this.ui.requestRender();
2808
+ }
2809
+ });
2810
+ pendingToolFinalizations.add(finalization);
2811
+ try {
2812
+ await finalization;
2813
+ }
2814
+ finally {
2815
+ pendingToolFinalizations.delete(finalization);
2816
+ }
2275
2817
  }
2276
2818
  break;
2277
2819
  }
2278
- case "agent_end":
2820
+ case "agent_end": {
2821
+ const runGeneration = this.agentRunGeneration;
2822
+ const pendingToolFinalizations = this.pendingToolFinalizations;
2823
+ await Promise.all(pendingToolFinalizations);
2824
+ if (runGeneration !== this.agentRunGeneration || pendingToolFinalizations !== this.pendingToolFinalizations)
2825
+ break;
2279
2826
  if (this.settingsManager.getShowTerminalProgress()) {
2280
2827
  this.ui.terminal.setProgress(false);
2281
2828
  }
@@ -2286,13 +2833,20 @@ export class InteractiveMode {
2286
2833
  }
2287
2834
  if (this.streamingComponent) {
2288
2835
  this.chatContainer.removeChild(this.streamingComponent);
2836
+ this.setChatComponentMutable(this.streamingComponent, false);
2289
2837
  this.streamingComponent = undefined;
2290
2838
  this.streamingMessage = undefined;
2291
2839
  }
2840
+ for (const component of this.pendingTools.values()) {
2841
+ component.seal();
2842
+ this.setChatComponentMutable(component, false);
2843
+ }
2292
2844
  this.pendingTools.clear();
2845
+ this.promoteFinalizedChatPrefix();
2293
2846
  await this.checkShutdownRequested();
2294
2847
  this.ui.requestRender();
2295
2848
  break;
2849
+ }
2296
2850
  case "compaction_start": {
2297
2851
  if (this.settingsManager.getShowTerminalProgress()) {
2298
2852
  this.ui.terminal.setProgress(true);
@@ -2334,7 +2888,6 @@ export class InteractiveMode {
2334
2888
  }
2335
2889
  }
2336
2890
  else if (event.result) {
2337
- this.chatContainer.clear();
2338
2891
  this.rebuildChatFromMessages();
2339
2892
  this.addMessageToChat(createCompactionSummaryMessage(event.result.summary, event.result.tokensBefore, new Date().toISOString()));
2340
2893
  this.footer.invalidate();
@@ -2346,6 +2899,7 @@ export class InteractiveMode {
2346
2899
  else {
2347
2900
  this.chatContainer.addChild(new Spacer(1));
2348
2901
  this.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0));
2902
+ this.commitFinalizedChatOutput();
2349
2903
  }
2350
2904
  }
2351
2905
  void this.flushCompactionQueue({ willRetry: event.willRetry });
@@ -2361,7 +2915,13 @@ export class InteractiveMode {
2361
2915
  // Show retry indicator
2362
2916
  this.statusContainer.clear();
2363
2917
  this.retryCountdown?.dispose();
2364
- const retryMessage = (seconds) => `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`;
2918
+ const retryMessage = (seconds) => {
2919
+ const base = `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s...`;
2920
+ const cumulative = event.cumulativeErrors && event.cumulativeErrors > 1
2921
+ ? ` [${event.cumulativeErrors} total failures this prompt]`
2922
+ : "";
2923
+ return `${base}${cumulative} (${keyText("app.interrupt")} to cancel)`;
2924
+ };
2365
2925
  this.retryLoader = new Loader(this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), retryMessage(Math.ceil(event.delayMs / 1000)));
2366
2926
  this.retryCountdown = new CountdownTimer(event.delayMs, this.ui, (seconds) => {
2367
2927
  this.retryLoader?.setMessage(retryMessage(seconds));
@@ -2425,11 +2985,14 @@ export class InteractiveMode {
2425
2985
  const text = new Text(theme.fg("dim", message), 1, 0);
2426
2986
  this.chatContainer.addChild(spacer);
2427
2987
  this.chatContainer.addChild(text);
2988
+ this.setChatComponentMutable(spacer, true);
2989
+ this.setChatComponentMutable(text, true);
2428
2990
  this.lastStatusSpacer = spacer;
2429
2991
  this.lastStatusText = text;
2430
2992
  this.ui.requestRender();
2431
2993
  }
2432
2994
  addMessageToChat(message, options) {
2995
+ this.sealStatus();
2433
2996
  switch (message.role) {
2434
2997
  case "bashExecution": {
2435
2998
  const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);
@@ -2480,9 +3043,10 @@ export class InteractiveMode {
2480
3043
  this.chatContainer.addChild(userComponent);
2481
3044
  }
2482
3045
  if (options?.populateHistory) {
2483
- const compactForm = scramjetBlock.userContext
2484
- ? `/${scramjetBlock.name} ${scramjetBlock.userContext}`
2485
- : `/${scramjetBlock.name}`;
3046
+ const compactForm = options.historyText ??
3047
+ (scramjetBlock.userContext
3048
+ ? `/${scramjetBlock.name} ${scramjetBlock.userContext}`
3049
+ : `/${scramjetBlock.name}`);
2486
3050
  this.editor.addToHistory?.(compactForm);
2487
3051
  }
2488
3052
  }
@@ -2521,6 +3085,7 @@ export class InteractiveMode {
2521
3085
  const _exhaustive = message;
2522
3086
  }
2523
3087
  }
3088
+ this.promoteFinalizedChatPrefix();
2524
3089
  }
2525
3090
  /**
2526
3091
  * Render session context to chat. Used for initial load and rebuild after compaction.
@@ -2531,6 +3096,11 @@ export class InteractiveMode {
2531
3096
  renderSessionContext(sessionContext, options = {}) {
2532
3097
  this.pendingTools.clear();
2533
3098
  const renderedPendingTools = new Map();
3099
+ const entries = options.populateHistory ? this.sessionManager.getEntries() : [];
3100
+ const commandStarts = indexScramjetCommandStarts(entries);
3101
+ const entriesByMessage = new Map(entries
3102
+ .filter((entry) => entry.type === "message")
3103
+ .map((entry) => [entry.message, entry]));
2534
3104
  if (options.updateFooter) {
2535
3105
  this.footer.invalidate();
2536
3106
  this.updateEditorBorderColor();
@@ -2548,6 +3118,7 @@ export class InteractiveMode {
2548
3118
  }, this.getRegisteredToolDefinition(content.name), this.ui, this.sessionManager.getCwd());
2549
3119
  component.setExpanded(this.toolOutputExpanded);
2550
3120
  this.chatContainer.addChild(component);
3121
+ this.setChatComponentMutable(component, true);
2551
3122
  if (message.stopReason === "aborted" || message.stopReason === "error") {
2552
3123
  let errorMessage;
2553
3124
  if (message.stopReason === "aborted") {
@@ -2561,6 +3132,8 @@ export class InteractiveMode {
2561
3132
  errorMessage = message.errorMessage || "Error";
2562
3133
  }
2563
3134
  component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
3135
+ component.seal();
3136
+ this.setChatComponentMutable(component, false);
2564
3137
  }
2565
3138
  else {
2566
3139
  renderedPendingTools.set(content.id, component);
@@ -2572,18 +3145,45 @@ export class InteractiveMode {
2572
3145
  // Match tool results to pending tool components
2573
3146
  const component = renderedPendingTools.get(message.toolCallId);
2574
3147
  if (component) {
3148
+ component.markExecutionStarted();
3149
+ component.setArgsComplete();
2575
3150
  component.updateResult(message);
2576
3151
  renderedPendingTools.delete(message.toolCallId);
3152
+ if (component.hasPendingImageConversions()) {
3153
+ const pendingToolFinalizations = this.pendingToolFinalizations;
3154
+ const finalization = component.waitForImageConversions().then(() => {
3155
+ if (!this.mutableChatComponents.has(component) ||
3156
+ !this.chatContainer.children.includes(component)) {
3157
+ component.seal();
3158
+ return;
3159
+ }
3160
+ component.seal();
3161
+ this.setChatComponentMutable(component, false);
3162
+ this.promoteFinalizedChatPrefix();
3163
+ this.ui.requestRender();
3164
+ });
3165
+ pendingToolFinalizations.add(finalization);
3166
+ void finalization.finally(() => pendingToolFinalizations.delete(finalization));
3167
+ }
3168
+ else {
3169
+ component.seal();
3170
+ this.setChatComponentMutable(component, false);
3171
+ }
2577
3172
  }
2578
3173
  }
2579
3174
  else {
2580
3175
  // All other messages use standard rendering
2581
- this.addMessageToChat(message, options);
3176
+ const entry = entriesByMessage.get(message);
3177
+ this.addMessageToChat(message, {
3178
+ ...options,
3179
+ historyText: entry ? restoreScramjetCommandInvocation(entry, commandStarts) : undefined,
3180
+ });
2582
3181
  }
2583
3182
  }
2584
3183
  for (const [toolCallId, component] of renderedPendingTools) {
2585
3184
  this.pendingTools.set(toolCallId, component);
2586
3185
  }
3186
+ this.promoteFinalizedChatPrefix();
2587
3187
  this.ui.requestRender();
2588
3188
  }
2589
3189
  renderInitialMessages() {
@@ -2610,7 +3210,7 @@ export class InteractiveMode {
2610
3210
  });
2611
3211
  }
2612
3212
  rebuildChatFromMessages() {
2613
- this.chatContainer.clear();
3213
+ this.clearTranscript();
2614
3214
  const context = this.sessionManager.buildSessionContext();
2615
3215
  this.renderSessionContext(context);
2616
3216
  }
@@ -2645,9 +3245,18 @@ export class InteractiveMode {
2645
3245
  // Drain any in-flight Kitty key release events before stopping.
2646
3246
  // This prevents escape sequences from leaking to the parent shell over slow SSH.
2647
3247
  await this.ui.terminal.drainInput(1000);
2648
- this.stop();
3248
+ await this.settingsManager.flush();
3249
+ let exitCode = 0;
3250
+ try {
3251
+ this.stop({ retainContent: this.settingsManager.getRetainTranscriptOnExit() });
3252
+ }
3253
+ catch (error) {
3254
+ exitCode = 1;
3255
+ console.error(`Could not retain terminal history: ${error instanceof Error ? error.message : String(error)}`);
3256
+ }
3257
+ await this.ui.terminal.flush?.();
2649
3258
  await this.runtimeHost.dispose();
2650
- process.exit(0);
3259
+ process.exit(exitCode);
2651
3260
  }
2652
3261
  emergencyTerminalExit() {
2653
3262
  this.isShuttingDown = true;
@@ -2737,7 +3346,7 @@ export class InteractiveMode {
2737
3346
  }
2738
3347
  this.signalCleanupHandlers = [];
2739
3348
  }
2740
- handleCtrlZ() {
3349
+ async handleCtrlZ() {
2741
3350
  if (process.platform === "win32") {
2742
3351
  this.showStatus("Suspend to background is not supported on Windows");
2743
3352
  return;
@@ -2755,10 +3364,11 @@ export class InteractiveMode {
2755
3364
  clearInterval(suspendKeepAlive);
2756
3365
  process.removeListener("SIGINT", ignoreSigint);
2757
3366
  this.ui.start();
2758
- this.ui.requestRender(true);
3367
+ this.ui.rebuild();
2759
3368
  });
2760
3369
  try {
2761
- // Stop the TUI (restore terminal to normal mode)
3370
+ // SCRAMJET-DIVERGENCE: drain releases before handing keyboard ownership back to the shell.
3371
+ await this.ui.terminal.drainInput();
2762
3372
  this.ui.stop();
2763
3373
  // Send SIGTSTP to process group (pid=0 means all processes in group)
2764
3374
  process.kill(0, "SIGTSTP");
@@ -2773,6 +3383,7 @@ export class InteractiveMode {
2773
3383
  const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
2774
3384
  if (!text)
2775
3385
  return;
3386
+ this.ui.followViewport();
2776
3387
  // Queue input during compaction (extension commands execute immediately)
2777
3388
  if (this.session.isCompacting) {
2778
3389
  if (this.isExtensionCommand(text)) {
@@ -2858,28 +3469,28 @@ export class InteractiveMode {
2858
3469
  if (isExpandable(activeHeader)) {
2859
3470
  activeHeader.setExpanded(expanded);
2860
3471
  }
2861
- for (const child of this.chatContainer.children) {
3472
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
2862
3473
  if (isExpandable(child)) {
2863
3474
  child.setExpanded(expanded);
2864
3475
  }
2865
3476
  }
2866
- this.ui.requestRender();
3477
+ this.ui.rebuild();
2867
3478
  }
2868
3479
  toggleThinkingBlockVisibility() {
2869
- this.hideThinkingBlock = !this.hideThinkingBlock;
2870
- this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
2871
- // Rebuild chat from session messages
2872
- this.chatContainer.clear();
2873
- this.rebuildChatFromMessages();
2874
- // If streaming, re-add the streaming component with updated visibility and re-render
2875
- if (this.streamingComponent && this.streamingMessage) {
2876
- this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
2877
- this.streamingComponent.updateContent(this.streamingMessage);
2878
- this.chatContainer.addChild(this.streamingComponent);
2879
- }
3480
+ this.setThinkingBlockVisibility(!this.hideThinkingBlock);
2880
3481
  this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
2881
3482
  }
2882
- openExternalEditor() {
3483
+ // SCRAMJET-DIVERGENCE: presentation changes retain live tools and browsing identities.
3484
+ setThinkingBlockVisibility(hidden) {
3485
+ this.hideThinkingBlock = hidden;
3486
+ this.settingsManager.setHideThinkingBlock(hidden);
3487
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
3488
+ if (child instanceof AssistantMessageComponent)
3489
+ child.setHideThinkingBlock(hidden);
3490
+ }
3491
+ this.ui.rebuild();
3492
+ }
3493
+ async openExternalEditor() {
2883
3494
  // Determine editor (respect $VISUAL, then $EDITOR)
2884
3495
  const editorCmd = process.env.VISUAL || process.env.EDITOR;
2885
3496
  if (!editorCmd) {
@@ -2891,7 +3502,8 @@ export class InteractiveMode {
2891
3502
  try {
2892
3503
  // Write current content to temp file
2893
3504
  fs.writeFileSync(tmpFile, currentText, "utf-8");
2894
- // Stop TUI to release terminal
3505
+ // SCRAMJET-DIVERGENCE: the editor must not inherit the key release that opened it.
3506
+ await this.ui.terminal.drainInput();
2895
3507
  this.ui.stop();
2896
3508
  // Split by space to support editor arguments (e.g., "code --wait")
2897
3509
  const [editor, ...editorArgs] = editorCmd.split(" ");
@@ -2917,8 +3529,7 @@ export class InteractiveMode {
2917
3529
  }
2918
3530
  // Restart TUI
2919
3531
  this.ui.start();
2920
- // Force full re-render since external editor uses alternate screen
2921
- this.ui.requestRender(true);
3532
+ this.ui.rebuild();
2922
3533
  }
2923
3534
  }
2924
3535
  // =========================================================================
@@ -2929,14 +3540,18 @@ export class InteractiveMode {
2929
3540
  this.ui.requestRender();
2930
3541
  }
2931
3542
  showError(errorMessage) {
3543
+ this.sealStatus();
2932
3544
  this.chatContainer.addChild(new Spacer(1));
2933
3545
  this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
2934
3546
  this.chatContainer.addChild(new Spacer(1));
3547
+ this.promoteFinalizedChatPrefix();
2935
3548
  this.ui.requestRender();
2936
3549
  }
2937
3550
  showWarning(warningMessage) {
3551
+ this.sealStatus();
2938
3552
  this.chatContainer.addChild(new Spacer(1));
2939
3553
  this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
3554
+ this.promoteFinalizedChatPrefix();
2940
3555
  this.ui.requestRender();
2941
3556
  }
2942
3557
  // SCRAMJET-DIVERGENCE: showNewVersionNotification removed (version check gutted).
@@ -2948,7 +3563,7 @@ export class InteractiveMode {
2948
3563
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2949
3564
  this.chatContainer.addChild(new Text(`${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction}\n${theme.fg("muted", "Packages:")}\n${packageLines}`, 1, 0));
2950
3565
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2951
- this.ui.requestRender();
3566
+ this.commitFinalizedChatOutput();
2952
3567
  }
2953
3568
  /**
2954
3569
  * Get all queued messages (read-only).
@@ -3108,11 +3723,14 @@ export class InteractiveMode {
3108
3723
  }
3109
3724
  /** Move pending bash components from pending area to chat */
3110
3725
  flushPendingBashComponents() {
3726
+ if (this.pendingBashComponents.length > 0)
3727
+ this.sealStatus();
3111
3728
  for (const component of this.pendingBashComponents) {
3112
3729
  this.pendingMessagesContainer.removeChild(component);
3113
3730
  this.chatContainer.addChild(component);
3114
3731
  }
3115
3732
  this.pendingBashComponents = [];
3733
+ this.promoteFinalizedChatPrefix();
3116
3734
  }
3117
3735
  // =========================================================================
3118
3736
  // Selectors
@@ -3122,7 +3740,10 @@ export class InteractiveMode {
3122
3740
  * @param create Factory that receives a `done` callback and returns the component and focus target
3123
3741
  */
3124
3742
  showSelector(create) {
3743
+ this.selectorOpenGeneration++;
3744
+ this.pendingSelectorOpenGeneration = undefined;
3125
3745
  const done = () => {
3746
+ this.selectorOpenGeneration++;
3126
3747
  this.editorContainer.clear();
3127
3748
  this.editorContainer.addChild(this.editor);
3128
3749
  this.ui.setFocus(this.editor);
@@ -3133,6 +3754,23 @@ export class InteractiveMode {
3133
3754
  this.ui.setFocus(focus);
3134
3755
  this.ui.requestRender();
3135
3756
  }
3757
+ async settleLayoutSettings(selector) {
3758
+ const generation = ++this.layoutSettingsSettlementGeneration;
3759
+ const settings = this.settingsManager;
3760
+ await settings.flush();
3761
+ // SCRAMJET-DIVERGENCE: only the latest settlement may drain a shared batch of save errors.
3762
+ if (generation !== this.layoutSettingsSettlementGeneration)
3763
+ return;
3764
+ const errors = settings.drainErrors();
3765
+ selector.setSaveError(errors.length ? "Changes not saved; see warning" : undefined);
3766
+ if (errors.length) {
3767
+ const details = stripVTControlCharacters(errors.map(({ scope, error }) => `${scope}: ${error.message}`).join("; "))
3768
+ .replace(/[\r\n\t]/g, " ")
3769
+ .slice(0, 200);
3770
+ this.showWarning(`Display settings not saved (${details}). Changes may be lost on restart; project overrides still apply.`);
3771
+ }
3772
+ this.ui.requestRender();
3773
+ }
3136
3774
  showSettingsSelector() {
3137
3775
  this.showSelector((done) => {
3138
3776
  const selector = new SettingsSelectorComponent({
@@ -3147,7 +3785,8 @@ export class InteractiveMode {
3147
3785
  transport: this.settingsManager.getTransport(),
3148
3786
  thinkingLevel: this.session.thinkingLevel,
3149
3787
  availableThinkingLevels: this.session.getAvailableThinkingLevels(),
3150
- currentTheme: this.settingsManager.getTheme() || "dark",
3788
+ // SCRAMJET-DIVERGENCE: display fallback routed to the renamed builtin.
3789
+ currentTheme: this.settingsManager.getTheme() || "pi-dark",
3151
3790
  availableThemes: getAvailableThemes(),
3152
3791
  hideThinkingBlock: this.hideThinkingBlock,
3153
3792
  collapseChangelog: this.settingsManager.getCollapseChangelog(),
@@ -3158,9 +3797,14 @@ export class InteractiveMode {
3158
3797
  editorPaddingX: this.settingsManager.getEditorPaddingX(),
3159
3798
  autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
3160
3799
  quietStartup: this.settingsManager.getQuietStartup(),
3161
- clearOnShrink: this.settingsManager.getClearOnShrink(),
3162
3800
  showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
3163
3801
  warnings: this.settingsManager.getWarnings(),
3802
+ tuiMode: this.tuiMode,
3803
+ dockEditor: this.settingsManager.getDockEditor(),
3804
+ retainTranscriptOnExit: this.settingsManager.getRetainTranscriptOnExit(),
3805
+ editorMaxHeightPercent: this.settingsManager.getEditorMaxHeightPercent(),
3806
+ scrollWheelStep: this.settingsManager.getScrollWheelStep(),
3807
+ viewportProjectOverrides: Object.keys(this.settingsManager.getProjectSettings()),
3164
3808
  }, {
3165
3809
  onAutoCompactChange: (enabled) => {
3166
3810
  this.session.setAutoCompactionEnabled(enabled);
@@ -3168,19 +3812,11 @@ export class InteractiveMode {
3168
3812
  },
3169
3813
  onShowImagesChange: (enabled) => {
3170
3814
  this.settingsManager.setShowImages(enabled);
3171
- for (const child of this.chatContainer.children) {
3172
- if (child instanceof ToolExecutionComponent) {
3173
- child.setShowImages(enabled);
3174
- }
3175
- }
3815
+ this.updateToolImagePresentation((component) => component.setShowImages(enabled));
3176
3816
  },
3177
3817
  onImageWidthCellsChange: (width) => {
3178
3818
  this.settingsManager.setImageWidthCells(width);
3179
- for (const child of this.chatContainer.children) {
3180
- if (child instanceof ToolExecutionComponent) {
3181
- child.setImageWidthCells(width);
3182
- }
3183
- }
3819
+ this.updateToolImagePresentation((component) => component.setImageWidthCells(width));
3184
3820
  },
3185
3821
  onAutoResizeImagesChange: (enabled) => {
3186
3822
  this.settingsManager.setImageAutoResize(enabled);
@@ -3212,7 +3848,8 @@ export class InteractiveMode {
3212
3848
  this.settingsManager.setTheme(themeName);
3213
3849
  this.ui.invalidate();
3214
3850
  if (!result.success) {
3215
- this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
3851
+ // SCRAMJET-DIVERGENCE: fallback message names the renamed builtin.
3852
+ this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to pi-dark theme.`);
3216
3853
  }
3217
3854
  },
3218
3855
  onThemePreview: (themeName) => {
@@ -3222,17 +3859,7 @@ export class InteractiveMode {
3222
3859
  this.ui.requestRender();
3223
3860
  }
3224
3861
  },
3225
- onHideThinkingBlockChange: (hidden) => {
3226
- this.hideThinkingBlock = hidden;
3227
- this.settingsManager.setHideThinkingBlock(hidden);
3228
- for (const child of this.chatContainer.children) {
3229
- if (child instanceof AssistantMessageComponent) {
3230
- child.setHideThinkingBlock(hidden);
3231
- }
3232
- }
3233
- this.chatContainer.clear();
3234
- this.rebuildChatFromMessages();
3235
- },
3862
+ onHideThinkingBlockChange: (hidden) => this.setThinkingBlockVisibility(hidden),
3236
3863
  onCollapseChangelogChange: (collapsed) => {
3237
3864
  this.settingsManager.setCollapseChangelog(collapsed);
3238
3865
  },
@@ -3266,21 +3893,42 @@ export class InteractiveMode {
3266
3893
  this.editor.setAutocompleteMaxVisible(maxVisible);
3267
3894
  }
3268
3895
  },
3269
- onClearOnShrinkChange: (enabled) => {
3270
- this.settingsManager.setClearOnShrink(enabled);
3271
- this.ui.setClearOnShrink(enabled);
3272
- },
3273
3896
  onShowTerminalProgressChange: (enabled) => {
3274
3897
  this.settingsManager.setShowTerminalProgress(enabled);
3275
3898
  },
3276
3899
  onWarningsChange: (warnings) => {
3277
3900
  this.settingsManager.setWarnings(warnings);
3278
3901
  },
3902
+ onRetainTranscriptOnExitChange: (enabled) => {
3903
+ this.settingsManager.setRetainTranscriptOnExit(enabled);
3904
+ void this.settleLayoutSettings(selector);
3905
+ return this.settingsManager.getRetainTranscriptOnExit();
3906
+ },
3907
+ onDockEditorChange: (enabled) => {
3908
+ this.settingsManager.setDockEditor(enabled);
3909
+ void this.settleLayoutSettings(selector);
3910
+ this.ui.refreshViewportLayout();
3911
+ if (!this.settingsManager.getDockEditor())
3912
+ this.ui.revealComponent(this.editorContainer);
3913
+ return this.settingsManager.getDockEditor();
3914
+ },
3915
+ onEditorMaxHeightPercentChange: (percent) => {
3916
+ this.settingsManager.setEditorMaxHeightPercent(percent);
3917
+ void this.settleLayoutSettings(selector);
3918
+ this.ui.refreshViewportLayout();
3919
+ return this.settingsManager.getEditorMaxHeightPercent();
3920
+ },
3921
+ onScrollWheelStepChange: (step) => {
3922
+ this.settingsManager.setScrollWheelStep(step);
3923
+ void this.settleLayoutSettings(selector);
3924
+ this.ui.requestRender();
3925
+ return this.settingsManager.getScrollWheelStep();
3926
+ },
3279
3927
  onCancel: () => {
3280
3928
  done();
3281
3929
  this.ui.requestRender();
3282
3930
  },
3283
- });
3931
+ }, () => Math.max(1, Math.min(this.editorAvailableRows ?? Infinity, this.ui.terminal.rows - 4)));
3284
3932
  return { component: selector, focus: selector.getSettingsList() };
3285
3933
  });
3286
3934
  }
@@ -3356,7 +4004,22 @@ export class InteractiveMode {
3356
4004
  // Ignore auth lookup failures for warning-only checks.
3357
4005
  }
3358
4006
  }
3359
- showModelSelector(initialSearchInput) {
4007
+ selectorPreparationIsCurrent(session, generation) {
4008
+ const current = this.isInitialized && this.session === session && this.selectorOpenGeneration === generation;
4009
+ if (!current && this.pendingSelectorOpenGeneration === generation)
4010
+ this.pendingSelectorOpenGeneration = undefined;
4011
+ return current;
4012
+ }
4013
+ // SCRAMJET-DIVERGENCE: Refresh profile-scoped output-rate history before opening model selectors (issue 476).
4014
+ async showModelSelector(initialSearchInput) {
4015
+ const session = this.session;
4016
+ const generation = ++this.selectorOpenGeneration;
4017
+ this.pendingSelectorOpenGeneration = generation;
4018
+ await session.refreshOutputThroughputHistory().catch(() => undefined);
4019
+ if (!this.selectorPreparationIsCurrent(session, generation))
4020
+ return;
4021
+ this.pendingSelectorOpenGeneration = undefined;
4022
+ const outputThroughputHistory = session.outputThroughputHistory;
3360
4023
  this.showSelector((done) => {
3361
4024
  const selector = new ModelSelectorComponent(this.ui, this.session.model, this.settingsManager, this.session.modelRegistry, this.session.scopedModels, async (model) => {
3362
4025
  try {
@@ -3375,20 +4038,28 @@ export class InteractiveMode {
3375
4038
  }, () => {
3376
4039
  done();
3377
4040
  this.ui.requestRender();
3378
- }, initialSearchInput);
4041
+ }, initialSearchInput, outputThroughputHistory);
3379
4042
  return { component: selector, focus: selector };
3380
4043
  });
3381
4044
  }
3382
4045
  async showModelsSelector() {
4046
+ const session = this.session;
4047
+ const generation = ++this.selectorOpenGeneration;
4048
+ this.pendingSelectorOpenGeneration = generation;
4049
+ await session.refreshOutputThroughputHistory().catch(() => undefined);
4050
+ if (!this.selectorPreparationIsCurrent(session, generation))
4051
+ return;
4052
+ const outputThroughputHistory = session.outputThroughputHistory;
3383
4053
  // Get all available models
3384
- this.session.modelRegistry.refresh();
3385
- const allModels = this.session.modelRegistry.getAvailable();
4054
+ session.modelRegistry.refresh();
4055
+ const allModels = session.modelRegistry.getAvailable();
3386
4056
  if (allModels.length === 0) {
4057
+ this.pendingSelectorOpenGeneration = undefined;
3387
4058
  this.showStatus("No models available");
3388
4059
  return;
3389
4060
  }
3390
4061
  // Check if session has scoped models (from previous session-only changes or CLI --models)
3391
- const sessionScopedModels = this.session.scopedModels;
4062
+ const sessionScopedModels = session.scopedModels;
3392
4063
  const hasSessionScope = sessionScopedModels.length > 0;
3393
4064
  // Build enabled model IDs from session state or settings
3394
4065
  let currentEnabledIds = null;
@@ -3400,10 +4071,13 @@ export class InteractiveMode {
3400
4071
  // Fall back to settings
3401
4072
  const patterns = this.settingsManager.getEnabledModels();
3402
4073
  if (patterns !== undefined && patterns.length > 0) {
3403
- const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
4074
+ const scopedModels = await resolveModelScope(patterns, session.modelRegistry);
3404
4075
  currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
3405
4076
  }
3406
4077
  }
4078
+ if (!this.selectorPreparationIsCurrent(session, generation))
4079
+ return;
4080
+ this.pendingSelectorOpenGeneration = undefined;
3407
4081
  // Helper to update session's scoped models (session-only, no persist)
3408
4082
  const updateSessionModels = async (enabledIds) => {
3409
4083
  currentEnabledIds = enabledIds === null ? null : [...enabledIds];
@@ -3441,7 +4115,7 @@ export class InteractiveMode {
3441
4115
  done();
3442
4116
  this.ui.requestRender();
3443
4117
  },
3444
- });
4118
+ }, outputThroughputHistory);
3445
4119
  return { component: selector, focus: selector };
3446
4120
  });
3447
4121
  }
@@ -3468,7 +4142,7 @@ export class InteractiveMode {
3468
4142
  }
3469
4143
  catch (error) {
3470
4144
  done();
3471
- this.showError(error instanceof Error ? error.message : String(error));
4145
+ this.showError(describeRuntimeError(error));
3472
4146
  }
3473
4147
  }, () => {
3474
4148
  done();
@@ -3494,7 +4168,7 @@ export class InteractiveMode {
3494
4168
  this.showStatus("Cloned to new session");
3495
4169
  }
3496
4170
  catch (error) {
3497
- this.showError(error instanceof Error ? error.message : String(error));
4171
+ this.showError(describeRuntimeError(error));
3498
4172
  }
3499
4173
  }
3500
4174
  showTreeSelector(initialSelectedId) {
@@ -3571,7 +4245,7 @@ export class InteractiveMode {
3571
4245
  return;
3572
4246
  }
3573
4247
  // Update UI
3574
- this.chatContainer.clear();
4248
+ this.clearTranscript();
3575
4249
  this.renderInitialMessages();
3576
4250
  if (result.editorText && !this.editor.getText().trim()) {
3577
4251
  this.editor.setText(result.editorText);
@@ -4006,7 +4680,7 @@ export class InteractiveMode {
4006
4680
  this.editorContainer.clear();
4007
4681
  this.editorContainer.addChild(reloadBox);
4008
4682
  this.ui.setFocus(reloadBox);
4009
- this.ui.requestRender(true);
4683
+ this.ui.rebuild();
4010
4684
  await new Promise((resolve) => process.nextTick(resolve));
4011
4685
  const dismissReloadBox = (editor) => {
4012
4686
  this.editorContainer.clear();
@@ -4023,11 +4697,10 @@ export class InteractiveMode {
4023
4697
  }
4024
4698
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
4025
4699
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
4026
- const themeName = this.settingsManager.getTheme();
4027
- const themeResult = themeName ? setTheme(themeName, true) : { success: true };
4028
- if (!themeResult.success) {
4029
- this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
4030
- }
4700
+ // SCRAMJET-DIVERGENCE: re-resolve through the resolver after reload so newly registered
4701
+ // themes (and the captured classification) are honored; force a re-read since reload's purpose
4702
+ // is to pick up on-disk theme edits even when the active theme's name is unchanged.
4703
+ this.reapplyResolvedTheme(true);
4031
4704
  const editorPaddingX = this.settingsManager.getEditorPaddingX();
4032
4705
  const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
4033
4706
  this.defaultEditor.setPaddingX(editorPaddingX);
@@ -4037,7 +4710,6 @@ export class InteractiveMode {
4037
4710
  this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
4038
4711
  }
4039
4712
  this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
4040
- this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
4041
4713
  this.setupAutocompleteProvider();
4042
4714
  const runner = this.session.extensionRunner;
4043
4715
  this.setupExtensionShortcuts(runner);
@@ -4055,7 +4727,7 @@ export class InteractiveMode {
4055
4727
  }
4056
4728
  catch (error) {
4057
4729
  dismissReloadBox(previousEditor);
4058
- this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
4730
+ this.showError(`Reload failed: ${describeRuntimeError(error)}`);
4059
4731
  }
4060
4732
  }
4061
4733
  async handleExportCommand(text) {
@@ -4173,13 +4845,13 @@ export class InteractiveMode {
4173
4845
  else {
4174
4846
  this.showWarning("Usage: /name <name>");
4175
4847
  }
4176
- this.ui.requestRender();
4848
+ this.commitFinalizedChatOutput();
4177
4849
  return;
4178
4850
  }
4179
4851
  this.session.setSessionName(name);
4180
4852
  this.chatContainer.addChild(new Spacer(1));
4181
4853
  this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));
4182
- this.ui.requestRender();
4854
+ this.commitFinalizedChatOutput();
4183
4855
  }
4184
4856
  handleSessionCommand() {
4185
4857
  const stats = this.session.getSessionStats();
@@ -4212,7 +4884,7 @@ export class InteractiveMode {
4212
4884
  }
4213
4885
  this.chatContainer.addChild(new Spacer(1));
4214
4886
  this.chatContainer.addChild(new Text(info, 1, 0));
4215
- this.ui.requestRender();
4887
+ this.commitFinalizedChatOutput();
4216
4888
  }
4217
4889
  handleChangelogCommand() {
4218
4890
  const changelogPath = getChangelogPath();
@@ -4229,7 +4901,7 @@ export class InteractiveMode {
4229
4901
  this.chatContainer.addChild(new Spacer(1));
4230
4902
  this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings()));
4231
4903
  this.chatContainer.addChild(new DynamicBorder());
4232
- this.ui.requestRender();
4904
+ this.commitFinalizedChatOutput();
4233
4905
  }
4234
4906
  /**
4235
4907
  * Get capitalized display string for an app keybinding action.
@@ -4350,7 +5022,7 @@ export class InteractiveMode {
4350
5022
  this.chatContainer.addChild(new Spacer(1));
4351
5023
  this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()));
4352
5024
  this.chatContainer.addChild(new DynamicBorder());
4353
- this.ui.requestRender();
5025
+ this.commitFinalizedChatOutput();
4354
5026
  }
4355
5027
  async handleClearCommand() {
4356
5028
  if (this.loadingAnimation) {
@@ -4366,7 +5038,7 @@ export class InteractiveMode {
4366
5038
  this.renderCurrentSessionState();
4367
5039
  this.chatContainer.addChild(new Spacer(1));
4368
5040
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
4369
- this.ui.requestRender();
5041
+ this.commitFinalizedChatOutput();
4370
5042
  }
4371
5043
  catch (error) {
4372
5044
  await this.handleFatalRuntimeError("Failed to create session", error);
@@ -4397,22 +5069,38 @@ export class InteractiveMode {
4397
5069
  fs.writeFileSync(debugLogPath, debugData);
4398
5070
  this.chatContainer.addChild(new Spacer(1));
4399
5071
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1));
4400
- this.ui.requestRender();
5072
+ this.commitFinalizedChatOutput();
4401
5073
  }
4402
5074
  handleArminSaysHi() {
5075
+ const component = new ArminComponent(this.ui, () => {
5076
+ if (!this.mutableChatComponents.has(component) || !this.chatContainer.children.includes(component))
5077
+ return;
5078
+ this.setChatComponentMutable(component, false);
5079
+ this.promoteFinalizedChatPrefix();
5080
+ this.ui.requestRender();
5081
+ });
4403
5082
  this.chatContainer.addChild(new Spacer(1));
4404
- this.chatContainer.addChild(new ArminComponent(this.ui));
4405
- this.ui.requestRender();
5083
+ this.chatContainer.addChild(component);
5084
+ this.setChatComponentMutable(component, true);
5085
+ this.commitFinalizedChatOutput();
4406
5086
  }
4407
5087
  handleDementedDelves() {
4408
5088
  this.chatContainer.addChild(new Spacer(1));
4409
5089
  this.chatContainer.addChild(new EarendilAnnouncementComponent());
4410
- this.ui.requestRender();
5090
+ this.commitFinalizedChatOutput();
4411
5091
  }
4412
5092
  handleDaxnuts() {
5093
+ const component = new DaxnutsComponent(this.ui, () => {
5094
+ if (!this.mutableChatComponents.has(component) || !this.chatContainer.children.includes(component))
5095
+ return;
5096
+ this.setChatComponentMutable(component, false);
5097
+ this.promoteFinalizedChatPrefix();
5098
+ this.ui.requestRender();
5099
+ });
4413
5100
  this.chatContainer.addChild(new Spacer(1));
4414
- this.chatContainer.addChild(new DaxnutsComponent(this.ui));
4415
- this.ui.requestRender();
5101
+ this.chatContainer.addChild(component);
5102
+ this.setChatComponentMutable(component, true);
5103
+ this.commitFinalizedChatOutput();
4416
5104
  }
4417
5105
  checkDaxnutsEasterEgg(model) {
4418
5106
  if (model.provider === "opencode" && model.id.toLowerCase().includes("kimi-k2.5")) {
@@ -4438,6 +5126,7 @@ export class InteractiveMode {
4438
5126
  this.pendingBashComponents.push(this.bashComponent);
4439
5127
  }
4440
5128
  else {
5129
+ this.sealStatus();
4441
5130
  this.chatContainer.addChild(this.bashComponent);
4442
5131
  }
4443
5132
  // Show output and complete
@@ -4447,6 +5136,7 @@ export class InteractiveMode {
4447
5136
  this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true, content: result.output } : undefined, result.fullOutputPath);
4448
5137
  // Record the result in session
4449
5138
  this.session.recordBashResult(command, result, { excludeFromContext });
5139
+ this.promoteFinalizedChatPrefix();
4450
5140
  this.bashComponent = undefined;
4451
5141
  this.ui.requestRender();
4452
5142
  return;
@@ -4454,6 +5144,7 @@ export class InteractiveMode {
4454
5144
  // Normal execution path (possibly with custom operations)
4455
5145
  const isDeferred = this.session.isStreaming;
4456
5146
  this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
5147
+ this.setChatComponentMutable(this.bashComponent, true);
4457
5148
  if (isDeferred) {
4458
5149
  // Show in pending area when agent is streaming
4459
5150
  this.pendingMessagesContainer.addChild(this.bashComponent);
@@ -4461,6 +5152,7 @@ export class InteractiveMode {
4461
5152
  }
4462
5153
  else {
4463
5154
  // Show in chat immediately when agent is idle
5155
+ this.sealStatus();
4464
5156
  this.chatContainer.addChild(this.bashComponent);
4465
5157
  }
4466
5158
  this.ui.requestRender();
@@ -4481,6 +5173,9 @@ export class InteractiveMode {
4481
5173
  }
4482
5174
  this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
4483
5175
  }
5176
+ if (this.bashComponent)
5177
+ this.setChatComponentMutable(this.bashComponent, false);
5178
+ this.promoteFinalizedChatPrefix();
4484
5179
  this.bashComponent = undefined;
4485
5180
  this.ui.requestRender();
4486
5181
  }
@@ -4503,24 +5198,30 @@ export class InteractiveMode {
4503
5198
  // Ignore, will be emitted as an event
4504
5199
  }
4505
5200
  }
4506
- stop() {
4507
- this.unregisterSignalHandlers();
4508
- if (this.settingsManager.getShowTerminalProgress()) {
4509
- this.ui.terminal.setProgress(false);
4510
- }
4511
- if (this.loadingAnimation) {
4512
- this.loadingAnimation.stop();
4513
- this.loadingAnimation = undefined;
4514
- }
4515
- this.clearExtensionTerminalInputListeners();
4516
- this.footer.dispose();
4517
- this.footerDataProvider.dispose();
4518
- if (this.unsubscribe) {
4519
- this.unsubscribe();
5201
+ stop(options) {
5202
+ try {
5203
+ if (this.isInitialized) {
5204
+ this.isInitialized = false;
5205
+ this.ui.stop(options);
5206
+ }
4520
5207
  }
4521
- if (this.isInitialized) {
4522
- this.ui.stop();
4523
- this.isInitialized = false;
5208
+ finally {
5209
+ this.selectorOpenGeneration++;
5210
+ this.pendingSelectorOpenGeneration = undefined;
5211
+ this.unregisterSignalHandlers();
5212
+ if (this.settingsManager.getShowTerminalProgress()) {
5213
+ this.ui.terminal.setProgress(false);
5214
+ }
5215
+ if (this.loadingAnimation) {
5216
+ this.loadingAnimation.stop();
5217
+ this.loadingAnimation = undefined;
5218
+ }
5219
+ this.clearExtensionTerminalInputListeners();
5220
+ this.footer.dispose();
5221
+ this.footerDataProvider.dispose();
5222
+ if (this.unsubscribe) {
5223
+ this.unsubscribe();
5224
+ }
4524
5225
  }
4525
5226
  }
4526
5227
  }