erosolar-cli 2.1.171 → 2.1.172

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 (209) hide show
  1. package/README.md +1 -1
  2. package/agents/erosolar-code.rules.json +2 -2
  3. package/agents/general.rules.json +3 -21
  4. package/dist/StringUtils.d.ts +8 -0
  5. package/dist/StringUtils.d.ts.map +1 -0
  6. package/dist/StringUtils.js +11 -0
  7. package/dist/StringUtils.js.map +1 -0
  8. package/dist/capabilities/statusCapability.js +2 -2
  9. package/dist/capabilities/statusCapability.js.map +1 -1
  10. package/dist/contracts/agent-schemas.json +5 -5
  11. package/dist/core/agent.d.ts +24 -83
  12. package/dist/core/agent.d.ts.map +1 -1
  13. package/dist/core/agent.js +248 -499
  14. package/dist/core/agent.js.map +1 -1
  15. package/dist/core/aiFlowSupervisor.d.ts +44 -0
  16. package/dist/core/aiFlowSupervisor.d.ts.map +1 -0
  17. package/dist/core/aiFlowSupervisor.js +299 -0
  18. package/dist/core/aiFlowSupervisor.js.map +1 -0
  19. package/dist/core/cliTestHarness.d.ts +200 -0
  20. package/dist/core/cliTestHarness.d.ts.map +1 -0
  21. package/dist/core/cliTestHarness.js +549 -0
  22. package/dist/core/cliTestHarness.js.map +1 -0
  23. package/dist/core/preferences.d.ts +0 -1
  24. package/dist/core/preferences.d.ts.map +1 -1
  25. package/dist/core/preferences.js +1 -8
  26. package/dist/core/preferences.js.map +1 -1
  27. package/dist/core/schemaValidator.js +3 -3
  28. package/dist/core/schemaValidator.js.map +1 -1
  29. package/dist/core/testUtils.d.ts +121 -0
  30. package/dist/core/testUtils.d.ts.map +1 -0
  31. package/dist/core/testUtils.js +235 -0
  32. package/dist/core/testUtils.js.map +1 -0
  33. package/dist/core/toolPreconditions.d.ts +11 -0
  34. package/dist/core/toolPreconditions.d.ts.map +1 -1
  35. package/dist/core/toolPreconditions.js +164 -33
  36. package/dist/core/toolPreconditions.js.map +1 -1
  37. package/dist/core/toolRuntime.d.ts.map +1 -1
  38. package/dist/core/toolRuntime.js +114 -9
  39. package/dist/core/toolRuntime.js.map +1 -1
  40. package/dist/core/toolValidation.d.ts +116 -0
  41. package/dist/core/toolValidation.d.ts.map +1 -0
  42. package/dist/core/toolValidation.js +282 -0
  43. package/dist/core/toolValidation.js.map +1 -0
  44. package/dist/core/updateChecker.d.ts +1 -61
  45. package/dist/core/updateChecker.d.ts.map +1 -1
  46. package/dist/core/updateChecker.js +3 -147
  47. package/dist/core/updateChecker.js.map +1 -1
  48. package/dist/headless/evalMode.d.ts.map +1 -1
  49. package/dist/headless/evalMode.js +0 -6
  50. package/dist/headless/evalMode.js.map +1 -1
  51. package/dist/headless/headlessApp.d.ts.map +1 -1
  52. package/dist/headless/headlessApp.js +39 -6
  53. package/dist/headless/headlessApp.js.map +1 -1
  54. package/dist/mcp/sseClient.d.ts +1 -4
  55. package/dist/mcp/sseClient.d.ts.map +1 -1
  56. package/dist/mcp/sseClient.js +2 -36
  57. package/dist/mcp/sseClient.js.map +1 -1
  58. package/dist/mcp/stdioClient.d.ts +1 -4
  59. package/dist/mcp/stdioClient.d.ts.map +1 -1
  60. package/dist/mcp/stdioClient.js +1 -41
  61. package/dist/mcp/stdioClient.js.map +1 -1
  62. package/dist/mcp/toolBridge.d.ts +0 -3
  63. package/dist/mcp/toolBridge.d.ts.map +1 -1
  64. package/dist/mcp/toolBridge.js +2 -2
  65. package/dist/mcp/toolBridge.js.map +1 -1
  66. package/dist/mcp/types.d.ts +0 -18
  67. package/dist/mcp/types.d.ts.map +1 -1
  68. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  69. package/dist/plugins/tools/nodeDefaults.js +2 -0
  70. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  71. package/dist/providers/openaiResponsesProvider.d.ts.map +1 -1
  72. package/dist/providers/openaiResponsesProvider.js +74 -79
  73. package/dist/providers/openaiResponsesProvider.js.map +1 -1
  74. package/dist/runtime/agentController.d.ts.map +1 -1
  75. package/dist/runtime/agentController.js +3 -6
  76. package/dist/runtime/agentController.js.map +1 -1
  77. package/dist/runtime/agentSession.d.ts +2 -0
  78. package/dist/runtime/agentSession.d.ts.map +1 -1
  79. package/dist/runtime/agentSession.js +2 -2
  80. package/dist/runtime/agentSession.js.map +1 -1
  81. package/dist/shell/interactiveShell.d.ts +18 -20
  82. package/dist/shell/interactiveShell.d.ts.map +1 -1
  83. package/dist/shell/interactiveShell.js +291 -329
  84. package/dist/shell/interactiveShell.js.map +1 -1
  85. package/dist/shell/shellApp.d.ts.map +1 -1
  86. package/dist/shell/shellApp.js +8 -16
  87. package/dist/shell/shellApp.js.map +1 -1
  88. package/dist/shell/systemPrompt.d.ts.map +1 -1
  89. package/dist/shell/systemPrompt.js +15 -4
  90. package/dist/shell/systemPrompt.js.map +1 -1
  91. package/dist/subagents/taskRunner.js +1 -2
  92. package/dist/subagents/taskRunner.js.map +1 -1
  93. package/dist/tools/bashTools.d.ts.map +1 -1
  94. package/dist/tools/bashTools.js +8 -101
  95. package/dist/tools/bashTools.js.map +1 -1
  96. package/dist/tools/diffUtils.d.ts +2 -8
  97. package/dist/tools/diffUtils.d.ts.map +1 -1
  98. package/dist/tools/diffUtils.js +13 -72
  99. package/dist/tools/diffUtils.js.map +1 -1
  100. package/dist/tools/grepTools.d.ts.map +1 -1
  101. package/dist/tools/grepTools.js +2 -10
  102. package/dist/tools/grepTools.js.map +1 -1
  103. package/dist/tools/planningTools.d.ts +10 -0
  104. package/dist/tools/planningTools.d.ts.map +1 -1
  105. package/dist/tools/planningTools.js +16 -0
  106. package/dist/tools/planningTools.js.map +1 -1
  107. package/dist/tools/searchTools.d.ts.map +1 -1
  108. package/dist/tools/searchTools.js +2 -4
  109. package/dist/tools/searchTools.js.map +1 -1
  110. package/dist/ui/PromptController.d.ts +4 -4
  111. package/dist/ui/PromptController.d.ts.map +1 -1
  112. package/dist/ui/PromptController.js +7 -1
  113. package/dist/ui/PromptController.js.map +1 -1
  114. package/dist/ui/ShellUIAdapter.d.ts +28 -292
  115. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  116. package/dist/ui/ShellUIAdapter.js +121 -1513
  117. package/dist/ui/ShellUIAdapter.js.map +1 -1
  118. package/dist/ui/UnifiedUIRenderer.d.ts +30 -136
  119. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
  120. package/dist/ui/UnifiedUIRenderer.js +370 -955
  121. package/dist/ui/UnifiedUIRenderer.js.map +1 -1
  122. package/dist/ui/animatedStatus.d.ts +6 -128
  123. package/dist/ui/animatedStatus.d.ts.map +1 -1
  124. package/dist/ui/animatedStatus.js +50 -383
  125. package/dist/ui/animatedStatus.js.map +1 -1
  126. package/dist/ui/display.d.ts +26 -182
  127. package/dist/ui/display.d.ts.map +1 -1
  128. package/dist/ui/display.js +97 -678
  129. package/dist/ui/display.js.map +1 -1
  130. package/dist/ui/layout.d.ts +1 -0
  131. package/dist/ui/layout.d.ts.map +1 -1
  132. package/dist/ui/layout.js +12 -0
  133. package/dist/ui/layout.js.map +1 -1
  134. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts +7 -61
  135. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts.map +1 -1
  136. package/dist/ui/orchestration/UIUpdateCoordinator.js +20 -232
  137. package/dist/ui/orchestration/UIUpdateCoordinator.js.map +1 -1
  138. package/dist/ui/planOverlay.d.ts +28 -0
  139. package/dist/ui/planOverlay.d.ts.map +1 -0
  140. package/dist/ui/planOverlay.js +156 -0
  141. package/dist/ui/planOverlay.js.map +1 -0
  142. package/dist/ui/shortcutsHelp.d.ts.map +1 -1
  143. package/dist/ui/shortcutsHelp.js +1 -0
  144. package/dist/ui/shortcutsHelp.js.map +1 -1
  145. package/dist/ui/streamingFormatter.d.ts +30 -0
  146. package/dist/ui/streamingFormatter.d.ts.map +1 -0
  147. package/dist/ui/streamingFormatter.js +91 -0
  148. package/dist/ui/streamingFormatter.js.map +1 -0
  149. package/dist/ui/unified/index.d.ts +1 -30
  150. package/dist/ui/unified/index.d.ts.map +1 -1
  151. package/dist/ui/unified/index.js +2 -45
  152. package/dist/ui/unified/index.js.map +1 -1
  153. package/dist/utils/errorUtils.d.ts +16 -0
  154. package/dist/utils/errorUtils.d.ts.map +1 -0
  155. package/dist/utils/errorUtils.js +66 -0
  156. package/dist/utils/errorUtils.js.map +1 -0
  157. package/package.json +2 -1
  158. package/dist/codex/capabilities/codexCoreCapability.d.ts +0 -6
  159. package/dist/codex/capabilities/codexCoreCapability.d.ts.map +0 -1
  160. package/dist/codex/capabilities/codexCoreCapability.js +0 -516
  161. package/dist/codex/capabilities/codexCoreCapability.js.map +0 -1
  162. package/dist/codex/fs.d.ts +0 -4
  163. package/dist/codex/fs.d.ts.map +0 -1
  164. package/dist/codex/fs.js +0 -25
  165. package/dist/codex/fs.js.map +0 -1
  166. package/dist/codex/persistence/planStore.d.ts +0 -4
  167. package/dist/codex/persistence/planStore.d.ts.map +0 -1
  168. package/dist/codex/persistence/planStore.js +0 -59
  169. package/dist/codex/persistence/planStore.js.map +0 -1
  170. package/dist/codex/pluginAllowlist.d.ts +0 -4
  171. package/dist/codex/pluginAllowlist.d.ts.map +0 -1
  172. package/dist/codex/pluginAllowlist.js +0 -14
  173. package/dist/codex/pluginAllowlist.js.map +0 -1
  174. package/dist/codex/types.d.ts +0 -21
  175. package/dist/codex/types.d.ts.map +0 -1
  176. package/dist/codex/types.js +0 -62
  177. package/dist/codex/types.js.map +0 -1
  178. package/dist/core/reliabilityPrompt.d.ts +0 -9
  179. package/dist/core/reliabilityPrompt.d.ts.map +0 -1
  180. package/dist/core/reliabilityPrompt.js +0 -31
  181. package/dist/core/reliabilityPrompt.js.map +0 -1
  182. package/dist/ui/UnifiedUIController.d.ts +0 -81
  183. package/dist/ui/UnifiedUIController.d.ts.map +0 -1
  184. package/dist/ui/UnifiedUIController.js +0 -212
  185. package/dist/ui/UnifiedUIController.js.map +0 -1
  186. package/dist/ui/animation/AnimationScheduler.d.ts +0 -192
  187. package/dist/ui/animation/AnimationScheduler.d.ts.map +0 -1
  188. package/dist/ui/animation/AnimationScheduler.js +0 -432
  189. package/dist/ui/animation/AnimationScheduler.js.map +0 -1
  190. package/dist/ui/inPlaceUpdater.d.ts +0 -181
  191. package/dist/ui/inPlaceUpdater.d.ts.map +0 -1
  192. package/dist/ui/inPlaceUpdater.js +0 -515
  193. package/dist/ui/inPlaceUpdater.js.map +0 -1
  194. package/dist/ui/interrupts/InterruptManager.d.ts +0 -142
  195. package/dist/ui/interrupts/InterruptManager.d.ts.map +0 -1
  196. package/dist/ui/interrupts/InterruptManager.js +0 -439
  197. package/dist/ui/interrupts/InterruptManager.js.map +0 -1
  198. package/dist/ui/telemetry/ResponseTracker.d.ts +0 -22
  199. package/dist/ui/telemetry/ResponseTracker.d.ts.map +0 -1
  200. package/dist/ui/telemetry/ResponseTracker.js +0 -60
  201. package/dist/ui/telemetry/ResponseTracker.js.map +0 -1
  202. package/dist/ui/telemetry/UITelemetry.d.ts +0 -181
  203. package/dist/ui/telemetry/UITelemetry.d.ts.map +0 -1
  204. package/dist/ui/telemetry/UITelemetry.js +0 -446
  205. package/dist/ui/telemetry/UITelemetry.js.map +0 -1
  206. package/dist/ui/unified/layout.d.ts +0 -12
  207. package/dist/ui/unified/layout.d.ts.map +0 -1
  208. package/dist/ui/unified/layout.js +0 -96
  209. package/dist/ui/unified/layout.js.map +0 -1
@@ -11,9 +11,10 @@
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, spinnerFrames } from './theme.js';
14
+ import { theme } from './theme.js';
15
15
  import { isPlainOutputMode } from './outputMode.js';
16
- import { ContextMeter, disposeAnimations } from './animatedStatus.js';
16
+ import { renderDivider } from './layout.js';
17
+ import { colorizeActivity, createFrameTicker, formatElapsed, formatTinyProgressBar, formatTokenDelta, } from './animatedStatus.js';
17
18
  const ESC = {
18
19
  HIDE_CURSOR: '\x1b[?25l',
19
20
  SHOW_CURSOR: '\x1b[?25h',
@@ -27,11 +28,6 @@ const ESC = {
27
28
  ERASE_DOWN: '\x1b[J',
28
29
  REVERSE: '\x1b[7m',
29
30
  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',
35
31
  };
36
32
  const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
37
33
  const NEWLINE_PLACEHOLDER = '↵';
@@ -56,38 +52,13 @@ export class UnifiedUIRenderer extends EventEmitter {
56
52
  hotkeysInToggleLine = new Set();
57
53
  collapsedPaste = null;
58
54
  mode = 'idle';
59
- streamingStartTime = null;
60
55
  statusMessage = null;
61
56
  statusOverride = null;
62
57
  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
- ];
88
58
  statusMeta = {};
89
59
  toggleState = {
90
60
  verificationEnabled: false,
61
+ autoContinueEnabled: false,
91
62
  criticalApprovalMode: 'auto',
92
63
  };
93
64
  // ------------ Helpers ------------
@@ -99,14 +70,16 @@ export class UnifiedUIRenderer extends EventEmitter {
99
70
  lastPromptEvent = null;
100
71
  promptHeight = 0;
101
72
  lastOverlayHeight = 0;
73
+ lastPromptIndex = 0;
74
+ overlayBottomPadding = 1;
102
75
  inlinePanel = [];
76
+ persistentPanel = [];
77
+ overlayInvalidated = false;
103
78
  hasConversationContent = false;
104
79
  isPromptActive = false;
105
- inputRenderOffset = 0;
106
80
  plainPasteIdleMs = 24;
107
81
  plainPasteWindowMs = 60;
108
82
  plainPasteTriggerChars = 24;
109
- cursorVisibleColumn = 1;
110
83
  inBracketedPaste = false;
111
84
  pasteBuffer = '';
112
85
  inPlainPaste = false;
@@ -118,9 +91,14 @@ export class UnifiedUIRenderer extends EventEmitter {
118
91
  lastRenderedEventKey = null;
119
92
  lastOutputEndedWithNewline = true;
120
93
  hasRenderedPrompt = false;
121
- hasEverRenderedOverlay = false; // Track if we've ever rendered for inline clearing
94
+ hasEverRenderedOverlay = false; // Track if we've ever rendered to prevent first-render scrollback pollution
122
95
  lastOverlay = null;
123
96
  allowPromptRender = true;
97
+ streamingStart = null;
98
+ activityInterval = null;
99
+ activityTicker = createFrameTicker('sparkle');
100
+ renderedContextPercent = null;
101
+ lastToolResult = null;
124
102
  inputCapture = null;
125
103
  constructor(output = process.stdout, input = process.stdin, options) {
126
104
  super();
@@ -128,8 +106,6 @@ export class UnifiedUIRenderer extends EventEmitter {
128
106
  this.input = input;
129
107
  this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
130
108
  this.plainMode = isPlainOutputMode() || !this.interactive;
131
- // Initialize animated components
132
- this.contextMeter = new ContextMeter();
133
109
  this.rl = readline.createInterface({
134
110
  input: this.input,
135
111
  output: this.output,
@@ -161,6 +137,9 @@ export class UnifiedUIRenderer extends EventEmitter {
161
137
  this.updateTerminalSize();
162
138
  this.hasRenderedPrompt = false;
163
139
  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.
164
143
  this.write(ESC.SHOW_CURSOR);
165
144
  return;
166
145
  }
@@ -173,27 +152,12 @@ export class UnifiedUIRenderer extends EventEmitter {
173
152
  cleanup() {
174
153
  this.cancelInputCapture(new Error('Renderer disposed'));
175
154
  this.cancelPlainPasteCapture();
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();
155
+ this.stopActivityTimer();
191
156
  if (!this.interactive) {
192
157
  this.rl.close();
193
158
  return;
194
159
  }
195
160
  if (!this.plainMode) {
196
- // Clear the prompt area so it doesn't remain in scrollback history
197
161
  this.clearPromptArea();
198
162
  this.write(ESC.DISABLE_BRACKETED_PASTE);
199
163
  this.write(ESC.SHOW_CURSOR);
@@ -245,25 +209,14 @@ export class UnifiedUIRenderer extends EventEmitter {
245
209
  return;
246
210
  }
247
211
  if (key.ctrl && key.name === 'c') {
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();
212
+ const hadBuffer = this.buffer.length > 0;
213
+ this.emit('ctrl-c', { hadBuffer });
214
+ if (hadBuffer) {
215
+ this.clearBuffer();
258
216
  }
259
217
  else if (this.mode === 'streaming') {
260
- // Stage 2: Interrupt the AI run
261
218
  this.emit('interrupt');
262
219
  }
263
- else {
264
- // Stage 3: Quit the CLI (emit exit signal)
265
- this.emit('exit');
266
- }
267
220
  return;
268
221
  }
269
222
  if (key.ctrl && key.name === 'd') {
@@ -272,6 +225,12 @@ export class UnifiedUIRenderer extends EventEmitter {
272
225
  }
273
226
  return;
274
227
  }
228
+ if (key.ctrl && key.name === 'o') {
229
+ if (!this.expandLastToolResult()) {
230
+ this.emit('expand-tool-result');
231
+ }
232
+ return;
233
+ }
275
234
  if (key.ctrl && key.name === 'u') {
276
235
  this.clearBuffer();
277
236
  return;
@@ -282,11 +241,6 @@ export class UnifiedUIRenderer extends EventEmitter {
282
241
  return;
283
242
  }
284
243
  }
285
- // Ctrl+O: Expand last tool result
286
- if (key.ctrl && key.name === 'o') {
287
- this.emit('expand-tool-result');
288
- return;
289
- }
290
244
  if (key.name === 'return' || key.name === 'enter') {
291
245
  if (this.collapsedPaste) {
292
246
  this.expandCollapsedPaste();
@@ -296,12 +250,9 @@ export class UnifiedUIRenderer extends EventEmitter {
296
250
  // If a slash command suggestion is highlighted, pressing Enter submits it immediately
297
251
  if (this.applySuggestion(true))
298
252
  return;
299
- // Fallback: if buffer starts with '/' and suggestions exist, use the selected/first one
253
+ // If buffer starts with '/' and the first suggestion exists, submit it
300
254
  if (this.buffer.startsWith('/') && this.suggestions.length > 0) {
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;
255
+ this.buffer = this.suggestions[this.suggestionIndex >= 0 ? this.suggestionIndex : 0]?.command ?? this.buffer;
305
256
  }
306
257
  this.submitText(this.buffer);
307
258
  return;
@@ -347,6 +298,18 @@ export class UnifiedUIRenderer extends EventEmitter {
347
298
  }
348
299
  return;
349
300
  }
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
+ }
350
313
  if (key.name === 'up') {
351
314
  if (this.navigateSuggestions(-1)) {
352
315
  return;
@@ -611,7 +574,6 @@ export class UnifiedUIRenderer extends EventEmitter {
611
574
  this.inputCapture = null;
612
575
  this.buffer = '';
613
576
  this.cursor = 0;
614
- this.inputRenderOffset = 0;
615
577
  this.resetSuggestions();
616
578
  this.renderPrompt();
617
579
  this.emitInputChange();
@@ -678,11 +640,7 @@ export class UnifiedUIRenderer extends EventEmitter {
678
640
  if (!this.buffer.startsWith('/') || this.suggestions.length === 0) {
679
641
  return false;
680
642
  }
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];
643
+ const selected = this.suggestions[this.suggestionIndex] ?? this.suggestions[0];
686
644
  if (!selected) {
687
645
  return false;
688
646
  }
@@ -715,10 +673,22 @@ export class UnifiedUIRenderer extends EventEmitter {
715
673
  this.renderPrompt();
716
674
  return true;
717
675
  }
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
+ }
718
686
  // ------------ Event queue ------------
719
- addEvent(type, content) {
687
+ addEvent(type, content, options) {
720
688
  if (!content)
721
689
  return;
690
+ if (this.isGarbageOutput(content))
691
+ return;
722
692
  const normalized = this.normalizeEventType(type);
723
693
  if (!normalized)
724
694
  return;
@@ -727,7 +697,6 @@ export class UnifiedUIRenderer extends EventEmitter {
727
697
  normalized === 'thought' ||
728
698
  normalized === 'stream' ||
729
699
  normalized === 'tool' ||
730
- normalized === 'tool-result' ||
731
700
  normalized === 'build' ||
732
701
  normalized === 'test') {
733
702
  this.hasConversationContent = true;
@@ -751,6 +720,7 @@ export class UnifiedUIRenderer extends EventEmitter {
751
720
  rawType: type,
752
721
  content,
753
722
  timestamp: Date.now(),
723
+ isCompacted: options?.compact === true,
754
724
  };
755
725
  // Priority queue: prompt events are inserted at the front to ensure immediate display
756
726
  // This guarantees user input is echoed before any async processing responses
@@ -854,7 +824,6 @@ export class UnifiedUIRenderer extends EventEmitter {
854
824
  if (event.type !== 'prompt') {
855
825
  this.lastRenderedEventKey = signature;
856
826
  }
857
- // Clear the prompt area before writing new content
858
827
  if (this.promptHeight > 0 || this.lastOverlay) {
859
828
  this.clearPromptArea();
860
829
  }
@@ -866,6 +835,10 @@ export class UnifiedUIRenderer extends EventEmitter {
866
835
  }
867
836
  this.output.write(formatted);
868
837
  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
869
842
  }
870
843
  normalizeEventType(type) {
871
844
  switch (type) {
@@ -878,9 +851,8 @@ export class UnifiedUIRenderer extends EventEmitter {
878
851
  return 'stream';
879
852
  case 'tool':
880
853
  case 'tool-call':
881
- return 'tool';
882
854
  case 'tool-result':
883
- return 'tool-result';
855
+ return 'tool';
884
856
  case 'build':
885
857
  return 'build';
886
858
  case 'test':
@@ -896,388 +868,83 @@ export class UnifiedUIRenderer extends EventEmitter {
896
868
  }
897
869
  }
898
870
  formatContent(event) {
899
- const bullet = '⏺'; // Claude Code uses plain bullet, no color
900
- // Compacted blocks already have separator and formatting
871
+ const bullet = '⏺';
901
872
  if (event.isCompacted) {
902
873
  return event.content;
903
874
  }
904
875
  if (event.rawType === 'banner') {
905
- // Banners display without bullet prefix
906
876
  const lines = event.content.split('\n').map(line => line.trimEnd());
907
877
  return `${lines.join('\n')}\n`;
908
878
  }
909
- // Compact, user-friendly formatting
910
879
  switch (event.type) {
911
880
  case 'prompt':
912
- // User prompt - just the text (prompt box handles styling)
913
- return `${theme.primary('>')} ${event.content}\n`;
881
+ return `\n> ${event.content}\n`;
914
882
  case 'thought': {
915
- // Programmatic filter: reject content that looks like internal/garbage output
916
- if (this.isGarbageOutput(event.content)) {
917
- return '';
918
- }
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);
883
+ return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
931
884
  }
885
+ case 'tool':
886
+ if (event.rawType === 'tool-call') {
887
+ return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
888
+ }
889
+ if (event.rawType === 'tool-result') {
890
+ return this.formatToolResult(event.content, event.isCompacted);
891
+ }
892
+ return `\n${event.content}\n`;
932
893
  case 'build':
933
- return `${bullet} ${theme.warning('Build')} ${theme.ui.muted('→')} ${event.content}\n`;
894
+ return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
934
895
  case 'test':
935
- return `${bullet} ${theme.info('Test')} ${theme.ui.muted('→')} ${event.content}\n`;
896
+ return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
936
897
  case 'stream':
937
898
  return event.content;
938
899
  case 'response':
939
900
  default: {
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);
901
+ return `\n${this.formatBulletBlock(event.content, bullet)}\n`;
946
902
  }
947
903
  }
948
904
  }
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;
959
- }
960
- // Structural check: contains "to=functions." or "to=tools." (internal routing)
961
- if (content.includes('to=functions.') || content.includes('to=tools.')) {
962
- return true;
963
- }
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
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`;
977
916
  }
978
- return false;
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`;
979
923
  }
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 = '';
924
+ wrapText(content, width) {
925
+ if (!content) {
926
+ return [''];
927
+ }
928
+ const lines = [];
929
+ const rawLines = content.split('\n');
930
+ for (const rawLine of rawLines) {
931
+ const words = rawLine.split(/(\s+)/);
932
+ let current = '';
1001
933
  for (const word of words) {
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();
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();
1007
940
  }
1008
941
  else {
1009
- currentLine += word;
942
+ current = candidate;
1010
943
  }
1011
944
  }
1012
- if (currentLine.trim()) {
1013
- const prefix = result.length === 0 && lineIdx === 0 ? `${bullet} ` : ' ';
1014
- result.push(`${prefix}${currentLine.trimEnd()}`);
1015
- }
1016
- }
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
- }
945
+ lines.push(current.trimEnd());
1063
946
  }
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`;
947
+ return lines;
1281
948
  }
1282
949
  /**
1283
950
  * Format a compact conversation block (Claude Code style)
@@ -1314,81 +981,26 @@ export class UnifiedUIRenderer extends EventEmitter {
1314
981
  setMode(mode) {
1315
982
  const wasStreaming = this.mode === 'streaming';
1316
983
  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
- }
1327
984
  if (wasStreaming && mode === 'idle' && !this.lastOutputEndedWithNewline) {
1328
985
  // Finish streaming on a fresh line so the next prompt/event doesn't collide
1329
986
  this.write('\n');
1330
987
  this.lastOutputEndedWithNewline = true;
1331
988
  }
1332
- if (!this.plainMode) {
1333
- // Always render prompt to keep bottom UI persistent (rich mode only)
1334
- this.renderPrompt();
1335
- }
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();
989
+ if (mode === 'streaming') {
990
+ if (!this.streamingStart) {
991
+ this.streamingStart = Date.now();
1351
992
  }
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;
993
+ this.startActivityTimer();
994
+ }
995
+ else {
996
+ this.streamingStart = null;
997
+ this.stopActivityTimer();
1361
998
  }
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;
1372
999
  if (!this.plainMode) {
1000
+ // Always render prompt to keep bottom UI persistent (rich mode only)
1373
1001
  this.renderPrompt();
1374
1002
  }
1375
1003
  }
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
- }
1392
1004
  getMode() {
1393
1005
  return this.mode;
1394
1006
  }
@@ -1427,7 +1039,15 @@ export class UnifiedUIRenderer extends EventEmitter {
1427
1039
  }
1428
1040
  }
1429
1041
  updateStatusMeta(meta, options = {}) {
1430
- const next = { ...this.statusMeta, ...meta };
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
+ }
1431
1051
  const changed = JSON.stringify(next) !== JSON.stringify(this.statusMeta);
1432
1052
  this.statusMeta = next;
1433
1053
  const shouldRender = options.render !== false && changed;
@@ -1437,7 +1057,8 @@ export class UnifiedUIRenderer extends EventEmitter {
1437
1057
  }
1438
1058
  updateModeToggles(state) {
1439
1059
  this.toggleState = { ...this.toggleState, ...state };
1440
- if (!state.verificationHotkey &&
1060
+ if (!state.autoContinueHotkey &&
1061
+ !state.verificationHotkey &&
1441
1062
  !state.thinkingHotkey &&
1442
1063
  !state.criticalApprovalHotkey) {
1443
1064
  this.hotkeysInToggleLine.clear();
@@ -1460,6 +1081,51 @@ export class UnifiedUIRenderer extends EventEmitter {
1460
1081
  this.inlinePanel = [];
1461
1082
  this.renderPrompt();
1462
1083
  }
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
+ }
1463
1129
  // ------------ Prompt rendering ------------
1464
1130
  renderPrompt() {
1465
1131
  if (!this.interactive) {
@@ -1473,7 +1139,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1473
1139
  this.lastOutputEndedWithNewline = true;
1474
1140
  }
1475
1141
  this.write(`\r${ESC.CLEAR_LINE}${line}`);
1476
- this.cursorVisibleColumn = line.length + 1;
1477
1142
  this.hasRenderedPrompt = true;
1478
1143
  this.isPromptActive = true;
1479
1144
  this.lastOutputEndedWithNewline = false; // prompt ends mid-line by design
@@ -1483,9 +1148,23 @@ export class UnifiedUIRenderer extends EventEmitter {
1483
1148
  if (!this.allowPromptRender) {
1484
1149
  return;
1485
1150
  }
1486
- // Rich inline mode: prompt flows naturally with content
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)
1487
1162
  this.updateTerminalSize();
1488
1163
  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
+ }
1489
1168
  this.lastRenderWidth = maxWidth;
1490
1169
  const overlay = this.buildOverlayLines();
1491
1170
  if (!overlay.lines.length) {
@@ -1495,188 +1174,99 @@ export class UnifiedUIRenderer extends EventEmitter {
1495
1174
  if (!renderedLines.length) {
1496
1175
  return;
1497
1176
  }
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
- }
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);
1527
1196
  }
1528
- // Write prompt lines (no trailing newline on last line)
1529
- for (let i = 0; i < renderedLines.length; i++) {
1530
- this.write('\r');
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));
1531
1202
  this.write(ESC.CLEAR_LINE);
1532
- this.write(renderedLines[i] || '');
1533
- if (i < renderedLines.length - 1) {
1534
- this.write('\n');
1203
+ if (line) {
1204
+ this.write(line);
1535
1205
  }
1536
1206
  }
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;
1207
+ // Position cursor at prompt row/col
1208
+ this.write(ESC.TO(promptRow, promptCol));
1546
1209
  this.hasRenderedPrompt = true;
1547
- this.hasEverRenderedOverlay = true;
1210
+ this.hasEverRenderedOverlay = true; // Mark that we've rendered at least once
1548
1211
  this.isPromptActive = true;
1549
1212
  this.lastOverlayHeight = height;
1213
+ this.lastPromptIndex = promptIndex;
1550
1214
  this.lastOverlay = { lines: renderedLines, promptIndex };
1551
- this.lastOutputEndedWithNewline = false;
1215
+ this.overlayInvalidated = false;
1216
+ this.lastOutputEndedWithNewline = true;
1552
1217
  this.promptHeight = height;
1553
1218
  }
1554
1219
  buildOverlayLines() {
1555
1220
  const lines = [];
1556
1221
  const maxWidth = this.safeWidth();
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) {
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) {
1588
1231
  lines.push(this.truncateLine(line, maxWidth));
1589
1232
  }
1590
- // Bottom divider
1591
- lines.push(divider);
1592
- // Inline panel (pinned scroll box for live output/menus)
1233
+ lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96)), maxWidth));
1593
1234
  if (this.inlinePanel.length > 0) {
1594
1235
  for (const panelLine of this.inlinePanel) {
1595
- lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
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));
1596
1242
  }
1597
- // Separate inline content from suggestions/toggles
1598
- lines.push(divider);
1599
1243
  }
1600
- // Slash command suggestions
1601
1244
  if (this.suggestions.length > 0) {
1602
1245
  for (let index = 0; index < this.suggestions.length; index++) {
1603
1246
  const suggestion = this.suggestions[index];
1604
1247
  const isActive = index === this.suggestionIndex;
1605
- const marker = isActive ? theme.primary('') : theme.ui.muted(' ');
1248
+ const marker = isActive ? theme.primary('') : theme.ui.muted('');
1606
1249
  const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
1607
1250
  const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
1608
- lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
1251
+ lines.push(this.truncateLine(`${marker} ${cmdText} — ${descText}`, maxWidth));
1609
1252
  }
1610
1253
  }
1611
- // Model and context info
1612
- const modelContextLine = this.buildModelContextLine();
1613
- if (modelContextLine) {
1614
- lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
1254
+ const modelLine = this.buildModelLine(maxWidth);
1255
+ if (modelLine) {
1256
+ lines.push(modelLine);
1615
1257
  }
1616
- // Mode toggles
1617
- const toggleLine = this.buildInlineToggleLine();
1258
+ const toggleLine = this.buildToggleLine();
1618
1259
  if (toggleLine) {
1619
- lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
1260
+ lines.push(toggleLine);
1620
1261
  }
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));
1637
- }
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')}`);
1262
+ const shortcutLine = this.buildShortcutLine();
1263
+ if (shortcutLine) {
1264
+ lines.push(shortcutLine);
1647
1265
  }
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')}`);
1266
+ else {
1267
+ lines.push(`${theme.ui.muted('?')} shortcuts`);
1670
1268
  }
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];
1269
+ return { lines, cursorRow: promptRow, cursorCol: promptCol };
1680
1270
  }
1681
1271
  abbreviatePath(pathValue) {
1682
1272
  const home = homedir();
@@ -1685,139 +1275,57 @@ export class UnifiedUIRenderer extends EventEmitter {
1685
1275
  }
1686
1276
  return pathValue;
1687
1277
  }
1688
- buildStatusBlock(maxWidth) {
1689
- const statusLabel = this.composeStatusLabel();
1690
- if (!statusLabel) {
1691
- return [];
1692
- }
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());
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;
1710
1290
  }
1711
- return this.wrapSegments(segments, maxWidth);
1291
+ return this.truncateLine(parts.join(theme.ui.muted(' • ')), maxWidth);
1712
1292
  }
1713
- buildMetaBlock(maxWidth) {
1293
+ buildModelLine(maxWidth) {
1714
1294
  const segments = [];
1715
1295
  if (this.statusMeta.profile) {
1716
- segments.push(this.formatMetaSegment('profile', this.statusMeta.profile, 'info'));
1296
+ segments.push(`${theme.ui.muted('profile')} ${theme.info(this.statusMeta.profile)}`);
1717
1297
  }
1718
1298
  const model = this.statusMeta.provider && this.statusMeta.model
1719
1299
  ? `${this.statusMeta.provider} / ${this.statusMeta.model}`
1720
1300
  : this.statusMeta.model || this.statusMeta.provider;
1721
1301
  if (model) {
1722
- segments.push(this.formatMetaSegment('model', model, 'info'));
1302
+ segments.push(`${theme.ui.muted('model')} ${theme.info(model)}`);
1723
1303
  }
1724
1304
  const workspace = this.statusMeta.workspace || this.statusMeta.directory;
1725
1305
  if (workspace) {
1726
- segments.push(this.formatMetaSegment('dir', this.abbreviatePath(workspace), 'muted'));
1727
- }
1728
- if (this.statusMeta.sandbox) {
1729
- const tone = this.statusMeta.sandbox.includes('danger')
1730
- ? 'error'
1731
- : this.statusMeta.sandbox.includes('read')
1732
- ? 'warn'
1733
- : 'muted';
1734
- segments.push(this.formatMetaSegment('sandbox', this.statusMeta.sandbox, tone));
1735
- }
1736
- if (this.statusMeta.network) {
1737
- const tone = this.statusMeta.network === 'restricted' ? 'warn' : 'info';
1738
- segments.push(this.formatMetaSegment('network', this.statusMeta.network, tone));
1739
- }
1740
- if (this.statusMeta.approvals) {
1741
- const tone = this.statusMeta.approvals === 'auto' ? 'muted' : 'warn';
1742
- segments.push(this.formatMetaSegment('approvals', this.statusMeta.approvals, tone));
1306
+ segments.push(`${theme.ui.muted('dir')} ${theme.ui.muted(this.abbreviatePath(workspace))}`);
1743
1307
  }
1744
1308
  if (this.statusMeta.writes) {
1745
- segments.push(this.formatMetaSegment('writes', this.statusMeta.writes, 'muted'));
1309
+ segments.push(`${theme.ui.muted('writes')} ${theme.ui.muted(this.statusMeta.writes)}`);
1746
1310
  }
1747
1311
  if (this.statusMeta.toolSummary) {
1748
- segments.push(this.formatMetaSegment('tools', this.statusMeta.toolSummary, 'muted'));
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)}`);
1749
1316
  }
1750
1317
  if (this.statusMeta.sessionLabel) {
1751
- segments.push(this.formatMetaSegment('session', this.statusMeta.sessionLabel, 'muted'));
1318
+ segments.push(`${theme.ui.muted('session')} ${theme.ui.muted(this.statusMeta.sessionLabel)}`);
1752
1319
  }
1753
- if (this.statusMeta.version) {
1754
- segments.push(this.formatMetaSegment('build', `v${this.statusMeta.version}`, 'muted'));
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)}`);
1755
1324
  }
1756
1325
  if (segments.length === 0) {
1757
- return [];
1758
- }
1759
- return this.wrapSegments(segments, maxWidth);
1760
- }
1761
- composeStatusLabel() {
1762
- const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1763
- const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
1764
- if (!text.trim()) {
1765
1326
  return null;
1766
1327
  }
1767
- const normalized = text.toLowerCase();
1768
- const tone = normalized.includes('ready') ? 'success' : 'info';
1769
- return { text, tone };
1770
- }
1771
- formatMetaSegment(label, value, tone) {
1772
- const colorizer = tone === 'success'
1773
- ? theme.success
1774
- : tone === 'warn'
1775
- ? theme.warning
1776
- : tone === 'error'
1777
- ? theme.error
1778
- : tone === 'muted'
1779
- ? theme.ui.muted
1780
- : theme.info;
1781
- return `${theme.ui.muted(label)} ${colorizer(value)}`;
1782
- }
1783
- applyTone(text, tone) {
1784
- switch (tone) {
1785
- case 'success':
1786
- return theme.success(text);
1787
- case 'warn':
1788
- return theme.warning(text);
1789
- case 'error':
1790
- return theme.error(text);
1791
- case 'info':
1792
- default:
1793
- return theme.info(text);
1794
- }
1795
- }
1796
- wrapSegments(segments, maxWidth) {
1797
- const lines = [];
1798
- const separator = theme.ui.muted(' | ');
1799
- let current = '';
1800
- for (const segment of segments) {
1801
- const normalized = segment.trim();
1802
- if (!normalized)
1803
- continue;
1804
- if (!current) {
1805
- current = this.truncateLine(normalized, maxWidth);
1806
- continue;
1807
- }
1808
- const candidate = `${current}${separator}${normalized}`;
1809
- if (this.visibleLength(candidate) <= maxWidth) {
1810
- current = candidate;
1811
- }
1812
- else {
1813
- lines.push(this.truncateLine(current, maxWidth));
1814
- current = this.truncateLine(normalized, maxWidth);
1815
- }
1816
- }
1817
- if (current) {
1818
- lines.push(this.truncateLine(current, maxWidth));
1819
- }
1820
- return lines;
1328
+ return this.truncateLine(segments.join(theme.ui.muted(' • ')), maxWidth);
1821
1329
  }
1822
1330
  buildControlLines() {
1823
1331
  const lines = [];
@@ -1831,34 +1339,12 @@ export class UnifiedUIRenderer extends EventEmitter {
1831
1339
  }
1832
1340
  return lines;
1833
1341
  }
1834
- /**
1835
- * Build a compact toggle line like Claude Code:
1836
- * "⏵⏵ accept edits on (shift+tab to cycle)"
1837
- */
1838
- buildCompactToggleLine() {
1839
- // Show the most relevant mode based on current state
1840
- const parts = [];
1841
- // Edit mode indicator
1842
- const editIcon = '⏵⏵';
1843
- const editState = this.toggleState.verificationEnabled ? 'approval required' : 'accept edits';
1844
- parts.push(`${theme.ui.muted(editIcon)} ${editState} ${theme.success('on')}`);
1845
- // Thinking mode (if active)
1846
- const thinkingLabel = (this.toggleState.thinkingModeLabel || '').trim().toLowerCase();
1847
- if (thinkingLabel && thinkingLabel !== 'off') {
1848
- parts.push(`${theme.ui.muted('thinking')} ${theme.info(thinkingLabel)}`);
1849
- }
1850
- // Cycle hint
1851
- const cycleHint = theme.ui.muted('(shift+tab to cycle)');
1852
- if (parts.length === 0) {
1853
- return null;
1854
- }
1855
- return ` ${parts.join(theme.ui.muted(' · '))} ${cycleHint}`;
1856
- }
1857
1342
  buildToggleLine() {
1858
1343
  const toggles = [];
1859
1344
  const addToggle = (label, on, hotkey, value) => {
1860
1345
  toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
1861
1346
  };
1347
+ addToggle('Auto', this.toggleState.autoContinueEnabled, this.toggleState.autoContinueHotkey);
1862
1348
  addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
1863
1349
  const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1864
1350
  const approvalActive = approvalMode !== 'auto';
@@ -1903,129 +1389,86 @@ export class UnifiedUIRenderer extends EventEmitter {
1903
1389
  addHotkey('interrupt', 'Ctrl+C');
1904
1390
  addHotkey('clear input', 'Ctrl+U');
1905
1391
  // Feature toggles (only if hotkeys are defined)
1392
+ addHotkey('auto-run', this.toggleState.autoContinueHotkey);
1906
1393
  addHotkey('verify', this.toggleState.verificationHotkey);
1907
1394
  addHotkey('thinking', this.toggleState.thinkingHotkey);
1908
1395
  if (parts.length === 0) {
1909
1396
  return null;
1910
1397
  }
1911
- return parts.join(theme.ui.muted(' '));
1398
+ const body = parts.join(theme.ui.muted(' '));
1399
+ return `${body}${theme.ui.muted(' ? shortcuts')}`;
1912
1400
  }
1913
- buildInputLine() {
1401
+ buildInputOverlay(maxWidth) {
1402
+ const prompt = theme.primary('› ');
1403
+ const promptWidth = this.visibleLength(prompt);
1404
+ const usableWidth = Math.max(8, maxWidth - promptWidth);
1914
1405
  if (this.collapsedPaste) {
1915
1406
  const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
1916
- return this.truncateLine(`${theme.primary('> ')}${theme.ui.muted(summary)}`, this.safeWidth());
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 };
1917
1410
  }
1918
- // Claude Code uses simple '>' prompt
1919
- const prompt = theme.primary('> ');
1920
- const promptWidth = this.visibleLength(prompt);
1921
- const maxWidth = this.safeWidth();
1922
- const continuationIndent = ' '; // 2 spaces for continuation lines
1923
- const continuationWidth = continuationIndent.length;
1924
- // Handle multi-line input - split buffer on newlines first
1925
1411
  const normalized = this.buffer.replace(/\r/g, '\n');
1926
- const bufferLines = normalized.split('\n');
1927
- // Wrap each logical line to fit terminal width, expanding vertically
1928
- const result = [];
1929
- let totalChars = 0;
1930
- let cursorLine = 0;
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;
1931
1418
  let cursorCol = 0;
1932
- let foundCursor = false;
1933
- for (let lineIndex = 0; lineIndex < bufferLines.length; lineIndex++) {
1934
- const line = bufferLines[lineIndex] ?? '';
1935
- const isFirstLogicalLine = lineIndex === 0;
1936
- const lineStartChar = totalChars;
1937
- // Determine available width for this line
1938
- const firstLineWidth = maxWidth - promptWidth;
1939
- const contLineWidth = maxWidth - continuationWidth;
1940
- // Wrap this logical line into display lines
1941
- let remaining = line;
1942
- let isFirstDisplayLine = true;
1943
- while (remaining.length > 0 || isFirstDisplayLine) {
1944
- const availableWidth = (isFirstLogicalLine && isFirstDisplayLine) ? firstLineWidth : contLineWidth;
1945
- const chunk = remaining.slice(0, availableWidth);
1946
- remaining = remaining.slice(availableWidth);
1947
- // Build the display line
1948
- let displayLine;
1949
- if (isFirstLogicalLine && isFirstDisplayLine) {
1950
- displayLine = `${prompt}${chunk}`;
1951
- }
1952
- else {
1953
- displayLine = `${continuationIndent}${chunk}`;
1954
- }
1955
- // Track cursor position
1956
- if (!foundCursor) {
1957
- const chunkStart = lineStartChar + (line.length - remaining.length - chunk.length);
1958
- const chunkEnd = chunkStart + chunk.length;
1959
- if (this.cursor >= chunkStart && this.cursor <= chunkEnd) {
1960
- cursorLine = result.length;
1961
- const offsetInChunk = this.cursor - chunkStart;
1962
- cursorCol = ((isFirstLogicalLine && isFirstDisplayLine) ? promptWidth : continuationWidth) + offsetInChunk;
1963
- foundCursor = true;
1964
- }
1965
- }
1966
- result.push(displayLine);
1967
- isFirstDisplayLine = false;
1968
- // If nothing left and this was an empty line, we already added it
1969
- if (remaining.length === 0 && chunk.length === 0)
1970
- break;
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 = '';
1971
1440
  }
1972
- totalChars += line.length + 1; // +1 for the newline separator
1973
- }
1974
- // Handle cursor at very end
1975
- if (!foundCursor) {
1976
- cursorLine = Math.max(0, result.length - 1);
1977
- const lastLine = result[cursorLine] ?? '';
1978
- cursorCol = this.visibleLength(lastLine);
1979
- }
1980
- // Add cursor highlight to the appropriate position
1981
- if (result.length > 0) {
1982
- const targetLine = result[cursorLine] ?? '';
1983
- const visiblePart = this.stripAnsi(targetLine);
1984
- const cursorPos = Math.min(cursorCol, visiblePart.length);
1985
- // Rebuild the line with cursor highlight
1986
- const before = visiblePart.slice(0, cursorPos);
1987
- const at = visiblePart.charAt(cursorPos) || ' ';
1988
- const after = visiblePart.slice(cursorPos + 1);
1989
- // Preserve the prompt/indent styling
1990
- const prefix = cursorLine === 0 ? prompt : continuationIndent;
1991
- const textPart = cursorLine === 0 ? before.slice(promptWidth) : before.slice(continuationWidth);
1992
- result[cursorLine] = `${prefix}${textPart}${ESC.REVERSE}${at}${ESC.RESET}${after}`;
1993
- }
1994
- // Store cursor column for terminal positioning
1995
- this.cursorVisibleColumn = cursorCol + 1;
1996
- return result.join('\n');
1997
- }
1998
- buildInputWindow(available) {
1999
- if (available <= 0) {
2000
- return { text: '', cursor: 0 };
1441
+ current += char;
2001
1442
  }
2002
- if (this.collapsedPaste) {
2003
- return { text: '', cursor: 0 };
1443
+ if (current || rawLines.length === 0) {
1444
+ rawLines.push(current);
2004
1445
  }
2005
- const normalized = this.buffer.replace(/\r/g, '\n');
2006
- const cursorIndex = Math.min(this.cursor, normalized.length);
2007
- let offset = this.inputRenderOffset;
2008
- if (cursorIndex < offset) {
2009
- offset = cursorIndex;
2010
- }
2011
- const overflow = cursorIndex - offset - available + 1;
2012
- if (overflow > 0) {
2013
- offset += overflow;
2014
- }
2015
- const maxOffset = Math.max(0, normalized.length - available);
2016
- if (offset > maxOffset) {
2017
- offset = maxOffset;
2018
- }
2019
- this.inputRenderOffset = offset;
2020
- const window = normalized.slice(offset, offset + available);
2021
- const display = window.split('').map(char => (char === '\n' ? NEWLINE_PLACEHOLDER : char)).join('');
2022
- const cursorInWindow = Math.min(display.length, Math.max(0, cursorIndex - offset));
2023
- const before = display.slice(0, cursorInWindow);
2024
- const at = display.charAt(cursorInWindow) || ' ';
2025
- const after = display.slice(cursorInWindow + 1);
1446
+ if (cursorIndex === normalized.length) {
1447
+ cursorRow = rawLines.length - 1;
1448
+ cursorCol = rawLines[cursorRow]?.length ?? 0;
1449
+ }
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
+ });
2026
1468
  return {
2027
- text: `${before}${ESC.REVERSE}${at}${ESC.RESET}${after}`,
2028
- cursor: cursorInWindow,
1469
+ lines,
1470
+ cursorRow,
1471
+ cursorCol: cursorColumn,
2029
1472
  };
2030
1473
  }
2031
1474
  expandCollapsedPaste() {
@@ -2045,7 +1488,6 @@ export class UnifiedUIRenderer extends EventEmitter {
2045
1488
  if (options.resetBuffer) {
2046
1489
  this.buffer = '';
2047
1490
  this.cursor = 0;
2048
- this.inputRenderOffset = 0;
2049
1491
  this.resetSuggestions();
2050
1492
  this.renderPrompt();
2051
1493
  this.emitInputChange();
@@ -2124,6 +1566,17 @@ export class UnifiedUIRenderer extends EventEmitter {
2124
1566
  }
2125
1567
  return result;
2126
1568
  }
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
+ }
2127
1580
  getBuffer() {
2128
1581
  return this.buffer;
2129
1582
  }
@@ -2133,7 +1586,6 @@ export class UnifiedUIRenderer extends EventEmitter {
2133
1586
  setBuffer(text, cursorPos) {
2134
1587
  this.buffer = text;
2135
1588
  this.cursor = cursorPos ?? text.length;
2136
- this.inputRenderOffset = 0;
2137
1589
  this.updateSuggestions();
2138
1590
  this.renderPrompt();
2139
1591
  this.emitInputChange();
@@ -2142,7 +1594,6 @@ export class UnifiedUIRenderer extends EventEmitter {
2142
1594
  this.cancelPlainPasteCapture();
2143
1595
  this.buffer = '';
2144
1596
  this.cursor = 0;
2145
- this.inputRenderOffset = 0;
2146
1597
  this.suggestions = [];
2147
1598
  this.suggestionIndex = -1;
2148
1599
  this.renderPrompt();
@@ -2151,35 +1602,6 @@ export class UnifiedUIRenderer extends EventEmitter {
2151
1602
  setModeStatus(status) {
2152
1603
  this.updateStatus(status);
2153
1604
  }
2154
- /**
2155
- * Show a compacting status with animated spinner (Claude Code style)
2156
- * Uses ✻ character with animation to indicate context compaction in progress
2157
- */
2158
- showCompactingStatus(message) {
2159
- this.statusMessage = message;
2160
- if (!this.spinnerInterval) {
2161
- this.spinnerInterval = setInterval(() => {
2162
- this.spinnerFrame++;
2163
- // Cycle activity phrase every ~4 seconds (50 frames at 80ms)
2164
- if (this.spinnerFrame % 50 === 0) {
2165
- this.activityPhraseIndex++;
2166
- }
2167
- this.renderPrompt();
2168
- }, 80);
2169
- }
2170
- this.renderPrompt();
2171
- }
2172
- /**
2173
- * Hide the compacting status and stop spinner animation
2174
- */
2175
- hideCompactingStatus() {
2176
- if (this.spinnerInterval) {
2177
- clearInterval(this.spinnerInterval);
2178
- this.spinnerInterval = null;
2179
- }
2180
- this.statusMessage = null;
2181
- this.renderPrompt();
2182
- }
2183
1605
  emitPrompt(content) {
2184
1606
  this.pushPromptEvent(content);
2185
1607
  }
@@ -2223,30 +1645,23 @@ export class UnifiedUIRenderer extends EventEmitter {
2223
1645
  const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
2224
1646
  if (height === 0)
2225
1647
  return;
2226
- // Cursor is at prompt row. Move up to top of overlay first.
2227
- if (this.lastOverlay) {
2228
- const linesToTop = this.lastOverlay.promptIndex;
2229
- if (linesToTop > 0) {
2230
- this.write(`\x1b[${linesToTop}A`);
2231
- }
2232
- }
2233
- // Now at top, clear each line downward
2234
- for (let i = 0; i < height; i++) {
2235
- this.write('\r');
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));
2236
1656
  this.write(ESC.CLEAR_LINE);
2237
- if (i < height - 1) {
2238
- this.write('\x1b[B');
2239
- }
2240
- }
2241
- // Move back to top (where content should continue from)
2242
- if (height > 1) {
2243
- this.write(`\x1b[${height - 1}A`);
2244
1657
  }
2245
- this.write('\r');
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;
2246
1662
  this.lastOverlay = null;
1663
+ this.overlayInvalidated = true;
2247
1664
  this.promptHeight = 0;
2248
- this.lastOverlayHeight = 0;
2249
- this.isPromptActive = false;
2250
1665
  }
2251
1666
  updateTerminalSize() {
2252
1667
  if (this.output.isTTY) {