erosolar-cli 2.1.171 → 2.1.173

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 (42) hide show
  1. package/dist/capabilities/askUserCapability.js +1 -1
  2. package/dist/capabilities/askUserCapability.js.map +1 -1
  3. package/dist/codex/types.js +1 -1
  4. package/dist/codex/types.js.map +1 -1
  5. package/dist/shell/interactiveShell.d.ts +5 -0
  6. package/dist/shell/interactiveShell.d.ts.map +1 -1
  7. package/dist/shell/interactiveShell.js +18 -2
  8. package/dist/shell/interactiveShell.js.map +1 -1
  9. package/dist/shell/shellApp.d.ts.map +1 -1
  10. package/dist/shell/shellApp.js +0 -1
  11. package/dist/shell/shellApp.js.map +1 -1
  12. package/dist/ui/PromptController.d.ts +3 -0
  13. package/dist/ui/PromptController.d.ts.map +1 -1
  14. package/dist/ui/PromptController.js +3 -0
  15. package/dist/ui/PromptController.js.map +1 -1
  16. package/dist/ui/ShellUIAdapter.d.ts +0 -6
  17. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  18. package/dist/ui/ShellUIAdapter.js +12 -40
  19. package/dist/ui/ShellUIAdapter.js.map +1 -1
  20. package/dist/ui/UnifiedUIController.d.ts +1 -2
  21. package/dist/ui/UnifiedUIController.d.ts.map +1 -1
  22. package/dist/ui/UnifiedUIController.js +0 -1
  23. package/dist/ui/UnifiedUIController.js.map +1 -1
  24. package/dist/ui/UnifiedUIRenderer.d.ts +3 -53
  25. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -1
  26. package/dist/ui/UnifiedUIRenderer.js +72 -587
  27. package/dist/ui/UnifiedUIRenderer.js.map +1 -1
  28. package/dist/ui/display.d.ts +2 -4
  29. package/dist/ui/display.d.ts.map +1 -1
  30. package/dist/ui/display.js +8 -7
  31. package/dist/ui/display.js.map +1 -1
  32. package/dist/ui/orchestration/StatusOrchestrator.d.ts +1 -1
  33. package/dist/ui/orchestration/StatusOrchestrator.js +1 -1
  34. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts +2 -2
  35. package/dist/ui/orchestration/UIUpdateCoordinator.d.ts.map +1 -1
  36. package/dist/ui/orchestration/UIUpdateCoordinator.js +1 -1
  37. package/dist/ui/orchestration/UIUpdateCoordinator.js.map +1 -1
  38. package/dist/ui/unified/index.d.ts +0 -2
  39. package/dist/ui/unified/index.d.ts.map +1 -1
  40. package/dist/ui/unified/index.js +0 -4
  41. package/dist/ui/unified/index.js.map +1 -1
  42. package/package.json +1 -1
@@ -10,28 +10,15 @@
10
10
  */
11
11
  import * as readline from 'node:readline';
12
12
  import { EventEmitter } from 'node:events';
13
- import { homedir } from 'node:os';
14
13
  import { theme, spinnerFrames } from './theme.js';
15
14
  import { isPlainOutputMode } from './outputMode.js';
16
- import { ContextMeter, disposeAnimations } from './animatedStatus.js';
17
15
  const ESC = {
18
- HIDE_CURSOR: '\x1b[?25l',
19
16
  SHOW_CURSOR: '\x1b[?25h',
20
- CLEAR_SCREEN: '\x1b[2J',
21
17
  CLEAR_LINE: '\x1b[2K',
22
- HOME: '\x1b[H',
23
18
  ENABLE_BRACKETED_PASTE: '\x1b[?2004h',
24
19
  DISABLE_BRACKETED_PASTE: '\x1b[?2004l',
25
- TO: (row, col) => `\x1b[${row};${col}H`,
26
- TO_COL: (col) => `\x1b[${col}G`,
27
- ERASE_DOWN: '\x1b[J',
28
20
  REVERSE: '\x1b[7m',
29
21
  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
22
  };
36
23
  const COLLAPSE_HINT = theme.ui.muted('…'); // subtle ellipsis, no key hint
37
24
  const NEWLINE_PLACEHOLDER = '↵';
@@ -41,9 +28,7 @@ export class UnifiedUIRenderer extends EventEmitter {
41
28
  rl;
42
29
  plainMode;
43
30
  interactive;
44
- rows = 24;
45
31
  cols = 80;
46
- lastRenderWidth = null;
47
32
  eventQueue = [];
48
33
  isProcessingQueue = false;
49
34
  buffer = '';
@@ -53,7 +38,6 @@ export class UnifiedUIRenderer extends EventEmitter {
53
38
  suggestions = [];
54
39
  suggestionIndex = -1;
55
40
  availableCommands = [];
56
- hotkeysInToggleLine = new Set();
57
41
  collapsedPaste = null;
58
42
  mode = 'idle';
59
43
  streamingStartTime = null;
@@ -61,52 +45,26 @@ export class UnifiedUIRenderer extends EventEmitter {
61
45
  statusOverride = null;
62
46
  statusStreaming = null;
63
47
  // Animated UI components
64
- streamingSpinner = null;
65
- thinkingIndicator = null;
66
- contextMeter;
67
48
  spinnerFrame = 0;
68
49
  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)")
50
+ // Activity/status tracking
75
51
  activityMessage = null;
76
- activityPhraseIndex = 0;
77
- activityStarFrame = 0;
78
- activityStarFrames = ['✳', '✴', '✵', '✶', '✷', '✸'];
79
- // Token count during streaming
80
52
  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
53
  statusMeta = {};
89
54
  toggleState = {
90
55
  verificationEnabled: false,
91
56
  criticalApprovalMode: 'auto',
92
57
  };
93
58
  // ------------ Helpers ------------
94
- formatHotkey(combo) {
95
- if (!combo?.trim())
96
- return null;
97
- return combo.trim().toUpperCase();
98
- }
99
59
  lastPromptEvent = null;
100
60
  promptHeight = 0;
101
- lastOverlayHeight = 0;
102
- inlinePanel = [];
103
- hasConversationContent = false;
104
61
  isPromptActive = false;
105
62
  inputRenderOffset = 0;
106
63
  plainPasteIdleMs = 24;
107
64
  plainPasteWindowMs = 60;
108
65
  plainPasteTriggerChars = 24;
109
66
  cursorVisibleColumn = 1;
67
+ cursorVisibleRow = 0;
110
68
  inBracketedPaste = false;
111
69
  pasteBuffer = '';
112
70
  inPlainPaste = false;
@@ -117,10 +75,6 @@ export class UnifiedUIRenderer extends EventEmitter {
117
75
  plainRecentChunks = [];
118
76
  lastRenderedEventKey = null;
119
77
  lastOutputEndedWithNewline = true;
120
- hasRenderedPrompt = false;
121
- hasEverRenderedOverlay = false; // Track if we've ever rendered for inline clearing
122
- lastOverlay = null;
123
- allowPromptRender = true;
124
78
  inputCapture = null;
125
79
  constructor(output = process.stdout, input = process.stdin, options) {
126
80
  super();
@@ -128,8 +82,6 @@ export class UnifiedUIRenderer extends EventEmitter {
128
82
  this.input = input;
129
83
  this.interactive = Boolean(this.output.isTTY && this.input.isTTY && !process.env['CI']);
130
84
  this.plainMode = isPlainOutputMode() || !this.interactive;
131
- // Initialize animated components
132
- this.contextMeter = new ContextMeter();
133
85
  this.rl = readline.createInterface({
134
86
  input: this.input,
135
87
  output: this.output,
@@ -140,7 +92,7 @@ export class UnifiedUIRenderer extends EventEmitter {
140
92
  this.rl.setPrompt('');
141
93
  this.updateTerminalSize();
142
94
  this.output.on('resize', () => {
143
- if (!this.plainMode) {
95
+ if (this.interactive) {
144
96
  this.updateTerminalSize();
145
97
  this.renderPrompt();
146
98
  }
@@ -151,59 +103,30 @@ export class UnifiedUIRenderer extends EventEmitter {
151
103
  if (!this.interactive) {
152
104
  return;
153
105
  }
154
- if (!this.plainMode) {
155
- // If an overlay was already rendered before initialization (e.g., banner emitted early),
156
- // clear it so initialize() doesn't stack a second control bar in scrollback.
157
- if (this.hasRenderedPrompt || this.lastOverlay) {
158
- this.clearPromptArea();
159
- }
160
- this.write(ESC.ENABLE_BRACKETED_PASTE);
161
- this.updateTerminalSize();
162
- this.hasRenderedPrompt = false;
163
- this.lastOutputEndedWithNewline = true;
164
- this.write(ESC.SHOW_CURSOR);
165
- return;
166
- }
167
- // Plain mode: minimal setup, still render a simple prompt line
106
+ this.write(ESC.ENABLE_BRACKETED_PASTE);
168
107
  this.updateTerminalSize();
169
- this.hasRenderedPrompt = false;
170
108
  this.lastOutputEndedWithNewline = true;
109
+ this.write(ESC.SHOW_CURSOR);
171
110
  this.renderPrompt();
172
111
  }
173
112
  cleanup() {
174
113
  this.cancelInputCapture(new Error('Renderer disposed'));
175
114
  this.cancelPlainPasteCapture();
176
- // Stop any running animations
177
115
  if (this.spinnerInterval) {
178
116
  clearInterval(this.spinnerInterval);
179
117
  this.spinnerInterval = null;
180
118
  }
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();
191
119
  if (!this.interactive) {
192
120
  this.rl.close();
193
121
  return;
194
122
  }
195
- if (!this.plainMode) {
196
- // Clear the prompt area so it doesn't remain in scrollback history
197
- this.clearPromptArea();
198
- this.write(ESC.DISABLE_BRACKETED_PASTE);
199
- this.write(ESC.SHOW_CURSOR);
200
- this.write('\n');
201
- }
123
+ this.write(ESC.DISABLE_BRACKETED_PASTE);
124
+ this.write(ESC.SHOW_CURSOR);
125
+ this.write('\n');
202
126
  if (this.input.isTTY) {
203
127
  this.input.setRawMode(false);
204
128
  }
205
129
  this.rl.close();
206
- this.lastOverlay = null;
207
130
  }
208
131
  // ------------ Input handling ------------
209
132
  setupInputHandlers() {
@@ -244,6 +167,10 @@ export class UnifiedUIRenderer extends EventEmitter {
244
167
  this.emit('toggle-critical-approval');
245
168
  return;
246
169
  }
170
+ if (key.ctrl && key.shift && key.name?.toLowerCase() === 'n') {
171
+ this.emit('toggle-network');
172
+ return;
173
+ }
247
174
  if (key.ctrl && key.name === 'c') {
248
175
  // Three-stage Ctrl+C behavior:
249
176
  // 1. Clear chat box if it has text
@@ -722,16 +649,6 @@ export class UnifiedUIRenderer extends EventEmitter {
722
649
  const normalized = this.normalizeEventType(type);
723
650
  if (!normalized)
724
651
  return;
725
- if (normalized === 'prompt' ||
726
- normalized === 'response' ||
727
- normalized === 'thought' ||
728
- normalized === 'stream' ||
729
- normalized === 'tool' ||
730
- normalized === 'tool-result' ||
731
- normalized === 'build' ||
732
- normalized === 'test') {
733
- this.hasConversationContent = true;
734
- }
735
652
  if (this.plainMode) {
736
653
  const formatted = this.formatContent({
737
654
  type: normalized,
@@ -789,23 +706,11 @@ export class UnifiedUIRenderer extends EventEmitter {
789
706
  const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown renderer error';
790
707
  this.output.write(`\n[renderer] ${message}\n`);
791
708
  }
792
- // For prompt events, ensure the overlay is rendered immediately
793
- // This guarantees prompts are visible before async processing continues
794
- if (event.type === 'prompt') {
795
- if (this.output.isTTY) {
796
- this.allowPromptRender = true;
797
- this.renderPrompt();
798
- }
799
- // No delay for prompt events - render immediately
800
- }
801
- else {
709
+ if (event.type !== 'prompt') {
802
710
  await this.delay(1);
803
711
  }
804
712
  }
805
- // ALWAYS render prompt after queue completes to keep bottom UI persistent
806
- // This ensures status/toggles stay pinned and responses are fully rendered
807
- if (this.output.isTTY) {
808
- this.allowPromptRender = true;
713
+ if (this.output.isTTY && this.interactive) {
809
714
  this.renderPrompt();
810
715
  }
811
716
  }
@@ -820,28 +725,18 @@ export class UnifiedUIRenderer extends EventEmitter {
820
725
  */
821
726
  async flushEvents(timeoutMs = 250) {
822
727
  // Kick off processing if idle
823
- if (!this.plainMode && !this.isProcessingQueue && this.eventQueue.length > 0) {
728
+ if (!this.isProcessingQueue && this.eventQueue.length > 0) {
824
729
  void this.processQueue();
825
730
  }
826
731
  const start = Date.now();
827
732
  while ((this.isProcessingQueue || this.eventQueue.length > 0) && Date.now() - start < timeoutMs) {
828
733
  await this.delay(5);
829
734
  }
830
- if (!this.plainMode && this.output.isTTY) {
831
- this.allowPromptRender = true;
735
+ if (this.output.isTTY && this.interactive) {
832
736
  this.renderPrompt();
833
737
  }
834
738
  }
835
739
  async renderEvent(event) {
836
- if (this.plainMode) {
837
- const formattedPlain = this.formatContent(event);
838
- if (formattedPlain) {
839
- const text = formattedPlain.endsWith('\n') ? formattedPlain : `${formattedPlain}\n`;
840
- this.output.write(text);
841
- this.lastOutputEndedWithNewline = text.endsWith('\n');
842
- }
843
- return;
844
- }
845
740
  const formatted = this.formatContent(event);
846
741
  if (!formatted)
847
742
  return;
@@ -854,11 +749,9 @@ export class UnifiedUIRenderer extends EventEmitter {
854
749
  if (event.type !== 'prompt') {
855
750
  this.lastRenderedEventKey = signature;
856
751
  }
857
- // Clear the prompt area before writing new content
858
- if (this.promptHeight > 0 || this.lastOverlay) {
859
- this.clearPromptArea();
752
+ if (this.isPromptActive) {
753
+ this.clearPromptArea(true);
860
754
  }
861
- this.isPromptActive = false;
862
755
  if (event.type !== 'stream' && !this.lastOutputEndedWithNewline && formatted.trim()) {
863
756
  // Keep scrollback ordering predictable when previous output ended mid-line
864
757
  this.output.write('\n');
@@ -866,6 +759,9 @@ export class UnifiedUIRenderer extends EventEmitter {
866
759
  }
867
760
  this.output.write(formatted);
868
761
  this.lastOutputEndedWithNewline = formatted.endsWith('\n');
762
+ if (this.interactive && !this.plainMode) {
763
+ this.renderPrompt();
764
+ }
869
765
  }
870
766
  normalizeEventType(type) {
871
767
  switch (type) {
@@ -1263,22 +1159,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1263
1159
  }
1264
1160
  return result.join('\n') + '\n';
1265
1161
  }
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`;
1281
- }
1282
1162
  /**
1283
1163
  * Format a compact conversation block (Claude Code style)
1284
1164
  * Shows a visual separator with "history" label and ctrl+o hint
@@ -1329,8 +1209,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1329
1209
  this.write('\n');
1330
1210
  this.lastOutputEndedWithNewline = true;
1331
1211
  }
1332
- if (!this.plainMode) {
1333
- // Always render prompt to keep bottom UI persistent (rich mode only)
1212
+ if (this.interactive) {
1334
1213
  this.renderPrompt();
1335
1214
  }
1336
1215
  }
@@ -1341,15 +1220,12 @@ export class UnifiedUIRenderer extends EventEmitter {
1341
1220
  if (this.spinnerInterval)
1342
1221
  return; // Already running
1343
1222
  this.spinnerFrame = 0;
1344
- this.activityStarFrame = 0;
1345
1223
  this.spinnerInterval = setInterval(() => {
1346
1224
  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') {
1225
+ if (this.mode === 'streaming') {
1350
1226
  this.renderPrompt();
1351
1227
  }
1352
- }, 80); // ~12 FPS for smooth spinner animation
1228
+ }, 120);
1353
1229
  }
1354
1230
  /**
1355
1231
  * Stop the animated spinner
@@ -1360,7 +1236,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1360
1236
  this.spinnerInterval = null;
1361
1237
  }
1362
1238
  this.spinnerFrame = 0;
1363
- this.activityStarFrame = 0;
1364
1239
  this.activityMessage = null;
1365
1240
  }
1366
1241
  /**
@@ -1369,7 +1244,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1369
1244
  */
1370
1245
  setActivity(message) {
1371
1246
  this.activityMessage = message;
1372
- if (!this.plainMode) {
1247
+ if (this.interactive) {
1373
1248
  this.renderPrompt();
1374
1249
  }
1375
1250
  }
@@ -1379,16 +1254,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1379
1254
  updateStreamingTokens(tokens) {
1380
1255
  this.streamingTokens = tokens;
1381
1256
  }
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
1257
  getMode() {
1393
1258
  return this.mode;
1394
1259
  }
@@ -1437,28 +1302,19 @@ export class UnifiedUIRenderer extends EventEmitter {
1437
1302
  }
1438
1303
  updateModeToggles(state) {
1439
1304
  this.toggleState = { ...this.toggleState, ...state };
1440
- if (!state.verificationHotkey &&
1441
- !state.thinkingHotkey &&
1442
- !state.criticalApprovalHotkey) {
1443
- this.hotkeysInToggleLine.clear();
1444
- }
1445
1305
  this.renderPrompt();
1446
1306
  }
1447
1307
  setInlinePanel(lines) {
1448
1308
  const normalized = (lines ?? [])
1449
1309
  .map(line => line.replace(/\s+$/g, ''))
1450
1310
  .filter(line => line.trim().length > 0);
1451
- if (JSON.stringify(normalized) === JSON.stringify(this.inlinePanel)) {
1311
+ if (!normalized.length) {
1452
1312
  return;
1453
1313
  }
1454
- this.inlinePanel = normalized;
1455
- this.renderPrompt();
1314
+ this.addEvent('response', `${normalized.join('\n')}\n`);
1456
1315
  }
1457
1316
  clearInlinePanel() {
1458
- if (!this.inlinePanel.length)
1459
- return;
1460
- this.inlinePanel = [];
1461
- this.renderPrompt();
1317
+ // No-op: inline panels render directly into scrollback
1462
1318
  }
1463
1319
  // ------------ Prompt rendering ------------
1464
1320
  renderPrompt() {
@@ -1466,300 +1322,42 @@ export class UnifiedUIRenderer extends EventEmitter {
1466
1322
  this.isPromptActive = false;
1467
1323
  return;
1468
1324
  }
1469
- if (this.plainMode) {
1470
- const line = `> ${this.buffer}`;
1471
- if (!this.isPromptActive && !this.lastOutputEndedWithNewline) {
1472
- this.write('\n');
1473
- this.lastOutputEndedWithNewline = true;
1474
- }
1475
- this.write(`\r${ESC.CLEAR_LINE}${line}`);
1476
- this.cursorVisibleColumn = line.length + 1;
1477
- this.hasRenderedPrompt = true;
1478
- this.isPromptActive = true;
1479
- this.lastOutputEndedWithNewline = false; // prompt ends mid-line by design
1480
- this.promptHeight = 1;
1481
- return;
1482
- }
1483
- if (!this.allowPromptRender) {
1484
- return;
1485
- }
1486
- // Rich inline mode: prompt flows naturally with content
1487
1325
  this.updateTerminalSize();
1488
- const maxWidth = this.safeWidth();
1489
- this.lastRenderWidth = maxWidth;
1490
- const overlay = this.buildOverlayLines();
1491
- if (!overlay.lines.length) {
1492
- return;
1493
- }
1494
- const renderedLines = overlay.lines.map(line => this.truncateLine(line, maxWidth));
1495
- if (!renderedLines.length) {
1496
- return;
1326
+ const status = this.composeStatusLabel();
1327
+ const inputLine = this.buildInputLine();
1328
+ const inputLines = inputLine.split('\n');
1329
+ const lines = [];
1330
+ if (status) {
1331
+ lines.push(this.applyTone(status.text, status.tone));
1497
1332
  }
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
- }
1333
+ lines.push(...inputLines);
1334
+ const hadPrompt = this.isPromptActive;
1335
+ this.clearPromptArea();
1336
+ if (!hadPrompt && !this.lastOutputEndedWithNewline) {
1337
+ this.write('\n');
1338
+ this.lastOutputEndedWithNewline = true;
1527
1339
  }
1528
- // Write prompt lines (no trailing newline on last line)
1529
- for (let i = 0; i < renderedLines.length; i++) {
1340
+ for (let i = 0; i < lines.length; i++) {
1530
1341
  this.write('\r');
1531
1342
  this.write(ESC.CLEAR_LINE);
1532
- this.write(renderedLines[i] || '');
1533
- if (i < renderedLines.length - 1) {
1343
+ this.write(lines[i] || '');
1344
+ if (i < lines.length - 1) {
1534
1345
  this.write('\n');
1535
1346
  }
1536
1347
  }
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;
1546
- this.hasRenderedPrompt = true;
1547
- this.hasEverRenderedOverlay = true;
1348
+ const cursorRow = Math.min(lines.length - 1, this.cursorVisibleRow ?? lines.length - 1);
1349
+ const rowsToMoveUp = lines.length - 1 - cursorRow;
1350
+ if (rowsToMoveUp > 0) {
1351
+ this.write(`\x1b[${rowsToMoveUp}A`);
1352
+ }
1353
+ const cursorCol = Math.max(1, this.cursorVisibleColumn);
1354
+ this.write(`\x1b[${cursorCol}G`);
1548
1355
  this.isPromptActive = true;
1549
- this.lastOverlayHeight = height;
1550
- this.lastOverlay = { lines: renderedLines, promptIndex };
1356
+ this.promptHeight = lines.length;
1551
1357
  this.lastOutputEndedWithNewline = false;
1552
- this.promptHeight = height;
1553
- }
1554
- buildOverlayLines() {
1555
- const lines = [];
1556
- 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) {
1588
- lines.push(this.truncateLine(line, maxWidth));
1589
- }
1590
- // Bottom divider
1591
- lines.push(divider);
1592
- // Inline panel (pinned scroll box for live output/menus)
1593
- if (this.inlinePanel.length > 0) {
1594
- for (const panelLine of this.inlinePanel) {
1595
- lines.push(this.truncateLine(` ${panelLine}`, maxWidth));
1596
- }
1597
- // Separate inline content from suggestions/toggles
1598
- lines.push(divider);
1599
- }
1600
- // Slash command suggestions
1601
- if (this.suggestions.length > 0) {
1602
- for (let index = 0; index < this.suggestions.length; index++) {
1603
- const suggestion = this.suggestions[index];
1604
- const isActive = index === this.suggestionIndex;
1605
- const marker = isActive ? theme.primary('▸') : theme.ui.muted(' ');
1606
- const cmdText = isActive ? theme.primary(suggestion.command) : theme.ui.muted(suggestion.command);
1607
- const descText = isActive ? suggestion.description : theme.ui.muted(suggestion.description);
1608
- lines.push(this.truncateLine(` ${marker} ${cmdText} — ${descText}`, maxWidth));
1609
- }
1610
- }
1611
- // Model and context info
1612
- const modelContextLine = this.buildModelContextLine();
1613
- if (modelContextLine) {
1614
- lines.push(this.truncateLine(` ${modelContextLine}`, maxWidth));
1615
- }
1616
- // Mode toggles
1617
- const toggleLine = this.buildInlineToggleLine();
1618
- if (toggleLine) {
1619
- lines.push(this.truncateLine(` ${toggleLine}`, maxWidth));
1620
- }
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')}`);
1647
- }
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];
1680
- }
1681
- abbreviatePath(pathValue) {
1682
- const home = homedir();
1683
- if (home && pathValue.startsWith(home)) {
1684
- return pathValue.replace(home, '~');
1685
- }
1686
- return pathValue;
1687
- }
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());
1710
- }
1711
- return this.wrapSegments(segments, maxWidth);
1712
- }
1713
- buildMetaBlock(maxWidth) {
1714
- const segments = [];
1715
- if (this.statusMeta.profile) {
1716
- segments.push(this.formatMetaSegment('profile', this.statusMeta.profile, 'info'));
1717
- }
1718
- const model = this.statusMeta.provider && this.statusMeta.model
1719
- ? `${this.statusMeta.provider} / ${this.statusMeta.model}`
1720
- : this.statusMeta.model || this.statusMeta.provider;
1721
- if (model) {
1722
- segments.push(this.formatMetaSegment('model', model, 'info'));
1723
- }
1724
- const workspace = this.statusMeta.workspace || this.statusMeta.directory;
1725
- 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));
1743
- }
1744
- if (this.statusMeta.writes) {
1745
- segments.push(this.formatMetaSegment('writes', this.statusMeta.writes, 'muted'));
1746
- }
1747
- if (this.statusMeta.toolSummary) {
1748
- segments.push(this.formatMetaSegment('tools', this.statusMeta.toolSummary, 'muted'));
1749
- }
1750
- if (this.statusMeta.sessionLabel) {
1751
- segments.push(this.formatMetaSegment('session', this.statusMeta.sessionLabel, 'muted'));
1752
- }
1753
- if (this.statusMeta.version) {
1754
- segments.push(this.formatMetaSegment('build', `v${this.statusMeta.version}`, 'muted'));
1755
- }
1756
- if (segments.length === 0) {
1757
- return [];
1758
- }
1759
- return this.wrapSegments(segments, maxWidth);
1760
1358
  }
1761
1359
  composeStatusLabel() {
1762
- const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1360
+ const statuses = [this.activityMessage, this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1763
1361
  const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
1764
1362
  if (!text.trim()) {
1765
1363
  return null;
@@ -1819,97 +1417,6 @@ export class UnifiedUIRenderer extends EventEmitter {
1819
1417
  }
1820
1418
  return lines;
1821
1419
  }
1822
- buildControlLines() {
1823
- const lines = [];
1824
- const toggleLine = this.buildToggleLine();
1825
- if (toggleLine) {
1826
- lines.push(`${theme.ui.muted('modes')} ${theme.ui.muted('›')} ${toggleLine}`);
1827
- }
1828
- const shortcutLine = this.buildShortcutLine();
1829
- if (shortcutLine) {
1830
- lines.push(`${theme.ui.muted('keys')} ${shortcutLine}`);
1831
- }
1832
- return lines;
1833
- }
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
- buildToggleLine() {
1858
- const toggles = [];
1859
- const addToggle = (label, on, hotkey, value) => {
1860
- toggles.push({ label, on, hotkey: this.formatHotkey(hotkey), value });
1861
- };
1862
- addToggle('Verify', this.toggleState.verificationEnabled, this.toggleState.verificationHotkey);
1863
- const approvalMode = this.toggleState.criticalApprovalMode || 'auto';
1864
- const approvalActive = approvalMode !== 'auto';
1865
- addToggle('Approvals', approvalActive, this.toggleState.criticalApprovalHotkey, approvalMode === 'auto' ? 'auto' : 'ask');
1866
- const thinkingLabel = (this.toggleState.thinkingModeLabel || 'off').trim();
1867
- const thinkingActive = thinkingLabel.toLowerCase() !== 'off';
1868
- addToggle('Thinking', thinkingActive, this.toggleState.thinkingHotkey, thinkingLabel);
1869
- const buildLine = (includeHotkeys) => {
1870
- return toggles
1871
- .map(toggle => {
1872
- const stateText = toggle.on ? theme.success(toggle.value || 'on') : theme.ui.muted(toggle.value || 'off');
1873
- const hotkeyText = includeHotkeys && toggle.hotkey ? theme.ui.muted(` [${toggle.hotkey}]`) : '';
1874
- return `${theme.ui.muted(`${toggle.label}:`)} ${stateText}${hotkeyText}`;
1875
- })
1876
- .join(theme.ui.muted(' '));
1877
- };
1878
- const maxWidth = this.safeWidth();
1879
- let line = buildLine(true);
1880
- // Record which hotkeys are actually shown so the shortcut line can avoid duplicates
1881
- this.hotkeysInToggleLine = new Set(toggles
1882
- .map(toggle => (toggle.hotkey ? toggle.hotkey : null))
1883
- .filter((key) => Boolean(key)));
1884
- // If the line is too wide, drop hotkey hints to preserve all toggle labels
1885
- if (this.visibleLength(line) > maxWidth) {
1886
- this.hotkeysInToggleLine.clear();
1887
- line = buildLine(false);
1888
- }
1889
- return line.trim() ? line : null;
1890
- }
1891
- buildShortcutLine() {
1892
- const parts = [];
1893
- const addHotkey = (label, combo) => {
1894
- const normalized = this.formatHotkey(combo);
1895
- if (!normalized)
1896
- return;
1897
- if (this.hotkeysInToggleLine.has(normalized)) {
1898
- return;
1899
- }
1900
- parts.push(`${theme.info(normalized)} ${theme.ui.muted(label)}`);
1901
- };
1902
- // Core controls
1903
- addHotkey('interrupt', 'Ctrl+C');
1904
- addHotkey('clear input', 'Ctrl+U');
1905
- // Feature toggles (only if hotkeys are defined)
1906
- addHotkey('verify', this.toggleState.verificationHotkey);
1907
- addHotkey('thinking', this.toggleState.thinkingHotkey);
1908
- if (parts.length === 0) {
1909
- return null;
1910
- }
1911
- return parts.join(theme.ui.muted(' '));
1912
- }
1913
1420
  buildInputLine() {
1914
1421
  if (this.collapsedPaste) {
1915
1422
  const summary = `[pasted ${this.collapsedPaste.lines} lines, ${this.collapsedPaste.chars} chars] (Ctrl+L insert, Backspace discard)`;
@@ -1977,21 +1484,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1977
1484
  const lastLine = result[cursorLine] ?? '';
1978
1485
  cursorCol = this.visibleLength(lastLine);
1979
1486
  }
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
1487
+ this.cursorVisibleRow = cursorLine;
1995
1488
  this.cursorVisibleColumn = cursorCol + 1;
1996
1489
  return result.join('\n');
1997
1490
  }
@@ -2160,12 +1653,8 @@ export class UnifiedUIRenderer extends EventEmitter {
2160
1653
  if (!this.spinnerInterval) {
2161
1654
  this.spinnerInterval = setInterval(() => {
2162
1655
  this.spinnerFrame++;
2163
- // Cycle activity phrase every ~4 seconds (50 frames at 80ms)
2164
- if (this.spinnerFrame % 50 === 0) {
2165
- this.activityPhraseIndex++;
2166
- }
2167
1656
  this.renderPrompt();
2168
- }, 80);
1657
+ }, 120);
2169
1658
  }
2170
1659
  this.renderPrompt();
2171
1660
  }
@@ -2219,38 +1708,34 @@ export class UnifiedUIRenderer extends EventEmitter {
2219
1708
  this.lastPromptEvent = { text: normalized, at: now };
2220
1709
  this.addEvent('prompt', normalized);
2221
1710
  }
2222
- clearPromptArea() {
2223
- const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
2224
- if (height === 0)
2225
- 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`);
1711
+ clearPromptArea(insertNewline = false) {
1712
+ if (!this.isPromptActive || this.promptHeight <= 0) {
1713
+ if (insertNewline && !this.lastOutputEndedWithNewline) {
1714
+ this.write('\n');
1715
+ this.lastOutputEndedWithNewline = true;
2231
1716
  }
1717
+ return;
2232
1718
  }
2233
- // Now at top, clear each line downward
2234
- for (let i = 0; i < height; i++) {
2235
- this.write('\r');
2236
- this.write(ESC.CLEAR_LINE);
2237
- if (i < height - 1) {
2238
- this.write('\x1b[B');
1719
+ if (this.promptHeight > 1) {
1720
+ readline.moveCursor(this.output, 0, -(this.promptHeight - 1));
1721
+ }
1722
+ for (let i = 0; i < this.promptHeight; i++) {
1723
+ readline.cursorTo(this.output, 0);
1724
+ readline.clearLine(this.output, 0);
1725
+ if (i < this.promptHeight - 1) {
1726
+ readline.moveCursor(this.output, 0, 1);
2239
1727
  }
2240
1728
  }
2241
- // Move back to top (where content should continue from)
2242
- if (height > 1) {
2243
- this.write(`\x1b[${height - 1}A`);
1729
+ readline.cursorTo(this.output, 0);
1730
+ if (insertNewline) {
1731
+ this.write('\n');
1732
+ this.lastOutputEndedWithNewline = true;
2244
1733
  }
2245
- this.write('\r');
2246
- this.lastOverlay = null;
2247
1734
  this.promptHeight = 0;
2248
- this.lastOverlayHeight = 0;
2249
1735
  this.isPromptActive = false;
2250
1736
  }
2251
1737
  updateTerminalSize() {
2252
1738
  if (this.output.isTTY) {
2253
- this.rows = this.output.rows || 24;
2254
1739
  this.cols = this.output.columns || 80;
2255
1740
  }
2256
1741
  }