@leanandmean/coding-agent 0.74.1-scramjet.58 → 0.74.1-scramjet.59

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 (66) hide show
  1. package/README.md +14 -2
  2. package/dist/core/extensions/runner.d.ts.map +1 -1
  3. package/dist/core/extensions/runner.js +2 -0
  4. package/dist/core/extensions/runner.js.map +1 -1
  5. package/dist/core/extensions/types.d.ts +3 -1
  6. package/dist/core/extensions/types.d.ts.map +1 -1
  7. package/dist/core/extensions/types.js.map +1 -1
  8. package/dist/core/keybindings.d.ts +8 -0
  9. package/dist/core/keybindings.d.ts.map +1 -1
  10. package/dist/core/settings-manager.d.ts +15 -0
  11. package/dist/core/settings-manager.d.ts.map +1 -1
  12. package/dist/core/settings-manager.js +74 -0
  13. package/dist/core/settings-manager.js.map +1 -1
  14. package/dist/core/tools/bash.d.ts.map +1 -1
  15. package/dist/core/tools/bash.js +4 -3
  16. package/dist/core/tools/bash.js.map +1 -1
  17. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  18. package/dist/modes/interactive/components/assistant-message.js +3 -2
  19. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  20. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  21. package/dist/modes/interactive/components/extension-editor.js +3 -1
  22. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  23. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  24. package/dist/modes/interactive/components/extension-selector.js +4 -3
  25. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  26. package/dist/modes/interactive/components/settings-selector.d.ts +17 -1
  27. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  28. package/dist/modes/interactive/components/settings-selector.js +84 -2
  29. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  30. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  31. package/dist/modes/interactive/components/tool-execution.js +6 -1
  32. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  33. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  34. package/dist/modes/interactive/components/user-message.js +3 -2
  35. package/dist/modes/interactive/components/user-message.js.map +1 -1
  36. package/dist/modes/interactive/components/visual-truncate.d.ts.map +1 -1
  37. package/dist/modes/interactive/components/visual-truncate.js +2 -2
  38. package/dist/modes/interactive/components/visual-truncate.js.map +1 -1
  39. package/dist/modes/interactive/interactive-mode.d.ts +18 -1
  40. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  41. package/dist/modes/interactive/interactive-mode.js +480 -100
  42. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  43. package/dist/utils/clipboard-native.d.ts +1 -0
  44. package/dist/utils/clipboard-native.d.ts.map +1 -1
  45. package/dist/utils/clipboard-native.js.map +1 -1
  46. package/dist/utils/clipboard.d.ts +6 -0
  47. package/dist/utils/clipboard.d.ts.map +1 -1
  48. package/dist/utils/clipboard.js +278 -10
  49. package/dist/utils/clipboard.js.map +1 -1
  50. package/dist/utils/tools-manager.d.ts +1 -1
  51. package/dist/utils/tools-manager.d.ts.map +1 -1
  52. package/dist/utils/tools-manager.js +7 -6
  53. package/dist/utils/tools-manager.js.map +1 -1
  54. package/docs/extensions.md +27 -11
  55. package/docs/keybindings.md +23 -1
  56. package/docs/settings.md +11 -0
  57. package/docs/terminal-setup.md +79 -2
  58. package/docs/terminal-verification.md +71 -0
  59. package/docs/tui.md +91 -29
  60. package/examples/extensions/doom-overlay/doom-component.ts +3 -3
  61. package/examples/extensions/modal-editor.ts +13 -8
  62. package/examples/extensions/overlay-qa-tests.ts +22 -9
  63. package/examples/extensions/overlay-test.ts +5 -3
  64. package/examples/extensions/snake.ts +3 -1
  65. package/examples/extensions/space-invaders.ts +6 -3
  66. package/package.json +4 -4
@@ -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";
@@ -24,7 +25,7 @@ import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../cor
24
25
  import { SessionManager } from "../../core/session-manager.js";
25
26
  import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
26
27
  import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
27
- import { copyToClipboard } from "../../utils/clipboard.js";
28
+ import { copyToClipboard, createWslClipboardReader, readClipboardText } from "../../utils/clipboard.js";
28
29
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
29
30
  import { parseGitUrl } from "../../utils/git.js";
30
31
  import { getCwdRelativePath } from "../../utils/paths.js";
@@ -108,6 +109,7 @@ export class InteractiveMode {
108
109
  options;
109
110
  runtimeHost;
110
111
  ui;
112
+ tuiMode;
111
113
  committedChatContainer;
112
114
  chatContainer;
113
115
  mutableChatComponents = new Set();
@@ -115,6 +117,7 @@ export class InteractiveMode {
115
117
  statusContainer;
116
118
  defaultEditor;
117
119
  editor;
120
+ editorAvailableRows;
118
121
  editorComponentFactory;
119
122
  autocompleteProvider;
120
123
  autocompleteProviderWrappers = [];
@@ -156,6 +159,7 @@ export class InteractiveMode {
156
159
  agentRunGeneration = 0;
157
160
  selectorOpenGeneration = 0;
158
161
  pendingSelectorOpenGeneration;
162
+ layoutSettingsSettlementGeneration = 0;
159
163
  // Tool output expansion state
160
164
  toolOutputExpanded = false;
161
165
  // Thinking block visibility state
@@ -186,6 +190,7 @@ export class InteractiveMode {
186
190
  extensionSelector = undefined;
187
191
  extensionInput = undefined;
188
192
  extensionEditor = undefined;
193
+ customHeightAllocation;
189
194
  extensionTerminalInputUnsubscribers = new Set();
190
195
  // Extension widgets (components rendered above/below the editor)
191
196
  extensionWidgetsAbove = new Map();
@@ -223,8 +228,28 @@ export class InteractiveMode {
223
228
  await this.rebindCurrentSession();
224
229
  });
225
230
  this.version = VERSION;
226
- // SCRAMJET-DIVERGENCE: mandatory committed/live rendering and injectable terminals (#389).
231
+ this.tuiMode = this.settingsManager.getTuiMode();
232
+ // SCRAMJET-DIVERGENCE: retained interactive rendering and injectable terminals.
227
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
+ });
228
253
  this.headerContainer = new Container();
229
254
  this.committedChatContainer = new Container();
230
255
  this.chatContainer = new Container();
@@ -241,6 +266,7 @@ export class InteractiveMode {
241
266
  autocompleteMaxVisible,
242
267
  });
243
268
  this.editor = this.defaultEditor;
269
+ this.configureEditorHeight(this.editor);
244
270
  this.defaultEditor.setSpellcheckProvider(new NspellProvider());
245
271
  this.editorContainer = new Container();
246
272
  this.editorContainer.addChild(this.editor);
@@ -257,6 +283,187 @@ export class InteractiveMode {
257
283
  this.detectedClassification = detectThemeFromEnvironment()?.theme;
258
284
  initTheme(this.settingsManager.getTheme(), true);
259
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
+ }
260
467
  // SCRAMJET-DIVERGENCE: finalized transcript components are promoted atomically into append-only history (#389).
261
468
  promoteFinalizedChatPrefix() {
262
469
  let promoted = false;
@@ -270,8 +477,12 @@ export class InteractiveMode {
270
477
  this.committedChatContainer.addChild(child);
271
478
  promoted = true;
272
479
  }
273
- if (promoted)
274
- this.ui.commit();
480
+ if (promoted) {
481
+ if (this.ui.getViewportState())
482
+ this.ui.requestRender();
483
+ else
484
+ this.ui.commit();
485
+ }
275
486
  }
276
487
  setChatComponentMutable(component, mutable) {
277
488
  if (mutable)
@@ -289,6 +500,9 @@ export class InteractiveMode {
289
500
  this.promoteFinalizedChatPrefix();
290
501
  }
291
502
  clearTranscript() {
503
+ this.clipboardPasteGeneration++;
504
+ if (this.ui.getViewportState())
505
+ this.ui.resetViewport();
292
506
  this.pendingToolFinalizations = new Set();
293
507
  const tools = new Set([
294
508
  ...this.pendingTools.values(),
@@ -492,26 +706,20 @@ export class InteractiveMode {
492
706
  this.changelogMarkdown = this.getChangelogForDisplay();
493
707
  // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
494
708
  // Both are needed: fd for autocomplete, rg for grep tool and bash commands
495
- 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)]);
496
714
  this.fdPath = fdPath;
497
- if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
498
- const modelList = this.session.scopedModels
499
- .map((sm) => {
500
- const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
501
- return `${sm.model.id}${thinkingStr}`;
502
- })
503
- .join(", ");
504
- const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
505
- const cycleHint = cycleKeys.length > 0
506
- ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
507
- : "";
508
- console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
509
- }
510
715
  // Add header container as first child (content built after theme detection below)
511
716
  this.ui.addChild(this.headerContainer);
512
717
  this.ui.addChild(this.committedChatContainer);
513
718
  this.ui.addChild(this.chatContainer);
514
- this.ui.setLiveRegionStart(this.chatContainer);
719
+ if (this.tuiMode === "retained")
720
+ this.configureRetainedViewport();
721
+ else
722
+ this.ui.setLiveRegionStart(this.chatContainer);
515
723
  this.ui.addChild(this.pendingMessagesContainer);
516
724
  this.ui.addChild(this.statusContainer);
517
725
  this.renderWidgets(); // Initialize with default spacer
@@ -541,6 +749,14 @@ export class InteractiveMode {
541
749
  }
542
750
  // Build themed header content (after theme detection so colors are correct)
543
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
+ }
544
760
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
545
761
  const hint = (keybinding, description) => keyHint(keybinding, description);
546
762
  const expandedInstructions = [
@@ -584,6 +800,8 @@ export class InteractiveMode {
584
800
  this.builtInHeader = new Text("", 0, 0);
585
801
  this.headerContainer.addChild(this.builtInHeader);
586
802
  }
803
+ for (const diagnostic of startupDiagnostics)
804
+ this.headerContainer.addChild(new Text(diagnostic, 1, 0));
587
805
  // Initialize extensions first so resources are shown before messages
588
806
  await this.rebindCurrentSession();
589
807
  // Render initial messages AFTER showing loaded resources
@@ -1503,7 +1721,19 @@ export class InteractiveMode {
1503
1721
  this.extensionWidgetsBelow.clear();
1504
1722
  this.renderWidgets();
1505
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
+ }
1506
1735
  resetExtensionUI() {
1736
+ this.releaseCustomHeight();
1507
1737
  if (this.extensionSelector) {
1508
1738
  this.hideExtensionSelector();
1509
1739
  }
@@ -1875,6 +2105,7 @@ export class InteractiveMode {
1875
2105
  }
1876
2106
  }
1877
2107
  this.editor = newEditor;
2108
+ this.configureEditorHeight(this.editor);
1878
2109
  }
1879
2110
  else {
1880
2111
  // Restore default editor with text from custom editor
@@ -1901,29 +2132,77 @@ export class InteractiveMode {
1901
2132
  }
1902
2133
  /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
1903
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");
1904
2137
  const savedText = this.editor.getText();
1905
2138
  const isOverlay = options?.overlay ?? false;
1906
- const restoreEditor = () => {
2139
+ const restoreEditor = (restoreFocus = true) => {
1907
2140
  this.editorContainer.clear();
1908
2141
  this.editorContainer.addChild(this.editor);
1909
2142
  this.editor.setText(savedText);
1910
- this.ui.setFocus(this.editor);
2143
+ if (restoreFocus)
2144
+ this.ui.setFocus(this.editor);
1911
2145
  this.ui.requestRender();
1912
2146
  };
1913
2147
  return new Promise((resolve, reject) => {
1914
2148
  let component;
1915
2149
  let attachedTool;
1916
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
+ };
1917
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
+ };
1918
2192
  const close = (result) => {
1919
2193
  if (closed)
1920
2194
  return;
1921
2195
  closed = true;
2196
+ removeInputGuard?.();
2197
+ releaseHeight();
1922
2198
  attachedTool?.detachCommittedContext();
2199
+ const current = !attachedTool || this.chatContainer.children.includes(attachedTool);
2200
+ if (attachedTool)
2201
+ restoreAttachedFocus(current);
1923
2202
  if (isOverlay)
1924
2203
  this.ui.hideOverlay();
1925
- else
1926
- restoreEditor();
2204
+ else if (current)
2205
+ restoreEditor(!attachedTool || !retained);
1927
2206
  // Note: both branches above already call requestRender
1928
2207
  resolve(result);
1929
2208
  try {
@@ -1951,21 +2230,70 @@ export class InteractiveMode {
1951
2230
  committedContext = attachment.render(this.ui, theme);
1952
2231
  this.committedChatContainer.addChild(committedContext);
1953
2232
  tool.attachCommittedContext(component);
1954
- this.ui.setFocus(null);
2233
+ if (retained)
2234
+ this.ui.replaceFocus(this.editor, pendingFocus);
2235
+ else
2236
+ this.ui.setFocus(null);
1955
2237
  this.editorContainer.clear();
1956
- try {
1957
- await this.ui.commitNow({ requireFlush: true });
1958
- }
1959
- catch (error) {
1960
- tool.cancelCommittedContext();
1961
- this.committedChatContainer.removeChild(committedContext);
1962
- committedContext = undefined;
1963
- this.ui.rebuild();
1964
- throw error;
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
+ });
1965
2293
  }
2294
+ await reveal();
1966
2295
  if (closed)
1967
2296
  return;
1968
- this.ui.setFocus(tool);
1969
2297
  this.ui.requestRender();
1970
2298
  return;
1971
2299
  }
@@ -1987,30 +2315,23 @@ export class InteractiveMode {
1987
2315
  options?.onHandle?.(handle);
1988
2316
  }
1989
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
+ }
1990
2328
  this.editorContainer.clear();
1991
2329
  this.editorContainer.addChild(component);
1992
2330
  this.ui.setFocus(component);
1993
2331
  this.ui.requestRender();
1994
2332
  }
1995
2333
  })
1996
- .catch((err) => {
1997
- if (closed)
1998
- return;
1999
- attachedTool?.cancelCommittedContext();
2000
- if (committedContext) {
2001
- this.committedChatContainer.removeChild(committedContext);
2002
- this.ui.rebuild();
2003
- }
2004
- try {
2005
- component?.dispose?.();
2006
- }
2007
- catch {
2008
- /* ignore dispose errors */
2009
- }
2010
- if (!isOverlay)
2011
- restoreEditor();
2012
- reject(err);
2013
- });
2334
+ .catch(fail);
2014
2335
  });
2015
2336
  }
2016
2337
  /**
@@ -2096,6 +2417,7 @@ export class InteractiveMode {
2096
2417
  this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector());
2097
2418
  this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector());
2098
2419
  this.defaultEditor.onChange = (text) => {
2420
+ this.clipboardPasteGeneration++;
2099
2421
  const wasBashMode = this.isBashMode;
2100
2422
  this.isBashMode = text.trimStart().startsWith("!");
2101
2423
  if (wasBashMode !== this.isBashMode) {
@@ -2132,6 +2454,8 @@ export class InteractiveMode {
2132
2454
  text = text.trim();
2133
2455
  if (!text)
2134
2456
  return;
2457
+ // SCRAMJET-DIVERGENCE: submission intent precedes command routing and queued-message delivery.
2458
+ this.ui.followViewport();
2135
2459
  // Handle commands
2136
2460
  if (text === "/settings") {
2137
2461
  this.showSettingsSelector();
@@ -2358,6 +2682,7 @@ export class InteractiveMode {
2358
2682
  }
2359
2683
  else if (event.message.role === "user") {
2360
2684
  this.addMessageToChat(event.message);
2685
+ this.ui.followViewport();
2361
2686
  this.updatePendingMessagesDisplay();
2362
2687
  this.ui.requestRender();
2363
2688
  }
@@ -2920,9 +3245,18 @@ export class InteractiveMode {
2920
3245
  // Drain any in-flight Kitty key release events before stopping.
2921
3246
  // This prevents escape sequences from leaking to the parent shell over slow SSH.
2922
3247
  await this.ui.terminal.drainInput(1000);
2923
- this.stop();
3248
+ await this.settingsManager.flush();
3249
+ let exitCode = 0;
3250
+ try {
3251
+ this.stop({ retainContent: this.settingsManager.getRetainTranscriptOnExit() });
3252
+ }
3253
+ catch (error) {
3254
+ exitCode = 1;
3255
+ console.error(`Could not retain terminal history: ${error instanceof Error ? error.message : String(error)}`);
3256
+ }
3257
+ await this.ui.terminal.flush?.();
2924
3258
  await this.runtimeHost.dispose();
2925
- process.exit(0);
3259
+ process.exit(exitCode);
2926
3260
  }
2927
3261
  emergencyTerminalExit() {
2928
3262
  this.isShuttingDown = true;
@@ -3012,7 +3346,7 @@ export class InteractiveMode {
3012
3346
  }
3013
3347
  this.signalCleanupHandlers = [];
3014
3348
  }
3015
- handleCtrlZ() {
3349
+ async handleCtrlZ() {
3016
3350
  if (process.platform === "win32") {
3017
3351
  this.showStatus("Suspend to background is not supported on Windows");
3018
3352
  return;
@@ -3033,7 +3367,8 @@ export class InteractiveMode {
3033
3367
  this.ui.rebuild();
3034
3368
  });
3035
3369
  try {
3036
- // Stop the TUI (restore terminal to normal mode)
3370
+ // SCRAMJET-DIVERGENCE: drain releases before handing keyboard ownership back to the shell.
3371
+ await this.ui.terminal.drainInput();
3037
3372
  this.ui.stop();
3038
3373
  // Send SIGTSTP to process group (pid=0 means all processes in group)
3039
3374
  process.kill(0, "SIGTSTP");
@@ -3048,6 +3383,7 @@ export class InteractiveMode {
3048
3383
  const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
3049
3384
  if (!text)
3050
3385
  return;
3386
+ this.ui.followViewport();
3051
3387
  // Queue input during compaction (extension commands execute immediately)
3052
3388
  if (this.session.isCompacting) {
3053
3389
  if (this.isExtensionCommand(text)) {
@@ -3141,20 +3477,20 @@ export class InteractiveMode {
3141
3477
  this.ui.rebuild();
3142
3478
  }
3143
3479
  toggleThinkingBlockVisibility() {
3144
- this.hideThinkingBlock = !this.hideThinkingBlock;
3145
- this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
3146
- // Rebuild chat from session messages
3147
- this.rebuildChatFromMessages();
3148
- // If streaming, re-add the streaming component with updated visibility and re-render
3149
- if (this.streamingComponent && this.streamingMessage) {
3150
- this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
3151
- this.streamingComponent.updateContent(this.streamingMessage);
3152
- this.chatContainer.addChild(this.streamingComponent);
3153
- this.setChatComponentMutable(this.streamingComponent, true);
3154
- }
3480
+ this.setThinkingBlockVisibility(!this.hideThinkingBlock);
3155
3481
  this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
3156
3482
  }
3157
- openExternalEditor() {
3483
+ // SCRAMJET-DIVERGENCE: presentation changes retain live tools and browsing identities.
3484
+ setThinkingBlockVisibility(hidden) {
3485
+ this.hideThinkingBlock = hidden;
3486
+ this.settingsManager.setHideThinkingBlock(hidden);
3487
+ for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
3488
+ if (child instanceof AssistantMessageComponent)
3489
+ child.setHideThinkingBlock(hidden);
3490
+ }
3491
+ this.ui.rebuild();
3492
+ }
3493
+ async openExternalEditor() {
3158
3494
  // Determine editor (respect $VISUAL, then $EDITOR)
3159
3495
  const editorCmd = process.env.VISUAL || process.env.EDITOR;
3160
3496
  if (!editorCmd) {
@@ -3166,7 +3502,8 @@ export class InteractiveMode {
3166
3502
  try {
3167
3503
  // Write current content to temp file
3168
3504
  fs.writeFileSync(tmpFile, currentText, "utf-8");
3169
- // Stop TUI to release terminal
3505
+ // SCRAMJET-DIVERGENCE: the editor must not inherit the key release that opened it.
3506
+ await this.ui.terminal.drainInput();
3170
3507
  this.ui.stop();
3171
3508
  // Split by space to support editor arguments (e.g., "code --wait")
3172
3509
  const [editor, ...editorArgs] = editorCmd.split(" ");
@@ -3417,6 +3754,23 @@ export class InteractiveMode {
3417
3754
  this.ui.setFocus(focus);
3418
3755
  this.ui.requestRender();
3419
3756
  }
3757
+ async settleLayoutSettings(selector) {
3758
+ const generation = ++this.layoutSettingsSettlementGeneration;
3759
+ const settings = this.settingsManager;
3760
+ await settings.flush();
3761
+ // SCRAMJET-DIVERGENCE: only the latest settlement may drain a shared batch of save errors.
3762
+ if (generation !== this.layoutSettingsSettlementGeneration)
3763
+ return;
3764
+ const errors = settings.drainErrors();
3765
+ selector.setSaveError(errors.length ? "Changes not saved; see warning" : undefined);
3766
+ if (errors.length) {
3767
+ const details = stripVTControlCharacters(errors.map(({ scope, error }) => `${scope}: ${error.message}`).join("; "))
3768
+ .replace(/[\r\n\t]/g, " ")
3769
+ .slice(0, 200);
3770
+ this.showWarning(`Display settings not saved (${details}). Changes may be lost on restart; project overrides still apply.`);
3771
+ }
3772
+ this.ui.requestRender();
3773
+ }
3420
3774
  showSettingsSelector() {
3421
3775
  this.showSelector((done) => {
3422
3776
  const selector = new SettingsSelectorComponent({
@@ -3445,6 +3799,12 @@ export class InteractiveMode {
3445
3799
  quietStartup: this.settingsManager.getQuietStartup(),
3446
3800
  showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
3447
3801
  warnings: this.settingsManager.getWarnings(),
3802
+ tuiMode: this.tuiMode,
3803
+ dockEditor: this.settingsManager.getDockEditor(),
3804
+ retainTranscriptOnExit: this.settingsManager.getRetainTranscriptOnExit(),
3805
+ editorMaxHeightPercent: this.settingsManager.getEditorMaxHeightPercent(),
3806
+ scrollWheelStep: this.settingsManager.getScrollWheelStep(),
3807
+ viewportProjectOverrides: Object.keys(this.settingsManager.getProjectSettings()),
3448
3808
  }, {
3449
3809
  onAutoCompactChange: (enabled) => {
3450
3810
  this.session.setAutoCompactionEnabled(enabled);
@@ -3499,16 +3859,7 @@ export class InteractiveMode {
3499
3859
  this.ui.requestRender();
3500
3860
  }
3501
3861
  },
3502
- onHideThinkingBlockChange: (hidden) => {
3503
- this.hideThinkingBlock = hidden;
3504
- this.settingsManager.setHideThinkingBlock(hidden);
3505
- for (const child of [...this.committedChatContainer.children, ...this.chatContainer.children]) {
3506
- if (child instanceof AssistantMessageComponent) {
3507
- child.setHideThinkingBlock(hidden);
3508
- }
3509
- }
3510
- this.rebuildChatFromMessages();
3511
- },
3862
+ onHideThinkingBlockChange: (hidden) => this.setThinkingBlockVisibility(hidden),
3512
3863
  onCollapseChangelogChange: (collapsed) => {
3513
3864
  this.settingsManager.setCollapseChangelog(collapsed);
3514
3865
  },
@@ -3548,11 +3899,36 @@ export class InteractiveMode {
3548
3899
  onWarningsChange: (warnings) => {
3549
3900
  this.settingsManager.setWarnings(warnings);
3550
3901
  },
3902
+ onRetainTranscriptOnExitChange: (enabled) => {
3903
+ this.settingsManager.setRetainTranscriptOnExit(enabled);
3904
+ void this.settleLayoutSettings(selector);
3905
+ return this.settingsManager.getRetainTranscriptOnExit();
3906
+ },
3907
+ onDockEditorChange: (enabled) => {
3908
+ this.settingsManager.setDockEditor(enabled);
3909
+ void this.settleLayoutSettings(selector);
3910
+ this.ui.refreshViewportLayout();
3911
+ if (!this.settingsManager.getDockEditor())
3912
+ this.ui.revealComponent(this.editorContainer);
3913
+ return this.settingsManager.getDockEditor();
3914
+ },
3915
+ onEditorMaxHeightPercentChange: (percent) => {
3916
+ this.settingsManager.setEditorMaxHeightPercent(percent);
3917
+ void this.settleLayoutSettings(selector);
3918
+ this.ui.refreshViewportLayout();
3919
+ return this.settingsManager.getEditorMaxHeightPercent();
3920
+ },
3921
+ onScrollWheelStepChange: (step) => {
3922
+ this.settingsManager.setScrollWheelStep(step);
3923
+ void this.settleLayoutSettings(selector);
3924
+ this.ui.requestRender();
3925
+ return this.settingsManager.getScrollWheelStep();
3926
+ },
3551
3927
  onCancel: () => {
3552
3928
  done();
3553
3929
  this.ui.requestRender();
3554
3930
  },
3555
- });
3931
+ }, () => Math.max(1, Math.min(this.editorAvailableRows ?? Infinity, this.ui.terminal.rows - 4)));
3556
3932
  return { component: selector, focus: selector.getSettingsList() };
3557
3933
  });
3558
3934
  }
@@ -4822,26 +5198,30 @@ export class InteractiveMode {
4822
5198
  // Ignore, will be emitted as an event
4823
5199
  }
4824
5200
  }
4825
- stop() {
4826
- this.selectorOpenGeneration++;
4827
- this.pendingSelectorOpenGeneration = undefined;
4828
- this.unregisterSignalHandlers();
4829
- if (this.settingsManager.getShowTerminalProgress()) {
4830
- this.ui.terminal.setProgress(false);
4831
- }
4832
- if (this.loadingAnimation) {
4833
- this.loadingAnimation.stop();
4834
- this.loadingAnimation = undefined;
4835
- }
4836
- this.clearExtensionTerminalInputListeners();
4837
- this.footer.dispose();
4838
- this.footerDataProvider.dispose();
4839
- if (this.unsubscribe) {
4840
- this.unsubscribe();
5201
+ stop(options) {
5202
+ try {
5203
+ if (this.isInitialized) {
5204
+ this.isInitialized = false;
5205
+ this.ui.stop(options);
5206
+ }
4841
5207
  }
4842
- if (this.isInitialized) {
4843
- this.ui.stop();
4844
- this.isInitialized = false;
5208
+ finally {
5209
+ this.selectorOpenGeneration++;
5210
+ this.pendingSelectorOpenGeneration = undefined;
5211
+ this.unregisterSignalHandlers();
5212
+ if (this.settingsManager.getShowTerminalProgress()) {
5213
+ this.ui.terminal.setProgress(false);
5214
+ }
5215
+ if (this.loadingAnimation) {
5216
+ this.loadingAnimation.stop();
5217
+ this.loadingAnimation = undefined;
5218
+ }
5219
+ this.clearExtensionTerminalInputListeners();
5220
+ this.footer.dispose();
5221
+ this.footerDataProvider.dispose();
5222
+ if (this.unsubscribe) {
5223
+ this.unsubscribe();
5224
+ }
4845
5225
  }
4846
5226
  }
4847
5227
  }