erosolar-cli 2.1.172 → 2.1.174

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 (172) hide show
  1. package/README.md +1 -1
  2. package/agents/erosolar-code.rules.json +2 -2
  3. package/agents/general.rules.json +21 -3
  4. package/dist/capabilities/statusCapability.js +2 -2
  5. package/dist/capabilities/statusCapability.js.map +1 -1
  6. package/dist/contracts/agent-schemas.json +5 -5
  7. package/dist/core/agent.d.ts +83 -24
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +499 -248
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/preferences.d.ts +1 -0
  12. package/dist/core/preferences.d.ts.map +1 -1
  13. package/dist/core/preferences.js +8 -1
  14. package/dist/core/preferences.js.map +1 -1
  15. package/dist/core/reliabilityPrompt.d.ts +9 -0
  16. package/dist/core/reliabilityPrompt.d.ts.map +1 -0
  17. package/dist/core/reliabilityPrompt.js +31 -0
  18. package/dist/core/reliabilityPrompt.js.map +1 -0
  19. package/dist/core/schemaValidator.js +3 -3
  20. package/dist/core/schemaValidator.js.map +1 -1
  21. package/dist/core/toolPreconditions.d.ts +0 -11
  22. package/dist/core/toolPreconditions.d.ts.map +1 -1
  23. package/dist/core/toolPreconditions.js +33 -164
  24. package/dist/core/toolPreconditions.js.map +1 -1
  25. package/dist/core/toolRuntime.d.ts.map +1 -1
  26. package/dist/core/toolRuntime.js +9 -114
  27. package/dist/core/toolRuntime.js.map +1 -1
  28. package/dist/core/updateChecker.d.ts +61 -1
  29. package/dist/core/updateChecker.d.ts.map +1 -1
  30. package/dist/core/updateChecker.js +147 -3
  31. package/dist/core/updateChecker.js.map +1 -1
  32. package/dist/headless/headlessApp.d.ts.map +1 -1
  33. package/dist/headless/headlessApp.js +0 -39
  34. package/dist/headless/headlessApp.js.map +1 -1
  35. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  36. package/dist/plugins/tools/nodeDefaults.js +0 -2
  37. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  38. package/dist/providers/openaiResponsesProvider.d.ts.map +1 -1
  39. package/dist/providers/openaiResponsesProvider.js +79 -74
  40. package/dist/providers/openaiResponsesProvider.js.map +1 -1
  41. package/dist/runtime/agentController.d.ts.map +1 -1
  42. package/dist/runtime/agentController.js +6 -3
  43. package/dist/runtime/agentController.js.map +1 -1
  44. package/dist/runtime/agentSession.d.ts +0 -2
  45. package/dist/runtime/agentSession.d.ts.map +1 -1
  46. package/dist/runtime/agentSession.js +2 -2
  47. package/dist/runtime/agentSession.js.map +1 -1
  48. package/dist/shell/interactiveShell.d.ts +11 -18
  49. package/dist/shell/interactiveShell.d.ts.map +1 -1
  50. package/dist/shell/interactiveShell.js +273 -291
  51. package/dist/shell/interactiveShell.js.map +1 -1
  52. package/dist/shell/shellApp.d.ts.map +1 -1
  53. package/dist/shell/shellApp.js +7 -1
  54. package/dist/shell/shellApp.js.map +1 -1
  55. package/dist/shell/systemPrompt.d.ts.map +1 -1
  56. package/dist/shell/systemPrompt.js +4 -15
  57. package/dist/shell/systemPrompt.js.map +1 -1
  58. package/dist/subagents/taskRunner.js +2 -1
  59. package/dist/subagents/taskRunner.js.map +1 -1
  60. package/dist/tools/bashTools.d.ts.map +1 -1
  61. package/dist/tools/bashTools.js +101 -8
  62. package/dist/tools/bashTools.js.map +1 -1
  63. package/dist/tools/diffUtils.d.ts +8 -2
  64. package/dist/tools/diffUtils.d.ts.map +1 -1
  65. package/dist/tools/diffUtils.js +72 -13
  66. package/dist/tools/diffUtils.js.map +1 -1
  67. package/dist/tools/grepTools.d.ts.map +1 -1
  68. package/dist/tools/grepTools.js +10 -2
  69. package/dist/tools/grepTools.js.map +1 -1
  70. package/dist/tools/planningTools.d.ts +0 -10
  71. package/dist/tools/planningTools.d.ts.map +1 -1
  72. package/dist/tools/planningTools.js +0 -16
  73. package/dist/tools/planningTools.js.map +1 -1
  74. package/dist/tools/searchTools.d.ts.map +1 -1
  75. package/dist/tools/searchTools.js +4 -2
  76. package/dist/tools/searchTools.js.map +1 -1
  77. package/dist/ui/PromptController.d.ts +1 -4
  78. package/dist/ui/PromptController.d.ts.map +1 -1
  79. package/dist/ui/PromptController.js +1 -7
  80. package/dist/ui/PromptController.js.map +1 -1
  81. package/dist/ui/ShellUIAdapter.d.ts +292 -28
  82. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  83. package/dist/ui/ShellUIAdapter.js +1513 -121
  84. package/dist/ui/ShellUIAdapter.js.map +1 -1
  85. package/dist/ui/UnifiedUIController.d.ts +81 -0
  86. package/dist/ui/UnifiedUIController.d.ts.map +1 -0
  87. package/dist/ui/UnifiedUIController.js +212 -0
  88. package/dist/ui/UnifiedUIController.js.map +1 -0
  89. package/dist/ui/UnifiedUIRenderer.d.ts +133 -30
  90. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
  91. package/dist/ui/UnifiedUIRenderer.js +939 -370
  92. package/dist/ui/UnifiedUIRenderer.js.map +1 -1
  93. package/dist/ui/animatedStatus.d.ts +128 -6
  94. package/dist/ui/animatedStatus.d.ts.map +1 -1
  95. package/dist/ui/animatedStatus.js +383 -50
  96. package/dist/ui/animatedStatus.js.map +1 -1
  97. package/dist/ui/animation/AnimationScheduler.d.ts +192 -0
  98. package/dist/ui/animation/AnimationScheduler.d.ts.map +1 -0
  99. package/dist/ui/animation/AnimationScheduler.js +432 -0
  100. package/dist/ui/animation/AnimationScheduler.js.map +1 -0
  101. package/dist/ui/display.d.ts +182 -26
  102. package/dist/ui/display.d.ts.map +1 -1
  103. package/dist/ui/display.js +678 -97
  104. package/dist/ui/display.js.map +1 -1
  105. package/dist/ui/inPlaceUpdater.d.ts +181 -0
  106. package/dist/ui/inPlaceUpdater.d.ts.map +1 -0
  107. package/dist/ui/inPlaceUpdater.js +515 -0
  108. package/dist/ui/inPlaceUpdater.js.map +1 -0
  109. package/dist/ui/interrupts/InterruptManager.d.ts +142 -0
  110. package/dist/ui/interrupts/InterruptManager.d.ts.map +1 -0
  111. package/dist/ui/interrupts/InterruptManager.js +439 -0
  112. package/dist/ui/interrupts/InterruptManager.js.map +1 -0
  113. package/dist/ui/layout.d.ts +0 -1
  114. package/dist/ui/layout.d.ts.map +1 -1
  115. package/dist/ui/layout.js +0 -12
  116. package/dist/ui/layout.js.map +1 -1
  117. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts +61 -7
  118. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts.map +1 -1
  119. package/dist/ui/orchestration/UIUpdateCoordinator.js +232 -20
  120. package/dist/ui/orchestration/UIUpdateCoordinator.js.map +1 -1
  121. package/dist/ui/shortcutsHelp.d.ts.map +1 -1
  122. package/dist/ui/shortcutsHelp.js +0 -1
  123. package/dist/ui/shortcutsHelp.js.map +1 -1
  124. package/dist/ui/telemetry/ResponseTracker.d.ts +22 -0
  125. package/dist/ui/telemetry/ResponseTracker.d.ts.map +1 -0
  126. package/dist/ui/telemetry/ResponseTracker.js +60 -0
  127. package/dist/ui/telemetry/ResponseTracker.js.map +1 -0
  128. package/dist/ui/telemetry/UITelemetry.d.ts +181 -0
  129. package/dist/ui/telemetry/UITelemetry.d.ts.map +1 -0
  130. package/dist/ui/telemetry/UITelemetry.js +446 -0
  131. package/dist/ui/telemetry/UITelemetry.js.map +1 -0
  132. package/dist/ui/unified/index.d.ts +30 -1
  133. package/dist/ui/unified/index.d.ts.map +1 -1
  134. package/dist/ui/unified/index.js +45 -2
  135. package/dist/ui/unified/index.js.map +1 -1
  136. package/dist/ui/unified/layout.d.ts +12 -0
  137. package/dist/ui/unified/layout.d.ts.map +1 -0
  138. package/dist/ui/unified/layout.js +96 -0
  139. package/dist/ui/unified/layout.js.map +1 -0
  140. package/package.json +2 -3
  141. package/dist/StringUtils.d.ts +0 -8
  142. package/dist/StringUtils.d.ts.map +0 -1
  143. package/dist/StringUtils.js +0 -11
  144. package/dist/StringUtils.js.map +0 -1
  145. package/dist/core/aiFlowSupervisor.d.ts +0 -44
  146. package/dist/core/aiFlowSupervisor.d.ts.map +0 -1
  147. package/dist/core/aiFlowSupervisor.js +0 -299
  148. package/dist/core/aiFlowSupervisor.js.map +0 -1
  149. package/dist/core/cliTestHarness.d.ts +0 -200
  150. package/dist/core/cliTestHarness.d.ts.map +0 -1
  151. package/dist/core/cliTestHarness.js +0 -549
  152. package/dist/core/cliTestHarness.js.map +0 -1
  153. package/dist/core/testUtils.d.ts +0 -121
  154. package/dist/core/testUtils.d.ts.map +0 -1
  155. package/dist/core/testUtils.js +0 -235
  156. package/dist/core/testUtils.js.map +0 -1
  157. package/dist/core/toolValidation.d.ts +0 -116
  158. package/dist/core/toolValidation.d.ts.map +0 -1
  159. package/dist/core/toolValidation.js +0 -282
  160. package/dist/core/toolValidation.js.map +0 -1
  161. package/dist/ui/planOverlay.d.ts +0 -28
  162. package/dist/ui/planOverlay.d.ts.map +0 -1
  163. package/dist/ui/planOverlay.js +0 -156
  164. package/dist/ui/planOverlay.js.map +0 -1
  165. package/dist/ui/streamingFormatter.d.ts +0 -30
  166. package/dist/ui/streamingFormatter.d.ts.map +0 -1
  167. package/dist/ui/streamingFormatter.js +0 -91
  168. package/dist/ui/streamingFormatter.js.map +0 -1
  169. package/dist/utils/errorUtils.d.ts +0 -16
  170. package/dist/utils/errorUtils.d.ts.map +0 -1
  171. package/dist/utils/errorUtils.js +0 -66
  172. package/dist/utils/errorUtils.js.map +0 -1
@@ -11,10 +11,9 @@
11
11
  import * as readline from 'node:readline';
12
12
  import { EventEmitter } from 'node:events';
13
13
  import { homedir } from 'node:os';
14
- import { theme } from './theme.js';
14
+ import { theme, spinnerFrames } from './theme.js';
15
15
  import { isPlainOutputMode } from './outputMode.js';
16
- import { renderDivider } from './layout.js';
17
- import { colorizeActivity, createFrameTicker, formatElapsed, formatTinyProgressBar, formatTokenDelta, } from './animatedStatus.js';
16
+ import { ContextMeter, disposeAnimations } from './animatedStatus.js';
18
17
  const ESC = {
19
18
  HIDE_CURSOR: '\x1b[?25l',
20
19
  SHOW_CURSOR: '\x1b[?25h',
@@ -28,6 +27,11 @@ const ESC = {
28
27
  ERASE_DOWN: '\x1b[J',
29
28
  REVERSE: '\x1b[7m',
30
29
  RESET: '\x1b[0m',
30
+ // Scroll region control - CRITICAL for fixed bottom overlay
31
+ SET_SCROLL_REGION: (top, bottom) => `\x1b[${top};${bottom}r`,
32
+ RESET_SCROLL_REGION: '\x1b[r',
33
+ SAVE_CURSOR: '\x1b[s',
34
+ RESTORE_CURSOR: '\x1b[u',
31
35
  };
32
36
  const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
33
37
  const NEWLINE_PLACEHOLDER = '↵';
@@ -52,13 +56,38 @@ export class UnifiedUIRenderer extends EventEmitter {
52
56
  hotkeysInToggleLine = new Set();
53
57
  collapsedPaste = null;
54
58
  mode = 'idle';
59
+ streamingStartTime = null;
55
60
  statusMessage = null;
56
61
  statusOverride = null;
57
62
  statusStreaming = null;
63
+ // Animated UI components
64
+ streamingSpinner = null;
65
+ thinkingIndicator = null;
66
+ contextMeter;
67
+ spinnerFrame = 0;
68
+ spinnerInterval = null;
69
+ // Compacting status animation
70
+ compactingStatusMessage = '';
71
+ compactingStatusFrame = 0;
72
+ compactingStatusInterval = null;
73
+ compactingSpinnerFrames = ['✻', '✼', '✻', '✺'];
74
+ // Animated activity line (e.g., "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)")
75
+ activityMessage = null;
76
+ activityPhraseIndex = 0;
77
+ activityStarFrame = 0;
78
+ activityStarFrames = ['✳', '✴', '✵', '✶', '✷', '✸'];
79
+ // Token count during streaming
80
+ streamingTokens = 0;
81
+ // Fun phrases to cycle through when no specific activity is provided
82
+ funActivityPhrases = [
83
+ 'Moseying', 'Ruminating', 'Pondering', 'Cogitating', 'Mulling',
84
+ 'Contemplating', 'Deliberating', 'Noodling', 'Percolating', 'Stewing',
85
+ 'Brewing', 'Simmering', 'Churning', 'Puzzling', 'Meandering',
86
+ 'Wandering', 'Musing', 'Daydreaming', 'Woolgathering', 'Chewing',
87
+ ];
58
88
  statusMeta = {};
59
89
  toggleState = {
60
90
  verificationEnabled: false,
61
- autoContinueEnabled: false,
62
91
  criticalApprovalMode: 'auto',
63
92
  };
64
93
  // ------------ Helpers ------------
@@ -70,16 +99,14 @@ export class UnifiedUIRenderer extends EventEmitter {
70
99
  lastPromptEvent = null;
71
100
  promptHeight = 0;
72
101
  lastOverlayHeight = 0;
73
- lastPromptIndex = 0;
74
- overlayBottomPadding = 1;
75
102
  inlinePanel = [];
76
- persistentPanel = [];
77
- overlayInvalidated = false;
78
103
  hasConversationContent = false;
79
104
  isPromptActive = false;
105
+ inputRenderOffset = 0;
80
106
  plainPasteIdleMs = 24;
81
107
  plainPasteWindowMs = 60;
82
108
  plainPasteTriggerChars = 24;
109
+ cursorVisibleColumn = 1;
83
110
  inBracketedPaste = false;
84
111
  pasteBuffer = '';
85
112
  inPlainPaste = false;
@@ -91,14 +118,9 @@ export class UnifiedUIRenderer extends EventEmitter {
91
118
  lastRenderedEventKey = null;
92
119
  lastOutputEndedWithNewline = true;
93
120
  hasRenderedPrompt = false;
94
- hasEverRenderedOverlay = false; // Track if we've ever rendered to prevent first-render scrollback pollution
121
+ hasEverRenderedOverlay = false; // Track if we've ever rendered for inline clearing
95
122
  lastOverlay = null;
96
123
  allowPromptRender = true;
97
- streamingStart = null;
98
- activityInterval = null;
99
- activityTicker = createFrameTicker('sparkle');
100
- renderedContextPercent = null;
101
- lastToolResult = null;
102
124
  inputCapture = null;
103
125
  constructor(output = process.stdout, input = process.stdin, options) {
104
126
  super();
@@ -106,6 +128,8 @@ export class UnifiedUIRenderer extends EventEmitter {
106
128
  this.input = input;
107
129
  this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
108
130
  this.plainMode = isPlainOutputMode() || !this.interactive;
131
+ // Initialize animated components
132
+ this.contextMeter = new ContextMeter();
109
133
  this.rl = readline.createInterface({
110
134
  input: this.input,
111
135
  output: this.output,
@@ -137,9 +161,6 @@ export class UnifiedUIRenderer extends EventEmitter {
137
161
  this.updateTerminalSize();
138
162
  this.hasRenderedPrompt = false;
139
163
  this.lastOutputEndedWithNewline = true;
140
- // Don't render prompt immediately - wait for banner/content to be added first.
141
- // The prompt will render after the event queue processes the welcome banner.
142
- // This prevents the prompt from appearing at the bottom before the banner shows.
143
164
  this.write(ESC.SHOW_CURSOR);
144
165
  return;
145
166
  }
@@ -152,12 +173,27 @@ export class UnifiedUIRenderer extends EventEmitter {
152
173
  cleanup() {
153
174
  this.cancelInputCapture(new Error('Renderer disposed'));
154
175
  this.cancelPlainPasteCapture();
155
- this.stopActivityTimer();
176
+ // Stop any running animations
177
+ if (this.spinnerInterval) {
178
+ clearInterval(this.spinnerInterval);
179
+ this.spinnerInterval = null;
180
+ }
181
+ if (this.streamingSpinner) {
182
+ this.streamingSpinner.stop();
183
+ this.streamingSpinner = null;
184
+ }
185
+ if (this.thinkingIndicator) {
186
+ this.thinkingIndicator.stop();
187
+ this.thinkingIndicator = null;
188
+ }
189
+ this.contextMeter.dispose();
190
+ disposeAnimations();
156
191
  if (!this.interactive) {
157
192
  this.rl.close();
158
193
  return;
159
194
  }
160
195
  if (!this.plainMode) {
196
+ // Clear the prompt area so it doesn't remain in scrollback history
161
197
  this.clearPromptArea();
162
198
  this.write(ESC.DISABLE_BRACKETED_PASTE);
163
199
  this.write(ESC.SHOW_CURSOR);
@@ -209,14 +245,25 @@ export class UnifiedUIRenderer extends EventEmitter {
209
245
  return;
210
246
  }
211
247
  if (key.ctrl && key.name === 'c') {
212
- const hadBuffer = this.buffer.length > 0;
213
- this.emit('ctrl-c', { hadBuffer });
214
- if (hadBuffer) {
215
- this.clearBuffer();
248
+ // Three-stage Ctrl+C behavior:
249
+ // 1. Clear chat box if it has text
250
+ // 2. Interrupt/pause the AI if streaming
251
+ // 3. Quit the CLI if already idle
252
+ if (this.buffer.length > 0) {
253
+ // Stage 1: Clear the input buffer
254
+ this.buffer = '';
255
+ this.cursor = 0;
256
+ this.renderPrompt();
257
+ this.emitInputChange();
216
258
  }
217
259
  else if (this.mode === 'streaming') {
260
+ // Stage 2: Interrupt the AI run
218
261
  this.emit('interrupt');
219
262
  }
263
+ else {
264
+ // Stage 3: Quit the CLI (emit exit signal)
265
+ this.emit('exit');
266
+ }
220
267
  return;
221
268
  }
222
269
  if (key.ctrl && key.name === 'd') {
@@ -225,12 +272,6 @@ export class UnifiedUIRenderer extends EventEmitter {
225
272
  }
226
273
  return;
227
274
  }
228
- if (key.ctrl && key.name === 'o') {
229
- if (!this.expandLastToolResult()) {
230
- this.emit('expand-tool-result');
231
- }
232
- return;
233
- }
234
275
  if (key.ctrl && key.name === 'u') {
235
276
  this.clearBuffer();
236
277
  return;
@@ -241,6 +282,11 @@ export class UnifiedUIRenderer extends EventEmitter {
241
282
  return;
242
283
  }
243
284
  }
285
+ // Ctrl+O: Expand last tool result
286
+ if (key.ctrl && key.name === 'o') {
287
+ this.emit('expand-tool-result');
288
+ return;
289
+ }
244
290
  if (key.name === 'return' || key.name === 'enter') {
245
291
  if (this.collapsedPaste) {
246
292
  this.expandCollapsedPaste();
@@ -250,9 +296,12 @@ export class UnifiedUIRenderer extends EventEmitter {
250
296
  // If a slash command suggestion is highlighted, pressing Enter submits it immediately
251
297
  if (this.applySuggestion(true))
252
298
  return;
253
- // If buffer starts with '/' and the first suggestion exists, submit it
299
+ // Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
254
300
  if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
255
- this.buffer = this.suggestions[this.suggestionIndex >= 0 ? this.suggestionIndex : 0]?.command ?? this.buffer;
301
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
302
+ ? this.suggestionIndex
303
+ : 0;
304
+ this.buffer = this.suggestions[safeIndex]?.command ?? this.buffer;
256
305
  }
257
306
  this.submitText(this.buffer);
258
307
  return;
@@ -298,18 +347,6 @@ export class UnifiedUIRenderer extends EventEmitter {
298
347
  }
299
348
  return;
300
349
  }
301
- if (key.name === 'home') {
302
- this.cursor = 0;
303
- this.renderPrompt();
304
- this.emitInputChange();
305
- return;
306
- }
307
- if (key.name === 'end') {
308
- this.cursor = this.buffer.length;
309
- this.renderPrompt();
310
- this.emitInputChange();
311
- return;
312
- }
313
350
  if (key.name === 'up') {
314
351
  if (this.navigateSuggestions(-1)) {
315
352
  return;
@@ -574,6 +611,7 @@ export class UnifiedUIRenderer extends EventEmitter {
574
611
  this.inputCapture = null;
575
612
  this.buffer = '';
576
613
  this.cursor = 0;
614
+ this.inputRenderOffset = 0;
577
615
  this.resetSuggestions();
578
616
  this.renderPrompt();
579
617
  this.emitInputChange();
@@ -640,7 +678,11 @@ export class UnifiedUIRenderer extends EventEmitter {
640
678
  if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
641
679
  return false;
642
680
  }
643
- const selected = this.suggestions[this.suggestionIndex] ?? this.suggestions[0];
681
+ // Ensure suggestionIndex is valid, default to first item
682
+ const safeIndex = this.suggestionIndex >= 0 && this.suggestionIndex < this.suggestions.length
683
+ ? this.suggestionIndex
684
+ : 0;
685
+ const selected = this.suggestions[safeIndex];
644
686
  if (!selected) {
645
687
  return false;
646
688
  }
@@ -673,22 +715,10 @@ export class UnifiedUIRenderer extends EventEmitter {
673
715
  this.renderPrompt();
674
716
  return true;
675
717
  }
676
- isGarbageOutput(content) {
677
- const text = content.replace(/\u001B\[[0-9;]*m/gu, '').replace(/\s+/g, '');
678
- if (!text)
679
- return true;
680
- if (text.length <= 2 && !/[a-zA-Z0-9]/.test(text))
681
- return true;
682
- if (text.length < 6 && /^[\.\-_,;:~`'"!@#$%^&*+=|\\/]+$/.test(text))
683
- return true;
684
- return false;
685
- }
686
718
  // ------------ Event queue ------------
687
- addEvent(type, content, options) {
719
+ addEvent(type, content) {
688
720
  if (!content)
689
721
  return;
690
- if (this.isGarbageOutput(content))
691
- return;
692
722
  const normalized = this.normalizeEventType(type);
693
723
  if (!normalized)
694
724
  return;
@@ -697,6 +727,7 @@ export class UnifiedUIRenderer extends EventEmitter {
697
727
  normalized === 'thought' ||
698
728
  normalized === 'stream' ||
699
729
  normalized === 'tool' ||
730
+ normalized === 'tool-result' ||
700
731
  normalized === 'build' ||
701
732
  normalized === 'test') {
702
733
  this.hasConversationContent = true;
@@ -720,7 +751,6 @@ export class UnifiedUIRenderer extends EventEmitter {
720
751
  rawType: type,
721
752
  content,
722
753
  timestamp: Date.now(),
723
- isCompacted: options?.compact === true,
724
754
  };
725
755
  // Priority queue: prompt events are inserted at the front to ensure immediate display
726
756
  // This guarantees user input is echoed before any async processing responses
@@ -824,6 +854,7 @@ export class UnifiedUIRenderer extends EventEmitter {
824
854
  if (event.type !== 'prompt') {
825
855
  this.lastRenderedEventKey = signature;
826
856
  }
857
+ // Clear the prompt area before writing new content
827
858
  if (this.promptHeight > 0 || this.lastOverlay) {
828
859
  this.clearPromptArea();
829
860
  }
@@ -835,10 +866,6 @@ export class UnifiedUIRenderer extends EventEmitter {
835
866
  }
836
867
  this.output.write(formatted);
837
868
  this.lastOutputEndedWithNewline = formatted.endsWith('\n');
838
- // Overlay must be re-anchored after new scrollback is written
839
- this.overlayInvalidated = true;
840
- // Don't re-render prompt after every event - wait for queue to finish
841
- // This prevents premature prompt rendering that cuts off responses
842
869
  }
843
870
  normalizeEventType(type) {
844
871
  switch (type) {
@@ -851,8 +878,9 @@ export class UnifiedUIRenderer extends EventEmitter {
851
878
  return 'stream';
852
879
  case 'tool':
853
880
  case 'tool-call':
854
- case 'tool-result':
855
881
  return 'tool';
882
+ case 'tool-result':
883
+ return 'tool-result';
856
884
  case 'build':
857
885
  return 'build';
858
886
  case 'test':
@@ -868,83 +896,388 @@ export class UnifiedUIRenderer extends EventEmitter {
868
896
  }
869
897
  }
870
898
  formatContent(event) {
871
- const bullet = '⏺';
899
+ const bullet = '⏺'; // Claude Code uses plain bullet, no color
900
+ // Compacted blocks already have separator and formatting
872
901
  if (event.isCompacted) {
873
902
  return event.content;
874
903
  }
875
904
  if (event.rawType === 'banner') {
905
+ // Banners display without bullet prefix
876
906
  const lines = event.content.split('\n').map(line => line.trimEnd());
877
907
  return `${lines.join('\n')}\n`;
878
908
  }
909
+ // Compact, user-friendly formatting
879
910
  switch (event.type) {
880
911
  case 'prompt':
881
- return `\n> ${event.content}\n`;
912
+ // User prompt - just the text (prompt box handles styling)
913
+ return `${theme.primary('>')} ${event.content}\n`;
882
914
  case 'thought': {
883
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
884
- }
885
- case 'tool':
886
- if (event.rawType === 'tool-call') {
887
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
915
+ // Programmatic filter: reject content that looks like internal/garbage output
916
+ if (this.isGarbageOutput(event.content)) {
917
+ return '';
888
918
  }
889
- if (event.rawType === 'tool-result') {
890
- return this.formatToolResult(event.content, event.isCompacted);
891
- }
892
- return `\n${event.content}\n`;
919
+ // Strip any existing bullet prefix ( or ) and use consistent ⏺
920
+ const cleanContent = event.content.replace(/^[○⏺]\s*/, '');
921
+ return `⏺ ${cleanContent}\n`;
922
+ }
923
+ case 'tool': {
924
+ // Compact tool display: ⚡ToolName → result
925
+ const content = event.content.replace(/^[⏺⚙○]\s*/, '');
926
+ return this.formatCompactToolCall(content);
927
+ }
928
+ case 'tool-result': {
929
+ // Inline result: └─ summary
930
+ return this.formatCompactToolResult(event.content);
931
+ }
893
932
  case 'build':
894
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
933
+ return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
895
934
  case 'test':
896
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
935
+ return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
897
936
  case 'stream':
898
937
  return event.content;
899
938
  case 'response':
900
939
  default: {
901
- return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
940
+ // Programmatic filter: reject content that looks like internal/garbage output
941
+ if (this.isGarbageOutput(event.content)) {
942
+ return '';
943
+ }
944
+ // Clean response without excessive bullets
945
+ return this.formatCompactResponse(event.content);
902
946
  }
903
947
  }
904
948
  }
905
- formatBulletBlock(content, bullet) {
906
- const width = Math.max(24, this.safeWidth() - 4);
907
- const wrapped = this.wrapText(content, width);
908
- return wrapped
909
- .map((line, index) => (index === 0 ? `${bullet} ${line}` : ` ${line}`))
910
- .join('\n');
911
- }
912
- formatToolResult(content, compacted) {
913
- const prefix = '';
914
- if (compacted) {
915
- return `\n${prefix} ${content}\n`;
949
+ /**
950
+ * Programmatic garbage detection - checks if content looks like internal/system output
951
+ * that shouldn't be shown to users. Uses structural checks, not pattern matching.
952
+ */
953
+ isGarbageOutput(content) {
954
+ if (!content || content.trim().length === 0)
955
+ return true;
956
+ // Structural check: content starting with < that isn't valid markdown/code
957
+ if (content.startsWith('<') && !content.startsWith('<http') && !content.startsWith('<!')) {
958
+ return true;
916
959
  }
917
- const width = Math.max(24, this.safeWidth() - 4);
918
- const wrapped = this.wrapText(content, width);
919
- const body = wrapped
920
- .map((line, index) => (index === 0 ? `${prefix} ${line}` : ` ${line}`))
921
- .join('\n');
922
- return `\n${body}\n`;
923
- }
924
- wrapText(content, width) {
925
- if (!content) {
926
- return [''];
960
+ // Structural check: contains "to=functions." or "to=tools." (internal routing)
961
+ if (content.includes('to=functions.') || content.includes('to=tools.')) {
962
+ return true;
927
963
  }
928
- const lines = [];
929
- const rawLines = content.split('\n');
930
- for (const rawLine of rawLines) {
931
- const words = rawLine.split(/(\s+)/);
932
- let current = '';
964
+ // Structural check: looks like internal instruction (quoted system text)
965
+ if (content.startsWith('"') && content.includes('block') && content.includes('tool')) {
966
+ return true;
967
+ }
968
+ // Structural check: very short content that's just timing info
969
+ if (content.length < 30 && /elapsed|seconds?|ms\b/i.test(content)) {
970
+ return true;
971
+ }
972
+ // Structural check: gibberish - high ratio of non-word characters
973
+ const alphaCount = (content.match(/[a-zA-Z]/g) || []).length;
974
+ const totalCount = content.replace(/\s/g, '').length;
975
+ if (totalCount > 20 && alphaCount / totalCount < 0.5) {
976
+ return true; // Less than 50% letters = likely garbage
977
+ }
978
+ return false;
979
+ }
980
+ /**
981
+ * Format text in Claude Code style: ⏺ prefix with wrapped continuation lines
982
+ * Example:
983
+ * ⏺ The AI ran tools but gave no response. Need to fix
984
+ * the response handling. Let me check where the AI's
985
+ * text response should be displayed:
986
+ */
987
+ formatClaudeCodeBlock(content) {
988
+ const bullet = '⏺';
989
+ const maxWidth = Math.min(this.cols - 4, 56); // Leave room for prefix and margins
990
+ const lines = content.split('\n');
991
+ const result = [];
992
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
993
+ const line = lines[lineIdx];
994
+ if (!line.trim()) {
995
+ result.push('');
996
+ continue;
997
+ }
998
+ // Word-wrap each line
999
+ const words = line.split(/(\s+)/);
1000
+ let currentLine = '';
933
1001
  for (const word of words) {
934
- if (!word)
935
- continue;
936
- const candidate = current ? `${current}${word}` : word;
937
- if (this.visibleLength(candidate) > width && current) {
938
- lines.push(current.trimEnd());
939
- current = word.trimStart();
1002
+ if ((currentLine + word).length > maxWidth && currentLine.trim()) {
1003
+ // First line of this paragraph gets ⏺, rest get indent
1004
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
1005
+ result.push(`${prefix}${currentLine.trimEnd()}`);
1006
+ currentLine = word.trimStart();
940
1007
  }
941
1008
  else {
942
- current = candidate;
1009
+ currentLine += word;
943
1010
  }
944
1011
  }
945
- lines.push(current.trimEnd());
1012
+ if (currentLine.trim()) {
1013
+ const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
1014
+ result.push(`${prefix}${currentLine.trimEnd()}`);
1015
+ }
946
1016
  }
947
- return lines;
1017
+ return result.join('\n') + '\n';
1018
+ }
1019
+ /**
1020
+ * Format a tool call in Claude Code style:
1021
+ * ⏺ Search(pattern: "foo", path: "src",
1022
+ * output_mode: "content", head_limit: 30)
1023
+ */
1024
+ formatToolCall(content) {
1025
+ const bullet = '⏺';
1026
+ // Parse tool name and arguments
1027
+ const match = content.match(/^(\w+)\((.*)\)$/s);
1028
+ if (!match) {
1029
+ // Simple format without args
1030
+ const nameMatch = content.match(/^(\w+)/);
1031
+ if (nameMatch) {
1032
+ return `${bullet} ${theme.info(nameMatch[1])}\n`;
1033
+ }
1034
+ return `${bullet} ${content}\n`;
1035
+ }
1036
+ const toolName = match[1];
1037
+ const argsStr = match[2];
1038
+ const maxWidth = Math.min(this.cols - 4, 56);
1039
+ // Format: ⏺ ToolName(args...)
1040
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
1041
+ const prefixLen = toolName.length + 3; // "⏺ ToolName(" visible length
1042
+ const indent = ' '.repeat(prefixLen + 4); // Extra indent for wrapped args
1043
+ // Parse and format arguments
1044
+ const args = this.parseToolArgs(argsStr);
1045
+ if (args.length === 0) {
1046
+ return `${prefix})\n`;
1047
+ }
1048
+ const lines = [];
1049
+ let currentLine = prefix;
1050
+ for (let i = 0; i < args.length; i++) {
1051
+ const arg = args[i];
1052
+ const argText = `${theme.ui.muted(arg.key + ':')} ${this.formatArgValue(arg.value)}`;
1053
+ const separator = i < args.length - 1 ? ', ' : ')';
1054
+ // Check if this arg fits on current line
1055
+ const testLine = currentLine + argText + separator;
1056
+ if (this.stripAnsi(testLine).length > maxWidth && currentLine !== prefix) {
1057
+ lines.push(currentLine.trimEnd());
1058
+ currentLine = indent + argText + separator;
1059
+ }
1060
+ else {
1061
+ currentLine += argText + separator;
1062
+ }
1063
+ }
1064
+ if (currentLine.trim()) {
1065
+ lines.push(currentLine.trimEnd());
1066
+ }
1067
+ return lines.join('\n') + '\n';
1068
+ }
1069
+ /**
1070
+ * Parse tool arguments from string like: key: "value", key2: value2
1071
+ */
1072
+ parseToolArgs(argsStr) {
1073
+ const args = [];
1074
+ // Simple regex to extract key: value pairs
1075
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\)]+)/g;
1076
+ let match;
1077
+ while ((match = regex.exec(argsStr)) !== null) {
1078
+ args.push({ key: match[1], value: match[2].trim() });
1079
+ }
1080
+ return args;
1081
+ }
1082
+ /**
1083
+ * Format an argument value (truncate long strings)
1084
+ */
1085
+ formatArgValue(value) {
1086
+ // Remove surrounding quotes if present
1087
+ const isQuoted = value.startsWith('"') && value.endsWith('"');
1088
+ const inner = isQuoted ? value.slice(1, -1) : value;
1089
+ // Truncate long values
1090
+ const maxLen = 40;
1091
+ const truncated = inner.length > maxLen ? inner.slice(0, maxLen - 3) + '...' : inner;
1092
+ return isQuoted ? `"${truncated}"` : truncated;
1093
+ }
1094
+ /**
1095
+ * Format a tool result in Claude Code style:
1096
+ * ⎿ Found 12 lines (ctrl+o to expand)
1097
+ */
1098
+ formatToolResult(content) {
1099
+ // Check if this is a summary line (e.g., "Found X lines")
1100
+ const summaryMatch = content.match(/^(Found \d+ (?:lines?|files?|matches?)|Read \d+ lines?|Wrote \d+ lines?|Edited|Created|Deleted)/i);
1101
+ if (summaryMatch) {
1102
+ return ` ${theme.ui.muted('⎿')} ${content} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
1103
+ }
1104
+ // For other results, show truncated preview
1105
+ const lines = content.split('\n');
1106
+ if (lines.length > 3) {
1107
+ const preview = lines.slice(0, 2).join('\n');
1108
+ return ` ${theme.ui.muted('⎿')} ${preview}\n ${theme.ui.muted(`... ${lines.length - 2} more lines (ctrl+o to expand)`)}\n`;
1109
+ }
1110
+ return ` ${theme.ui.muted('⎿')} ${content}\n`;
1111
+ }
1112
+ /**
1113
+ * Format a compact tool call: ⏺ Read → file.ts
1114
+ */
1115
+ formatCompactToolCall(content) {
1116
+ const bullet = '⏺';
1117
+ // Parse tool name and args
1118
+ const match = content.match(/^(\w+)\s*(?:\((.*)\))?$/s);
1119
+ if (!match) {
1120
+ return `${bullet} ${content}\n`;
1121
+ }
1122
+ const toolName = match[1];
1123
+ const argsStr = match[2]?.trim() || '';
1124
+ // If no args, just show tool name
1125
+ if (!argsStr) {
1126
+ return `${bullet} ${theme.info(toolName)}\n`;
1127
+ }
1128
+ // Format full params in Claude Code style with line wrapping
1129
+ // For long args, wrap them nicely with continuation indent
1130
+ const prefix = `${bullet} ${theme.info(toolName)}(`;
1131
+ const suffix = ')';
1132
+ const maxWidth = this.cols - 8; // Leave room for margins
1133
+ // Parse individual params
1134
+ const params = this.parseToolParams(argsStr);
1135
+ if (params.length === 0) {
1136
+ return `${prefix}${argsStr}${suffix}\n`;
1137
+ }
1138
+ // Format params with proper wrapping
1139
+ return this.formatToolParams(toolName, params, maxWidth);
1140
+ }
1141
+ /**
1142
+ * Parse tool params from args string
1143
+ */
1144
+ parseToolParams(argsStr) {
1145
+ const params = [];
1146
+ // Match key: "value" or key: value patterns
1147
+ const regex = /(\w+):\s*("(?:[^"\\]|\\.)*"|[^,\n]+)/g;
1148
+ let match;
1149
+ while ((match = regex.exec(argsStr)) !== null) {
1150
+ params.push({ key: match[1], value: match[2].trim() });
1151
+ }
1152
+ return params;
1153
+ }
1154
+ /**
1155
+ * Format tool params in Claude Code style with wrapping
1156
+ */
1157
+ formatToolParams(toolName, params, maxWidth) {
1158
+ const bullet = '⏺';
1159
+ const lines = [];
1160
+ const indent = ' '; // 8 spaces for continuation
1161
+ let currentLine = `${bullet} ${theme.info(toolName)}(`;
1162
+ let firstParam = true;
1163
+ for (const param of params) {
1164
+ const paramStr = firstParam
1165
+ ? `${param.key}: ${param.value}`
1166
+ : `, ${param.key}: ${param.value}`;
1167
+ // Check if adding this param would exceed width
1168
+ const testLine = currentLine + paramStr;
1169
+ const plainLength = testLine.replace(/\x1b\[[0-9;]*m/g, '').length;
1170
+ if (plainLength > maxWidth && !firstParam) {
1171
+ // Start new line
1172
+ lines.push(currentLine);
1173
+ currentLine = indent + `${param.key}: ${param.value}`;
1174
+ }
1175
+ else {
1176
+ currentLine += paramStr;
1177
+ }
1178
+ firstParam = false;
1179
+ }
1180
+ currentLine += ')';
1181
+ lines.push(currentLine);
1182
+ return lines.join('\n') + '\n';
1183
+ }
1184
+ /**
1185
+ * Extract a short summary from tool args
1186
+ */
1187
+ extractToolSummary(toolName, argsStr) {
1188
+ const tool = toolName.toLowerCase();
1189
+ // Extract path/file for file operations
1190
+ if (['read', 'write', 'edit', 'glob', 'grep', 'search'].includes(tool)) {
1191
+ const pathMatch = argsStr.match(/(?:path|file_path|pattern):\s*"([^"]+)"/);
1192
+ if (pathMatch) {
1193
+ const path = pathMatch[1];
1194
+ // Shorten long paths
1195
+ const short = path.length > 30 ? '…' + path.slice(-28) : path;
1196
+ return theme.ui.muted(short);
1197
+ }
1198
+ }
1199
+ // Extract command for bash
1200
+ if (tool === 'bash') {
1201
+ const cmdMatch = argsStr.match(/command:\s*"([^"]+)"/);
1202
+ if (cmdMatch) {
1203
+ const cmd = cmdMatch[1];
1204
+ const short = cmd.length > 40 ? cmd.slice(0, 37) + '…' : cmd;
1205
+ return theme.ui.muted(short);
1206
+ }
1207
+ }
1208
+ return null;
1209
+ }
1210
+ /**
1211
+ * Format a compact tool result: ⎿ Found X lines (ctrl+o to expand)
1212
+ */
1213
+ formatCompactToolResult(content) {
1214
+ // Parse common result patterns for summary
1215
+ const lineMatch = content.match(/(\d+)\s*lines?/i);
1216
+ const fileMatch = content.match(/(\d+)\s*(?:files?|matches?)/i);
1217
+ const readMatch = content.match(/read.*?(\d+)\s*lines?/i);
1218
+ let summary;
1219
+ if (readMatch) {
1220
+ summary = `Read ${readMatch[1]} lines`;
1221
+ }
1222
+ else if (lineMatch) {
1223
+ summary = `Found ${lineMatch[1]} line${lineMatch[1] === '1' ? '' : 's'}`;
1224
+ }
1225
+ else if (fileMatch) {
1226
+ summary = `Found ${fileMatch[1]} file${fileMatch[1] === '1' ? '' : 's'}`;
1227
+ }
1228
+ else if (content.match(/^(success|ok|done|completed|written|edited|created)/i)) {
1229
+ summary = '✓';
1230
+ }
1231
+ else {
1232
+ // Use content directly, truncated if needed
1233
+ summary = content.length > 40 ? content.slice(0, 37) + '…' : content;
1234
+ }
1235
+ return ` ${theme.ui.muted('⎿')} ${summary} ${theme.ui.muted('(ctrl+o to expand)')}\n`;
1236
+ }
1237
+ /**
1238
+ * Format a compact response with bullet on first line
1239
+ */
1240
+ formatCompactResponse(content) {
1241
+ const bullet = '⏺';
1242
+ const trimmed = content.trim();
1243
+ if (!trimmed)
1244
+ return '';
1245
+ // Single line responses - bullet prefix
1246
+ if (!trimmed.includes('\n') && trimmed.length < 80) {
1247
+ return `${bullet} ${trimmed}\n`;
1248
+ }
1249
+ // Multi-line: bullet on first, indent continuation
1250
+ const lines = trimmed.split('\n');
1251
+ const result = [];
1252
+ for (let i = 0; i < lines.length; i++) {
1253
+ const line = lines[i].trimEnd();
1254
+ if (!line) {
1255
+ result.push('');
1256
+ }
1257
+ else if (i === 0) {
1258
+ result.push(`${bullet} ${line}`);
1259
+ }
1260
+ else {
1261
+ result.push(` ${line}`);
1262
+ }
1263
+ }
1264
+ return result.join('\n') + '\n';
1265
+ }
1266
+ /**
1267
+ * Format streaming elapsed time in Claude Code style: 3m 30s
1268
+ */
1269
+ formatStreamingElapsed() {
1270
+ if (!this.streamingStartTime)
1271
+ return null;
1272
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1273
+ if (elapsed < 5)
1274
+ return null; // Don't show for very short durations
1275
+ const mins = Math.floor(elapsed / 60);
1276
+ const secs = elapsed % 60;
1277
+ if (mins > 0) {
1278
+ return `${mins}m ${secs}s`;
1279
+ }
1280
+ return `${secs}s`;
948
1281
  }
949
1282
  /**
950
1283
  * Format a compact conversation block (Claude Code style)
@@ -981,26 +1314,81 @@ export class UnifiedUIRenderer extends EventEmitter {
981
1314
  setMode(mode) {
982
1315
  const wasStreaming = this.mode === 'streaming';
983
1316
  this.mode = mode;
1317
+ // Track streaming start time for elapsed display
1318
+ if (mode === 'streaming' && !wasStreaming) {
1319
+ this.streamingStartTime = Date.now();
1320
+ this.streamingTokens = 0; // Reset token count
1321
+ this.startSpinnerAnimation();
1322
+ }
1323
+ else if (mode === 'idle' && wasStreaming) {
1324
+ this.streamingStartTime = null;
1325
+ this.stopSpinnerAnimation();
1326
+ }
984
1327
  if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
985
1328
  // Finish streaming on a fresh line so the next prompt/event doesn't collide
986
1329
  this.write('\n');
987
1330
  this.lastOutputEndedWithNewline = true;
988
1331
  }
989
- if (mode === 'streaming') {
990
- if (!this.streamingStart) {
991
- this.streamingStart = Date.now();
992
- }
993
- this.startActivityTimer();
1332
+ if (!this.plainMode) {
1333
+ // Always render prompt to keep bottom UI persistent (rich mode only)
1334
+ this.renderPrompt();
994
1335
  }
995
- else {
996
- this.streamingStart = null;
997
- this.stopActivityTimer();
1336
+ }
1337
+ /**
1338
+ * Start the animated spinner for streaming status
1339
+ */
1340
+ startSpinnerAnimation() {
1341
+ if (this.spinnerInterval)
1342
+ return; // Already running
1343
+ this.spinnerFrame = 0;
1344
+ this.activityStarFrame = 0;
1345
+ this.spinnerInterval = setInterval(() => {
1346
+ this.spinnerFrame = (this.spinnerFrame + 1) % spinnerFrames.braille.length;
1347
+ this.activityStarFrame = (this.activityStarFrame + 1) % this.activityStarFrames.length;
1348
+ // Re-render to show updated spinner/star frame
1349
+ if (!this.plainMode && this.mode === 'streaming') {
1350
+ this.renderPrompt();
1351
+ }
1352
+ }, 80); // ~12 FPS for smooth spinner animation
1353
+ }
1354
+ /**
1355
+ * Stop the animated spinner
1356
+ */
1357
+ stopSpinnerAnimation() {
1358
+ if (this.spinnerInterval) {
1359
+ clearInterval(this.spinnerInterval);
1360
+ this.spinnerInterval = null;
998
1361
  }
1362
+ this.spinnerFrame = 0;
1363
+ this.activityStarFrame = 0;
1364
+ this.activityMessage = null;
1365
+ }
1366
+ /**
1367
+ * Set the activity message displayed with animated star
1368
+ * Example: "Ruminating…" shows as "✳ Ruminating… (esc to interrupt · 34s · ↑1.2k)"
1369
+ */
1370
+ setActivity(message) {
1371
+ this.activityMessage = message;
999
1372
  if (!this.plainMode) {
1000
- // Always render prompt to keep bottom UI persistent (rich mode only)
1001
1373
  this.renderPrompt();
1002
1374
  }
1003
1375
  }
1376
+ /**
1377
+ * Update the token count displayed in the activity line
1378
+ */
1379
+ updateStreamingTokens(tokens) {
1380
+ this.streamingTokens = tokens;
1381
+ }
1382
+ /**
1383
+ * Format token count as compact string (e.g., 1.2k, 24k, 128k)
1384
+ */
1385
+ formatTokenCount(tokens) {
1386
+ if (tokens < 1000)
1387
+ return String(tokens);
1388
+ if (tokens < 10000)
1389
+ return `${(tokens / 1000).toFixed(1)}k`;
1390
+ return `${Math.round(tokens / 1000)}k`;
1391
+ }
1004
1392
  getMode() {
1005
1393
  return this.mode;
1006
1394
  }
@@ -1039,15 +1427,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1039
1427
  }
1040
1428
  }
1041
1429
  updateStatusMeta(meta, options = {}) {
1042
- const next = { ...this.statusMeta };
1043
- for (const [key, value] of Object.entries(meta)) {
1044
- if (value === null || value === undefined) {
1045
- delete next[key];
1046
- }
1047
- else {
1048
- next[key] = value;
1049
- }
1050
- }
1430
+ const next = { ...this.statusMeta, ...meta };
1051
1431
  const changed = JSON.stringify(next) !== JSON.stringify(this.statusMeta);
1052
1432
  this.statusMeta = next;
1053
1433
  const shouldRender = options.render !== false && changed;
@@ -1057,8 +1437,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1057
1437
  }
1058
1438
  updateModeToggles(state) {
1059
1439
  this.toggleState = { ...this.toggleState, ...state };
1060
- if (!state.autoContinueHotkey &&
1061
- !state.verificationHotkey &&
1440
+ if (!state.verificationHotkey &&
1062
1441
  !state.thinkingHotkey &&
1063
1442
  !state.criticalApprovalHotkey) {
1064
1443
  this.hotkeysInToggleLine.clear();
@@ -1081,51 +1460,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1081
1460
  this.inlinePanel = [];
1082
1461
  this.renderPrompt();
1083
1462
  }
1084
- setPersistentPanel(lines) {
1085
- const normalized = (lines ?? [])
1086
- .map(line => line.replace(/\s+$/g, ''))
1087
- .filter(line => line.trim().length > 0);
1088
- if (JSON.stringify(normalized) === JSON.stringify(this.persistentPanel)) {
1089
- return;
1090
- }
1091
- this.persistentPanel = normalized;
1092
- this.renderPrompt();
1093
- }
1094
- clearPersistentPanel() {
1095
- if (!this.persistentPanel.length)
1096
- return;
1097
- this.persistentPanel = [];
1098
- this.renderPrompt();
1099
- }
1100
- startActivityTimer() {
1101
- if (this.activityInterval || this.plainMode || !this.interactive) {
1102
- return;
1103
- }
1104
- this.activityInterval = setInterval(() => {
1105
- if (this.allowPromptRender) {
1106
- this.renderPrompt();
1107
- }
1108
- }, 120);
1109
- }
1110
- stopActivityTimer() {
1111
- if (this.activityInterval) {
1112
- clearInterval(this.activityInterval);
1113
- this.activityInterval = null;
1114
- }
1115
- }
1116
- rememberToolResult(content, summary) {
1117
- const normalized = content?.trim();
1118
- if (!normalized)
1119
- return;
1120
- this.lastToolResult = { full: normalized, summary: summary?.trim() || undefined };
1121
- }
1122
- expandLastToolResult() {
1123
- if (!this.lastToolResult) {
1124
- return false;
1125
- }
1126
- this.addEvent('tool-result', this.lastToolResult.full);
1127
- return true;
1128
- }
1129
1463
  // ------------ Prompt rendering ------------
1130
1464
  renderPrompt() {
1131
1465
  if (!this.interactive) {
@@ -1139,6 +1473,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1139
1473
  this.lastOutputEndedWithNewline = true;
1140
1474
  }
1141
1475
  this.write(`\r${ESC.CLEAR_LINE}${line}`);
1476
+ this.cursorVisibleColumn = line.length + 1;
1142
1477
  this.hasRenderedPrompt = true;
1143
1478
  this.isPromptActive = true;
1144
1479
  this.lastOutputEndedWithNewline = false; // prompt ends mid-line by design
@@ -1148,23 +1483,9 @@ export class UnifiedUIRenderer extends EventEmitter {
1148
1483
  if (!this.allowPromptRender) {
1149
1484
  return;
1150
1485
  }
1151
- if (typeof this.statusMeta.contextPercent === 'number') {
1152
- this.renderedContextPercent =
1153
- this.renderedContextPercent === null
1154
- ? this.statusMeta.contextPercent
1155
- : this.renderedContextPercent +
1156
- (this.statusMeta.contextPercent - this.renderedContextPercent) * 0.35;
1157
- }
1158
- else {
1159
- this.renderedContextPercent = null;
1160
- }
1161
- // Rich mode: inline overlay anchored to current scrollback (no full-screen clear)
1486
+ // Rich inline mode: prompt flows naturally with content
1162
1487
  this.updateTerminalSize();
1163
1488
  const maxWidth = this.safeWidth();
1164
- if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
1165
- // Terminal resized; force a clean anchor so the overlay doesn't jitter.
1166
- this.overlayInvalidated = true;
1167
- }
1168
1489
  this.lastRenderWidth = maxWidth;
1169
1490
  const overlay = this.buildOverlayLines();
1170
1491
  if (!overlay.lines.length) {
@@ -1174,99 +1495,188 @@ export class UnifiedUIRenderer extends EventEmitter {
1174
1495
  if (!renderedLines.length) {
1175
1496
  return;
1176
1497
  }
1177
- let promptIndex = Math.max(0, Math.min(overlay.cursorRow, renderedLines.length - 1));
1178
- let height = renderedLines.length;
1179
- // Keep at least one free line below the overlay so typing always has breathing room
1180
- const bottomPadding = this.overlayBottomPadding;
1181
- const totalRows = this.rows || 24;
1182
- const availableRows = Math.max(1, totalRows - bottomPadding);
1183
- if (height > availableRows) {
1184
- renderedLines.splice(availableRows);
1185
- height = renderedLines.length;
1186
- promptIndex = Math.max(0, Math.min(promptIndex, height - 1));
1187
- }
1188
- const startRow = Math.max(1, availableRows - height + 1);
1189
- const promptRow = startRow + promptIndex;
1190
- const promptCol = Math.min(Math.max(1, overlay.cursorCol), this.cols || 80);
1191
- // Clear any previous overlay footprint (status, prompt, controls) to avoid leaking into scrollback
1192
- this.clearOverlayRows(height, startRow);
1193
- if (bottomPadding > 0 && startRow + height <= totalRows) {
1194
- this.write(ESC.TO(startRow + height, 1));
1195
- this.write(ESC.CLEAR_LINE);
1498
+ const promptIndex = Math.max(0, Math.min(overlay.promptIndex, renderedLines.length - 1));
1499
+ const height = renderedLines.length;
1500
+ // Clear previous prompt and handle height changes
1501
+ if (this.hasEverRenderedOverlay && this.lastOverlayHeight > 0 && this.lastOverlay) {
1502
+ // Move up from prompt row to top of overlay
1503
+ const linesToTop = this.lastOverlay.promptIndex;
1504
+ if (linesToTop > 0) {
1505
+ this.write(`\x1b[${linesToTop}A`);
1506
+ }
1507
+ // Clear all previous lines
1508
+ for (let i = 0; i < this.lastOverlayHeight; i++) {
1509
+ this.write('\r');
1510
+ this.write(ESC.CLEAR_LINE);
1511
+ if (i < this.lastOverlayHeight - 1) {
1512
+ this.write('\x1b[B');
1513
+ }
1514
+ }
1515
+ // If new height is greater, we need to add blank lines
1516
+ const extraLines = height - this.lastOverlayHeight;
1517
+ if (extraLines > 0) {
1518
+ for (let i = 0; i < extraLines; i++) {
1519
+ this.write('\n');
1520
+ }
1521
+ }
1522
+ // Move back to top of where overlay should start
1523
+ const moveBackUp = Math.max(0, height - 1);
1524
+ if (moveBackUp > 0) {
1525
+ this.write(`\x1b[${moveBackUp}A`);
1526
+ }
1196
1527
  }
1197
- // Render overlay lines in place without pushing scrollback
1198
- for (let idx = 0; idx < height; idx++) {
1199
- const row = startRow + idx;
1200
- const line = renderedLines[idx] ?? '';
1201
- this.write(ESC.TO(row, 1));
1528
+ // Write prompt lines (no trailing newline on last line)
1529
+ for (let i = 0; i < renderedLines.length; i++) {
1530
+ this.write('\r');
1202
1531
  this.write(ESC.CLEAR_LINE);
1203
- if (line) {
1204
- this.write(line);
1532
+ this.write(renderedLines[i] || '');
1533
+ if (i < renderedLines.length - 1) {
1534
+ this.write('\n');
1205
1535
  }
1206
1536
  }
1207
- // Position cursor at prompt row/col
1208
- this.write(ESC.TO(promptRow, promptCol));
1537
+ // Position cursor at prompt input line
1538
+ const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols || 80);
1539
+ // Cursor is now at the last line. Move up to the prompt row.
1540
+ const linesToMoveUp = height - 1 - promptIndex;
1541
+ if (linesToMoveUp > 0) {
1542
+ this.write(`\x1b[${linesToMoveUp}A`);
1543
+ }
1544
+ this.write(`\x1b[${promptCol}G`);
1545
+ this.cursorVisibleColumn = promptCol;
1209
1546
  this.hasRenderedPrompt = true;
1210
- this.hasEverRenderedOverlay = true; // Mark that we've rendered at least once
1547
+ this.hasEverRenderedOverlay = true;
1211
1548
  this.isPromptActive = true;
1212
1549
  this.lastOverlayHeight = height;
1213
- this.lastPromptIndex = promptIndex;
1214
1550
  this.lastOverlay = { lines: renderedLines, promptIndex };
1215
- this.overlayInvalidated = false;
1216
- this.lastOutputEndedWithNewline = true;
1551
+ this.lastOutputEndedWithNewline = false;
1217
1552
  this.promptHeight = height;
1218
1553
  }
1219
1554
  buildOverlayLines() {
1220
1555
  const lines = [];
1221
1556
  const maxWidth = this.safeWidth();
1222
- const activity = this.buildActivityLine(maxWidth);
1223
- if (activity) {
1224
- lines.push(activity);
1225
- }
1226
- lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96)), maxWidth));
1227
- const inputOverlay = this.buildInputOverlay(maxWidth);
1228
- const promptRow = lines.length + inputOverlay.cursorRow;
1229
- const promptCol = inputOverlay.cursorCol;
1230
- for (const line of inputOverlay.lines) {
1557
+ // Simple horizontal divider - clean and reliable
1558
+ const divider = theme.ui.muted('─'.repeat(Math.min(maxWidth, 56)));
1559
+ // Activity line (only when streaming) - shows: ✽ Moseying… (esc to interrupt · 34s)
1560
+ if (this.mode === 'streaming' && this.activityMessage) {
1561
+ // Animated sparkle
1562
+ const spinnerChars = ['✽', '✾', '✿', '❀', '❁', '❂', '❃', '✻'];
1563
+ const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '✽';
1564
+ const elapsed = this.formatStreamingElapsed();
1565
+ // Use fun phrases for generic activity, otherwise show specific activity
1566
+ const genericActivities = ['Streaming', 'Thinking', 'Processing'];
1567
+ const displayActivity = genericActivities.includes(this.activityMessage)
1568
+ ? this.funActivityPhrases[this.activityPhraseIndex % this.funActivityPhrases.length]
1569
+ : this.activityMessage;
1570
+ // Format: ✽ Moseying… (esc to interrupt · 1m 19s · ↑1.2k tokens)
1571
+ const parts = ['esc to interrupt'];
1572
+ if (elapsed)
1573
+ parts.push(elapsed);
1574
+ if (this.streamingTokens > 0) {
1575
+ parts.push(`↑${this.formatTokenCount(this.streamingTokens)} tokens`);
1576
+ }
1577
+ const activityLine = `${theme.info(spinnerChar)} ${displayActivity}… ${theme.ui.muted(`(${parts.join(' · ')})`)}`;
1578
+ lines.push(this.truncateLine(activityLine, maxWidth));
1579
+ }
1580
+ // Top divider
1581
+ lines.push(divider);
1582
+ // Input prompt line
1583
+ const promptIndex = lines.length;
1584
+ const inputLine = this.buildInputLine();
1585
+ // Handle multi-line input by splitting on newlines
1586
+ const inputLines = inputLine.split('\n');
1587
+ for (const line of inputLines) {
1231
1588
  lines.push(this.truncateLine(line, maxWidth));
1232
1589
  }
1233
- lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96)), maxWidth));
1590
+ // Bottom divider
1591
+ lines.push(divider);
1592
+ // Inline panel (pinned scroll box for live output/menus)
1234
1593
  if (this.inlinePanel.length > 0) {
1235
1594
  for (const panelLine of this.inlinePanel) {
1236
- lines.push(this.truncateLine(panelLine, maxWidth));
1237
- }
1238
- }
1239
- if (this.persistentPanel.length > 0) {
1240
- for (const panelLine of this.persistentPanel) {
1241
- lines.push(this.truncateLine(panelLine, maxWidth));
1595
+ lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
1242
1596
  }
1597
+ // Separate inline content from suggestions/toggles
1598
+ lines.push(divider);
1243
1599
  }
1600
+ // Slash command suggestions
1244
1601
  if (this.suggestions.length > 0) {
1245
1602
  for (let index = 0; index < this.suggestions.length; index++) {
1246
1603
  const suggestion = this.suggestions[index];
1247
1604
  const isActive = index === this.suggestionIndex;
1248
- const marker = isActive ? theme.primary('') : theme.ui.muted('');
1605
+ const marker = isActive ? theme.primary('') : theme.ui.muted(' ');
1249
1606
  const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
1250
1607
  const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
1251
- lines.push(this.truncateLine(`${marker} ${cmdText} — ${descText}`, maxWidth));
1608
+ lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
1252
1609
  }
1253
1610
  }
1254
- const modelLine = this.buildModelLine(maxWidth);
1255
- if (modelLine) {
1256
- lines.push(modelLine);
1611
+ // Model and context info
1612
+ const modelContextLine = this.buildModelContextLine();
1613
+ if (modelContextLine) {
1614
+ lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
1257
1615
  }
1258
- const toggleLine = this.buildToggleLine();
1616
+ // Mode toggles
1617
+ const toggleLine = this.buildInlineToggleLine();
1259
1618
  if (toggleLine) {
1260
- lines.push(toggleLine);
1619
+ lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
1261
1620
  }
1262
- const shortcutLine = this.buildShortcutLine();
1263
- if (shortcutLine) {
1264
- lines.push(shortcutLine);
1621
+ // Help hint
1622
+ lines.push(this.truncateLine(` ${theme.ui.muted('? for shortcuts')}`, maxWidth));
1623
+ return { lines, promptIndex };
1624
+ }
1625
+ /**
1626
+ * Build model name and context usage line with mini progress bar
1627
+ * Format: gpt-4 · ████░░ 85% context
1628
+ */
1629
+ buildModelContextLine() {
1630
+ const parts = [];
1631
+ // Model name (provider / model or just model)
1632
+ const model = this.statusMeta.provider && this.statusMeta.model
1633
+ ? `${this.statusMeta.provider} · ${this.statusMeta.model}`
1634
+ : this.statusMeta.model || this.statusMeta.provider;
1635
+ if (model) {
1636
+ parts.push(theme.info(model));
1265
1637
  }
1266
- else {
1267
- lines.push(`${theme.ui.muted('?')} shortcuts`);
1638
+ // Context meter with mini progress bar
1639
+ if (this.statusMeta.contextPercent !== undefined) {
1640
+ const remaining = Math.max(0, 100 - this.statusMeta.contextPercent);
1641
+ const barWidth = 6;
1642
+ const filled = Math.round((remaining / 100) * barWidth);
1643
+ const empty = barWidth - filled;
1644
+ const barColor = remaining > 50 ? theme.success : remaining > 20 ? theme.warning : theme.error;
1645
+ const bar = barColor('█'.repeat(filled)) + theme.ui.muted('░'.repeat(empty));
1646
+ parts.push(`${bar} ${barColor(`${remaining}%`)} ${theme.ui.muted('ctx')}`);
1268
1647
  }
1269
- return { lines, cursorRow: promptRow, cursorCol: promptCol };
1648
+ return parts.length > 0 ? parts.join(theme.ui.muted(' · ')) : null;
1649
+ }
1650
+ /**
1651
+ * Build inline toggle controls - Claude Code style
1652
+ * Format: ⏵⏵ accept edits on (shift+tab to cycle)
1653
+ */
1654
+ buildInlineToggleLine() {
1655
+ const parts = [];
1656
+ // Edit acceptance mode - Claude Code style with ⏵⏵
1657
+ const editIcon = '⏵⏵';
1658
+ const editState = this.toggleState.verificationEnabled ? 'verify edits' : 'accept edits';
1659
+ const editStatus = this.toggleState.verificationEnabled ? theme.warning('on') : theme.success('on');
1660
+ parts.push(`${theme.ui.muted(editIcon)} ${editState} ${editStatus}`);
1661
+ // Thinking mode (if not default)
1662
+ const thinkingLabel = (this.toggleState.thinkingModeLabel || 'balanced').trim().toLowerCase();
1663
+ if (thinkingLabel === 'extended') {
1664
+ parts.push(`${theme.ui.muted('thinking')} ${theme.info('extended')}`);
1665
+ }
1666
+ // Approval mode (if not auto)
1667
+ const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1668
+ if (approvalMode === 'approval') {
1669
+ parts.push(`${theme.ui.muted('approvals')} ${theme.warning('ask')}`);
1670
+ }
1671
+ // Cycle hint
1672
+ const cycleHint = theme.ui.muted('(shift+tab to cycle)');
1673
+ return parts.length > 0 ? `${parts.join(theme.ui.muted(' · '))} ${cycleHint}` : null;
1674
+ }
1675
+ buildChromeLines() {
1676
+ const maxWidth = this.safeWidth();
1677
+ const statusLines = this.buildStatusBlock(maxWidth);
1678
+ const metaLines = this.buildMetaBlock(maxWidth);
1679
+ return [...statusLines, ...metaLines];
1270
1680
  }
1271
1681
  abbreviatePath(pathValue) {
1272
1682
  const home = homedir();
@@ -1275,57 +1685,123 @@ export class UnifiedUIRenderer extends EventEmitter {
1275
1685
  }
1276
1686
  return pathValue;
1277
1687
  }
1278
- buildActivityLine(maxWidth) {
1279
- const base = this.statusStreaming || this.statusOverride || this.statusMessage || 'Ready';
1280
- const spinner = this.mode === 'streaming' ? colorizeActivity(this.activityTicker()) : theme.ui.muted('∙');
1281
- const elapsed = this.mode === 'streaming' ? formatElapsed(this.streamingStart) : null;
1282
- const tokenText = this.mode === 'streaming' ? formatTokenDelta(this.statusMeta.tokensUsed) : null;
1283
- const parts = [
1284
- `${spinner} ${base}`,
1285
- elapsed ? theme.ui.muted(elapsed) : null,
1286
- tokenText ? theme.ui.muted(tokenText) : null,
1287
- ].filter(Boolean);
1288
- if (!parts.length) {
1289
- return null;
1688
+ buildStatusBlock(maxWidth) {
1689
+ const statusLabel = this.composeStatusLabel();
1690
+ if (!statusLabel) {
1691
+ return [];
1290
1692
  }
1291
- return this.truncateLine(parts.join(theme.ui.muted(' • ')), maxWidth);
1693
+ const segments = [];
1694
+ // Add animated spinner when streaming for dynamic visual feedback
1695
+ if (this.mode === 'streaming') {
1696
+ const spinnerChars = spinnerFrames.braille;
1697
+ const spinnerChar = spinnerChars[this.spinnerFrame % spinnerChars.length] ?? '⠋';
1698
+ segments.push(`${theme.info(spinnerChar)} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
1699
+ }
1700
+ else {
1701
+ segments.push(`${theme.ui.muted('status')} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
1702
+ }
1703
+ if (this.statusMeta.sessionTime) {
1704
+ segments.push(`${theme.ui.muted('runtime')} ${theme.ui.muted(this.statusMeta.sessionTime)}`);
1705
+ }
1706
+ if (this.statusMeta.contextPercent !== undefined) {
1707
+ // Use animated context meter for smooth color transitions
1708
+ this.contextMeter.update(this.statusMeta.contextPercent);
1709
+ segments.push(this.contextMeter.render());
1710
+ }
1711
+ return this.wrapSegments(segments, maxWidth);
1292
1712
  }
1293
- buildModelLine(maxWidth) {
1713
+ buildMetaBlock(maxWidth) {
1294
1714
  const segments = [];
1295
1715
  if (this.statusMeta.profile) {
1296
- segments.push(`${theme.ui.muted('profile')} ${theme.info(this.statusMeta.profile)}`);
1716
+ segments.push(this.formatMetaSegment('profile', this.statusMeta.profile, 'info'));
1297
1717
  }
1298
1718
  const model = this.statusMeta.provider && this.statusMeta.model
1299
1719
  ? `${this.statusMeta.provider} / ${this.statusMeta.model}`
1300
1720
  : this.statusMeta.model || this.statusMeta.provider;
1301
1721
  if (model) {
1302
- segments.push(`${theme.ui.muted('model')} ${theme.info(model)}`);
1722
+ segments.push(this.formatMetaSegment('model', model, 'info'));
1303
1723
  }
1304
1724
  const workspace = this.statusMeta.workspace || this.statusMeta.directory;
1305
1725
  if (workspace) {
1306
- segments.push(`${theme.ui.muted('dir')} ${theme.ui.muted(this.abbreviatePath(workspace))}`);
1726
+ segments.push(this.formatMetaSegment('dir', this.abbreviatePath(workspace), 'muted'));
1307
1727
  }
1308
1728
  if (this.statusMeta.writes) {
1309
- segments.push(`${theme.ui.muted('writes')} ${theme.ui.muted(this.statusMeta.writes)}`);
1729
+ segments.push(this.formatMetaSegment('writes', this.statusMeta.writes, 'muted'));
1310
1730
  }
1311
1731
  if (this.statusMeta.toolSummary) {
1312
- segments.push(`${theme.ui.muted('tools')} ${theme.ui.muted(this.statusMeta.toolSummary)}`);
1313
- }
1314
- if (this.statusMeta.version) {
1315
- segments.push(`${theme.ui.muted('v')} ${theme.ui.muted(this.statusMeta.version)}`);
1732
+ segments.push(this.formatMetaSegment('tools', this.statusMeta.toolSummary, 'muted'));
1316
1733
  }
1317
1734
  if (this.statusMeta.sessionLabel) {
1318
- segments.push(`${theme.ui.muted('session')} ${theme.ui.muted(this.statusMeta.sessionLabel)}`);
1735
+ segments.push(this.formatMetaSegment('session', this.statusMeta.sessionLabel, 'muted'));
1319
1736
  }
1320
- const ctxTarget = this.renderedContextPercent ?? this.statusMeta.contextPercent;
1321
- const contextBar = formatTinyProgressBar(ctxTarget, 10);
1322
- if (contextBar) {
1323
- segments.push(`${theme.ui.muted('ctx')} ${theme.ui.muted(contextBar)}`);
1737
+ if (this.statusMeta.version) {
1738
+ segments.push(this.formatMetaSegment('build', `v${this.statusMeta.version}`, 'muted'));
1324
1739
  }
1325
1740
  if (segments.length === 0) {
1741
+ return [];
1742
+ }
1743
+ return this.wrapSegments(segments, maxWidth);
1744
+ }
1745
+ composeStatusLabel() {
1746
+ const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1747
+ const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
1748
+ if (!text.trim()) {
1326
1749
  return null;
1327
1750
  }
1328
- return this.truncateLine(segments.join(theme.ui.muted(' • ')), maxWidth);
1751
+ const normalized = text.toLowerCase();
1752
+ const tone = normalized.includes('ready') ? 'success' : 'info';
1753
+ return { text, tone };
1754
+ }
1755
+ formatMetaSegment(label, value, tone) {
1756
+ const colorizer = tone === 'success'
1757
+ ? theme.success
1758
+ : tone === 'warn'
1759
+ ? theme.warning
1760
+ : tone === 'error'
1761
+ ? theme.error
1762
+ : tone === 'muted'
1763
+ ? theme.ui.muted
1764
+ : theme.info;
1765
+ return `${theme.ui.muted(label)} ${colorizer(value)}`;
1766
+ }
1767
+ applyTone(text, tone) {
1768
+ switch (tone) {
1769
+ case 'success':
1770
+ return theme.success(text);
1771
+ case 'warn':
1772
+ return theme.warning(text);
1773
+ case 'error':
1774
+ return theme.error(text);
1775
+ case 'info':
1776
+ default:
1777
+ return theme.info(text);
1778
+ }
1779
+ }
1780
+ wrapSegments(segments, maxWidth) {
1781
+ const lines = [];
1782
+ const separator = theme.ui.muted(' | ');
1783
+ let current = '';
1784
+ for (const segment of segments) {
1785
+ const normalized = segment.trim();
1786
+ if (!normalized)
1787
+ continue;
1788
+ if (!current) {
1789
+ current = this.truncateLine(normalized, maxWidth);
1790
+ continue;
1791
+ }
1792
+ const candidate = `${current}${separator}${normalized}`;
1793
+ if (this.visibleLength(candidate) <= maxWidth) {
1794
+ current = candidate;
1795
+ }
1796
+ else {
1797
+ lines.push(this.truncateLine(current, maxWidth));
1798
+ current = this.truncateLine(normalized, maxWidth);
1799
+ }
1800
+ }
1801
+ if (current) {
1802
+ lines.push(this.truncateLine(current, maxWidth));
1803
+ }
1804
+ return lines;
1329
1805
  }
1330
1806
  buildControlLines() {
1331
1807
  const lines = [];
@@ -1339,12 +1815,34 @@ export class UnifiedUIRenderer extends EventEmitter {
1339
1815
  }
1340
1816
  return lines;
1341
1817
  }
1818
+ /**
1819
+ * Build a compact toggle line like Claude Code:
1820
+ * "⏵⏵ accept edits on (shift+tab to cycle)"
1821
+ */
1822
+ buildCompactToggleLine() {
1823
+ // Show the most relevant mode based on current state
1824
+ const parts = [];
1825
+ // Edit mode indicator
1826
+ const editIcon = '⏵⏵';
1827
+ const editState = this.toggleState.verificationEnabled ? 'approval required' : 'accept edits';
1828
+ parts.push(`${theme.ui.muted(editIcon)} ${editState} ${theme.success('on')}`);
1829
+ // Thinking mode (if active)
1830
+ const thinkingLabel = (this.toggleState.thinkingModeLabel || '').trim().toLowerCase();
1831
+ if (thinkingLabel && thinkingLabel !== 'off') {
1832
+ parts.push(`${theme.ui.muted('thinking')} ${theme.info(thinkingLabel)}`);
1833
+ }
1834
+ // Cycle hint
1835
+ const cycleHint = theme.ui.muted('(shift+tab to cycle)');
1836
+ if (parts.length === 0) {
1837
+ return null;
1838
+ }
1839
+ return ` ${parts.join(theme.ui.muted(' · '))} ${cycleHint}`;
1840
+ }
1342
1841
  buildToggleLine() {
1343
1842
  const toggles = [];
1344
1843
  const addToggle = (label, on, hotkey, value) => {
1345
1844
  toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
1346
1845
  };
1347
- addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
1348
1846
  addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
1349
1847
  const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1350
1848
  const approvalActive = approvalMode !== 'auto';
@@ -1389,86 +1887,129 @@ export class UnifiedUIRenderer extends EventEmitter {
1389
1887
  addHotkey('interrupt', 'Ctrl+C');
1390
1888
  addHotkey('clear input', 'Ctrl+U');
1391
1889
  // Feature toggles (only if hotkeys are defined)
1392
- addHotkey('auto-run', this.toggleState.autoContinueHotkey);
1393
1890
  addHotkey('verify', this.toggleState.verificationHotkey);
1394
1891
  addHotkey('thinking', this.toggleState.thinkingHotkey);
1395
1892
  if (parts.length === 0) {
1396
1893
  return null;
1397
1894
  }
1398
- const body = parts.join(theme.ui.muted(' '));
1399
- return `${body}${theme.ui.muted(' ? shortcuts')}`;
1895
+ return parts.join(theme.ui.muted(' '));
1400
1896
  }
1401
- buildInputOverlay(maxWidth) {
1402
- const prompt = theme.primary('› ');
1403
- const promptWidth = this.visibleLength(prompt);
1404
- const usableWidth = Math.max(8, maxWidth - promptWidth);
1897
+ buildInputLine() {
1405
1898
  if (this.collapsedPaste) {
1406
1899
  const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
1407
- const line = `${prompt}${theme.ui.muted(summary)}`;
1408
- const cursorCol = Math.min(maxWidth, this.visibleLength(line) + 1);
1409
- return { lines: [this.truncateLine(line, maxWidth)], cursorRow: 0, cursorCol };
1900
+ return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
1410
1901
  }
1902
+ // Claude Code uses simple '>' prompt
1903
+ const prompt = theme.primary('> ');
1904
+ const promptWidth = this.visibleLength(prompt);
1905
+ const maxWidth = this.safeWidth();
1906
+ const continuationIndent = ' '; // 2 spaces for continuation lines
1907
+ const continuationWidth = continuationIndent.length;
1908
+ // Handle multi-line input - split buffer on newlines first
1411
1909
  const normalized = this.buffer.replace(/\r/g, '\n');
1412
- const cursorIndex = Math.min(this.cursor, normalized.length);
1413
- const rawLines = [];
1414
- let current = '';
1415
- let row = 0;
1416
- const limitForRow = (r) => (r === 0 ? usableWidth : usableWidth);
1417
- let cursorRow = 0;
1910
+ const bufferLines = normalized.split('\n');
1911
+ // Wrap each logical line to fit terminal width, expanding vertically
1912
+ const result = [];
1913
+ let totalChars = 0;
1914
+ let cursorLine = 0;
1418
1915
  let cursorCol = 0;
1419
- for (let idx = 0; idx <= normalized.length; idx += 1) {
1420
- if (idx === cursorIndex) {
1421
- cursorRow = row;
1422
- cursorCol = current.length;
1423
- }
1424
- if (idx === normalized.length) {
1425
- break;
1426
- }
1427
- const char = normalized[idx];
1428
- if (char === '\n') {
1429
- current += NEWLINE_PLACEHOLDER;
1430
- rawLines.push(current);
1431
- row += 1;
1432
- current = '';
1433
- continue;
1434
- }
1435
- const limit = limitForRow(row);
1436
- if (current.length >= limit) {
1437
- rawLines.push(current);
1438
- row += 1;
1439
- current = '';
1916
+ let foundCursor = false;
1917
+ for (let lineIndex = 0; lineIndex < bufferLines.length; lineIndex++) {
1918
+ const line = bufferLines[lineIndex] ?? '';
1919
+ const isFirstLogicalLine = lineIndex === 0;
1920
+ const lineStartChar = totalChars;
1921
+ // Determine available width for this line
1922
+ const firstLineWidth = maxWidth - promptWidth;
1923
+ const contLineWidth = maxWidth - continuationWidth;
1924
+ // Wrap this logical line into display lines
1925
+ let remaining = line;
1926
+ let isFirstDisplayLine = true;
1927
+ while (remaining.length > 0 || isFirstDisplayLine) {
1928
+ const availableWidth = (isFirstLogicalLine && isFirstDisplayLine) ? firstLineWidth : contLineWidth;
1929
+ const chunk = remaining.slice(0, availableWidth);
1930
+ remaining = remaining.slice(availableWidth);
1931
+ // Build the display line
1932
+ let displayLine;
1933
+ if (isFirstLogicalLine && isFirstDisplayLine) {
1934
+ displayLine = `${prompt}${chunk}`;
1935
+ }
1936
+ else {
1937
+ displayLine = `${continuationIndent}${chunk}`;
1938
+ }
1939
+ // Track cursor position
1940
+ if (!foundCursor) {
1941
+ const chunkStart = lineStartChar + (line.length - remaining.length - chunk.length);
1942
+ const chunkEnd = chunkStart + chunk.length;
1943
+ if (this.cursor >= chunkStart && this.cursor <= chunkEnd) {
1944
+ cursorLine = result.length;
1945
+ const offsetInChunk = this.cursor - chunkStart;
1946
+ cursorCol = ((isFirstLogicalLine && isFirstDisplayLine) ? promptWidth : continuationWidth) + offsetInChunk;
1947
+ foundCursor = true;
1948
+ }
1949
+ }
1950
+ result.push(displayLine);
1951
+ isFirstDisplayLine = false;
1952
+ // If nothing left and this was an empty line, we already added it
1953
+ if (remaining.length === 0 && chunk.length === 0)
1954
+ break;
1440
1955
  }
1441
- current += char;
1442
- }
1443
- if (current || rawLines.length === 0) {
1444
- rawLines.push(current);
1956
+ totalChars += line.length + 1; // +1 for the newline separator
1957
+ }
1958
+ // Handle cursor at very end
1959
+ if (!foundCursor) {
1960
+ cursorLine = Math.max(0, result.length - 1);
1961
+ const lastLine = result[cursorLine] ?? '';
1962
+ cursorCol = this.visibleLength(lastLine);
1963
+ }
1964
+ // Add cursor highlight to the appropriate position
1965
+ if (result.length > 0) {
1966
+ const targetLine = result[cursorLine] ?? '';
1967
+ const visiblePart = this.stripAnsi(targetLine);
1968
+ const cursorPos = Math.min(cursorCol, visiblePart.length);
1969
+ // Rebuild the line with cursor highlight
1970
+ const before = visiblePart.slice(0, cursorPos);
1971
+ const at = visiblePart.charAt(cursorPos) || ' ';
1972
+ const after = visiblePart.slice(cursorPos + 1);
1973
+ // Preserve the prompt/indent styling
1974
+ const prefix = cursorLine === 0 ? prompt : continuationIndent;
1975
+ const textPart = cursorLine === 0 ? before.slice(promptWidth) : before.slice(continuationWidth);
1976
+ result[cursorLine] = `${prefix}${textPart}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
1977
+ }
1978
+ // Store cursor column for terminal positioning
1979
+ this.cursorVisibleColumn = cursorCol + 1;
1980
+ return result.join('\n');
1981
+ }
1982
+ buildInputWindow(available) {
1983
+ if (available <= 0) {
1984
+ return { text: '', cursor: 0 };
1445
1985
  }
1446
- if (cursorIndex === normalized.length) {
1447
- cursorRow = rawLines.length - 1;
1448
- cursorCol = rawLines[cursorRow]?.length ?? 0;
1986
+ if (this.collapsedPaste) {
1987
+ return { text: '', cursor: 0 };
1449
1988
  }
1450
- const prefixPad = ' '.repeat(promptWidth);
1451
- const lines = [];
1452
- let cursorColumn = 1;
1453
- rawLines.forEach((line, index) => {
1454
- const prefix = index === 0 ? prompt : prefixPad;
1455
- const onCursorLine = index === cursorRow;
1456
- const text = line ?? '';
1457
- const col = Math.min(Math.max(cursorCol, 0), text.length);
1458
- let rendered = text;
1459
- if (onCursorLine) {
1460
- const before = rendered.slice(0, col);
1461
- const at = rendered.charAt(col) || ' ';
1462
- const after = rendered.slice(col + 1);
1463
- rendered = `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
1464
- cursorColumn = this.visibleLength(prefix) + col + 1;
1465
- }
1466
- lines.push(`${prefix}${rendered}`);
1467
- });
1989
+ const normalized = this.buffer.replace(/\r/g, '\n');
1990
+ const cursorIndex = Math.min(this.cursor, normalized.length);
1991
+ let offset = this.inputRenderOffset;
1992
+ if (cursorIndex < offset) {
1993
+ offset = cursorIndex;
1994
+ }
1995
+ const overflow = cursorIndex - offset - available + 1;
1996
+ if (overflow > 0) {
1997
+ offset += overflow;
1998
+ }
1999
+ const maxOffset = Math.max(0, normalized.length - available);
2000
+ if (offset > maxOffset) {
2001
+ offset = maxOffset;
2002
+ }
2003
+ this.inputRenderOffset = offset;
2004
+ const window = normalized.slice(offset, offset + available);
2005
+ const display = window.split('').map(char => (char === '\n' ? NEWLINE_PLACEHOLDER : char)).join('');
2006
+ const cursorInWindow = Math.min(display.length, Math.max(0, cursorIndex - offset));
2007
+ const before = display.slice(0, cursorInWindow);
2008
+ const at = display.charAt(cursorInWindow) || ' ';
2009
+ const after = display.slice(cursorInWindow + 1);
1468
2010
  return {
1469
- lines,
1470
- cursorRow,
1471
- cursorCol: cursorColumn,
2011
+ text: `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`,
2012
+ cursor: cursorInWindow,
1472
2013
  };
1473
2014
  }
1474
2015
  expandCollapsedPaste() {
@@ -1488,6 +2029,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1488
2029
  if (options.resetBuffer) {
1489
2030
  this.buffer = '';
1490
2031
  this.cursor = 0;
2032
+ this.inputRenderOffset = 0;
1491
2033
  this.resetSuggestions();
1492
2034
  this.renderPrompt();
1493
2035
  this.emitInputChange();
@@ -1566,17 +2108,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1566
2108
  }
1567
2109
  return result;
1568
2110
  }
1569
- clearOverlayRows(rows, startRow) {
1570
- const totalRows = this.rows || 24;
1571
- const limit = Math.max(0, Math.min(rows, totalRows));
1572
- for (let idx = 0; idx < limit; idx++) {
1573
- const row = startRow + idx;
1574
- if (row < 1 || row > totalRows)
1575
- continue;
1576
- this.write(ESC.TO(row, 1));
1577
- this.write(ESC.CLEAR_LINE);
1578
- }
1579
- }
1580
2111
  getBuffer() {
1581
2112
  return this.buffer;
1582
2113
  }
@@ -1586,6 +2117,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1586
2117
  setBuffer(text, cursorPos) {
1587
2118
  this.buffer = text;
1588
2119
  this.cursor = cursorPos ?? text.length;
2120
+ this.inputRenderOffset = 0;
1589
2121
  this.updateSuggestions();
1590
2122
  this.renderPrompt();
1591
2123
  this.emitInputChange();
@@ -1594,6 +2126,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1594
2126
  this.cancelPlainPasteCapture();
1595
2127
  this.buffer = '';
1596
2128
  this.cursor = 0;
2129
+ this.inputRenderOffset = 0;
1597
2130
  this.suggestions = [];
1598
2131
  this.suggestionIndex = -1;
1599
2132
  this.renderPrompt();
@@ -1602,6 +2135,35 @@ export class UnifiedUIRenderer extends EventEmitter {
1602
2135
  setModeStatus(status) {
1603
2136
  this.updateStatus(status);
1604
2137
  }
2138
+ /**
2139
+ * Show a compacting status with animated spinner (Claude Code style)
2140
+ * Uses ✻ character with animation to indicate context compaction in progress
2141
+ */
2142
+ showCompactingStatus(message) {
2143
+ this.statusMessage = message;
2144
+ if (!this.spinnerInterval) {
2145
+ this.spinnerInterval = setInterval(() => {
2146
+ this.spinnerFrame++;
2147
+ // Cycle activity phrase every ~4 seconds (50 frames at 80ms)
2148
+ if (this.spinnerFrame % 50 === 0) {
2149
+ this.activityPhraseIndex++;
2150
+ }
2151
+ this.renderPrompt();
2152
+ }, 80);
2153
+ }
2154
+ this.renderPrompt();
2155
+ }
2156
+ /**
2157
+ * Hide the compacting status and stop spinner animation
2158
+ */
2159
+ hideCompactingStatus() {
2160
+ if (this.spinnerInterval) {
2161
+ clearInterval(this.spinnerInterval);
2162
+ this.spinnerInterval = null;
2163
+ }
2164
+ this.statusMessage = null;
2165
+ this.renderPrompt();
2166
+ }
1605
2167
  emitPrompt(content) {
1606
2168
  this.pushPromptEvent(content);
1607
2169
  }
@@ -1645,23 +2207,30 @@ export class UnifiedUIRenderer extends EventEmitter {
1645
2207
  const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
1646
2208
  if (height === 0)
1647
2209
  return;
1648
- this.updateTerminalSize();
1649
- const totalRows = this.rows || 24;
1650
- const startRow = Math.max(1, Math.max(1, totalRows - this.overlayBottomPadding) - height + 1);
1651
- this.clearOverlayRows(height, startRow);
1652
- // Keep the padding row clean as well
1653
- const paddingRow = startRow + height;
1654
- if (this.overlayBottomPadding > 0 && paddingRow <= totalRows) {
1655
- this.write(ESC.TO(paddingRow, 1));
2210
+ // Cursor is at prompt row. Move up to top of overlay first.
2211
+ if (this.lastOverlay) {
2212
+ const linesToTop = this.lastOverlay.promptIndex;
2213
+ if (linesToTop > 0) {
2214
+ this.write(`\x1b[${linesToTop}A`);
2215
+ }
2216
+ }
2217
+ // Now at top, clear each line downward
2218
+ for (let i = 0; i < height; i++) {
2219
+ this.write('\r');
1656
2220
  this.write(ESC.CLEAR_LINE);
2221
+ if (i < height - 1) {
2222
+ this.write('\x1b[B');
2223
+ }
1657
2224
  }
1658
- // Move cursor to the bottom ready for new scrollback output
1659
- this.write(ESC.TO(totalRows, 1));
1660
- this.lastOverlayHeight = height;
1661
- this.lastPromptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex;
2225
+ // Move back to top (where content should continue from)
2226
+ if (height > 1) {
2227
+ this.write(`\x1b[${height - 1}A`);
2228
+ }
2229
+ this.write('\r');
1662
2230
  this.lastOverlay = null;
1663
- this.overlayInvalidated = true;
1664
2231
  this.promptHeight = 0;
2232
+ this.lastOverlayHeight = 0;
2233
+ this.isPromptActive = false;
1665
2234
  }
1666
2235
  updateTerminalSize() {
1667
2236
  if (this.output.isTTY) {