@leanandmean/coding-agent 0.74.1-scramjet.7 → 0.74.1-scramjet.71

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 (226) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +202 -0
  3. package/README.md +37 -9
  4. package/dist/cli/args.d.ts +2 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +45 -1
  7. package/dist/cli/args.js.map +1 -1
  8. package/dist/cli/initial-message.d.ts +1 -0
  9. package/dist/cli/initial-message.d.ts.map +1 -1
  10. package/dist/cli/initial-message.js +3 -0
  11. package/dist/cli/initial-message.js.map +1 -1
  12. package/dist/cli/list-models.d.ts +2 -0
  13. package/dist/cli/list-models.d.ts.map +1 -1
  14. package/dist/cli/list-models.js +4 -1
  15. package/dist/cli/list-models.js.map +1 -1
  16. package/dist/config.d.ts +9 -2
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +33 -22
  19. package/dist/config.js.map +1 -1
  20. package/dist/core/agent-session-runtime.d.ts +12 -3
  21. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  22. package/dist/core/agent-session-runtime.js +64 -38
  23. package/dist/core/agent-session-runtime.js.map +1 -1
  24. package/dist/core/agent-session-services.d.ts +2 -0
  25. package/dist/core/agent-session-services.d.ts.map +1 -1
  26. package/dist/core/agent-session-services.js +3 -0
  27. package/dist/core/agent-session-services.js.map +1 -1
  28. package/dist/core/agent-session.d.ts +84 -17
  29. package/dist/core/agent-session.d.ts.map +1 -1
  30. package/dist/core/agent-session.js +1082 -209
  31. package/dist/core/agent-session.js.map +1 -1
  32. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  33. package/dist/core/compaction/branch-summarization.js +21 -17
  34. package/dist/core/compaction/branch-summarization.js.map +1 -1
  35. package/dist/core/compaction/compaction.d.ts +3 -5
  36. package/dist/core/compaction/compaction.d.ts.map +1 -1
  37. package/dist/core/compaction/compaction.js +43 -10
  38. package/dist/core/compaction/compaction.js.map +1 -1
  39. package/dist/core/exec.d.ts +12 -0
  40. package/dist/core/exec.d.ts.map +1 -1
  41. package/dist/core/exec.js +74 -28
  42. package/dist/core/exec.js.map +1 -1
  43. package/dist/core/export-html/template.js +4 -0
  44. package/dist/core/extensions/index.d.ts +1 -1
  45. package/dist/core/extensions/index.d.ts.map +1 -1
  46. package/dist/core/extensions/index.js.map +1 -1
  47. package/dist/core/extensions/runner.d.ts +9 -5
  48. package/dist/core/extensions/runner.d.ts.map +1 -1
  49. package/dist/core/extensions/runner.js +160 -3
  50. package/dist/core/extensions/runner.js.map +1 -1
  51. package/dist/core/extensions/types.d.ts +85 -8
  52. package/dist/core/extensions/types.d.ts.map +1 -1
  53. package/dist/core/extensions/types.js.map +1 -1
  54. package/dist/core/keybindings.d.ts +8 -0
  55. package/dist/core/keybindings.d.ts.map +1 -1
  56. package/dist/core/model-registry.d.ts +103 -0
  57. package/dist/core/model-registry.d.ts.map +1 -1
  58. package/dist/core/model-registry.js +272 -28
  59. package/dist/core/model-registry.js.map +1 -1
  60. package/dist/core/model-resolver.d.ts.map +1 -1
  61. package/dist/core/model-resolver.js +24 -6
  62. package/dist/core/model-resolver.js.map +1 -1
  63. package/dist/core/output-throughput-history.d.ts +25 -0
  64. package/dist/core/output-throughput-history.d.ts.map +1 -0
  65. package/dist/core/output-throughput-history.js +240 -0
  66. package/dist/core/output-throughput-history.js.map +1 -0
  67. package/dist/core/output-throughput.d.ts +43 -0
  68. package/dist/core/output-throughput.d.ts.map +1 -0
  69. package/dist/core/output-throughput.js +161 -0
  70. package/dist/core/output-throughput.js.map +1 -0
  71. package/dist/core/resource-loader.d.ts +6 -0
  72. package/dist/core/resource-loader.d.ts.map +1 -1
  73. package/dist/core/resource-loader.js +76 -21
  74. package/dist/core/resource-loader.js.map +1 -1
  75. package/dist/core/scramjet-command-parser.d.ts +4 -0
  76. package/dist/core/scramjet-command-parser.d.ts.map +1 -1
  77. package/dist/core/scramjet-command-parser.js +72 -1
  78. package/dist/core/scramjet-command-parser.js.map +1 -1
  79. package/dist/core/sdk.d.ts +4 -0
  80. package/dist/core/sdk.d.ts.map +1 -1
  81. package/dist/core/sdk.js +70 -11
  82. package/dist/core/sdk.js.map +1 -1
  83. package/dist/core/session-manager.d.ts +11 -3
  84. package/dist/core/session-manager.d.ts.map +1 -1
  85. package/dist/core/session-manager.js +46 -11
  86. package/dist/core/session-manager.js.map +1 -1
  87. package/dist/core/settings-manager.d.ts +19 -6
  88. package/dist/core/settings-manager.d.ts.map +1 -1
  89. package/dist/core/settings-manager.js +131 -17
  90. package/dist/core/settings-manager.js.map +1 -1
  91. package/dist/core/skills.d.ts.map +1 -1
  92. package/dist/core/skills.js +6 -2
  93. package/dist/core/skills.js.map +1 -1
  94. package/dist/core/system-prompt.d.ts +2 -0
  95. package/dist/core/system-prompt.d.ts.map +1 -1
  96. package/dist/core/system-prompt.js +5 -2
  97. package/dist/core/system-prompt.js.map +1 -1
  98. package/dist/core/tools/bash.d.ts.map +1 -1
  99. package/dist/core/tools/bash.js +4 -3
  100. package/dist/core/tools/bash.js.map +1 -1
  101. package/dist/index.d.ts +4 -3
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +3 -2
  104. package/dist/index.js.map +1 -1
  105. package/dist/main.d.ts +119 -0
  106. package/dist/main.d.ts.map +1 -1
  107. package/dist/main.js +113 -11
  108. package/dist/main.js.map +1 -1
  109. package/dist/modes/interactive/components/armin.d.ts +4 -2
  110. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  111. package/dist/modes/interactive/components/armin.js +14 -8
  112. package/dist/modes/interactive/components/armin.js.map +1 -1
  113. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  114. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  115. package/dist/modes/interactive/components/assistant-message.js +19 -8
  116. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  117. package/dist/modes/interactive/components/daxnuts.d.ts +4 -1
  118. package/dist/modes/interactive/components/daxnuts.d.ts.map +1 -1
  119. package/dist/modes/interactive/components/daxnuts.js +14 -6
  120. package/dist/modes/interactive/components/daxnuts.js.map +1 -1
  121. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  122. package/dist/modes/interactive/components/extension-editor.js +5 -3
  123. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  124. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  125. package/dist/modes/interactive/components/extension-selector.js +4 -3
  126. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  127. package/dist/modes/interactive/components/footer.d.ts +4 -0
  128. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  129. package/dist/modes/interactive/components/footer.js +24 -20
  130. package/dist/modes/interactive/components/footer.js.map +1 -1
  131. package/dist/modes/interactive/components/model-selector.d.ts +3 -3
  132. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  133. package/dist/modes/interactive/components/model-selector.js +10 -7
  134. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  135. package/dist/modes/interactive/components/scoped-models-selector.d.ts +3 -1
  136. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  137. package/dist/modes/interactive/components/scoped-models-selector.js +8 -2
  138. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  139. package/dist/modes/interactive/components/settings-selector.d.ts +17 -3
  140. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  141. package/dist/modes/interactive/components/settings-selector.js +87 -16
  142. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  143. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  144. package/dist/modes/interactive/components/thinking-selector.js +2 -1
  145. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  146. package/dist/modes/interactive/components/tool-execution.d.ts +19 -1
  147. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  148. package/dist/modes/interactive/components/tool-execution.js +129 -36
  149. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  150. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  151. package/dist/modes/interactive/components/user-message.js +3 -2
  152. package/dist/modes/interactive/components/user-message.js.map +1 -1
  153. package/dist/modes/interactive/components/visual-truncate.d.ts.map +1 -1
  154. package/dist/modes/interactive/components/visual-truncate.js +2 -2
  155. package/dist/modes/interactive/components/visual-truncate.js.map +1 -1
  156. package/dist/modes/interactive/interactive-mode.d.ts +37 -1
  157. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  158. package/dist/modes/interactive/interactive-mode.js +902 -177
  159. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  160. package/dist/modes/interactive/spellcheck.d.ts +26 -0
  161. package/dist/modes/interactive/spellcheck.d.ts.map +1 -0
  162. package/dist/modes/interactive/spellcheck.js +144 -0
  163. package/dist/modes/interactive/spellcheck.js.map +1 -0
  164. package/dist/modes/interactive/theme/{dark.json → pi-dark.json} +3 -2
  165. package/dist/modes/interactive/theme/{light.json → pi-light.json} +27 -26
  166. package/dist/modes/interactive/theme/theme-schema.json +6 -1
  167. package/dist/modes/interactive/theme/theme.d.ts +24 -4
  168. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  169. package/dist/modes/interactive/theme/theme.js +123 -22
  170. package/dist/modes/interactive/theme/theme.js.map +1 -1
  171. package/dist/modes/print-mode.d.ts.map +1 -1
  172. package/dist/modes/print-mode.js +5 -0
  173. package/dist/modes/print-mode.js.map +1 -1
  174. package/dist/modes/rpc/rpc-client.d.ts +3 -1
  175. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  176. package/dist/modes/rpc/rpc-client.js.map +1 -1
  177. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  178. package/dist/modes/rpc/rpc-mode.js +6 -1
  179. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  180. package/dist/package-manager-cli.d.ts.map +1 -1
  181. package/dist/package-manager-cli.js +33 -14
  182. package/dist/package-manager-cli.js.map +1 -1
  183. package/dist/utils/clipboard-native.d.ts +1 -0
  184. package/dist/utils/clipboard-native.d.ts.map +1 -1
  185. package/dist/utils/clipboard-native.js.map +1 -1
  186. package/dist/utils/clipboard.d.ts +6 -0
  187. package/dist/utils/clipboard.d.ts.map +1 -1
  188. package/dist/utils/clipboard.js +278 -10
  189. package/dist/utils/clipboard.js.map +1 -1
  190. package/dist/utils/tools-manager.d.ts +1 -1
  191. package/dist/utils/tools-manager.d.ts.map +1 -1
  192. package/dist/utils/tools-manager.js +7 -6
  193. package/dist/utils/tools-manager.js.map +1 -1
  194. package/dist/utils/version-check.d.ts +17 -5
  195. package/dist/utils/version-check.d.ts.map +1 -1
  196. package/dist/utils/version-check.js +88 -21
  197. package/dist/utils/version-check.js.map +1 -1
  198. package/docs/compaction.md +21 -3
  199. package/docs/custom-provider.md +33 -4
  200. package/docs/extensions.md +150 -24
  201. package/docs/json.md +8 -0
  202. package/docs/keybindings.md +25 -1
  203. package/docs/models.md +103 -4
  204. package/docs/packages.md +2 -0
  205. package/docs/prompt-templates.md +2 -0
  206. package/docs/quickstart.md +1 -4
  207. package/docs/rpc.md +16 -6
  208. package/docs/sdk.md +48 -3
  209. package/docs/session-format.md +5 -1
  210. package/docs/settings.md +19 -5
  211. package/docs/skills.md +7 -5
  212. package/docs/terminal-setup.md +79 -2
  213. package/docs/terminal-verification.md +71 -0
  214. package/docs/themes.md +63 -10
  215. package/docs/tui.md +120 -25
  216. package/docs/usage.md +6 -4
  217. package/examples/extensions/border-status-editor.ts +2 -3
  218. package/examples/extensions/doom-overlay/doom-component.ts +3 -3
  219. package/examples/extensions/mac-system-theme.ts +3 -2
  220. package/examples/extensions/modal-editor.ts +13 -8
  221. package/examples/extensions/overlay-qa-tests.ts +22 -9
  222. package/examples/extensions/overlay-test.ts +5 -3
  223. package/examples/extensions/snake.ts +3 -1
  224. package/examples/extensions/space-invaders.ts +6 -3
  225. package/examples/extensions/subagent/index.ts +20 -17
  226. 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);
@@ -2466,7 +3029,31 @@ export class InteractiveMode {
2466
3029
  case "user": {
2467
3030
  const textContent = this.getUserMessageText(message);
2468
3031
  if (textContent) {
2469
- if (this.chatContainer.children.length > 0) {
3032
+ // SCRAMJET-DIVERGENCE: separate user rows from visible committed results without doubling gaps (#579).
3033
+ let committedTailRow;
3034
+ if (this.chatContainer.children.length === 0) {
3035
+ for (let i = this.committedChatContainer.children.length - 1; i >= 0; i--) {
3036
+ const lines = this.committedChatContainer.children[i].render(this.ui.terminal.columns);
3037
+ if (lines.length > 0) {
3038
+ committedTailRow = lines[lines.length - 1];
3039
+ break;
3040
+ }
3041
+ }
3042
+ }
3043
+ const tailNeedsGap = committedTailRow !== undefined &&
3044
+ (stripVTControlCharacters(committedTailRow).trim() !== "" ||
3045
+ [...committedTailRow.matchAll(/\x1b\[([\d;:]*)m/g)].some(([, params]) => {
3046
+ const codes = params.split(";");
3047
+ for (let i = 0; i < codes.length; i++) {
3048
+ const code = Number(codes[i].split(":")[0]);
3049
+ if (code === 48 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107))
3050
+ return true;
3051
+ if (code === 38)
3052
+ i += codes[i + 1] === "2" ? 4 : codes[i + 1] === "5" ? 2 : 0;
3053
+ }
3054
+ return false;
3055
+ }));
3056
+ if (this.chatContainer.children.length > 0 || tailNeedsGap) {
2470
3057
  this.chatContainer.addChild(new Spacer(1));
2471
3058
  }
2472
3059
  // SCRAMJET-DIVERGENCE: scramjet-command block rendering (issue 82)
@@ -2480,9 +3067,10 @@ export class InteractiveMode {
2480
3067
  this.chatContainer.addChild(userComponent);
2481
3068
  }
2482
3069
  if (options?.populateHistory) {
2483
- const compactForm = scramjetBlock.userContext
2484
- ? `/${scramjetBlock.name} ${scramjetBlock.userContext}`
2485
- : `/${scramjetBlock.name}`;
3070
+ const compactForm = options.historyText ??
3071
+ (scramjetBlock.userContext
3072
+ ? `/${scramjetBlock.name} ${scramjetBlock.userContext}`
3073
+ : `/${scramjetBlock.name}`);
2486
3074
  this.editor.addToHistory?.(compactForm);
2487
3075
  }
2488
3076
  }
@@ -2521,6 +3109,7 @@ export class InteractiveMode {
2521
3109
  const _exhaustive = message;
2522
3110
  }
2523
3111
  }
3112
+ this.promoteFinalizedChatPrefix();
2524
3113
  }
2525
3114
  /**
2526
3115
  * Render session context to chat. Used for initial load and rebuild after compaction.
@@ -2531,6 +3120,11 @@ export class InteractiveMode {
2531
3120
  renderSessionContext(sessionContext, options = {}) {
2532
3121
  this.pendingTools.clear();
2533
3122
  const renderedPendingTools = new Map();
3123
+ const entries = options.populateHistory ? this.sessionManager.getEntries() : [];
3124
+ const commandStarts = indexScramjetCommandStarts(entries);
3125
+ const entriesByMessage = new Map(entries
3126
+ .filter((entry) => entry.type === "message")
3127
+ .map((entry) => [entry.message, entry]));
2534
3128
  if (options.updateFooter) {
2535
3129
  this.footer.invalidate();
2536
3130
  this.updateEditorBorderColor();
@@ -2548,6 +3142,7 @@ export class InteractiveMode {
2548
3142
  }, this.getRegisteredToolDefinition(content.name), this.ui, this.sessionManager.getCwd());
2549
3143
  component.setExpanded(this.toolOutputExpanded);
2550
3144
  this.chatContainer.addChild(component);
3145
+ this.setChatComponentMutable(component, true);
2551
3146
  if (message.stopReason === "aborted" || message.stopReason === "error") {
2552
3147
  let errorMessage;
2553
3148
  if (message.stopReason === "aborted") {
@@ -2561,6 +3156,8 @@ export class InteractiveMode {
2561
3156
  errorMessage = message.errorMessage || "Error";
2562
3157
  }
2563
3158
  component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
3159
+ component.seal();
3160
+ this.setChatComponentMutable(component, false);
2564
3161
  }
2565
3162
  else {
2566
3163
  renderedPendingTools.set(content.id, component);
@@ -2572,18 +3169,45 @@ export class InteractiveMode {
2572
3169
  // Match tool results to pending tool components
2573
3170
  const component = renderedPendingTools.get(message.toolCallId);
2574
3171
  if (component) {
3172
+ component.markExecutionStarted();
3173
+ component.setArgsComplete();
2575
3174
  component.updateResult(message);
2576
3175
  renderedPendingTools.delete(message.toolCallId);
3176
+ if (component.hasPendingImageConversions()) {
3177
+ const pendingToolFinalizations = this.pendingToolFinalizations;
3178
+ const finalization = component.waitForImageConversions().then(() => {
3179
+ if (!this.mutableChatComponents.has(component) ||
3180
+ !this.chatContainer.children.includes(component)) {
3181
+ component.seal();
3182
+ return;
3183
+ }
3184
+ component.seal();
3185
+ this.setChatComponentMutable(component, false);
3186
+ this.promoteFinalizedChatPrefix();
3187
+ this.ui.requestRender();
3188
+ });
3189
+ pendingToolFinalizations.add(finalization);
3190
+ void finalization.finally(() => pendingToolFinalizations.delete(finalization));
3191
+ }
3192
+ else {
3193
+ component.seal();
3194
+ this.setChatComponentMutable(component, false);
3195
+ }
2577
3196
  }
2578
3197
  }
2579
3198
  else {
2580
3199
  // All other messages use standard rendering
2581
- this.addMessageToChat(message, options);
3200
+ const entry = entriesByMessage.get(message);
3201
+ this.addMessageToChat(message, {
3202
+ ...options,
3203
+ historyText: entry ? restoreScramjetCommandInvocation(entry, commandStarts) : undefined,
3204
+ });
2582
3205
  }
2583
3206
  }
2584
3207
  for (const [toolCallId, component] of renderedPendingTools) {
2585
3208
  this.pendingTools.set(toolCallId, component);
2586
3209
  }
3210
+ this.promoteFinalizedChatPrefix();
2587
3211
  this.ui.requestRender();
2588
3212
  }
2589
3213
  renderInitialMessages() {
@@ -2610,7 +3234,7 @@ export class InteractiveMode {
2610
3234
  });
2611
3235
  }
2612
3236
  rebuildChatFromMessages() {
2613
- this.chatContainer.clear();
3237
+ this.clearTranscript();
2614
3238
  const context = this.sessionManager.buildSessionContext();
2615
3239
  this.renderSessionContext(context);
2616
3240
  }
@@ -2645,9 +3269,18 @@ export class InteractiveMode {
2645
3269
  // Drain any in-flight Kitty key release events before stopping.
2646
3270
  // This prevents escape sequences from leaking to the parent shell over slow SSH.
2647
3271
  await this.ui.terminal.drainInput(1000);
2648
- this.stop();
3272
+ await this.settingsManager.flush();
3273
+ let exitCode = 0;
3274
+ try {
3275
+ this.stop({ retainContent: this.settingsManager.getRetainTranscriptOnExit() });
3276
+ }
3277
+ catch (error) {
3278
+ exitCode = 1;
3279
+ console.error(`Could not retain terminal history: ${error instanceof Error ? error.message : String(error)}`);
3280
+ }
3281
+ await this.ui.terminal.flush?.();
2649
3282
  await this.runtimeHost.dispose();
2650
- process.exit(0);
3283
+ process.exit(exitCode);
2651
3284
  }
2652
3285
  emergencyTerminalExit() {
2653
3286
  this.isShuttingDown = true;
@@ -2737,7 +3370,7 @@ export class InteractiveMode {
2737
3370
  }
2738
3371
  this.signalCleanupHandlers = [];
2739
3372
  }
2740
- handleCtrlZ() {
3373
+ async handleCtrlZ() {
2741
3374
  if (process.platform === "win32") {
2742
3375
  this.showStatus("Suspend to background is not supported on Windows");
2743
3376
  return;
@@ -2755,10 +3388,11 @@ export class InteractiveMode {
2755
3388
  clearInterval(suspendKeepAlive);
2756
3389
  process.removeListener("SIGINT", ignoreSigint);
2757
3390
  this.ui.start();
2758
- this.ui.requestRender(true);
3391
+ this.ui.rebuild();
2759
3392
  });
2760
3393
  try {
2761
- // Stop the TUI (restore terminal to normal mode)
3394
+ // SCRAMJET-DIVERGENCE: drain releases before handing keyboard ownership back to the shell.
3395
+ await this.ui.terminal.drainInput();
2762
3396
  this.ui.stop();
2763
3397
  // Send SIGTSTP to process group (pid=0 means all processes in group)
2764
3398
  process.kill(0, "SIGTSTP");
@@ -2773,6 +3407,7 @@ export class InteractiveMode {
2773
3407
  const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
2774
3408
  if (!text)
2775
3409
  return;
3410
+ this.ui.followViewport();
2776
3411
  // Queue input during compaction (extension commands execute immediately)
2777
3412
  if (this.session.isCompacting) {
2778
3413
  if (this.isExtensionCommand(text)) {
@@ -2858,28 +3493,28 @@ export class InteractiveMode {
2858
3493
  if (isExpandable(activeHeader)) {
2859
3494
  activeHeader.setExpanded(expanded);
2860
3495
  }
2861
- for (const child of this.chatContainer.children) {
3496
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
2862
3497
  if (isExpandable(child)) {
2863
3498
  child.setExpanded(expanded);
2864
3499
  }
2865
3500
  }
2866
- this.ui.requestRender();
3501
+ this.ui.rebuild();
2867
3502
  }
2868
3503
  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
- }
3504
+ this.setThinkingBlockVisibility(!this.hideThinkingBlock);
2880
3505
  this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
2881
3506
  }
2882
- openExternalEditor() {
3507
+ // SCRAMJET-DIVERGENCE: presentation changes retain live tools and browsing identities.
3508
+ setThinkingBlockVisibility(hidden) {
3509
+ this.hideThinkingBlock = hidden;
3510
+ this.settingsManager.setHideThinkingBlock(hidden);
3511
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
3512
+ if (child instanceof AssistantMessageComponent)
3513
+ child.setHideThinkingBlock(hidden);
3514
+ }
3515
+ this.ui.rebuild();
3516
+ }
3517
+ async openExternalEditor() {
2883
3518
  // Determine editor (respect $VISUAL, then $EDITOR)
2884
3519
  const editorCmd = process.env.VISUAL || process.env.EDITOR;
2885
3520
  if (!editorCmd) {
@@ -2891,7 +3526,8 @@ export class InteractiveMode {
2891
3526
  try {
2892
3527
  // Write current content to temp file
2893
3528
  fs.writeFileSync(tmpFile, currentText, "utf-8");
2894
- // Stop TUI to release terminal
3529
+ // SCRAMJET-DIVERGENCE: the editor must not inherit the key release that opened it.
3530
+ await this.ui.terminal.drainInput();
2895
3531
  this.ui.stop();
2896
3532
  // Split by space to support editor arguments (e.g., "code --wait")
2897
3533
  const [editor, ...editorArgs] = editorCmd.split(" ");
@@ -2917,8 +3553,7 @@ export class InteractiveMode {
2917
3553
  }
2918
3554
  // Restart TUI
2919
3555
  this.ui.start();
2920
- // Force full re-render since external editor uses alternate screen
2921
- this.ui.requestRender(true);
3556
+ this.ui.rebuild();
2922
3557
  }
2923
3558
  }
2924
3559
  // =========================================================================
@@ -2929,14 +3564,18 @@ export class InteractiveMode {
2929
3564
  this.ui.requestRender();
2930
3565
  }
2931
3566
  showError(errorMessage) {
3567
+ this.sealStatus();
2932
3568
  this.chatContainer.addChild(new Spacer(1));
2933
3569
  this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
2934
3570
  this.chatContainer.addChild(new Spacer(1));
3571
+ this.promoteFinalizedChatPrefix();
2935
3572
  this.ui.requestRender();
2936
3573
  }
2937
3574
  showWarning(warningMessage) {
3575
+ this.sealStatus();
2938
3576
  this.chatContainer.addChild(new Spacer(1));
2939
3577
  this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
3578
+ this.promoteFinalizedChatPrefix();
2940
3579
  this.ui.requestRender();
2941
3580
  }
2942
3581
  // SCRAMJET-DIVERGENCE: showNewVersionNotification removed (version check gutted).
@@ -2948,7 +3587,7 @@ export class InteractiveMode {
2948
3587
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2949
3588
  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
3589
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
2951
- this.ui.requestRender();
3590
+ this.commitFinalizedChatOutput();
2952
3591
  }
2953
3592
  /**
2954
3593
  * Get all queued messages (read-only).
@@ -3108,11 +3747,14 @@ export class InteractiveMode {
3108
3747
  }
3109
3748
  /** Move pending bash components from pending area to chat */
3110
3749
  flushPendingBashComponents() {
3750
+ if (this.pendingBashComponents.length > 0)
3751
+ this.sealStatus();
3111
3752
  for (const component of this.pendingBashComponents) {
3112
3753
  this.pendingMessagesContainer.removeChild(component);
3113
3754
  this.chatContainer.addChild(component);
3114
3755
  }
3115
3756
  this.pendingBashComponents = [];
3757
+ this.promoteFinalizedChatPrefix();
3116
3758
  }
3117
3759
  // =========================================================================
3118
3760
  // Selectors
@@ -3122,7 +3764,10 @@ export class InteractiveMode {
3122
3764
  * @param create Factory that receives a `done` callback and returns the component and focus target
3123
3765
  */
3124
3766
  showSelector(create) {
3767
+ this.selectorOpenGeneration++;
3768
+ this.pendingSelectorOpenGeneration = undefined;
3125
3769
  const done = () => {
3770
+ this.selectorOpenGeneration++;
3126
3771
  this.editorContainer.clear();
3127
3772
  this.editorContainer.addChild(this.editor);
3128
3773
  this.ui.setFocus(this.editor);
@@ -3133,6 +3778,23 @@ export class InteractiveMode {
3133
3778
  this.ui.setFocus(focus);
3134
3779
  this.ui.requestRender();
3135
3780
  }
3781
+ async settleLayoutSettings(selector) {
3782
+ const generation = ++this.layoutSettingsSettlementGeneration;
3783
+ const settings = this.settingsManager;
3784
+ await settings.flush();
3785
+ // SCRAMJET-DIVERGENCE: only the latest settlement may drain a shared batch of save errors.
3786
+ if (generation !== this.layoutSettingsSettlementGeneration)
3787
+ return;
3788
+ const errors = settings.drainErrors();
3789
+ selector.setSaveError(errors.length ? "Changes not saved; see warning" : undefined);
3790
+ if (errors.length) {
3791
+ const details = stripVTControlCharacters(errors.map(({ scope, error }) => `${scope}: ${error.message}`).join("; "))
3792
+ .replace(/[\r\n\t]/g, " ")
3793
+ .slice(0, 200);
3794
+ this.showWarning(`Display settings not saved (${details}). Changes may be lost on restart; project overrides still apply.`);
3795
+ }
3796
+ this.ui.requestRender();
3797
+ }
3136
3798
  showSettingsSelector() {
3137
3799
  this.showSelector((done) => {
3138
3800
  const selector = new SettingsSelectorComponent({
@@ -3147,7 +3809,8 @@ export class InteractiveMode {
3147
3809
  transport: this.settingsManager.getTransport(),
3148
3810
  thinkingLevel: this.session.thinkingLevel,
3149
3811
  availableThinkingLevels: this.session.getAvailableThinkingLevels(),
3150
- currentTheme: this.settingsManager.getTheme() || "dark",
3812
+ // SCRAMJET-DIVERGENCE: display fallback routed to the renamed builtin.
3813
+ currentTheme: this.settingsManager.getTheme() || "pi-dark",
3151
3814
  availableThemes: getAvailableThemes(),
3152
3815
  hideThinkingBlock: this.hideThinkingBlock,
3153
3816
  collapseChangelog: this.settingsManager.getCollapseChangelog(),
@@ -3158,9 +3821,14 @@ export class InteractiveMode {
3158
3821
  editorPaddingX: this.settingsManager.getEditorPaddingX(),
3159
3822
  autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
3160
3823
  quietStartup: this.settingsManager.getQuietStartup(),
3161
- clearOnShrink: this.settingsManager.getClearOnShrink(),
3162
3824
  showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
3163
3825
  warnings: this.settingsManager.getWarnings(),
3826
+ tuiMode: this.tuiMode,
3827
+ dockEditor: this.settingsManager.getDockEditor(),
3828
+ retainTranscriptOnExit: this.settingsManager.getRetainTranscriptOnExit(),
3829
+ editorMaxHeightPercent: this.settingsManager.getEditorMaxHeightPercent(),
3830
+ scrollWheelStep: this.settingsManager.getScrollWheelStep(),
3831
+ viewportProjectOverrides: Object.keys(this.settingsManager.getProjectSettings()),
3164
3832
  }, {
3165
3833
  onAutoCompactChange: (enabled) => {
3166
3834
  this.session.setAutoCompactionEnabled(enabled);
@@ -3168,19 +3836,11 @@ export class InteractiveMode {
3168
3836
  },
3169
3837
  onShowImagesChange: (enabled) => {
3170
3838
  this.settingsManager.setShowImages(enabled);
3171
- for (const child of this.chatContainer.children) {
3172
- if (child instanceof ToolExecutionComponent) {
3173
- child.setShowImages(enabled);
3174
- }
3175
- }
3839
+ this.updateToolImagePresentation((component) => component.setShowImages(enabled));
3176
3840
  },
3177
3841
  onImageWidthCellsChange: (width) => {
3178
3842
  this.settingsManager.setImageWidthCells(width);
3179
- for (const child of this.chatContainer.children) {
3180
- if (child instanceof ToolExecutionComponent) {
3181
- child.setImageWidthCells(width);
3182
- }
3183
- }
3843
+ this.updateToolImagePresentation((component) => component.setImageWidthCells(width));
3184
3844
  },
3185
3845
  onAutoResizeImagesChange: (enabled) => {
3186
3846
  this.settingsManager.setImageAutoResize(enabled);
@@ -3212,7 +3872,8 @@ export class InteractiveMode {
3212
3872
  this.settingsManager.setTheme(themeName);
3213
3873
  this.ui.invalidate();
3214
3874
  if (!result.success) {
3215
- this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
3875
+ // SCRAMJET-DIVERGENCE: fallback message names the renamed builtin.
3876
+ this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to pi-dark theme.`);
3216
3877
  }
3217
3878
  },
3218
3879
  onThemePreview: (themeName) => {
@@ -3222,17 +3883,7 @@ export class InteractiveMode {
3222
3883
  this.ui.requestRender();
3223
3884
  }
3224
3885
  },
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
- },
3886
+ onHideThinkingBlockChange: (hidden) => this.setThinkingBlockVisibility(hidden),
3236
3887
  onCollapseChangelogChange: (collapsed) => {
3237
3888
  this.settingsManager.setCollapseChangelog(collapsed);
3238
3889
  },
@@ -3266,21 +3917,42 @@ export class InteractiveMode {
3266
3917
  this.editor.setAutocompleteMaxVisible(maxVisible);
3267
3918
  }
3268
3919
  },
3269
- onClearOnShrinkChange: (enabled) => {
3270
- this.settingsManager.setClearOnShrink(enabled);
3271
- this.ui.setClearOnShrink(enabled);
3272
- },
3273
3920
  onShowTerminalProgressChange: (enabled) => {
3274
3921
  this.settingsManager.setShowTerminalProgress(enabled);
3275
3922
  },
3276
3923
  onWarningsChange: (warnings) => {
3277
3924
  this.settingsManager.setWarnings(warnings);
3278
3925
  },
3926
+ onRetainTranscriptOnExitChange: (enabled) => {
3927
+ this.settingsManager.setRetainTranscriptOnExit(enabled);
3928
+ void this.settleLayoutSettings(selector);
3929
+ return this.settingsManager.getRetainTranscriptOnExit();
3930
+ },
3931
+ onDockEditorChange: (enabled) => {
3932
+ this.settingsManager.setDockEditor(enabled);
3933
+ void this.settleLayoutSettings(selector);
3934
+ this.ui.refreshViewportLayout();
3935
+ if (!this.settingsManager.getDockEditor())
3936
+ this.ui.revealComponent(this.editorContainer);
3937
+ return this.settingsManager.getDockEditor();
3938
+ },
3939
+ onEditorMaxHeightPercentChange: (percent) => {
3940
+ this.settingsManager.setEditorMaxHeightPercent(percent);
3941
+ void this.settleLayoutSettings(selector);
3942
+ this.ui.refreshViewportLayout();
3943
+ return this.settingsManager.getEditorMaxHeightPercent();
3944
+ },
3945
+ onScrollWheelStepChange: (step) => {
3946
+ this.settingsManager.setScrollWheelStep(step);
3947
+ void this.settleLayoutSettings(selector);
3948
+ this.ui.requestRender();
3949
+ return this.settingsManager.getScrollWheelStep();
3950
+ },
3279
3951
  onCancel: () => {
3280
3952
  done();
3281
3953
  this.ui.requestRender();
3282
3954
  },
3283
- });
3955
+ }, () => Math.max(1, Math.min(this.editorAvailableRows ?? Infinity, this.ui.terminal.rows - 4)));
3284
3956
  return { component: selector, focus: selector.getSettingsList() };
3285
3957
  });
3286
3958
  }
@@ -3356,9 +4028,24 @@ export class InteractiveMode {
3356
4028
  // Ignore auth lookup failures for warning-only checks.
3357
4029
  }
3358
4030
  }
3359
- showModelSelector(initialSearchInput) {
4031
+ selectorPreparationIsCurrent(session, generation) {
4032
+ const current = this.isInitialized && this.session === session && this.selectorOpenGeneration === generation;
4033
+ if (!current && this.pendingSelectorOpenGeneration === generation)
4034
+ this.pendingSelectorOpenGeneration = undefined;
4035
+ return current;
4036
+ }
4037
+ // SCRAMJET-DIVERGENCE: Refresh profile-scoped output-rate history before opening model selectors (issue 476).
4038
+ async showModelSelector(initialSearchInput) {
4039
+ const session = this.session;
4040
+ const generation = ++this.selectorOpenGeneration;
4041
+ this.pendingSelectorOpenGeneration = generation;
4042
+ await session.refreshOutputThroughputHistory().catch(() => undefined);
4043
+ if (!this.selectorPreparationIsCurrent(session, generation))
4044
+ return;
4045
+ this.pendingSelectorOpenGeneration = undefined;
4046
+ const outputThroughputHistory = session.outputThroughputHistory;
3360
4047
  this.showSelector((done) => {
3361
- const selector = new ModelSelectorComponent(this.ui, this.session.model, this.settingsManager, this.session.modelRegistry, this.session.scopedModels, async (model) => {
4048
+ const selector = new ModelSelectorComponent(this.ui, this.session.model, this.session.modelRegistry, this.session.scopedModels, async (model) => {
3362
4049
  try {
3363
4050
  await this.session.setModel(model);
3364
4051
  this.footer.invalidate();
@@ -3375,20 +4062,28 @@ export class InteractiveMode {
3375
4062
  }, () => {
3376
4063
  done();
3377
4064
  this.ui.requestRender();
3378
- }, initialSearchInput);
4065
+ }, initialSearchInput, outputThroughputHistory);
3379
4066
  return { component: selector, focus: selector };
3380
4067
  });
3381
4068
  }
3382
4069
  async showModelsSelector() {
4070
+ const session = this.session;
4071
+ const generation = ++this.selectorOpenGeneration;
4072
+ this.pendingSelectorOpenGeneration = generation;
4073
+ await session.refreshOutputThroughputHistory().catch(() => undefined);
4074
+ if (!this.selectorPreparationIsCurrent(session, generation))
4075
+ return;
4076
+ const outputThroughputHistory = session.outputThroughputHistory;
3383
4077
  // Get all available models
3384
- this.session.modelRegistry.refresh();
3385
- const allModels = this.session.modelRegistry.getAvailable();
4078
+ session.modelRegistry.refresh();
4079
+ const allModels = session.modelRegistry.getAvailable();
3386
4080
  if (allModels.length === 0) {
4081
+ this.pendingSelectorOpenGeneration = undefined;
3387
4082
  this.showStatus("No models available");
3388
4083
  return;
3389
4084
  }
3390
4085
  // Check if session has scoped models (from previous session-only changes or CLI --models)
3391
- const sessionScopedModels = this.session.scopedModels;
4086
+ const sessionScopedModels = session.scopedModels;
3392
4087
  const hasSessionScope = sessionScopedModels.length > 0;
3393
4088
  // Build enabled model IDs from session state or settings
3394
4089
  let currentEnabledIds = null;
@@ -3400,10 +4095,13 @@ export class InteractiveMode {
3400
4095
  // Fall back to settings
3401
4096
  const patterns = this.settingsManager.getEnabledModels();
3402
4097
  if (patterns !== undefined && patterns.length > 0) {
3403
- const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
4098
+ const scopedModels = await resolveModelScope(patterns, session.modelRegistry);
3404
4099
  currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
3405
4100
  }
3406
4101
  }
4102
+ if (!this.selectorPreparationIsCurrent(session, generation))
4103
+ return;
4104
+ this.pendingSelectorOpenGeneration = undefined;
3407
4105
  // Helper to update session's scoped models (session-only, no persist)
3408
4106
  const updateSessionModels = async (enabledIds) => {
3409
4107
  currentEnabledIds = enabledIds === null ? null : [...enabledIds];
@@ -3441,7 +4139,7 @@ export class InteractiveMode {
3441
4139
  done();
3442
4140
  this.ui.requestRender();
3443
4141
  },
3444
- });
4142
+ }, outputThroughputHistory);
3445
4143
  return { component: selector, focus: selector };
3446
4144
  });
3447
4145
  }
@@ -3468,7 +4166,7 @@ export class InteractiveMode {
3468
4166
  }
3469
4167
  catch (error) {
3470
4168
  done();
3471
- this.showError(error instanceof Error ? error.message : String(error));
4169
+ this.showError(describeRuntimeError(error));
3472
4170
  }
3473
4171
  }, () => {
3474
4172
  done();
@@ -3494,7 +4192,7 @@ export class InteractiveMode {
3494
4192
  this.showStatus("Cloned to new session");
3495
4193
  }
3496
4194
  catch (error) {
3497
- this.showError(error instanceof Error ? error.message : String(error));
4195
+ this.showError(describeRuntimeError(error));
3498
4196
  }
3499
4197
  }
3500
4198
  showTreeSelector(initialSelectedId) {
@@ -3571,7 +4269,7 @@ export class InteractiveMode {
3571
4269
  return;
3572
4270
  }
3573
4271
  // Update UI
3574
- this.chatContainer.clear();
4272
+ this.clearTranscript();
3575
4273
  this.renderInitialMessages();
3576
4274
  if (result.editorText && !this.editor.getText().trim()) {
3577
4275
  this.editor.setText(result.editorText);
@@ -4006,7 +4704,7 @@ export class InteractiveMode {
4006
4704
  this.editorContainer.clear();
4007
4705
  this.editorContainer.addChild(reloadBox);
4008
4706
  this.ui.setFocus(reloadBox);
4009
- this.ui.requestRender(true);
4707
+ this.ui.rebuild();
4010
4708
  await new Promise((resolve) => process.nextTick(resolve));
4011
4709
  const dismissReloadBox = (editor) => {
4012
4710
  this.editorContainer.clear();
@@ -4023,11 +4721,10 @@ export class InteractiveMode {
4023
4721
  }
4024
4722
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
4025
4723
  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
- }
4724
+ // SCRAMJET-DIVERGENCE: re-resolve through the resolver after reload so newly registered
4725
+ // themes (and the captured classification) are honored; force a re-read since reload's purpose
4726
+ // is to pick up on-disk theme edits even when the active theme's name is unchanged.
4727
+ this.reapplyResolvedTheme(true);
4031
4728
  const editorPaddingX = this.settingsManager.getEditorPaddingX();
4032
4729
  const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
4033
4730
  this.defaultEditor.setPaddingX(editorPaddingX);
@@ -4037,7 +4734,6 @@ export class InteractiveMode {
4037
4734
  this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
4038
4735
  }
4039
4736
  this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
4040
- this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
4041
4737
  this.setupAutocompleteProvider();
4042
4738
  const runner = this.session.extensionRunner;
4043
4739
  this.setupExtensionShortcuts(runner);
@@ -4055,7 +4751,7 @@ export class InteractiveMode {
4055
4751
  }
4056
4752
  catch (error) {
4057
4753
  dismissReloadBox(previousEditor);
4058
- this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
4754
+ this.showError(`Reload failed: ${describeRuntimeError(error)}`);
4059
4755
  }
4060
4756
  }
4061
4757
  async handleExportCommand(text) {
@@ -4173,13 +4869,13 @@ export class InteractiveMode {
4173
4869
  else {
4174
4870
  this.showWarning("Usage: /name <name>");
4175
4871
  }
4176
- this.ui.requestRender();
4872
+ this.commitFinalizedChatOutput();
4177
4873
  return;
4178
4874
  }
4179
4875
  this.session.setSessionName(name);
4180
4876
  this.chatContainer.addChild(new Spacer(1));
4181
4877
  this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));
4182
- this.ui.requestRender();
4878
+ this.commitFinalizedChatOutput();
4183
4879
  }
4184
4880
  handleSessionCommand() {
4185
4881
  const stats = this.session.getSessionStats();
@@ -4212,7 +4908,7 @@ export class InteractiveMode {
4212
4908
  }
4213
4909
  this.chatContainer.addChild(new Spacer(1));
4214
4910
  this.chatContainer.addChild(new Text(info, 1, 0));
4215
- this.ui.requestRender();
4911
+ this.commitFinalizedChatOutput();
4216
4912
  }
4217
4913
  handleChangelogCommand() {
4218
4914
  const changelogPath = getChangelogPath();
@@ -4229,7 +4925,7 @@ export class InteractiveMode {
4229
4925
  this.chatContainer.addChild(new Spacer(1));
4230
4926
  this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings()));
4231
4927
  this.chatContainer.addChild(new DynamicBorder());
4232
- this.ui.requestRender();
4928
+ this.commitFinalizedChatOutput();
4233
4929
  }
4234
4930
  /**
4235
4931
  * Get capitalized display string for an app keybinding action.
@@ -4350,7 +5046,7 @@ export class InteractiveMode {
4350
5046
  this.chatContainer.addChild(new Spacer(1));
4351
5047
  this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()));
4352
5048
  this.chatContainer.addChild(new DynamicBorder());
4353
- this.ui.requestRender();
5049
+ this.commitFinalizedChatOutput();
4354
5050
  }
4355
5051
  async handleClearCommand() {
4356
5052
  if (this.loadingAnimation) {
@@ -4366,7 +5062,7 @@ export class InteractiveMode {
4366
5062
  this.renderCurrentSessionState();
4367
5063
  this.chatContainer.addChild(new Spacer(1));
4368
5064
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
4369
- this.ui.requestRender();
5065
+ this.commitFinalizedChatOutput();
4370
5066
  }
4371
5067
  catch (error) {
4372
5068
  await this.handleFatalRuntimeError("Failed to create session", error);
@@ -4397,22 +5093,38 @@ export class InteractiveMode {
4397
5093
  fs.writeFileSync(debugLogPath, debugData);
4398
5094
  this.chatContainer.addChild(new Spacer(1));
4399
5095
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1));
4400
- this.ui.requestRender();
5096
+ this.commitFinalizedChatOutput();
4401
5097
  }
4402
5098
  handleArminSaysHi() {
5099
+ const component = new ArminComponent(this.ui, () => {
5100
+ if (!this.mutableChatComponents.has(component) || !this.chatContainer.children.includes(component))
5101
+ return;
5102
+ this.setChatComponentMutable(component, false);
5103
+ this.promoteFinalizedChatPrefix();
5104
+ this.ui.requestRender();
5105
+ });
4403
5106
  this.chatContainer.addChild(new Spacer(1));
4404
- this.chatContainer.addChild(new ArminComponent(this.ui));
4405
- this.ui.requestRender();
5107
+ this.chatContainer.addChild(component);
5108
+ this.setChatComponentMutable(component, true);
5109
+ this.commitFinalizedChatOutput();
4406
5110
  }
4407
5111
  handleDementedDelves() {
4408
5112
  this.chatContainer.addChild(new Spacer(1));
4409
5113
  this.chatContainer.addChild(new EarendilAnnouncementComponent());
4410
- this.ui.requestRender();
5114
+ this.commitFinalizedChatOutput();
4411
5115
  }
4412
5116
  handleDaxnuts() {
5117
+ const component = new DaxnutsComponent(this.ui, () => {
5118
+ if (!this.mutableChatComponents.has(component) || !this.chatContainer.children.includes(component))
5119
+ return;
5120
+ this.setChatComponentMutable(component, false);
5121
+ this.promoteFinalizedChatPrefix();
5122
+ this.ui.requestRender();
5123
+ });
4413
5124
  this.chatContainer.addChild(new Spacer(1));
4414
- this.chatContainer.addChild(new DaxnutsComponent(this.ui));
4415
- this.ui.requestRender();
5125
+ this.chatContainer.addChild(component);
5126
+ this.setChatComponentMutable(component, true);
5127
+ this.commitFinalizedChatOutput();
4416
5128
  }
4417
5129
  checkDaxnutsEasterEgg(model) {
4418
5130
  if (model.provider === "opencode" && model.id.toLowerCase().includes("kimi-k2.5")) {
@@ -4438,6 +5150,7 @@ export class InteractiveMode {
4438
5150
  this.pendingBashComponents.push(this.bashComponent);
4439
5151
  }
4440
5152
  else {
5153
+ this.sealStatus();
4441
5154
  this.chatContainer.addChild(this.bashComponent);
4442
5155
  }
4443
5156
  // Show output and complete
@@ -4447,6 +5160,7 @@ export class InteractiveMode {
4447
5160
  this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true, content: result.output } : undefined, result.fullOutputPath);
4448
5161
  // Record the result in session
4449
5162
  this.session.recordBashResult(command, result, { excludeFromContext });
5163
+ this.promoteFinalizedChatPrefix();
4450
5164
  this.bashComponent = undefined;
4451
5165
  this.ui.requestRender();
4452
5166
  return;
@@ -4454,6 +5168,7 @@ export class InteractiveMode {
4454
5168
  // Normal execution path (possibly with custom operations)
4455
5169
  const isDeferred = this.session.isStreaming;
4456
5170
  this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
5171
+ this.setChatComponentMutable(this.bashComponent, true);
4457
5172
  if (isDeferred) {
4458
5173
  // Show in pending area when agent is streaming
4459
5174
  this.pendingMessagesContainer.addChild(this.bashComponent);
@@ -4461,6 +5176,7 @@ export class InteractiveMode {
4461
5176
  }
4462
5177
  else {
4463
5178
  // Show in chat immediately when agent is idle
5179
+ this.sealStatus();
4464
5180
  this.chatContainer.addChild(this.bashComponent);
4465
5181
  }
4466
5182
  this.ui.requestRender();
@@ -4481,6 +5197,9 @@ export class InteractiveMode {
4481
5197
  }
4482
5198
  this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
4483
5199
  }
5200
+ if (this.bashComponent)
5201
+ this.setChatComponentMutable(this.bashComponent, false);
5202
+ this.promoteFinalizedChatPrefix();
4484
5203
  this.bashComponent = undefined;
4485
5204
  this.ui.requestRender();
4486
5205
  }
@@ -4503,24 +5222,30 @@ export class InteractiveMode {
4503
5222
  // Ignore, will be emitted as an event
4504
5223
  }
4505
5224
  }
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();
5225
+ stop(options) {
5226
+ try {
5227
+ if (this.isInitialized) {
5228
+ this.isInitialized = false;
5229
+ this.ui.stop(options);
5230
+ }
4520
5231
  }
4521
- if (this.isInitialized) {
4522
- this.ui.stop();
4523
- this.isInitialized = false;
5232
+ finally {
5233
+ this.selectorOpenGeneration++;
5234
+ this.pendingSelectorOpenGeneration = undefined;
5235
+ this.unregisterSignalHandlers();
5236
+ if (this.settingsManager.getShowTerminalProgress()) {
5237
+ this.ui.terminal.setProgress(false);
5238
+ }
5239
+ if (this.loadingAnimation) {
5240
+ this.loadingAnimation.stop();
5241
+ this.loadingAnimation = undefined;
5242
+ }
5243
+ this.clearExtensionTerminalInputListeners();
5244
+ this.footer.dispose();
5245
+ this.footerDataProvider.dispose();
5246
+ if (this.unsubscribe) {
5247
+ this.unsubscribe();
5248
+ }
4524
5249
  }
4525
5250
  }
4526
5251
  }