erosolar-cli 1.7.256 → 1.7.258

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 (84) hide show
  1. package/README.md +22 -148
  2. package/dist/core/customCommands.d.ts +1 -0
  3. package/dist/core/customCommands.d.ts.map +1 -1
  4. package/dist/core/customCommands.js +3 -0
  5. package/dist/core/customCommands.js.map +1 -1
  6. package/dist/core/toolPreconditions.d.ts.map +1 -1
  7. package/dist/core/toolPreconditions.js +0 -14
  8. package/dist/core/toolPreconditions.js.map +1 -1
  9. package/dist/core/toolRuntime.d.ts.map +1 -1
  10. package/dist/core/toolRuntime.js +0 -5
  11. package/dist/core/toolRuntime.js.map +1 -1
  12. package/dist/core/toolValidation.d.ts.map +1 -1
  13. package/dist/core/toolValidation.js +14 -3
  14. package/dist/core/toolValidation.js.map +1 -1
  15. package/dist/mcp/sseClient.d.ts.map +1 -1
  16. package/dist/mcp/sseClient.js +9 -18
  17. package/dist/mcp/sseClient.js.map +1 -1
  18. package/dist/plugins/tools/build/buildPlugin.d.ts +0 -6
  19. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  20. package/dist/plugins/tools/build/buildPlugin.js +4 -10
  21. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  22. package/dist/shell/interactiveShell.d.ts +10 -2
  23. package/dist/shell/interactiveShell.d.ts.map +1 -1
  24. package/dist/shell/interactiveShell.js +190 -35
  25. package/dist/shell/interactiveShell.js.map +1 -1
  26. package/dist/shell/terminalInput.d.ts +66 -126
  27. package/dist/shell/terminalInput.d.ts.map +1 -1
  28. package/dist/shell/terminalInput.js +407 -592
  29. package/dist/shell/terminalInput.js.map +1 -1
  30. package/dist/shell/terminalInputAdapter.d.ts +20 -15
  31. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  32. package/dist/shell/terminalInputAdapter.js +14 -22
  33. package/dist/shell/terminalInputAdapter.js.map +1 -1
  34. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  35. package/dist/ui/ShellUIAdapter.js +13 -12
  36. package/dist/ui/ShellUIAdapter.js.map +1 -1
  37. package/dist/ui/display.d.ts +19 -0
  38. package/dist/ui/display.d.ts.map +1 -1
  39. package/dist/ui/display.js +135 -22
  40. package/dist/ui/display.js.map +1 -1
  41. package/dist/ui/theme.d.ts.map +1 -1
  42. package/dist/ui/theme.js +6 -8
  43. package/dist/ui/theme.js.map +1 -1
  44. package/dist/ui/unified/layout.d.ts +1 -0
  45. package/dist/ui/unified/layout.d.ts.map +1 -1
  46. package/dist/ui/unified/layout.js +15 -25
  47. package/dist/ui/unified/layout.js.map +1 -1
  48. package/package.json +1 -1
  49. package/dist/core/aiFlowOptimizer.d.ts +0 -26
  50. package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
  51. package/dist/core/aiFlowOptimizer.js +0 -31
  52. package/dist/core/aiFlowOptimizer.js.map +0 -1
  53. package/dist/core/aiOptimizationEngine.d.ts +0 -158
  54. package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
  55. package/dist/core/aiOptimizationEngine.js +0 -428
  56. package/dist/core/aiOptimizationEngine.js.map +0 -1
  57. package/dist/core/aiOptimizationIntegration.d.ts +0 -93
  58. package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
  59. package/dist/core/aiOptimizationIntegration.js +0 -250
  60. package/dist/core/aiOptimizationIntegration.js.map +0 -1
  61. package/dist/core/enhancedErrorRecovery.d.ts +0 -100
  62. package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
  63. package/dist/core/enhancedErrorRecovery.js +0 -345
  64. package/dist/core/enhancedErrorRecovery.js.map +0 -1
  65. package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
  66. package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
  67. package/dist/shell/claudeCodeStreamHandler.js +0 -322
  68. package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
  69. package/dist/shell/inputQueueManager.d.ts +0 -144
  70. package/dist/shell/inputQueueManager.d.ts.map +0 -1
  71. package/dist/shell/inputQueueManager.js +0 -290
  72. package/dist/shell/inputQueueManager.js.map +0 -1
  73. package/dist/shell/streamingOutputManager.d.ts +0 -115
  74. package/dist/shell/streamingOutputManager.d.ts.map +0 -1
  75. package/dist/shell/streamingOutputManager.js +0 -225
  76. package/dist/shell/streamingOutputManager.js.map +0 -1
  77. package/dist/ui/persistentPrompt.d.ts +0 -50
  78. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  79. package/dist/ui/persistentPrompt.js +0 -92
  80. package/dist/ui/persistentPrompt.js.map +0 -1
  81. package/dist/ui/terminalUISchema.d.ts +0 -195
  82. package/dist/ui/terminalUISchema.d.ts.map +0 -1
  83. package/dist/ui/terminalUISchema.js +0 -113
  84. package/dist/ui/terminalUISchema.js.map +0 -1
@@ -3,16 +3,18 @@
3
3
  *
4
4
  * Design principles:
5
5
  * - Single source of truth for input state
6
+ * - One bottom-pinned chat box for the entire session (no inline anchors)
6
7
  * - Native bracketed paste support (no heuristics)
7
8
  * - Clean cursor model with render-time wrapping
8
9
  * - State machine for different input modes
9
10
  * - No readline dependency for display
10
11
  */
11
12
  import { EventEmitter } from 'node:events';
12
- import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
13
+ import { isMultilinePaste } from '../core/multilinePasteHandler.js';
13
14
  import { writeLock } from '../ui/writeLock.js';
14
- import { renderDivider } from '../ui/unified/layout.js';
15
- import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
15
+ import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
+ import { isStreamingMode } from '../ui/globalWriteLock.js';
17
+ import { formatThinking } from '../ui/toolDisplay.js';
16
18
  // ANSI escape codes
17
19
  const ESC = {
18
20
  // Cursor control
@@ -67,6 +69,11 @@ export class TerminalInput extends EventEmitter {
67
69
  statusMessage = null;
68
70
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
69
71
  streamingLabel = null; // Streaming progress indicator
72
+ metaElapsedSeconds = null; // Optional elapsed time for header line
73
+ metaTokensUsed = null; // Optional token usage
74
+ metaTokenLimit = null; // Optional token window
75
+ metaThinkingMs = null; // Optional thinking duration
76
+ metaThinkingHasContent = false; // Whether collapsed thinking content exists
70
77
  reservedLines = 2;
71
78
  scrollRegionActive = false;
72
79
  lastRenderContent = '';
@@ -74,45 +81,35 @@ export class TerminalInput extends EventEmitter {
74
81
  renderDirty = false;
75
82
  isRendering = false;
76
83
  pinnedTopRows = 0;
77
- inlineAnchorRow = null;
78
- inlineLayout = false;
79
- anchorProvider = null;
80
- // Flow mode: when true, renders inline after content (no absolute positioning)
81
- flowMode = true;
82
- flowModeRenderedLines = 0; // Track lines rendered for clearing
83
- // Command suggestions (Claude Code style auto-complete)
84
- commandSuggestions = [];
85
- filteredSuggestions = [];
86
- selectedSuggestionIndex = 0;
87
- showSuggestions = false;
88
- maxVisibleSuggestions = 10;
89
84
  // Lifecycle
90
85
  disposed = false;
91
86
  enabled = true;
92
87
  contextUsage = null;
88
+ contextAutoCompactThreshold = 90;
89
+ thinkingModeLabel = null;
93
90
  editMode = 'display-edits';
94
91
  verificationEnabled = true;
95
92
  autoContinueEnabled = false;
96
93
  verificationHotkey = 'alt+v';
97
94
  autoContinueHotkey = 'alt+c';
95
+ thinkingHotkey = '/thinking';
96
+ modelLabel = null;
97
+ providerLabel = null;
98
98
  // Output interceptor cleanup
99
99
  outputInterceptorCleanup;
100
- // Metrics tracking for status bar
101
- streamingStartTime = null;
102
- tokensUsed = 0;
103
- thinkingEnabled = true;
104
- // Streaming input area render timer (updates elapsed time display)
100
+ // Streaming render throttle
101
+ lastStreamingRender = 0;
102
+ streamingRenderInterval = 250; // ms between renders during streaming
105
103
  streamingRenderTimer = null;
106
104
  constructor(writeStream = process.stdout, config = {}) {
107
105
  super();
108
106
  this.out = writeStream;
109
- // Use schema defaults for configuration consistency
110
107
  this.config = {
111
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
112
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
108
+ maxLines: config.maxLines ?? 1000,
109
+ maxLength: config.maxLength ?? 10000,
113
110
  maxQueueSize: config.maxQueueSize ?? 100,
114
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
115
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
111
+ promptChar: config.promptChar ?? '> ',
112
+ continuationChar: config.continuationChar ?? '│ ',
116
113
  };
117
114
  }
118
115
  // ===========================================================================
@@ -191,11 +188,6 @@ export class TerminalInput extends EventEmitter {
191
188
  if (handled)
192
189
  return;
193
190
  }
194
- // Handle '?' for help hint (if buffer is empty)
195
- if (str === '?' && this.buffer.length === 0) {
196
- this.emit('showHelp');
197
- return;
198
- }
199
191
  // Insert printable characters
200
192
  if (str && !key?.ctrl && !key?.meta) {
201
193
  this.insertText(str);
@@ -204,225 +196,38 @@ export class TerminalInput extends EventEmitter {
204
196
  /**
205
197
  * Set the input mode
206
198
  *
207
- * Streaming mode disables scroll region and lets content flow naturally.
208
- * The input area will be re-rendered after streaming ends at wherever
209
- * the cursor is (below the streamed content).
199
+ * Streaming keeps the scroll region active so the prompt/status stay pinned
200
+ * below the streaming output. When streaming ends, we refresh the input area.
210
201
  */
211
202
  setMode(mode) {
212
203
  const prevMode = this.mode;
213
204
  this.mode = mode;
214
205
  if (mode === 'streaming' && prevMode !== 'streaming') {
215
- // Track streaming start time for elapsed display
216
- this.streamingStartTime = Date.now();
217
- const { rows } = this.getSize();
218
- // Banner ~7 lines, full input area 5 lines (status + div + input + div + controls)
219
- const bannerLines = 7;
220
- const inputAreaLines = 5;
221
- this.pinnedTopRows = bannerLines;
222
- this.reservedLines = inputAreaLines;
223
- // Clear content area (between banner and input area)
224
- for (let i = bannerLines + 1; i <= rows - inputAreaLines; i++) {
225
- this.write(ESC.TO(i, 1));
226
- this.write(ESC.CLEAR_LINE);
227
- }
228
- // Enable scroll region: from below banner to above input area
206
+ // Keep scroll region active so status/prompt stay pinned while streaming
207
+ this.resetStreamingRenderThrottle();
229
208
  this.enableScrollRegion();
230
- // Position cursor in scroll region FIRST (so save/restore works correctly)
231
- this.write(ESC.TO(bannerLines + 1, 1));
232
- // Now render full input area at bottom (save/restore will preserve row 8 position)
233
- this.forceRender();
234
- // Start timer to update status bar with elapsed time (just re-renders input area)
235
- this.streamingRenderTimer = setInterval(() => {
236
- if (this.mode === 'streaming') {
237
- this.forceRender();
238
- }
239
- }, 1000);
240
209
  this.renderDirty = true;
210
+ this.render();
241
211
  }
242
212
  else if (mode !== 'streaming' && prevMode === 'streaming') {
243
- // Stop streaming render timer
244
- if (this.streamingRenderTimer) {
245
- clearInterval(this.streamingRenderTimer);
246
- this.streamingRenderTimer = null;
247
- }
248
- // Reset streaming time and pinned rows
249
- this.streamingStartTime = null;
250
- this.pinnedTopRows = 0;
251
- // Disable scroll region
252
- this.disableScrollRegion();
253
- // Show cursor again
254
- this.write(ESC.SHOW);
255
- // Position cursor at end of content area and add newline
256
- const { rows } = this.getSize();
257
- const contentEnd = Math.max(1, rows - this.reservedLines);
258
- this.write(ESC.TO(contentEnd, 1));
259
- this.write('\n');
260
- // Reset flow mode tracking
261
- this.flowModeRenderedLines = 0;
262
- // Re-render the input area in normal mode
213
+ // Streaming ended - render the input area
214
+ this.resetStreamingRenderThrottle();
215
+ this.enableScrollRegion();
263
216
  this.forceRender();
264
217
  }
265
218
  }
266
- /**
267
- * Enable or disable flow mode.
268
- * In flow mode, the input renders immediately after content (wherever cursor is).
269
- * When disabled, input renders at the absolute bottom of terminal.
270
- */
271
- setFlowMode(enabled) {
272
- if (this.flowMode === enabled)
273
- return;
274
- this.flowMode = enabled;
275
- this.renderDirty = true;
276
- this.scheduleRender();
277
- }
278
- /**
279
- * Check if flow mode is enabled.
280
- */
281
- isFlowMode() {
282
- return this.flowMode;
283
- }
284
- /**
285
- * Set available slash commands for auto-complete suggestions.
286
- */
287
- setCommands(commands) {
288
- this.commandSuggestions = commands;
289
- this.updateSuggestions();
290
- }
291
- /**
292
- * Update filtered suggestions based on current input.
293
- */
294
- updateSuggestions() {
295
- const input = this.buffer.trim();
296
- // Only show suggestions when input starts with "/"
297
- if (!input.startsWith('/')) {
298
- this.showSuggestions = false;
299
- this.filteredSuggestions = [];
300
- this.selectedSuggestionIndex = 0;
301
- return;
302
- }
303
- const query = input.toLowerCase();
304
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
305
- cmd.command.toLowerCase().includes(query.slice(1)));
306
- // Show suggestions if we have matches
307
- this.showSuggestions = this.filteredSuggestions.length > 0;
308
- // Keep selection in bounds
309
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
310
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
311
- }
312
- }
313
- /**
314
- * Select next suggestion (arrow down / tab).
315
- */
316
- selectNextSuggestion() {
317
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
318
- return;
319
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
320
- this.renderDirty = true;
321
- this.scheduleRender();
322
- }
323
- /**
324
- * Select previous suggestion (arrow up / shift+tab).
325
- */
326
- selectPrevSuggestion() {
327
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
328
- return;
329
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
330
- ? this.filteredSuggestions.length - 1
331
- : this.selectedSuggestionIndex - 1;
332
- this.renderDirty = true;
333
- this.scheduleRender();
334
- }
335
- /**
336
- * Accept current suggestion and insert into buffer.
337
- */
338
- acceptSuggestion() {
339
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
340
- return false;
341
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
342
- if (!selected)
343
- return false;
344
- // Replace buffer with selected command
345
- this.buffer = selected.command + ' ';
346
- this.cursor = this.buffer.length;
347
- this.showSuggestions = false;
348
- this.renderDirty = true;
349
- this.scheduleRender();
350
- return true;
351
- }
352
- /**
353
- * Check if suggestions are visible.
354
- */
355
- areSuggestionsVisible() {
356
- return this.showSuggestions && this.filteredSuggestions.length > 0;
357
- }
358
- /**
359
- * Update token count for metrics display
360
- */
361
- setTokensUsed(tokens) {
362
- this.tokensUsed = tokens;
363
- }
364
- /**
365
- * Toggle thinking/reasoning mode
366
- */
367
- toggleThinking() {
368
- this.thinkingEnabled = !this.thinkingEnabled;
369
- this.emit('thinkingToggle', this.thinkingEnabled);
370
- this.scheduleRender();
371
- }
372
- /**
373
- * Get thinking enabled state
374
- */
375
- isThinkingEnabled() {
376
- return this.thinkingEnabled;
377
- }
378
219
  /**
379
220
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
380
221
  */
381
222
  setPinnedHeaderLines(count) {
382
- // Set pinned header rows (banner area that scroll region excludes)
383
- if (this.pinnedTopRows !== count) {
384
- this.pinnedTopRows = count;
223
+ // No pinned header rows anymore; keep everything in the scroll region.
224
+ if (this.pinnedTopRows !== 0) {
225
+ this.pinnedTopRows = 0;
385
226
  if (this.scrollRegionActive) {
386
227
  this.applyScrollRegion();
387
228
  }
388
229
  }
389
230
  }
390
- /**
391
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
392
- * restore the default bottom-aligned layout.
393
- */
394
- setInlineAnchor(row) {
395
- if (row === null || row === undefined) {
396
- this.inlineAnchorRow = null;
397
- this.inlineLayout = false;
398
- this.renderDirty = true;
399
- this.render();
400
- return;
401
- }
402
- const { rows } = this.getSize();
403
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
404
- this.inlineAnchorRow = clamped;
405
- this.inlineLayout = true;
406
- this.renderDirty = true;
407
- this.render();
408
- }
409
- /**
410
- * Provide a dynamic anchor callback. When set, the prompt will follow the
411
- * output by re-evaluating the anchor before each render.
412
- */
413
- setInlineAnchorProvider(provider) {
414
- this.anchorProvider = provider;
415
- if (!provider) {
416
- this.inlineLayout = false;
417
- this.inlineAnchorRow = null;
418
- this.renderDirty = true;
419
- this.render();
420
- return;
421
- }
422
- this.inlineLayout = true;
423
- this.renderDirty = true;
424
- this.render();
425
- }
426
231
  /**
427
232
  * Get current mode
428
233
  */
@@ -532,6 +337,37 @@ export class TerminalInput extends EventEmitter {
532
337
  this.streamingLabel = next;
533
338
  this.scheduleRender();
534
339
  }
340
+ /**
341
+ * Surface meta status just above the divider (e.g., elapsed time or token usage).
342
+ */
343
+ setMetaStatus(meta) {
344
+ const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
345
+ ? Math.floor(meta.elapsedSeconds)
346
+ : null;
347
+ const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
348
+ ? Math.floor(meta.tokensUsed)
349
+ : null;
350
+ const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
351
+ ? Math.floor(meta.tokenLimit)
352
+ : null;
353
+ const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
354
+ ? Math.floor(meta.thinkingMs)
355
+ : null;
356
+ const nextThinkingHasContent = !!meta.thinkingHasContent;
357
+ if (this.metaElapsedSeconds === nextElapsed &&
358
+ this.metaTokensUsed === nextTokens &&
359
+ this.metaTokenLimit === nextLimit &&
360
+ this.metaThinkingMs === nextThinking &&
361
+ this.metaThinkingHasContent === nextThinkingHasContent) {
362
+ return;
363
+ }
364
+ this.metaElapsedSeconds = nextElapsed;
365
+ this.metaTokensUsed = nextTokens;
366
+ this.metaTokenLimit = nextLimit;
367
+ this.metaThinkingMs = nextThinking;
368
+ this.metaThinkingHasContent = nextThinkingHasContent;
369
+ this.scheduleRender();
370
+ }
535
371
  /**
536
372
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
537
373
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -541,16 +377,22 @@ export class TerminalInput extends EventEmitter {
541
377
  const nextAutoContinue = !!options.autoContinueEnabled;
542
378
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
543
379
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
380
+ const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
381
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
544
382
  if (this.verificationEnabled === nextVerification &&
545
383
  this.autoContinueEnabled === nextAutoContinue &&
546
384
  this.verificationHotkey === nextVerifyHotkey &&
547
- this.autoContinueHotkey === nextAutoHotkey) {
385
+ this.autoContinueHotkey === nextAutoHotkey &&
386
+ this.thinkingHotkey === nextThinkingHotkey &&
387
+ this.thinkingModeLabel === nextThinkingLabel) {
548
388
  return;
549
389
  }
550
390
  this.verificationEnabled = nextVerification;
551
391
  this.autoContinueEnabled = nextAutoContinue;
552
392
  this.verificationHotkey = nextVerifyHotkey;
553
393
  this.autoContinueHotkey = nextAutoHotkey;
394
+ this.thinkingHotkey = nextThinkingHotkey;
395
+ this.thinkingModeLabel = nextThinkingLabel;
554
396
  this.scheduleRender();
555
397
  }
556
398
  /**
@@ -562,195 +404,104 @@ export class TerminalInput extends EventEmitter {
562
404
  this.streamingLabel = null;
563
405
  this.scheduleRender();
564
406
  }
407
+ /**
408
+ * Surface model/provider context in the controls bar.
409
+ */
410
+ setModelContext(options) {
411
+ const nextModel = options.model?.trim() || null;
412
+ const nextProvider = options.provider?.trim() || null;
413
+ if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
414
+ return;
415
+ }
416
+ this.modelLabel = nextModel;
417
+ this.providerLabel = nextProvider;
418
+ this.scheduleRender();
419
+ }
565
420
  /**
566
421
  * Render the input area - Claude Code style with mode controls
567
422
  *
568
- * Same rendering for both normal and streaming modes - just different status bar.
569
- * During streaming, uses cursor save/restore to preserve streaming position.
423
+ * During streaming we keep the scroll region active and repaint only the
424
+ * pinned status/input block (throttled) so streamed content can scroll
425
+ * naturally above while elapsed time and status stay fresh.
570
426
  */
571
427
  render() {
572
428
  if (!this.canRender())
573
429
  return;
574
430
  if (this.isRendering)
575
431
  return;
432
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
433
+ // During streaming we still render the pinned input/status region, but throttle
434
+ // to avoid fighting with the streamed content flow.
435
+ if (streamingActive && this.lastStreamingRender > 0) {
436
+ const elapsed = Date.now() - this.lastStreamingRender;
437
+ const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
438
+ if (waitMs > 0) {
439
+ this.renderDirty = true;
440
+ this.scheduleStreamingRender(waitMs);
441
+ return;
442
+ }
443
+ }
576
444
  const shouldSkip = !this.renderDirty &&
577
445
  this.buffer === this.lastRenderContent &&
578
446
  this.cursor === this.lastRenderCursor;
579
447
  this.renderDirty = false;
580
- // Skip if nothing changed (unless explicitly forced)
448
+ // Skip if nothing changed and no explicit refresh requested
581
449
  if (shouldSkip) {
582
450
  return;
583
451
  }
584
- // If write lock is held, defer render
452
+ // If write lock is held, defer render to avoid race conditions
585
453
  if (writeLock.isLocked()) {
586
454
  writeLock.safeWrite(() => this.render());
587
455
  return;
588
456
  }
589
- this.isRendering = true;
590
- writeLock.lock('terminalInput.render');
591
- try {
592
- // Render input area at bottom (outside scroll region)
593
- this.renderBottomPinned();
594
- }
595
- finally {
596
- writeLock.unlock();
597
- this.isRendering = false;
598
- }
599
- }
600
- /**
601
- * Render in flow mode - delegates to bottom-pinned for stability.
602
- *
603
- * Flow mode attempted inline rendering but caused duplicate renders
604
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
605
- */
606
- renderFlowMode() {
607
- // Use stable bottom-pinned approach
608
- this.renderBottomPinned();
609
- }
610
- /**
611
- * Render in bottom-pinned mode - Claude Code style with suggestions
612
- *
613
- * Works for both normal and streaming modes:
614
- * - During streaming: saves/restores cursor position
615
- * - Status bar shows streaming info or "Type a message"
616
- *
617
- * Layout when suggestions visible:
618
- * - Top divider
619
- * - Input line(s)
620
- * - Bottom divider
621
- * - Suggestions (command list)
622
- *
623
- * Layout when suggestions hidden:
624
- * - Status bar (Ready/Streaming)
625
- * - Top divider
626
- * - Input line(s)
627
- * - Bottom divider
628
- * - Mode controls
629
- */
630
- renderBottomPinned() {
631
- const { rows, cols } = this.getSize();
632
- const maxWidth = Math.max(8, cols - 4);
633
- const isStreaming = this.mode === 'streaming';
634
- // During streaming, save cursor position to restore after render
635
- if (isStreaming) {
636
- this.write(ESC.SAVE);
637
- }
638
- // Wrap buffer into display lines
639
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
640
- const availableForContent = Math.max(1, rows - 3);
641
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
642
- const displayLines = Math.min(lines.length, maxVisible);
643
- // Calculate display window (keep cursor visible)
644
- let startLine = 0;
645
- if (lines.length > displayLines) {
646
- startLine = Math.max(0, cursorLine - displayLines + 1);
647
- startLine = Math.min(startLine, lines.length - displayLines);
648
- }
649
- const visibleLines = lines.slice(startLine, startLine + displayLines);
650
- const adjustedCursorLine = cursorLine - startLine;
651
- // Calculate suggestion display (not during streaming)
652
- const suggestionsToShow = (!isStreaming && this.showSuggestions)
653
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
654
- : [];
655
- const suggestionLines = suggestionsToShow.length;
656
- this.write(ESC.HIDE);
657
- this.write(ESC.RESET);
658
- const divider = renderDivider(cols - 2);
659
- // Calculate positions from absolute bottom
660
- let currentRow;
661
- if (suggestionLines > 0) {
662
- // With suggestions: input area + dividers + suggestions
663
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
664
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
665
- currentRow = Math.max(1, rows - totalHeight + 1);
666
- this.updateReservedLines(totalHeight);
667
- // Top divider
668
- this.write(ESC.TO(currentRow, 1));
669
- this.write(ESC.CLEAR_LINE);
670
- this.write(divider);
671
- currentRow++;
672
- // Input lines
673
- let finalRow = currentRow;
674
- let finalCol = 3;
675
- for (let i = 0; i < visibleLines.length; i++) {
676
- this.write(ESC.TO(currentRow, 1));
677
- this.write(ESC.CLEAR_LINE);
678
- const line = visibleLines[i] ?? '';
679
- const absoluteLineIdx = startLine + i;
680
- const isFirstLine = absoluteLineIdx === 0;
681
- const isCursorLine = i === adjustedCursorLine;
682
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
683
- if (isCursorLine) {
684
- const col = Math.min(cursorCol, line.length);
685
- this.write(line.slice(0, col));
686
- this.write(ESC.REVERSE);
687
- this.write(col < line.length ? line[col] : ' ');
688
- this.write(ESC.RESET);
689
- this.write(line.slice(col + 1));
690
- finalRow = currentRow;
691
- finalCol = this.config.promptChar.length + col + 1;
692
- }
693
- else {
694
- this.write(line);
695
- }
696
- currentRow++;
457
+ const performRender = () => {
458
+ if (!this.scrollRegionActive) {
459
+ this.enableScrollRegion();
697
460
  }
698
- // Bottom divider
699
- this.write(ESC.TO(currentRow, 1));
700
- this.write(ESC.CLEAR_LINE);
701
- this.write(divider);
702
- currentRow++;
703
- // Suggestions (Claude Code style)
704
- for (let i = 0; i < suggestionsToShow.length; i++) {
461
+ const { rows, cols } = this.getSize();
462
+ const maxWidth = Math.max(8, cols - 4);
463
+ // Wrap buffer into display lines
464
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
465
+ const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
466
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
467
+ const displayLines = Math.min(lines.length, maxVisible);
468
+ const metaLines = this.buildMetaLines(cols - 2);
469
+ // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
470
+ this.updateReservedLines(displayLines + 2 + metaLines.length);
471
+ // Calculate display window (keep cursor visible)
472
+ let startLine = 0;
473
+ if (lines.length > displayLines) {
474
+ startLine = Math.max(0, cursorLine - displayLines + 1);
475
+ startLine = Math.min(startLine, lines.length - displayLines);
476
+ }
477
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
478
+ const adjustedCursorLine = cursorLine - startLine;
479
+ // Render
480
+ this.write(ESC.HIDE);
481
+ this.write(ESC.RESET);
482
+ const startRow = Math.max(1, rows - this.reservedLines + 1);
483
+ let currentRow = startRow;
484
+ // Clear the reserved block to avoid stale meta/status lines
485
+ this.clearReservedArea(startRow, this.reservedLines, cols);
486
+ // Meta/status header (elapsed, tokens/context)
487
+ for (const metaLine of metaLines) {
705
488
  this.write(ESC.TO(currentRow, 1));
706
489
  this.write(ESC.CLEAR_LINE);
707
- const suggestion = suggestionsToShow[i];
708
- const isSelected = i === this.selectedSuggestionIndex;
709
- // Indent and highlight selected
710
- this.write(' ');
711
- if (isSelected) {
712
- this.write(ESC.REVERSE);
713
- this.write(ESC.BOLD);
714
- }
715
- this.write(suggestion.command);
716
- if (isSelected) {
717
- this.write(ESC.RESET);
718
- }
719
- // Description (dimmed)
720
- const descSpace = cols - suggestion.command.length - 8;
721
- if (descSpace > 10 && suggestion.description) {
722
- const desc = suggestion.description.slice(0, descSpace);
723
- this.write(ESC.RESET);
724
- this.write(ESC.DIM);
725
- this.write(' ');
726
- this.write(desc);
727
- this.write(ESC.RESET);
728
- }
729
- currentRow++;
490
+ this.write(metaLine);
491
+ currentRow += 1;
730
492
  }
731
- // Position cursor in input area
732
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
733
- }
734
- else {
735
- // Without suggestions: normal layout with status bar and controls
736
- const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
737
- currentRow = Math.max(1, rows - totalHeight + 1);
738
- this.updateReservedLines(totalHeight);
739
- // Status bar (streaming or normal)
740
- this.write(ESC.TO(currentRow, 1));
741
- this.write(ESC.CLEAR_LINE);
742
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
743
- currentRow++;
744
- // Top divider
493
+ // Separator line
745
494
  this.write(ESC.TO(currentRow, 1));
746
495
  this.write(ESC.CLEAR_LINE);
496
+ const divider = renderDivider(cols - 2);
747
497
  this.write(divider);
748
- currentRow++;
749
- // Input lines
498
+ currentRow += 1;
499
+ // Render input lines
750
500
  let finalRow = currentRow;
751
501
  let finalCol = 3;
752
502
  for (let i = 0; i < visibleLines.length; i++) {
753
- this.write(ESC.TO(currentRow, 1));
503
+ const rowNum = currentRow + i;
504
+ this.write(ESC.TO(rowNum, 1));
754
505
  this.write(ESC.CLEAR_LINE);
755
506
  const line = visibleLines[i] ?? '';
756
507
  const absoluteLineIdx = startLine + i;
@@ -764,6 +515,7 @@ export class TerminalInput extends EventEmitter {
764
515
  this.write(ESC.RESET);
765
516
  this.write(ESC.BG_DARK);
766
517
  if (isCursorLine) {
518
+ // Render with block cursor
767
519
  const col = Math.min(cursorCol, line.length);
768
520
  const before = line.slice(0, col);
769
521
  const at = col < line.length ? line[col] : ' ';
@@ -773,157 +525,251 @@ export class TerminalInput extends EventEmitter {
773
525
  this.write(at);
774
526
  this.write(ESC.RESET + ESC.BG_DARK);
775
527
  this.write(after);
776
- finalRow = currentRow;
528
+ finalRow = rowNum;
777
529
  finalCol = this.config.promptChar.length + col + 1;
778
530
  }
779
531
  else {
780
532
  this.write(line);
781
533
  }
782
- // Pad to edge
534
+ // Pad to edge for clean look
783
535
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
784
536
  const padding = Math.max(0, cols - lineLen - 1);
785
537
  if (padding > 0)
786
538
  this.write(' '.repeat(padding));
787
539
  this.write(ESC.RESET);
788
- currentRow++;
789
540
  }
790
- // Bottom divider
791
- this.write(ESC.TO(currentRow, 1));
792
- this.write(ESC.CLEAR_LINE);
793
- this.write(divider);
794
- currentRow++;
795
- // Mode controls
796
- this.write(ESC.TO(currentRow, 1));
541
+ // Mode controls line (Claude Code style)
542
+ const controlRow = currentRow + visibleLines.length;
543
+ this.write(ESC.TO(controlRow, 1));
797
544
  this.write(ESC.CLEAR_LINE);
798
545
  this.write(this.buildModeControls(cols));
799
- // Position cursor: restore for streaming, or position in input for normal
800
- if (isStreaming) {
801
- this.write(ESC.RESTORE);
802
- }
803
- else {
804
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
546
+ // Position cursor
547
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
548
+ this.write(ESC.SHOW);
549
+ // Update state
550
+ this.lastRenderContent = this.buffer;
551
+ this.lastRenderCursor = this.cursor;
552
+ this.lastStreamingRender = streamingActive ? Date.now() : 0;
553
+ if (this.streamingRenderTimer) {
554
+ clearTimeout(this.streamingRenderTimer);
555
+ this.streamingRenderTimer = null;
805
556
  }
557
+ };
558
+ // Use write lock during render to prevent interleaved output
559
+ writeLock.lock('terminalInput.render');
560
+ this.isRendering = true;
561
+ try {
562
+ performRender();
563
+ }
564
+ finally {
565
+ writeLock.unlock();
566
+ this.isRendering = false;
806
567
  }
807
- this.write(ESC.SHOW);
808
- // Update state
809
- this.lastRenderContent = this.buffer;
810
- this.lastRenderCursor = this.cursor;
811
568
  }
812
569
  /**
813
- * Build status bar for streaming mode (shows elapsed time, queue count).
570
+ * Build one or more compact meta lines above the divider (thinking, status, usage).
814
571
  */
815
- buildStreamingStatusBar(cols) {
816
- const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
817
- // Streaming status with elapsed time
818
- let elapsed = '0s';
819
- if (this.streamingStartTime) {
820
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
821
- const mins = Math.floor(secs / 60);
822
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
823
- }
824
- let status = `${GREEN}● Streaming${R} ${elapsed}`;
825
- // Queue indicator
572
+ buildMetaLines(width) {
573
+ const lines = [];
574
+ if (this.metaThinkingMs !== null) {
575
+ const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
576
+ lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
577
+ }
578
+ if (this.modelLabel) {
579
+ const modelText = this.providerLabel
580
+ ? `${this.modelLabel} @ ${this.providerLabel}`
581
+ : this.modelLabel;
582
+ lines.push(renderStatusLine([{ text: `model ${modelText}`, tone: 'muted' }], width));
583
+ }
584
+ const statusParts = [];
585
+ const statusLabel = this.statusMessage ?? this.streamingLabel;
586
+ if (statusLabel) {
587
+ statusParts.push({ text: statusLabel, tone: 'info' });
588
+ }
589
+ if (this.mode === 'streaming' || isStreamingMode()) {
590
+ statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
591
+ }
592
+ if (this.metaElapsedSeconds !== null) {
593
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
594
+ }
595
+ const tokensRemaining = this.computeTokensRemaining();
596
+ if (tokensRemaining !== null) {
597
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
598
+ }
599
+ if (statusParts.length) {
600
+ lines.push(renderStatusLine(statusParts, width));
601
+ }
602
+ const usageParts = [];
603
+ if (this.metaTokensUsed !== null) {
604
+ const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
605
+ const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
606
+ usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
607
+ }
608
+ if (this.contextUsage !== null) {
609
+ const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
610
+ const left = Math.max(0, 100 - this.contextUsage);
611
+ usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
612
+ }
826
613
  if (this.queue.length > 0) {
827
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
614
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
615
+ }
616
+ if (usageParts.length) {
617
+ lines.push(renderStatusLine(usageParts, width));
828
618
  }
829
- // Hint for typing
830
- status += ` ${DIM}· type to queue message${R}`;
831
- return status;
619
+ return lines;
832
620
  }
833
621
  /**
834
- * Build status bar showing streaming/ready status and key info.
835
- * This is the TOP line above the input area - minimal Claude Code style.
622
+ * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
836
623
  */
837
- buildStatusBar(cols) {
838
- const maxWidth = cols - 2;
839
- const parts = [];
840
- // Streaming status with elapsed time (left side)
841
- if (this.mode === 'streaming') {
842
- let statusText = ' Streaming';
843
- if (this.streamingStartTime) {
844
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
845
- const mins = Math.floor(elapsed / 60);
846
- const secs = elapsed % 60;
847
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
848
- }
849
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
624
+ clearReservedArea(startRow, reservedLines, cols) {
625
+ const width = Math.max(1, cols);
626
+ for (let i = 0; i < reservedLines; i++) {
627
+ const row = startRow + i;
628
+ this.write(ESC.TO(row, 1));
629
+ this.write(' '.repeat(width));
850
630
  }
851
- // Queue indicator during streaming
852
- if (this.mode === 'streaming' && this.queue.length > 0) {
853
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
631
+ }
632
+ /**
633
+ * Build Claude Code style mode controls line.
634
+ * Combines streaming label + override status + main status for simultaneous display.
635
+ */
636
+ buildModeControls(cols) {
637
+ const width = Math.max(8, cols - 2);
638
+ const leftParts = [];
639
+ const rightParts = [];
640
+ if (this.streamingLabel) {
641
+ leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
642
+ }
643
+ if (this.overrideStatusMessage) {
644
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
645
+ }
646
+ if (this.statusMessage) {
647
+ leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
648
+ }
649
+ const editHotkey = this.formatHotkey('shift+tab');
650
+ const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
651
+ const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
652
+ leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
653
+ const verifyHotkey = this.formatHotkey(this.verificationHotkey);
654
+ const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
655
+ leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
656
+ const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
657
+ const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
658
+ leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
659
+ if (this.queue.length > 0 && this.mode !== 'streaming') {
660
+ leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
661
+ }
662
+ if (this.buffer.includes('\n')) {
663
+ const lineCount = this.buffer.split('\n').length;
664
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
854
665
  }
855
- // Paste indicator
856
666
  if (this.pastePlaceholders.length > 0) {
857
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
858
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
667
+ const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
668
+ leftParts.push({
669
+ text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
670
+ tone: 'info',
671
+ });
859
672
  }
860
- // Override/warning status
861
- if (this.overrideStatusMessage) {
862
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
673
+ const contextRemaining = this.computeContextRemaining();
674
+ if (this.thinkingModeLabel) {
675
+ const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
676
+ rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
677
+ }
678
+ if (this.modelLabel) {
679
+ const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
680
+ rightParts.push({ text: modelText, tone: 'muted' });
681
+ }
682
+ if (contextRemaining !== null) {
683
+ const tone = contextRemaining <= 10 ? 'warn' : 'muted';
684
+ const label = contextRemaining === 0 && this.contextUsage !== null
685
+ ? 'Context auto-compact imminent'
686
+ : `Context left until auto-compact: ${contextRemaining}%`;
687
+ rightParts.push({ text: label, tone });
688
+ }
689
+ if (!rightParts.length || width < 60) {
690
+ const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
691
+ return renderStatusLine(merged, width);
692
+ }
693
+ const leftWidth = Math.max(12, Math.floor(width * 0.6));
694
+ const rightWidth = Math.max(14, width - leftWidth - 1);
695
+ const leftText = renderStatusLine(leftParts, leftWidth);
696
+ const rightText = renderStatusLine(rightParts, rightWidth);
697
+ const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
698
+ return `${leftText}${' '.repeat(spacing)}${rightText}`;
699
+ }
700
+ formatHotkey(hotkey) {
701
+ const normalized = hotkey.trim().toLowerCase();
702
+ if (!normalized)
703
+ return hotkey;
704
+ const parts = normalized.split('+').filter(Boolean);
705
+ const map = {
706
+ shift: '⇧',
707
+ sh: '⇧',
708
+ alt: '⌥',
709
+ option: '⌥',
710
+ opt: '⌥',
711
+ ctrl: '⌃',
712
+ control: '⌃',
713
+ cmd: '⌘',
714
+ meta: '⌘',
715
+ };
716
+ const formatted = parts
717
+ .map((part) => {
718
+ const symbol = map[part];
719
+ if (symbol)
720
+ return symbol;
721
+ return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
722
+ })
723
+ .join('');
724
+ return formatted || hotkey;
725
+ }
726
+ computeContextRemaining() {
727
+ if (this.contextUsage === null) {
728
+ return null;
863
729
  }
864
- // If idle with empty buffer, show quick shortcuts
865
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
866
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
730
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
731
+ }
732
+ computeTokensRemaining() {
733
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
734
+ return null;
867
735
  }
868
- // Multi-line indicator
869
- if (this.buffer.includes('\n')) {
870
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
736
+ const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
737
+ return this.formatTokenCount(remaining);
738
+ }
739
+ formatElapsedLabel(seconds) {
740
+ if (seconds < 60) {
741
+ return `${seconds}s`;
742
+ }
743
+ const mins = Math.floor(seconds / 60);
744
+ const secs = seconds % 60;
745
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
746
+ }
747
+ formatTokenCount(value) {
748
+ if (!Number.isFinite(value)) {
749
+ return `${value}`;
750
+ }
751
+ if (value >= 1_000_000) {
752
+ return `${(value / 1_000_000).toFixed(1)}M`;
871
753
  }
872
- if (parts.length === 0) {
873
- return ''; // Empty status bar when idle
754
+ if (value >= 1_000) {
755
+ return `${(value / 1_000).toFixed(1)}k`;
874
756
  }
875
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
876
- return joined.slice(0, maxWidth);
757
+ return `${Math.round(value)}`;
758
+ }
759
+ visibleLength(value) {
760
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
761
+ return value.replace(ansiPattern, '').length;
877
762
  }
878
763
  /**
879
- * Build mode controls line showing toggles and context info.
880
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
881
- *
882
- * Layout: [toggles on left] ... [context info on right]
764
+ * Debug-only snapshot used by tests to assert rendered strings without
765
+ * needing a TTY. Not used by production code.
883
766
  */
884
- buildModeControls(cols) {
885
- const maxWidth = cols - 2;
886
- // Use schema-defined colors for consistency
887
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
888
- // Mode toggles with colors (following ModeControlsSchema)
889
- const toggles = [];
890
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
891
- if (this.editMode === 'display-edits') {
892
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
893
- }
894
- else {
895
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
896
- }
897
- // Thinking mode (cyan when on) - per schema.thinkingMode
898
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
899
- // Verification (green when on) - per schema.verificationMode
900
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
901
- // Auto-continue (magenta when on) - per schema.autoContinueMode
902
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
903
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
904
- // Context usage with color - per schema.contextUsage thresholds
905
- let rightPart = '';
906
- if (this.contextUsage !== null) {
907
- const rem = Math.max(0, 100 - this.contextUsage);
908
- // Thresholds: critical < 10%, warning < 25%
909
- if (rem < 10)
910
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
911
- else if (rem < 25)
912
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
913
- else
914
- rightPart = `${DIM}ctx: ${rem}%${R}`;
915
- }
916
- // Calculate visible lengths (strip ANSI)
917
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
918
- const leftLen = strip(leftPart).length;
919
- const rightLen = strip(rightPart).length;
920
- if (leftLen + rightLen < maxWidth - 4) {
921
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
922
- }
923
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
924
- return `${leftPart} ${rightPart}`;
925
- }
926
- return leftPart;
767
+ getDebugUiSnapshot(width) {
768
+ const cols = Math.max(8, width ?? this.getSize().cols);
769
+ return {
770
+ meta: this.buildMetaLines(cols - 2),
771
+ controls: this.buildModeControls(cols),
772
+ };
927
773
  }
928
774
  /**
929
775
  * Force a re-render
@@ -946,17 +792,19 @@ export class TerminalInput extends EventEmitter {
946
792
  handleResize() {
947
793
  this.lastRenderContent = '';
948
794
  this.lastRenderCursor = -1;
795
+ this.resetStreamingRenderThrottle();
949
796
  // Re-clamp pinned header rows to the new terminal height
950
797
  this.setPinnedHeaderLines(this.pinnedTopRows);
798
+ if (this.scrollRegionActive) {
799
+ this.disableScrollRegion();
800
+ this.enableScrollRegion();
801
+ }
951
802
  this.scheduleRender();
952
803
  }
953
804
  /**
954
805
  * Register with display's output interceptor to position cursor correctly.
955
806
  * When scroll region is active, output needs to go to the scroll region,
956
807
  * not the protected bottom area where the input is rendered.
957
- *
958
- * NOTE: With scroll region properly set, content naturally stays within
959
- * the region boundaries - no cursor manipulation needed per-write.
960
808
  */
961
809
  registerOutputInterceptor(display) {
962
810
  if (this.outputInterceptorCleanup) {
@@ -964,11 +812,20 @@ export class TerminalInput extends EventEmitter {
964
812
  }
965
813
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
966
814
  beforeWrite: () => {
967
- // Scroll region handles content containment automatically
968
- // No per-write cursor manipulation needed
815
+ // When the scroll region is active, temporarily move the cursor into
816
+ // the scrollable area so streamed output lands above the pinned prompt.
817
+ if (this.scrollRegionActive) {
818
+ const { rows } = this.getSize();
819
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
820
+ this.write(ESC.SAVE);
821
+ this.write(ESC.TO(scrollBottom, 1));
822
+ }
969
823
  },
970
824
  afterWrite: () => {
971
- // No cursor manipulation needed
825
+ // Restore cursor back to the pinned prompt after output completes.
826
+ if (this.scrollRegionActive) {
827
+ this.write(ESC.RESTORE);
828
+ }
972
829
  },
973
830
  });
974
831
  }
@@ -978,11 +835,6 @@ export class TerminalInput extends EventEmitter {
978
835
  dispose() {
979
836
  if (this.disposed)
980
837
  return;
981
- // Clean up streaming render timer
982
- if (this.streamingRenderTimer) {
983
- clearInterval(this.streamingRenderTimer);
984
- this.streamingRenderTimer = null;
985
- }
986
838
  // Clean up output interceptor
987
839
  if (this.outputInterceptorCleanup) {
988
840
  this.outputInterceptorCleanup();
@@ -990,6 +842,7 @@ export class TerminalInput extends EventEmitter {
990
842
  }
991
843
  this.disposed = true;
992
844
  this.enabled = false;
845
+ this.resetStreamingRenderThrottle();
993
846
  this.disableScrollRegion();
994
847
  this.disableBracketedPaste();
995
848
  this.buffer = '';
@@ -1095,22 +948,7 @@ export class TerminalInput extends EventEmitter {
1095
948
  this.toggleEditMode();
1096
949
  return true;
1097
950
  }
1098
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1099
- if (this.findPlaceholderAt(this.cursor)) {
1100
- this.togglePasteExpansion();
1101
- }
1102
- else {
1103
- this.toggleThinking();
1104
- }
1105
- return true;
1106
- case 'escape':
1107
- // Esc: interrupt if streaming, otherwise clear buffer
1108
- if (this.mode === 'streaming') {
1109
- this.emit('interrupt');
1110
- }
1111
- else if (this.buffer.length > 0) {
1112
- this.clear();
1113
- }
951
+ this.insertText(' ');
1114
952
  return true;
1115
953
  }
1116
954
  return false;
@@ -1128,7 +966,6 @@ export class TerminalInput extends EventEmitter {
1128
966
  this.insertPlainText(chunk, insertPos);
1129
967
  this.cursor = insertPos + chunk.length;
1130
968
  this.emit('change', this.buffer);
1131
- this.updateSuggestions();
1132
969
  this.scheduleRender();
1133
970
  }
1134
971
  insertNewline() {
@@ -1153,7 +990,6 @@ export class TerminalInput extends EventEmitter {
1153
990
  this.cursor = Math.max(0, this.cursor - 1);
1154
991
  }
1155
992
  this.emit('change', this.buffer);
1156
- this.updateSuggestions();
1157
993
  this.scheduleRender();
1158
994
  }
1159
995
  deleteForward() {
@@ -1403,7 +1239,9 @@ export class TerminalInput extends EventEmitter {
1403
1239
  if (available <= 0)
1404
1240
  return;
1405
1241
  const chunk = clean.slice(0, available);
1406
- if (isMultilinePaste(chunk)) {
1242
+ const isMultiline = isMultilinePaste(chunk);
1243
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1244
+ if (isMultiline && !isShortMultiline) {
1407
1245
  this.insertPastePlaceholder(chunk);
1408
1246
  }
1409
1247
  else {
@@ -1423,6 +1261,7 @@ export class TerminalInput extends EventEmitter {
1423
1261
  return;
1424
1262
  this.applyScrollRegion();
1425
1263
  this.scrollRegionActive = true;
1264
+ this.forceRender();
1426
1265
  }
1427
1266
  disableScrollRegion() {
1428
1267
  if (!this.scrollRegionActive)
@@ -1573,17 +1412,19 @@ export class TerminalInput extends EventEmitter {
1573
1412
  this.shiftPlaceholders(position, text.length);
1574
1413
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1575
1414
  }
1415
+ shouldInlineMultiline(content) {
1416
+ const lines = content.split('\n').length;
1417
+ const maxInlineLines = 4;
1418
+ const maxInlineChars = 240;
1419
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1420
+ }
1576
1421
  findPlaceholderAt(position) {
1577
1422
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1578
1423
  }
1579
- buildPlaceholder(summary) {
1424
+ buildPlaceholder(lineCount) {
1580
1425
  const id = ++this.pasteCounter;
1581
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1582
- // Show first line preview (truncated)
1583
- const preview = summary.preview.length > 30
1584
- ? `${summary.preview.slice(0, 30)}...`
1585
- : summary.preview;
1586
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1426
+ const plural = lineCount === 1 ? '' : 's';
1427
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1587
1428
  return { id, placeholder };
1588
1429
  }
1589
1430
  insertPastePlaceholder(content) {
@@ -1591,67 +1432,21 @@ export class TerminalInput extends EventEmitter {
1591
1432
  if (available <= 0)
1592
1433
  return;
1593
1434
  const cleanContent = content.slice(0, available);
1594
- const summary = generatePasteSummary(cleanContent);
1595
- // For short pastes (< 5 lines), show full content instead of placeholder
1596
- if (summary.lineCount < 5) {
1597
- const placeholder = this.findPlaceholderAt(this.cursor);
1598
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1599
- this.insertPlainText(cleanContent, insertPos);
1600
- this.cursor = insertPos + cleanContent.length;
1601
- return;
1602
- }
1603
- const { id, placeholder } = this.buildPlaceholder(summary);
1435
+ const lineCount = cleanContent.split('\n').length;
1436
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1604
1437
  const insertPos = this.cursor;
1605
1438
  this.shiftPlaceholders(insertPos, placeholder.length);
1606
1439
  this.pastePlaceholders.push({
1607
1440
  id,
1608
1441
  content: cleanContent,
1609
- lineCount: summary.lineCount,
1442
+ lineCount,
1610
1443
  placeholder,
1611
1444
  start: insertPos,
1612
1445
  end: insertPos + placeholder.length,
1613
- summary,
1614
- expanded: false,
1615
1446
  });
1616
1447
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1617
1448
  this.cursor = insertPos + placeholder.length;
1618
1449
  }
1619
- /**
1620
- * Toggle expansion of a paste placeholder at the current cursor position.
1621
- * When expanded, shows first 3 and last 2 lines of the content.
1622
- */
1623
- togglePasteExpansion() {
1624
- const placeholder = this.findPlaceholderAt(this.cursor);
1625
- if (!placeholder)
1626
- return false;
1627
- placeholder.expanded = !placeholder.expanded;
1628
- // Update the placeholder text in buffer
1629
- const newPlaceholder = placeholder.expanded
1630
- ? this.buildExpandedPlaceholder(placeholder)
1631
- : this.buildPlaceholder(placeholder.summary).placeholder;
1632
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1633
- // Update buffer
1634
- this.buffer =
1635
- this.buffer.slice(0, placeholder.start) +
1636
- newPlaceholder +
1637
- this.buffer.slice(placeholder.end);
1638
- // Update placeholder tracking
1639
- placeholder.placeholder = newPlaceholder;
1640
- placeholder.end = placeholder.start + newPlaceholder.length;
1641
- // Shift other placeholders
1642
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1643
- this.scheduleRender();
1644
- return true;
1645
- }
1646
- buildExpandedPlaceholder(ph) {
1647
- const lines = ph.content.split('\n');
1648
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1649
- const lastLines = lines.length > 5
1650
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1651
- : '';
1652
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1653
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1654
- }
1655
1450
  deletePlaceholder(placeholder) {
1656
1451
  const length = placeholder.end - placeholder.start;
1657
1452
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1659,7 +1454,11 @@ export class TerminalInput extends EventEmitter {
1659
1454
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1660
1455
  this.cursor = placeholder.start;
1661
1456
  }
1662
- updateContextUsage(value) {
1457
+ updateContextUsage(value, autoCompactThreshold) {
1458
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1459
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1460
+ this.contextAutoCompactThreshold = boundedThreshold;
1461
+ }
1663
1462
  if (value === null || !Number.isFinite(value)) {
1664
1463
  this.contextUsage = null;
1665
1464
  }
@@ -1686,6 +1485,22 @@ export class TerminalInput extends EventEmitter {
1686
1485
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1687
1486
  this.setEditMode(next);
1688
1487
  }
1488
+ scheduleStreamingRender(delayMs) {
1489
+ if (this.streamingRenderTimer)
1490
+ return;
1491
+ const wait = Math.max(16, delayMs);
1492
+ this.streamingRenderTimer = setTimeout(() => {
1493
+ this.streamingRenderTimer = null;
1494
+ this.render();
1495
+ }, wait);
1496
+ }
1497
+ resetStreamingRenderThrottle() {
1498
+ if (this.streamingRenderTimer) {
1499
+ clearTimeout(this.streamingRenderTimer);
1500
+ this.streamingRenderTimer = null;
1501
+ }
1502
+ this.lastStreamingRender = 0;
1503
+ }
1689
1504
  scheduleRender() {
1690
1505
  if (!this.canRender())
1691
1506
  return;