@dreb/coding-agent 2.25.4 → 2.27.3

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 (46) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +20 -10
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +7 -0
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/buddy/buddy-controller.d.ts.map +1 -1
  7. package/dist/core/buddy/buddy-controller.js +5 -23
  8. package/dist/core/buddy/buddy-controller.js.map +1 -1
  9. package/dist/core/context-buffer.d.ts +49 -0
  10. package/dist/core/context-buffer.d.ts.map +1 -0
  11. package/dist/core/context-buffer.js +84 -0
  12. package/dist/core/context-buffer.js.map +1 -0
  13. package/dist/core/settings-manager.d.ts.map +1 -1
  14. package/dist/core/settings-manager.js.map +1 -1
  15. package/dist/core/system-prompt.d.ts +5 -0
  16. package/dist/core/system-prompt.d.ts.map +1 -1
  17. package/dist/core/system-prompt.js +6 -0
  18. package/dist/core/system-prompt.js.map +1 -1
  19. package/dist/core/tools/subagent.d.ts.map +1 -1
  20. package/dist/core/tools/subagent.js +1 -0
  21. package/dist/core/tools/subagent.js.map +1 -1
  22. package/dist/core/tools/suggest-next.d.ts.map +1 -1
  23. package/dist/core/tools/suggest-next.js +5 -4
  24. package/dist/core/tools/suggest-next.js.map +1 -1
  25. package/dist/modes/interactive/components/tool-execution.d.ts +7 -0
  26. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  27. package/dist/modes/interactive/components/tool-execution.js +16 -0
  28. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  29. package/dist/modes/interactive/interactive-mode.d.ts +33 -0
  30. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  31. package/dist/modes/interactive/interactive-mode.js +184 -87
  32. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  33. package/dist/modes/interactive/tab-title.d.ts +21 -3
  34. package/dist/modes/interactive/tab-title.d.ts.map +1 -1
  35. package/dist/modes/interactive/tab-title.js +47 -25
  36. package/dist/modes/interactive/tab-title.js.map +1 -1
  37. package/docs/agent-models.md +10 -0
  38. package/docs/development.md +1 -1
  39. package/docs/providers.md +7 -0
  40. package/docs/settings.md +2 -2
  41. package/docs/tui.md +42 -0
  42. package/examples/extensions/custom-provider-anthropic/package.json +3 -0
  43. package/examples/extensions/custom-provider-gitlab-duo/package.json +3 -0
  44. package/examples/extensions/custom-provider-qwen-cli/package.json +3 -0
  45. package/examples/extensions/with-deps/package.json +3 -0
  46. package/package.json +2 -2
@@ -70,6 +70,7 @@ export class InteractiveMode {
70
70
  options;
71
71
  session;
72
72
  ui;
73
+ committedChatContainer;
73
74
  chatContainer;
74
75
  pendingMessagesContainer;
75
76
  statusContainer;
@@ -100,6 +101,8 @@ export class InteractiveMode {
100
101
  streamingMessage = undefined;
101
102
  // Tool execution tracking: toolCallId -> component
102
103
  pendingTools = new Map();
104
+ // Deferred commit: set to true when components finalize, commit runs after next render
105
+ commitNeeded = false;
103
106
  // Tool output expansion state
104
107
  toolOutputExpanded = false;
105
108
  // Thinking block visibility state
@@ -190,6 +193,7 @@ export class InteractiveMode {
190
193
  });
191
194
  this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
192
195
  this.headerContainer = new Container();
196
+ this.committedChatContainer = new Container();
193
197
  this.chatContainer = new Container();
194
198
  this.pendingMessagesContainer = new Container();
195
199
  this.statusContainer = new Container();
@@ -415,6 +419,7 @@ export class InteractiveMode {
415
419
  this.headerContainer.addChild(new Text(condensedText, 1, 0));
416
420
  }
417
421
  }
422
+ this.ui.addChild(this.committedChatContainer);
418
423
  this.ui.addChild(this.chatContainer);
419
424
  this.ui.addChild(this.pendingMessagesContainer);
420
425
  this.ui.addChild(this.statusContainer);
@@ -425,6 +430,20 @@ export class InteractiveMode {
425
430
  this.ui.addChild(this.widgetContainerBelow);
426
431
  this.ui.addChild(this.footer);
427
432
  this.ui.setFocus(this.editor);
433
+ // Mark header + committedChatContainer as committed to scrollback.
434
+ // The differential renderer will only manage children after this boundary.
435
+ this.ui.setCommittedChildCount(2);
436
+ // Wire up deferred commit: after each render cycle, commit any finalized
437
+ // components that were painted with their final state. This ensures
438
+ // components are committed to scrollback only AFTER their final content
439
+ // has been rendered to the terminal (not before, which would freeze
440
+ // stale/intermediate content in scrollback).
441
+ this.ui.onPostRender = () => {
442
+ if (this.commitNeeded) {
443
+ this.commitNeeded = false;
444
+ this.tryCommitPrefix();
445
+ }
446
+ };
428
447
  this.setupKeyHandlers();
429
448
  this.setupEditorSubmitHandler();
430
449
  // Start the UI before initializing extensions so session_start handlers can use interactive dialogs
@@ -435,8 +454,14 @@ export class InteractiveMode {
435
454
  this.activateStderrGuard();
436
455
  // Initialize extensions first so resources are shown before messages
437
456
  await this.initExtensions();
438
- // Render initial messages AFTER showing loaded resources
457
+ // Render initial messages AFTER showing loaded resources.
458
+ // Do NOT call resetChatDisplay() here — initExtensions() already populated
459
+ // chatContainer with Context/Memory/Skills via showLoadedResources().
460
+ // Clearing would erase that content. Just append session messages, commit,
461
+ // and repaint.
439
462
  this.renderInitialMessages();
463
+ this.tryCommitPrefix();
464
+ this.ui.recommitAll();
440
465
  // Set terminal title
441
466
  this.updateTerminalTitle();
442
467
  // Initialize tab title auto-generation
@@ -449,7 +474,8 @@ export class InteractiveMode {
449
474
  onThemeChange(() => {
450
475
  this.ui.invalidate();
451
476
  this.updateEditorBorderColor();
452
- this.ui.requestRender();
477
+ // Theme affects committed content — full re-commit to repaint
478
+ this.ui.recommitAll();
453
479
  });
454
480
  // Set up git branch watcher (uses provider instead of footer)
455
481
  this.footerDataProvider.onBranchChange(() => {
@@ -482,11 +508,17 @@ export class InteractiveMode {
482
508
  return;
483
509
  this.tabTitleGenerator = new TabTitleGenerator(settings, {
484
510
  setTitle: (title) => this.ui.terminal.setTitle(title),
511
+ setSessionName: (name) => {
512
+ this.sessionManager.appendSessionInfo(name);
513
+ },
485
514
  getMessages: () => this.session.state.messages,
486
515
  getModel: () => this.session.model,
487
516
  getModelRegistry: () => this.session.modelRegistry,
488
517
  getProvider: () => this.session.model?.provider,
489
518
  getAgentModelsOverride: (name) => this.settingsManager.getAgentModelsForAgent(name),
519
+ getBranch: () => this.footerDataProvider.getGitBranch(),
520
+ getRepo: () => path.basename(process.cwd()),
521
+ getCwd: () => process.cwd(),
490
522
  });
491
523
  }
492
524
  /**
@@ -996,15 +1028,13 @@ export class InteractiveMode {
996
1028
  }
997
1029
  // Clear UI state
998
1030
  this.editor.setGhostText?.(null);
999
- this.chatContainer.clear();
1000
1031
  this.pendingMessagesContainer.clear();
1001
1032
  this.compactionQueuedMessages = [];
1002
1033
  this.streamingComponent = undefined;
1003
1034
  this.streamingMessage = undefined;
1004
1035
  this.pendingTools.clear();
1005
- // Render any messages added via setup, or show empty session
1006
- this.renderInitialMessages();
1007
- this.ui.requestRender();
1036
+ // Re-render from new session state
1037
+ this.resetChatDisplay();
1008
1038
  return { cancelled: false };
1009
1039
  },
1010
1040
  fork: async (entryId) => {
@@ -1012,8 +1042,7 @@ export class InteractiveMode {
1012
1042
  if (result.cancelled) {
1013
1043
  return { cancelled: true };
1014
1044
  }
1015
- this.chatContainer.clear();
1016
- this.renderInitialMessages();
1045
+ this.resetChatDisplay();
1017
1046
  this.editor.setText(result.selectedText);
1018
1047
  this.showStatus("Forked to new session");
1019
1048
  return { cancelled: false };
@@ -1028,8 +1057,7 @@ export class InteractiveMode {
1028
1057
  if (result.cancelled) {
1029
1058
  return { cancelled: true };
1030
1059
  }
1031
- this.chatContainer.clear();
1032
- this.renderInitialMessages();
1060
+ this.resetChatDisplay();
1033
1061
  if (result.editorText && !this.editor.getText().trim()) {
1034
1062
  this.editor.setText(result.editorText);
1035
1063
  }
@@ -1297,7 +1325,9 @@ export class InteractiveMode {
1297
1325
  this.headerContainer.children[index] = this.builtInHeader;
1298
1326
  }
1299
1327
  }
1300
- this.ui.requestRender();
1328
+ // Header is committed content (child 0 of the TUI, before committedChildCount);
1329
+ // requestRender() only re-renders the live region and would silently drop this change.
1330
+ this.ui.recommitAll();
1301
1331
  }
1302
1332
  addExtensionTerminalInputListener(handler) {
1303
1333
  const unsubscribe = this.ui.addInputListener(handler);
@@ -1406,11 +1436,8 @@ export class InteractiveMode {
1406
1436
  */
1407
1437
  hideExtensionSelector() {
1408
1438
  this.extensionSelector?.dispose();
1409
- this.editorContainer.clear();
1410
- this.editorContainer.addChild(this.editor);
1411
1439
  this.extensionSelector = undefined;
1412
- this.ui.setFocus(this.editor);
1413
- this.ui.requestRender();
1440
+ this.restoreEditorComponent();
1414
1441
  }
1415
1442
  /**
1416
1443
  * Show a confirmation dialog for extensions.
@@ -1453,11 +1480,8 @@ export class InteractiveMode {
1453
1480
  */
1454
1481
  hideExtensionInput() {
1455
1482
  this.extensionInput?.dispose();
1456
- this.editorContainer.clear();
1457
- this.editorContainer.addChild(this.editor);
1458
1483
  this.extensionInput = undefined;
1459
- this.ui.setFocus(this.editor);
1460
- this.ui.requestRender();
1484
+ this.restoreEditorComponent();
1461
1485
  }
1462
1486
  /**
1463
1487
  * Show a multi-line editor for extensions (with Ctrl+G support).
@@ -1481,11 +1505,8 @@ export class InteractiveMode {
1481
1505
  * Hide the extension editor.
1482
1506
  */
1483
1507
  hideExtensionEditor() {
1484
- this.editorContainer.clear();
1485
- this.editorContainer.addChild(this.editor);
1486
1508
  this.extensionEditor = undefined;
1487
- this.ui.setFocus(this.editor);
1488
- this.ui.requestRender();
1509
+ this.restoreEditorComponent();
1489
1510
  }
1490
1511
  /**
1491
1512
  * Set a custom editor component from an extension.
@@ -1565,11 +1586,8 @@ export class InteractiveMode {
1565
1586
  const savedText = this.editor.getText();
1566
1587
  const isOverlay = options?.overlay ?? false;
1567
1588
  const restoreEditor = () => {
1568
- this.editorContainer.clear();
1569
- this.editorContainer.addChild(this.editor);
1570
1589
  this.editor.setText(savedText);
1571
- this.ui.setFocus(this.editor);
1572
- this.ui.requestRender();
1590
+ this.restoreEditorComponent();
1573
1591
  };
1574
1592
  return new Promise((resolve, reject) => {
1575
1593
  let component;
@@ -2057,6 +2075,10 @@ export class InteractiveMode {
2057
2075
  }
2058
2076
  // Capture assistant response for buddy context
2059
2077
  this.buddyController.handleEvent(event);
2078
+ // Capture context for tab title generation
2079
+ this.tabTitleGenerator?.onMessageEnd(event.message);
2080
+ // Defer commit until after the next render paints the final state
2081
+ this.commitNeeded = true;
2060
2082
  this.ui.requestRender();
2061
2083
  break;
2062
2084
  case "tool_execution_start": {
@@ -2086,12 +2108,19 @@ export class InteractiveMode {
2086
2108
  if (component) {
2087
2109
  component.updateResult({ ...event.result, isError: event.isError });
2088
2110
  this.pendingTools.delete(event.toolCallId);
2111
+ // Wire up Kitty conversion callback for deferred commit
2112
+ component.onConversionComplete = () => {
2113
+ this.commitNeeded = true;
2114
+ this.ui.requestRender();
2115
+ };
2116
+ // Defer commit until after the next render paints the final state
2117
+ this.commitNeeded = true;
2089
2118
  this.ui.requestRender();
2090
2119
  }
2091
2120
  // Buddy context + reaction for tool execution
2092
2121
  this.buddyController.handleEvent(event);
2093
2122
  // Tab title auto-generation
2094
- this.tabTitleGenerator?.onToolEnd();
2123
+ this.tabTitleGenerator?.onToolEnd(event);
2095
2124
  break;
2096
2125
  }
2097
2126
  case "agent_end":
@@ -2110,6 +2139,9 @@ export class InteractiveMode {
2110
2139
  this.streamingMessage = undefined;
2111
2140
  }
2112
2141
  this.pendingTools.clear();
2142
+ // Defer final commit sweep — all remaining finalized components will
2143
+ // move to scrollback after the next render paints their final state
2144
+ this.commitNeeded = true;
2113
2145
  await this.checkShutdownRequested();
2114
2146
  // Buddy reaction: session wrap-up quip
2115
2147
  this.buddyController.handleEvent(event);
@@ -2149,8 +2181,9 @@ export class InteractiveMode {
2149
2181
  }
2150
2182
  else if (event.result) {
2151
2183
  // Rebuild chat to show compacted state
2152
- this.chatContainer.clear();
2153
2184
  this.rebuildChatFromMessages();
2185
+ this.tryCommitPrefix();
2186
+ this.ui.recommitAll();
2154
2187
  // Add compaction component at bottom so user sees it without scrolling
2155
2188
  this.addMessageToChat({
2156
2189
  role: "compactionSummary",
@@ -2214,19 +2247,9 @@ export class InteractiveMode {
2214
2247
  this.chatContainer.removeChild(component);
2215
2248
  }
2216
2249
  this.pendingTools.clear();
2217
- // Show retry status
2218
- this.statusContainer.clear();
2219
- if (this.loadingAnimation) {
2220
- this.loadingAnimation.stop();
2221
- this.loadingAnimation = undefined;
2222
- }
2223
- if (this.retryLoader) {
2224
- this.retryLoader.stop();
2225
- this.retryLoader = undefined;
2226
- }
2227
- this.retryLoader = new Loader(this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), `Stream dropped, retrying (${event.attempt}/${event.maxAttempts})... (${keyText("app.interrupt")} to cancel)`);
2228
- this.statusContainer.addChild(this.retryLoader);
2229
- this.ui.requestRender();
2250
+ // Warn in the chat scrollback — keep the working spinner running so ESC
2251
+ // aborts via the normal loadingAnimation path (same AbortController).
2252
+ this.showWarning(`Stream dropped, retrying (${event.attempt}/${event.maxAttempts})…`);
2230
2253
  break;
2231
2254
  }
2232
2255
  case "length_retry": {
@@ -2241,19 +2264,9 @@ export class InteractiveMode {
2241
2264
  this.chatContainer.removeChild(component);
2242
2265
  }
2243
2266
  this.pendingTools.clear();
2244
- // Show retry status
2245
- this.statusContainer.clear();
2246
- if (this.loadingAnimation) {
2247
- this.loadingAnimation.stop();
2248
- this.loadingAnimation = undefined;
2249
- }
2250
- if (this.retryLoader) {
2251
- this.retryLoader.stop();
2252
- this.retryLoader = undefined;
2253
- }
2254
- this.retryLoader = new Loader(this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), `Response truncated, retrying with larger token budget (${event.attempt}/${event.maxAttempts})... (${keyText("app.interrupt")} to cancel)`);
2255
- this.statusContainer.addChild(this.retryLoader);
2256
- this.ui.requestRender();
2267
+ // Warn in the chat scrollback — keep the working spinner running so ESC
2268
+ // aborts via the normal loadingAnimation path (same AbortController).
2269
+ this.showWarning(`Response truncated, retrying with larger token budget (${event.attempt}/${event.maxAttempts})…`);
2257
2270
  break;
2258
2271
  }
2259
2272
  case "background_agent_start":
@@ -2489,6 +2502,10 @@ export class InteractiveMode {
2489
2502
  });
2490
2503
  }
2491
2504
  rebuildChatFromMessages() {
2505
+ // Clear both containers (committed content will be re-committed after rebuild).
2506
+ // Callers that use this for global actions should also call
2507
+ // tryCommitPrefix() + ui.recommitAll() after.
2508
+ this.committedChatContainer.clear();
2492
2509
  this.chatContainer.clear();
2493
2510
  const context = this.sessionManager.buildSessionContext();
2494
2511
  this.renderSessionContext(context);
@@ -2558,7 +2575,9 @@ export class InteractiveMode {
2558
2575
  process.removeListener("SIGINT", ignoreSigint);
2559
2576
  this.ui.start();
2560
2577
  this.activateStderrGuard();
2561
- this.ui.requestRender(true);
2578
+ // stop() moved the cursor, so hardwareCursorRow is stale. recommitAll()
2579
+ // uses position-independent sequences (2J/H/3J) — safe after terminal takeover.
2580
+ this.ui.recommitAll();
2562
2581
  });
2563
2582
  try {
2564
2583
  // Stop the TUI (restore terminal to normal mode)
@@ -2656,23 +2675,81 @@ export class InteractiveMode {
2656
2675
  this.ui.requestRender();
2657
2676
  this.showStatus(`Tasks panel: ${this.tasksPanel.visible ? "visible" : "hidden"}`);
2658
2677
  }
2678
+ /**
2679
+ * Clear committed and live chat, re-render from session state, commit the
2680
+ * finalized prefix, and repaint everything. Used after session switches,
2681
+ * imports, resumes, and navigation — any operation that replaces the
2682
+ * displayed transcript with a different session's content.
2683
+ */
2684
+ resetChatDisplay() {
2685
+ this.committedChatContainer.clear();
2686
+ this.chatContainer.clear();
2687
+ this.renderInitialMessages();
2688
+ this.tryCommitPrefix();
2689
+ this.ui.recommitAll();
2690
+ }
2691
+ /**
2692
+ * Scan the live chatContainer for the longest contiguous prefix of fully-finalized
2693
+ * components, move them to committedChatContainer, and advance the TUI's committed
2694
+ * boundary. This prevents the differential renderer from replaying history.
2695
+ *
2696
+ * A component is "finalized" if:
2697
+ * - AssistantMessageComponent: not the active streamingComponent
2698
+ * - ToolExecutionComponent: not in pendingTools AND no pending Kitty conversions
2699
+ * - Any other component (Spacer, Text, etc.): always finalized
2700
+ */
2701
+ tryCommitPrefix() {
2702
+ let commitCount = 0;
2703
+ for (const child of this.chatContainer.children) {
2704
+ if (child instanceof AssistantMessageComponent) {
2705
+ if (child === this.streamingComponent)
2706
+ break; // still streaming
2707
+ commitCount++;
2708
+ }
2709
+ else if (child instanceof ToolExecutionComponent) {
2710
+ if (this.pendingTools.has(child.getToolCallId()))
2711
+ break; // still executing
2712
+ if (child.hasPendingConversions())
2713
+ break; // Kitty conversion pending
2714
+ commitCount++;
2715
+ }
2716
+ else {
2717
+ commitCount++; // Spacer, Text, etc. — always safe
2718
+ }
2719
+ }
2720
+ if (commitCount === 0)
2721
+ return;
2722
+ // Move the finalized prefix from chatContainer to committedChatContainer
2723
+ const toCommit = this.chatContainer.children.splice(0, commitCount);
2724
+ for (const c of toCommit) {
2725
+ this.committedChatContainer.addChild(c);
2726
+ }
2727
+ // Update the TUI's committed line tracking
2728
+ this.ui.commit();
2729
+ }
2659
2730
  toggleToolOutputExpansion() {
2660
2731
  this.setToolsExpanded(!this.toolOutputExpanded);
2661
2732
  }
2662
2733
  setToolsExpanded(expanded) {
2663
2734
  this.toolOutputExpanded = expanded;
2735
+ // Expand/collapse in both committed and live containers
2736
+ for (const child of this.committedChatContainer.children) {
2737
+ if (isExpandable(child)) {
2738
+ child.setExpanded(expanded);
2739
+ }
2740
+ }
2664
2741
  for (const child of this.chatContainer.children) {
2665
2742
  if (isExpandable(child)) {
2666
2743
  child.setExpanded(expanded);
2667
2744
  }
2668
2745
  }
2669
- this.ui.requestRender();
2746
+ // Committed content changed — full re-commit to repaint
2747
+ this.ui.recommitAll();
2670
2748
  }
2671
2749
  toggleThinkingBlockVisibility() {
2672
2750
  this.hideThinkingBlock = !this.hideThinkingBlock;
2673
2751
  this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
2674
- // Rebuild chat from session messages
2675
- this.chatContainer.clear();
2752
+ // Full rebuild: rebuildChatFromMessages() clears both containers internally
2676
2753
  this.rebuildChatFromMessages();
2677
2754
  // If streaming, re-add the streaming component with updated visibility and re-render
2678
2755
  if (this.streamingComponent && this.streamingMessage) {
@@ -2680,6 +2757,9 @@ export class InteractiveMode {
2680
2757
  this.streamingComponent.updateContent(this.streamingMessage);
2681
2758
  this.chatContainer.addChild(this.streamingComponent);
2682
2759
  }
2760
+ // Committed content rebuilt — full re-commit to repaint
2761
+ this.tryCommitPrefix();
2762
+ this.ui.recommitAll();
2683
2763
  this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
2684
2764
  }
2685
2765
  openExternalEditor() {
@@ -2722,8 +2802,9 @@ export class InteractiveMode {
2722
2802
  // Restart TUI and re-intercept stderr
2723
2803
  this.ui.start();
2724
2804
  this.activateStderrGuard();
2725
- // Force full re-render since external editor uses alternate screen
2726
- this.ui.requestRender(true);
2805
+ // stop() moved the cursor, so hardwareCursorRow is stale. recommitAll()
2806
+ // uses position-independent sequences (2J/H/3J) — safe after terminal takeover.
2807
+ this.ui.recommitAll();
2727
2808
  }
2728
2809
  }
2729
2810
  // =========================================================================
@@ -2954,15 +3035,31 @@ export class InteractiveMode {
2954
3035
  // =========================================================================
2955
3036
  // Selectors
2956
3037
  // =========================================================================
3038
+ /**
3039
+ * Restore an editor component into the editor container after an inline modal
3040
+ * (selector, dialog, input, loader) was shown in its place, and repaint.
3041
+ *
3042
+ * Uses recommitAll() rather than requestRender(): an inline modal can grow the
3043
+ * live region tall enough to push committed content up into terminal scrollback.
3044
+ * Closing it shrinks the live region, and a differential redraw can't pull that
3045
+ * committed content back down — leaving ghost whitespace below the prompt.
3046
+ * recommitAll() repaints the whole transcript with position-independent sequences.
3047
+ * Safe here: these modals are interactive and the user is at the bottom with no
3048
+ * agent streaming.
3049
+ */
3050
+ restoreEditorComponent(editor = this.editor) {
3051
+ this.editorContainer.clear();
3052
+ this.editorContainer.addChild(editor);
3053
+ this.ui.setFocus(editor);
3054
+ this.ui.recommitAll();
3055
+ }
2957
3056
  /**
2958
3057
  * Shows a selector component in place of the editor.
2959
3058
  * @param create Factory that receives a `done` callback and returns the component and focus target
2960
3059
  */
2961
3060
  showSelector(create) {
2962
3061
  const done = () => {
2963
- this.editorContainer.clear();
2964
- this.editorContainer.addChild(this.editor);
2965
- this.ui.setFocus(this.editor);
3062
+ this.restoreEditorComponent();
2966
3063
  };
2967
3064
  const { component, focus } = create(done);
2968
3065
  this.editorContainer.clear();
@@ -3016,11 +3113,18 @@ export class InteractiveMode {
3016
3113
  },
3017
3114
  onShowImagesChange: (enabled) => {
3018
3115
  this.settingsManager.setShowImages(enabled);
3116
+ for (const child of this.committedChatContainer.children) {
3117
+ if (child instanceof ToolExecutionComponent) {
3118
+ child.setShowImages(enabled);
3119
+ }
3120
+ }
3019
3121
  for (const child of this.chatContainer.children) {
3020
3122
  if (child instanceof ToolExecutionComponent) {
3021
3123
  child.setShowImages(enabled);
3022
3124
  }
3023
3125
  }
3126
+ // Committed content changed — full re-commit to repaint
3127
+ this.ui.recommitAll();
3024
3128
  },
3025
3129
  onAutoResizeImagesChange: (enabled) => {
3026
3130
  this.settingsManager.setImageAutoResize(enabled);
@@ -3065,13 +3169,10 @@ export class InteractiveMode {
3065
3169
  onHideThinkingBlockChange: (hidden) => {
3066
3170
  this.hideThinkingBlock = hidden;
3067
3171
  this.settingsManager.setHideThinkingBlock(hidden);
3068
- for (const child of this.chatContainer.children) {
3069
- if (child instanceof AssistantMessageComponent) {
3070
- child.setHideThinkingBlock(hidden);
3071
- }
3072
- }
3073
- this.chatContainer.clear();
3172
+ // Full rebuild: rebuildChatFromMessages() clears both containers
3074
3173
  this.rebuildChatFromMessages();
3174
+ this.tryCommitPrefix();
3175
+ this.ui.recommitAll();
3075
3176
  },
3076
3177
  onThinkingDisplayChange: (display) => {
3077
3178
  const model = this.session.model;
@@ -3330,8 +3431,7 @@ export class InteractiveMode {
3330
3431
  this.ui.requestRender();
3331
3432
  return;
3332
3433
  }
3333
- this.chatContainer.clear();
3334
- this.renderInitialMessages();
3434
+ this.resetChatDisplay();
3335
3435
  this.editor.setText(result.selectedText);
3336
3436
  done();
3337
3437
  this.showStatus("Branched to new session");
@@ -3416,8 +3516,7 @@ export class InteractiveMode {
3416
3516
  return;
3417
3517
  }
3418
3518
  // Update UI
3419
- this.chatContainer.clear();
3420
- this.renderInitialMessages();
3519
+ this.resetChatDisplay();
3421
3520
  if (result.editorText && !this.editor.getText().trim()) {
3422
3521
  this.editor.setText(result.editorText);
3423
3522
  }
@@ -3485,8 +3584,7 @@ export class InteractiveMode {
3485
3584
  await this.session.switchSession(sessionPath);
3486
3585
  await this.footerDataProvider.refreshDailyCost();
3487
3586
  // Clear and re-render the chat
3488
- this.chatContainer.clear();
3489
- this.renderInitialMessages();
3587
+ this.resetChatDisplay();
3490
3588
  this.showStatus("Resumed session");
3491
3589
  }
3492
3590
  async showOAuthSelector(mode) {
@@ -3550,10 +3648,7 @@ export class InteractiveMode {
3550
3648
  });
3551
3649
  // Restore editor helper
3552
3650
  const restoreEditor = () => {
3553
- this.editorContainer.clear();
3554
- this.editorContainer.addChild(this.editor);
3555
- this.ui.setFocus(this.editor);
3556
- this.ui.requestRender();
3651
+ this.restoreEditorComponent();
3557
3652
  };
3558
3653
  try {
3559
3654
  await this.session.modelRegistry.authStorage.login(providerId, {
@@ -3628,10 +3723,7 @@ export class InteractiveMode {
3628
3723
  this.ui.requestRender();
3629
3724
  const dismissLoader = (editor) => {
3630
3725
  loader.dispose();
3631
- this.editorContainer.clear();
3632
- this.editorContainer.addChild(editor);
3633
- this.ui.setFocus(editor);
3634
- this.ui.requestRender();
3726
+ this.restoreEditorComponent(editor);
3635
3727
  };
3636
3728
  try {
3637
3729
  await this.session.reload();
@@ -3658,6 +3750,9 @@ export class InteractiveMode {
3658
3750
  this.setupExtensionShortcuts(runner);
3659
3751
  }
3660
3752
  this.rebuildChatFromMessages();
3753
+ this.tryCommitPrefix();
3754
+ // dismissLoader() restores the editor and calls recommitAll(), which
3755
+ // repaints the rebuilt committed content — no separate recommit needed.
3661
3756
  dismissLoader(this.editor);
3662
3757
  this.showLoadedResources({
3663
3758
  force: false,
@@ -3722,8 +3817,7 @@ export class InteractiveMode {
3722
3817
  return;
3723
3818
  }
3724
3819
  // Clear and re-render the chat
3725
- this.chatContainer.clear();
3726
- this.renderInitialMessages();
3820
+ this.resetChatDisplay();
3727
3821
  this.showStatus(`Session imported from: ${inputPath}`);
3728
3822
  }
3729
3823
  catch (error) {
@@ -4009,6 +4103,7 @@ ${cycleModelForward || cycleModelBackward ? `| \`${cycleModelForward}\` / \`${cy
4009
4103
  await this.footerDataProvider.refreshDailyCost();
4010
4104
  // Clear UI state
4011
4105
  this.headerContainer.clear();
4106
+ this.committedChatContainer.clear();
4012
4107
  this.chatContainer.clear();
4013
4108
  this.pendingMessagesContainer.clear();
4014
4109
  this.compactionQueuedMessages = [];
@@ -4017,7 +4112,7 @@ ${cycleModelForward || cycleModelBackward ? `| \`${cycleModelForward}\` / \`${cy
4017
4112
  this.pendingTools.clear();
4018
4113
  this.chatContainer.addChild(new Spacer(1));
4019
4114
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
4020
- this.ui.requestRender();
4115
+ this.ui.recommitAll();
4021
4116
  }
4022
4117
  handleDebugCommand() {
4023
4118
  const width = this.ui.terminal.columns;
@@ -4293,6 +4388,8 @@ ${cycleModelForward || cycleModelBackward ? `| \`${cycleModelForward}\` / \`${cy
4293
4388
  result = await this.session.compact(customInstructions);
4294
4389
  // Rebuild UI
4295
4390
  this.rebuildChatFromMessages();
4391
+ this.tryCommitPrefix();
4392
+ this.ui.recommitAll();
4296
4393
  // Add compaction component at bottom so user sees it without scrolling
4297
4394
  const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
4298
4395
  this.addMessageToChat(msg);