erosolar-cli 1.7.257 → 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 (91) 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/core/validationRunner.d.ts +1 -3
  16. package/dist/core/validationRunner.d.ts.map +1 -1
  17. package/dist/core/validationRunner.js.map +1 -1
  18. package/dist/mcp/sseClient.d.ts.map +1 -1
  19. package/dist/mcp/sseClient.js +9 -18
  20. package/dist/mcp/sseClient.js.map +1 -1
  21. package/dist/plugins/tools/build/buildPlugin.d.ts +0 -6
  22. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  23. package/dist/plugins/tools/build/buildPlugin.js +4 -10
  24. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  25. package/dist/shell/interactiveShell.d.ts +10 -2
  26. package/dist/shell/interactiveShell.d.ts.map +1 -1
  27. package/dist/shell/interactiveShell.js +190 -35
  28. package/dist/shell/interactiveShell.js.map +1 -1
  29. package/dist/shell/terminalInput.d.ts +66 -130
  30. package/dist/shell/terminalInput.d.ts.map +1 -1
  31. package/dist/shell/terminalInput.js +409 -606
  32. package/dist/shell/terminalInput.js.map +1 -1
  33. package/dist/shell/terminalInputAdapter.d.ts +20 -15
  34. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  35. package/dist/shell/terminalInputAdapter.js +14 -22
  36. package/dist/shell/terminalInputAdapter.js.map +1 -1
  37. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  38. package/dist/ui/ShellUIAdapter.js +13 -12
  39. package/dist/ui/ShellUIAdapter.js.map +1 -1
  40. package/dist/ui/display.d.ts +19 -0
  41. package/dist/ui/display.d.ts.map +1 -1
  42. package/dist/ui/display.js +135 -22
  43. package/dist/ui/display.js.map +1 -1
  44. package/dist/ui/theme.d.ts.map +1 -1
  45. package/dist/ui/theme.js +6 -8
  46. package/dist/ui/theme.js.map +1 -1
  47. package/dist/ui/toolDisplay.d.ts +0 -158
  48. package/dist/ui/toolDisplay.d.ts.map +1 -1
  49. package/dist/ui/toolDisplay.js +0 -348
  50. package/dist/ui/toolDisplay.js.map +1 -1
  51. package/dist/ui/unified/layout.d.ts +1 -0
  52. package/dist/ui/unified/layout.d.ts.map +1 -1
  53. package/dist/ui/unified/layout.js +15 -25
  54. package/dist/ui/unified/layout.js.map +1 -1
  55. package/package.json +1 -1
  56. package/dist/core/aiFlowOptimizer.d.ts +0 -26
  57. package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
  58. package/dist/core/aiFlowOptimizer.js +0 -31
  59. package/dist/core/aiFlowOptimizer.js.map +0 -1
  60. package/dist/core/aiOptimizationEngine.d.ts +0 -158
  61. package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
  62. package/dist/core/aiOptimizationEngine.js +0 -428
  63. package/dist/core/aiOptimizationEngine.js.map +0 -1
  64. package/dist/core/aiOptimizationIntegration.d.ts +0 -93
  65. package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
  66. package/dist/core/aiOptimizationIntegration.js +0 -250
  67. package/dist/core/aiOptimizationIntegration.js.map +0 -1
  68. package/dist/core/enhancedErrorRecovery.d.ts +0 -100
  69. package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
  70. package/dist/core/enhancedErrorRecovery.js +0 -345
  71. package/dist/core/enhancedErrorRecovery.js.map +0 -1
  72. package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
  73. package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
  74. package/dist/shell/claudeCodeStreamHandler.js +0 -322
  75. package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
  76. package/dist/shell/inputQueueManager.d.ts +0 -144
  77. package/dist/shell/inputQueueManager.d.ts.map +0 -1
  78. package/dist/shell/inputQueueManager.js +0 -290
  79. package/dist/shell/inputQueueManager.js.map +0 -1
  80. package/dist/shell/streamingOutputManager.d.ts +0 -115
  81. package/dist/shell/streamingOutputManager.d.ts.map +0 -1
  82. package/dist/shell/streamingOutputManager.js +0 -225
  83. package/dist/shell/streamingOutputManager.js.map +0 -1
  84. package/dist/ui/persistentPrompt.d.ts +0 -50
  85. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  86. package/dist/ui/persistentPrompt.js +0 -92
  87. package/dist/ui/persistentPrompt.js.map +0 -1
  88. package/dist/ui/terminalUISchema.d.ts +0 -195
  89. package/dist/ui/terminalUISchema.d.ts.map +0 -1
  90. package/dist/ui/terminalUISchema.js +0 -113
  91. 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,235 +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
- // NO scroll regions - let content flow naturally without being cut off
218
- // This ensures ALL streaming output is visible
219
- this.pinnedTopRows = 0;
220
- this.reservedLines = 0;
221
- // Disable any existing scroll region
222
- this.disableScrollRegion();
223
- // Hide cursor during streaming
224
- this.write(ESC.HIDE);
225
- // Start timer to update streaming status
226
- this.streamingRenderTimer = setInterval(() => {
227
- if (this.mode === 'streaming') {
228
- this.updateStreamingStatus();
229
- }
230
- }, 1000);
206
+ // Keep scroll region active so status/prompt stay pinned while streaming
207
+ this.resetStreamingRenderThrottle();
208
+ this.enableScrollRegion();
231
209
  this.renderDirty = true;
210
+ this.render();
232
211
  }
233
212
  else if (mode !== 'streaming' && prevMode === 'streaming') {
234
- // Stop streaming render timer
235
- if (this.streamingRenderTimer) {
236
- clearInterval(this.streamingRenderTimer);
237
- this.streamingRenderTimer = null;
238
- }
239
- // Reset streaming time
240
- this.streamingStartTime = null;
241
- this.pinnedTopRows = 0;
242
- // Ensure no scroll region is active
243
- this.disableScrollRegion();
244
- // Show cursor again
245
- this.write(ESC.SHOW);
246
- // Add newline after streaming content, then render input area
247
- this.write('\n');
248
- // Reset flow mode tracking
249
- this.flowModeRenderedLines = 0;
250
- // Re-render the input area in normal mode
213
+ // Streaming ended - render the input area
214
+ this.resetStreamingRenderThrottle();
215
+ this.enableScrollRegion();
251
216
  this.forceRender();
252
217
  }
253
218
  }
254
- /**
255
- * Update streaming status label (called by timer)
256
- */
257
- updateStreamingStatus() {
258
- if (this.mode !== 'streaming' || !this.streamingStartTime)
259
- return;
260
- // Calculate elapsed time
261
- const elapsed = Date.now() - this.streamingStartTime;
262
- const seconds = Math.floor(elapsed / 1000);
263
- const minutes = Math.floor(seconds / 60);
264
- const secs = seconds % 60;
265
- // Format elapsed time
266
- let elapsedStr;
267
- if (minutes > 0) {
268
- elapsedStr = `${minutes}m ${secs}s`;
269
- }
270
- else {
271
- elapsedStr = `${secs}s`;
272
- }
273
- // Update streaming label
274
- this.streamingLabel = `Streaming ${elapsedStr}`;
275
- }
276
- /**
277
- * Enable or disable flow mode.
278
- * In flow mode, the input renders immediately after content (wherever cursor is).
279
- * When disabled, input renders at the absolute bottom of terminal.
280
- */
281
- setFlowMode(enabled) {
282
- if (this.flowMode === enabled)
283
- return;
284
- this.flowMode = enabled;
285
- this.renderDirty = true;
286
- this.scheduleRender();
287
- }
288
- /**
289
- * Check if flow mode is enabled.
290
- */
291
- isFlowMode() {
292
- return this.flowMode;
293
- }
294
- /**
295
- * Set available slash commands for auto-complete suggestions.
296
- */
297
- setCommands(commands) {
298
- this.commandSuggestions = commands;
299
- this.updateSuggestions();
300
- }
301
- /**
302
- * Update filtered suggestions based on current input.
303
- */
304
- updateSuggestions() {
305
- const input = this.buffer.trim();
306
- // Only show suggestions when input starts with "/"
307
- if (!input.startsWith('/')) {
308
- this.showSuggestions = false;
309
- this.filteredSuggestions = [];
310
- this.selectedSuggestionIndex = 0;
311
- return;
312
- }
313
- const query = input.toLowerCase();
314
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
315
- cmd.command.toLowerCase().includes(query.slice(1)));
316
- // Show suggestions if we have matches
317
- this.showSuggestions = this.filteredSuggestions.length > 0;
318
- // Keep selection in bounds
319
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
320
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
321
- }
322
- }
323
- /**
324
- * Select next suggestion (arrow down / tab).
325
- */
326
- selectNextSuggestion() {
327
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
328
- return;
329
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
330
- this.renderDirty = true;
331
- this.scheduleRender();
332
- }
333
- /**
334
- * Select previous suggestion (arrow up / shift+tab).
335
- */
336
- selectPrevSuggestion() {
337
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
338
- return;
339
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
340
- ? this.filteredSuggestions.length - 1
341
- : this.selectedSuggestionIndex - 1;
342
- this.renderDirty = true;
343
- this.scheduleRender();
344
- }
345
- /**
346
- * Accept current suggestion and insert into buffer.
347
- */
348
- acceptSuggestion() {
349
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
350
- return false;
351
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
352
- if (!selected)
353
- return false;
354
- // Replace buffer with selected command
355
- this.buffer = selected.command + ' ';
356
- this.cursor = this.buffer.length;
357
- this.showSuggestions = false;
358
- this.renderDirty = true;
359
- this.scheduleRender();
360
- return true;
361
- }
362
- /**
363
- * Check if suggestions are visible.
364
- */
365
- areSuggestionsVisible() {
366
- return this.showSuggestions && this.filteredSuggestions.length > 0;
367
- }
368
- /**
369
- * Update token count for metrics display
370
- */
371
- setTokensUsed(tokens) {
372
- this.tokensUsed = tokens;
373
- }
374
- /**
375
- * Toggle thinking/reasoning mode
376
- */
377
- toggleThinking() {
378
- this.thinkingEnabled = !this.thinkingEnabled;
379
- this.emit('thinkingToggle', this.thinkingEnabled);
380
- this.scheduleRender();
381
- }
382
- /**
383
- * Get thinking enabled state
384
- */
385
- isThinkingEnabled() {
386
- return this.thinkingEnabled;
387
- }
388
219
  /**
389
220
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
390
221
  */
391
222
  setPinnedHeaderLines(count) {
392
- // Set pinned header rows (banner area that scroll region excludes)
393
- if (this.pinnedTopRows !== count) {
394
- this.pinnedTopRows = count;
223
+ // No pinned header rows anymore; keep everything in the scroll region.
224
+ if (this.pinnedTopRows !== 0) {
225
+ this.pinnedTopRows = 0;
395
226
  if (this.scrollRegionActive) {
396
227
  this.applyScrollRegion();
397
228
  }
398
229
  }
399
230
  }
400
- /**
401
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
402
- * restore the default bottom-aligned layout.
403
- */
404
- setInlineAnchor(row) {
405
- if (row === null || row === undefined) {
406
- this.inlineAnchorRow = null;
407
- this.inlineLayout = false;
408
- this.renderDirty = true;
409
- this.render();
410
- return;
411
- }
412
- const { rows } = this.getSize();
413
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
414
- this.inlineAnchorRow = clamped;
415
- this.inlineLayout = true;
416
- this.renderDirty = true;
417
- this.render();
418
- }
419
- /**
420
- * Provide a dynamic anchor callback. When set, the prompt will follow the
421
- * output by re-evaluating the anchor before each render.
422
- */
423
- setInlineAnchorProvider(provider) {
424
- this.anchorProvider = provider;
425
- if (!provider) {
426
- this.inlineLayout = false;
427
- this.inlineAnchorRow = null;
428
- this.renderDirty = true;
429
- this.render();
430
- return;
431
- }
432
- this.inlineLayout = true;
433
- this.renderDirty = true;
434
- this.render();
435
- }
436
231
  /**
437
232
  * Get current mode
438
233
  */
@@ -542,6 +337,37 @@ export class TerminalInput extends EventEmitter {
542
337
  this.streamingLabel = next;
543
338
  this.scheduleRender();
544
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
+ }
545
371
  /**
546
372
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
547
373
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -551,16 +377,22 @@ export class TerminalInput extends EventEmitter {
551
377
  const nextAutoContinue = !!options.autoContinueEnabled;
552
378
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
553
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);
554
382
  if (this.verificationEnabled === nextVerification &&
555
383
  this.autoContinueEnabled === nextAutoContinue &&
556
384
  this.verificationHotkey === nextVerifyHotkey &&
557
- this.autoContinueHotkey === nextAutoHotkey) {
385
+ this.autoContinueHotkey === nextAutoHotkey &&
386
+ this.thinkingHotkey === nextThinkingHotkey &&
387
+ this.thinkingModeLabel === nextThinkingLabel) {
558
388
  return;
559
389
  }
560
390
  this.verificationEnabled = nextVerification;
561
391
  this.autoContinueEnabled = nextAutoContinue;
562
392
  this.verificationHotkey = nextVerifyHotkey;
563
393
  this.autoContinueHotkey = nextAutoHotkey;
394
+ this.thinkingHotkey = nextThinkingHotkey;
395
+ this.thinkingModeLabel = nextThinkingLabel;
564
396
  this.scheduleRender();
565
397
  }
566
398
  /**
@@ -572,197 +404,104 @@ export class TerminalInput extends EventEmitter {
572
404
  this.streamingLabel = null;
573
405
  this.scheduleRender();
574
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
+ }
575
420
  /**
576
421
  * Render the input area - Claude Code style with mode controls
577
422
  *
578
- * Same rendering for both normal and streaming modes - just different status bar.
579
- * 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.
580
426
  */
581
427
  render() {
582
428
  if (!this.canRender())
583
429
  return;
584
430
  if (this.isRendering)
585
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
+ }
586
444
  const shouldSkip = !this.renderDirty &&
587
445
  this.buffer === this.lastRenderContent &&
588
446
  this.cursor === this.lastRenderCursor;
589
447
  this.renderDirty = false;
590
- // Skip if nothing changed (unless explicitly forced)
448
+ // Skip if nothing changed and no explicit refresh requested
591
449
  if (shouldSkip) {
592
450
  return;
593
451
  }
594
- // If write lock is held, defer render
452
+ // If write lock is held, defer render to avoid race conditions
595
453
  if (writeLock.isLocked()) {
596
454
  writeLock.safeWrite(() => this.render());
597
455
  return;
598
456
  }
599
- this.isRendering = true;
600
- writeLock.lock('terminalInput.render');
601
- try {
602
- // Render input area at bottom (outside scroll region)
603
- this.renderBottomPinned();
604
- }
605
- finally {
606
- writeLock.unlock();
607
- this.isRendering = false;
608
- }
609
- }
610
- /**
611
- * Render in flow mode - delegates to bottom-pinned for stability.
612
- *
613
- * Flow mode attempted inline rendering but caused duplicate renders
614
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
615
- */
616
- renderFlowMode() {
617
- // Use stable bottom-pinned approach
618
- this.renderBottomPinned();
619
- }
620
- /**
621
- * Render in bottom-pinned mode - Claude Code style with suggestions
622
- *
623
- * Works for both normal and streaming modes:
624
- * - During streaming: saves/restores cursor position
625
- * - Status bar shows streaming info or "Type a message"
626
- *
627
- * Layout when suggestions visible:
628
- * - Top divider
629
- * - Input line(s)
630
- * - Bottom divider
631
- * - Suggestions (command list)
632
- *
633
- * Layout when suggestions hidden:
634
- * - Status bar (Ready/Streaming)
635
- * - Top divider
636
- * - Input line(s)
637
- * - Bottom divider
638
- * - Mode controls
639
- */
640
- renderBottomPinned() {
641
- const { rows, cols } = this.getSize();
642
- const maxWidth = Math.max(8, cols - 4);
643
- const isStreaming = this.mode === 'streaming';
644
- // During streaming, skip rendering input area entirely
645
- // Content flows naturally to terminal - no positioning disruption
646
- // Terminal scrollback preserves full history
647
- if (isStreaming) {
648
- return;
649
- }
650
- // Wrap buffer into display lines
651
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
652
- const availableForContent = Math.max(1, rows - 3);
653
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
654
- const displayLines = Math.min(lines.length, maxVisible);
655
- // Calculate display window (keep cursor visible)
656
- let startLine = 0;
657
- if (lines.length > displayLines) {
658
- startLine = Math.max(0, cursorLine - displayLines + 1);
659
- startLine = Math.min(startLine, lines.length - displayLines);
660
- }
661
- const visibleLines = lines.slice(startLine, startLine + displayLines);
662
- const adjustedCursorLine = cursorLine - startLine;
663
- // Calculate suggestion display (not during streaming)
664
- const suggestionsToShow = (!isStreaming && this.showSuggestions)
665
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
666
- : [];
667
- const suggestionLines = suggestionsToShow.length;
668
- this.write(ESC.HIDE);
669
- this.write(ESC.RESET);
670
- const divider = renderDivider(cols - 2);
671
- // Calculate positions from absolute bottom
672
- let currentRow;
673
- if (suggestionLines > 0) {
674
- // With suggestions: input area + dividers + suggestions
675
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
676
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
677
- currentRow = Math.max(1, rows - totalHeight + 1);
678
- this.updateReservedLines(totalHeight);
679
- // Top divider
680
- this.write(ESC.TO(currentRow, 1));
681
- this.write(ESC.CLEAR_LINE);
682
- this.write(divider);
683
- currentRow++;
684
- // Input lines
685
- let finalRow = currentRow;
686
- let finalCol = 3;
687
- for (let i = 0; i < visibleLines.length; i++) {
688
- this.write(ESC.TO(currentRow, 1));
689
- this.write(ESC.CLEAR_LINE);
690
- const line = visibleLines[i] ?? '';
691
- const absoluteLineIdx = startLine + i;
692
- const isFirstLine = absoluteLineIdx === 0;
693
- const isCursorLine = i === adjustedCursorLine;
694
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
695
- if (isCursorLine) {
696
- const col = Math.min(cursorCol, line.length);
697
- this.write(line.slice(0, col));
698
- this.write(ESC.REVERSE);
699
- this.write(col < line.length ? line[col] : ' ');
700
- this.write(ESC.RESET);
701
- this.write(line.slice(col + 1));
702
- finalRow = currentRow;
703
- finalCol = this.config.promptChar.length + col + 1;
704
- }
705
- else {
706
- this.write(line);
707
- }
708
- currentRow++;
457
+ const performRender = () => {
458
+ if (!this.scrollRegionActive) {
459
+ this.enableScrollRegion();
709
460
  }
710
- // Bottom divider
711
- this.write(ESC.TO(currentRow, 1));
712
- this.write(ESC.CLEAR_LINE);
713
- this.write(divider);
714
- currentRow++;
715
- // Suggestions (Claude Code style)
716
- 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) {
717
488
  this.write(ESC.TO(currentRow, 1));
718
489
  this.write(ESC.CLEAR_LINE);
719
- const suggestion = suggestionsToShow[i];
720
- const isSelected = i === this.selectedSuggestionIndex;
721
- // Indent and highlight selected
722
- this.write(' ');
723
- if (isSelected) {
724
- this.write(ESC.REVERSE);
725
- this.write(ESC.BOLD);
726
- }
727
- this.write(suggestion.command);
728
- if (isSelected) {
729
- this.write(ESC.RESET);
730
- }
731
- // Description (dimmed)
732
- const descSpace = cols - suggestion.command.length - 8;
733
- if (descSpace > 10 && suggestion.description) {
734
- const desc = suggestion.description.slice(0, descSpace);
735
- this.write(ESC.RESET);
736
- this.write(ESC.DIM);
737
- this.write(' ');
738
- this.write(desc);
739
- this.write(ESC.RESET);
740
- }
741
- currentRow++;
490
+ this.write(metaLine);
491
+ currentRow += 1;
742
492
  }
743
- // Position cursor in input area
744
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
745
- }
746
- else {
747
- // Without suggestions: normal layout with status bar and controls
748
- const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
749
- currentRow = Math.max(1, rows - totalHeight + 1);
750
- this.updateReservedLines(totalHeight);
751
- // Status bar (streaming or normal)
752
- this.write(ESC.TO(currentRow, 1));
753
- this.write(ESC.CLEAR_LINE);
754
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
755
- currentRow++;
756
- // Top divider
493
+ // Separator line
757
494
  this.write(ESC.TO(currentRow, 1));
758
495
  this.write(ESC.CLEAR_LINE);
496
+ const divider = renderDivider(cols - 2);
759
497
  this.write(divider);
760
- currentRow++;
761
- // Input lines
498
+ currentRow += 1;
499
+ // Render input lines
762
500
  let finalRow = currentRow;
763
501
  let finalCol = 3;
764
502
  for (let i = 0; i < visibleLines.length; i++) {
765
- this.write(ESC.TO(currentRow, 1));
503
+ const rowNum = currentRow + i;
504
+ this.write(ESC.TO(rowNum, 1));
766
505
  this.write(ESC.CLEAR_LINE);
767
506
  const line = visibleLines[i] ?? '';
768
507
  const absoluteLineIdx = startLine + i;
@@ -776,6 +515,7 @@ export class TerminalInput extends EventEmitter {
776
515
  this.write(ESC.RESET);
777
516
  this.write(ESC.BG_DARK);
778
517
  if (isCursorLine) {
518
+ // Render with block cursor
779
519
  const col = Math.min(cursorCol, line.length);
780
520
  const before = line.slice(0, col);
781
521
  const at = col < line.length ? line[col] : ' ';
@@ -785,157 +525,251 @@ export class TerminalInput extends EventEmitter {
785
525
  this.write(at);
786
526
  this.write(ESC.RESET + ESC.BG_DARK);
787
527
  this.write(after);
788
- finalRow = currentRow;
528
+ finalRow = rowNum;
789
529
  finalCol = this.config.promptChar.length + col + 1;
790
530
  }
791
531
  else {
792
532
  this.write(line);
793
533
  }
794
- // Pad to edge
534
+ // Pad to edge for clean look
795
535
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
796
536
  const padding = Math.max(0, cols - lineLen - 1);
797
537
  if (padding > 0)
798
538
  this.write(' '.repeat(padding));
799
539
  this.write(ESC.RESET);
800
- currentRow++;
801
540
  }
802
- // Bottom divider
803
- this.write(ESC.TO(currentRow, 1));
804
- this.write(ESC.CLEAR_LINE);
805
- this.write(divider);
806
- currentRow++;
807
- // Mode controls
808
- 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));
809
544
  this.write(ESC.CLEAR_LINE);
810
545
  this.write(this.buildModeControls(cols));
811
- // Position cursor: restore for streaming, or position in input for normal
812
- if (isStreaming) {
813
- this.write(ESC.RESTORE);
814
- }
815
- else {
816
- 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;
817
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;
818
567
  }
819
- this.write(ESC.SHOW);
820
- // Update state
821
- this.lastRenderContent = this.buffer;
822
- this.lastRenderCursor = this.cursor;
823
568
  }
824
569
  /**
825
- * 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).
826
571
  */
827
- buildStreamingStatusBar(cols) {
828
- const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
829
- // Streaming status with elapsed time
830
- let elapsed = '0s';
831
- if (this.streamingStartTime) {
832
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
833
- const mins = Math.floor(secs / 60);
834
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
835
- }
836
- let status = `${GREEN}● Streaming${R} ${elapsed}`;
837
- // 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
+ }
838
613
  if (this.queue.length > 0) {
839
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
614
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
840
615
  }
841
- // Hint for typing
842
- status += ` ${DIM}· type to queue message${R}`;
843
- return status;
616
+ if (usageParts.length) {
617
+ lines.push(renderStatusLine(usageParts, width));
618
+ }
619
+ return lines;
844
620
  }
845
621
  /**
846
- * Build status bar showing streaming/ready status and key info.
847
- * 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.
848
623
  */
849
- buildStatusBar(cols) {
850
- const maxWidth = cols - 2;
851
- const parts = [];
852
- // Streaming status with elapsed time (left side)
853
- if (this.mode === 'streaming') {
854
- let statusText = ' Streaming';
855
- if (this.streamingStartTime) {
856
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
857
- const mins = Math.floor(elapsed / 60);
858
- const secs = elapsed % 60;
859
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
860
- }
861
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
862
- }
863
- // Queue indicator during streaming
864
- if (this.mode === 'streaming' && this.queue.length > 0) {
865
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
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));
866
630
  }
867
- // Paste indicator
868
- if (this.pastePlaceholders.length > 0) {
869
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
870
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
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' });
871
642
  }
872
- // Override/warning status
873
643
  if (this.overrideStatusMessage) {
874
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
875
- }
876
- // If idle with empty buffer, show quick shortcuts
877
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
878
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
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' });
879
661
  }
880
- // Multi-line indicator
881
662
  if (this.buffer.includes('\n')) {
882
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
663
+ const lineCount = this.buffer.split('\n').length;
664
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
883
665
  }
884
- if (parts.length === 0) {
885
- return ''; // Empty status bar when idle
666
+ if (this.pastePlaceholders.length > 0) {
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
+ });
886
672
  }
887
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
888
- return joined.slice(0, maxWidth);
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;
729
+ }
730
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
731
+ }
732
+ computeTokensRemaining() {
733
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
734
+ return null;
735
+ }
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`;
753
+ }
754
+ if (value >= 1_000) {
755
+ return `${(value / 1_000).toFixed(1)}k`;
756
+ }
757
+ return `${Math.round(value)}`;
758
+ }
759
+ visibleLength(value) {
760
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
761
+ return value.replace(ansiPattern, '').length;
889
762
  }
890
763
  /**
891
- * Build mode controls line showing toggles and context info.
892
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
893
- *
894
- * 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.
895
766
  */
896
- buildModeControls(cols) {
897
- const maxWidth = cols - 2;
898
- // Use schema-defined colors for consistency
899
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
900
- // Mode toggles with colors (following ModeControlsSchema)
901
- const toggles = [];
902
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
903
- if (this.editMode === 'display-edits') {
904
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
905
- }
906
- else {
907
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
908
- }
909
- // Thinking mode (cyan when on) - per schema.thinkingMode
910
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
911
- // Verification (green when on) - per schema.verificationMode
912
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
913
- // Auto-continue (magenta when on) - per schema.autoContinueMode
914
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
915
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
916
- // Context usage with color - per schema.contextUsage thresholds
917
- let rightPart = '';
918
- if (this.contextUsage !== null) {
919
- const rem = Math.max(0, 100 - this.contextUsage);
920
- // Thresholds: critical < 10%, warning < 25%
921
- if (rem < 10)
922
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
923
- else if (rem < 25)
924
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
925
- else
926
- rightPart = `${DIM}ctx: ${rem}%${R}`;
927
- }
928
- // Calculate visible lengths (strip ANSI)
929
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
930
- const leftLen = strip(leftPart).length;
931
- const rightLen = strip(rightPart).length;
932
- if (leftLen + rightLen < maxWidth - 4) {
933
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
934
- }
935
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
936
- return `${leftPart} ${rightPart}`;
937
- }
938
- 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
+ };
939
773
  }
940
774
  /**
941
775
  * Force a re-render
@@ -958,17 +792,19 @@ export class TerminalInput extends EventEmitter {
958
792
  handleResize() {
959
793
  this.lastRenderContent = '';
960
794
  this.lastRenderCursor = -1;
795
+ this.resetStreamingRenderThrottle();
961
796
  // Re-clamp pinned header rows to the new terminal height
962
797
  this.setPinnedHeaderLines(this.pinnedTopRows);
798
+ if (this.scrollRegionActive) {
799
+ this.disableScrollRegion();
800
+ this.enableScrollRegion();
801
+ }
963
802
  this.scheduleRender();
964
803
  }
965
804
  /**
966
805
  * Register with display's output interceptor to position cursor correctly.
967
806
  * When scroll region is active, output needs to go to the scroll region,
968
807
  * not the protected bottom area where the input is rendered.
969
- *
970
- * NOTE: With scroll region properly set, content naturally stays within
971
- * the region boundaries - no cursor manipulation needed per-write.
972
808
  */
973
809
  registerOutputInterceptor(display) {
974
810
  if (this.outputInterceptorCleanup) {
@@ -976,11 +812,20 @@ export class TerminalInput extends EventEmitter {
976
812
  }
977
813
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
978
814
  beforeWrite: () => {
979
- // Scroll region handles content containment automatically
980
- // 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
+ }
981
823
  },
982
824
  afterWrite: () => {
983
- // 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
+ }
984
829
  },
985
830
  });
986
831
  }
@@ -990,11 +835,6 @@ export class TerminalInput extends EventEmitter {
990
835
  dispose() {
991
836
  if (this.disposed)
992
837
  return;
993
- // Clean up streaming render timer
994
- if (this.streamingRenderTimer) {
995
- clearInterval(this.streamingRenderTimer);
996
- this.streamingRenderTimer = null;
997
- }
998
838
  // Clean up output interceptor
999
839
  if (this.outputInterceptorCleanup) {
1000
840
  this.outputInterceptorCleanup();
@@ -1002,6 +842,7 @@ export class TerminalInput extends EventEmitter {
1002
842
  }
1003
843
  this.disposed = true;
1004
844
  this.enabled = false;
845
+ this.resetStreamingRenderThrottle();
1005
846
  this.disableScrollRegion();
1006
847
  this.disableBracketedPaste();
1007
848
  this.buffer = '';
@@ -1107,22 +948,7 @@ export class TerminalInput extends EventEmitter {
1107
948
  this.toggleEditMode();
1108
949
  return true;
1109
950
  }
1110
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1111
- if (this.findPlaceholderAt(this.cursor)) {
1112
- this.togglePasteExpansion();
1113
- }
1114
- else {
1115
- this.toggleThinking();
1116
- }
1117
- return true;
1118
- case 'escape':
1119
- // Esc: interrupt if streaming, otherwise clear buffer
1120
- if (this.mode === 'streaming') {
1121
- this.emit('interrupt');
1122
- }
1123
- else if (this.buffer.length > 0) {
1124
- this.clear();
1125
- }
951
+ this.insertText(' ');
1126
952
  return true;
1127
953
  }
1128
954
  return false;
@@ -1140,7 +966,6 @@ export class TerminalInput extends EventEmitter {
1140
966
  this.insertPlainText(chunk, insertPos);
1141
967
  this.cursor = insertPos + chunk.length;
1142
968
  this.emit('change', this.buffer);
1143
- this.updateSuggestions();
1144
969
  this.scheduleRender();
1145
970
  }
1146
971
  insertNewline() {
@@ -1165,7 +990,6 @@ export class TerminalInput extends EventEmitter {
1165
990
  this.cursor = Math.max(0, this.cursor - 1);
1166
991
  }
1167
992
  this.emit('change', this.buffer);
1168
- this.updateSuggestions();
1169
993
  this.scheduleRender();
1170
994
  }
1171
995
  deleteForward() {
@@ -1415,7 +1239,9 @@ export class TerminalInput extends EventEmitter {
1415
1239
  if (available <= 0)
1416
1240
  return;
1417
1241
  const chunk = clean.slice(0, available);
1418
- if (isMultilinePaste(chunk)) {
1242
+ const isMultiline = isMultilinePaste(chunk);
1243
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1244
+ if (isMultiline && !isShortMultiline) {
1419
1245
  this.insertPastePlaceholder(chunk);
1420
1246
  }
1421
1247
  else {
@@ -1435,6 +1261,7 @@ export class TerminalInput extends EventEmitter {
1435
1261
  return;
1436
1262
  this.applyScrollRegion();
1437
1263
  this.scrollRegionActive = true;
1264
+ this.forceRender();
1438
1265
  }
1439
1266
  disableScrollRegion() {
1440
1267
  if (!this.scrollRegionActive)
@@ -1585,17 +1412,19 @@ export class TerminalInput extends EventEmitter {
1585
1412
  this.shiftPlaceholders(position, text.length);
1586
1413
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1587
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
+ }
1588
1421
  findPlaceholderAt(position) {
1589
1422
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1590
1423
  }
1591
- buildPlaceholder(summary) {
1424
+ buildPlaceholder(lineCount) {
1592
1425
  const id = ++this.pasteCounter;
1593
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1594
- // Show first line preview (truncated)
1595
- const preview = summary.preview.length > 30
1596
- ? `${summary.preview.slice(0, 30)}...`
1597
- : summary.preview;
1598
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1426
+ const plural = lineCount === 1 ? '' : 's';
1427
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1599
1428
  return { id, placeholder };
1600
1429
  }
1601
1430
  insertPastePlaceholder(content) {
@@ -1603,67 +1432,21 @@ export class TerminalInput extends EventEmitter {
1603
1432
  if (available <= 0)
1604
1433
  return;
1605
1434
  const cleanContent = content.slice(0, available);
1606
- const summary = generatePasteSummary(cleanContent);
1607
- // For short pastes (< 5 lines), show full content instead of placeholder
1608
- if (summary.lineCount < 5) {
1609
- const placeholder = this.findPlaceholderAt(this.cursor);
1610
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1611
- this.insertPlainText(cleanContent, insertPos);
1612
- this.cursor = insertPos + cleanContent.length;
1613
- return;
1614
- }
1615
- const { id, placeholder } = this.buildPlaceholder(summary);
1435
+ const lineCount = cleanContent.split('\n').length;
1436
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1616
1437
  const insertPos = this.cursor;
1617
1438
  this.shiftPlaceholders(insertPos, placeholder.length);
1618
1439
  this.pastePlaceholders.push({
1619
1440
  id,
1620
1441
  content: cleanContent,
1621
- lineCount: summary.lineCount,
1442
+ lineCount,
1622
1443
  placeholder,
1623
1444
  start: insertPos,
1624
1445
  end: insertPos + placeholder.length,
1625
- summary,
1626
- expanded: false,
1627
1446
  });
1628
1447
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1629
1448
  this.cursor = insertPos + placeholder.length;
1630
1449
  }
1631
- /**
1632
- * Toggle expansion of a paste placeholder at the current cursor position.
1633
- * When expanded, shows first 3 and last 2 lines of the content.
1634
- */
1635
- togglePasteExpansion() {
1636
- const placeholder = this.findPlaceholderAt(this.cursor);
1637
- if (!placeholder)
1638
- return false;
1639
- placeholder.expanded = !placeholder.expanded;
1640
- // Update the placeholder text in buffer
1641
- const newPlaceholder = placeholder.expanded
1642
- ? this.buildExpandedPlaceholder(placeholder)
1643
- : this.buildPlaceholder(placeholder.summary).placeholder;
1644
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1645
- // Update buffer
1646
- this.buffer =
1647
- this.buffer.slice(0, placeholder.start) +
1648
- newPlaceholder +
1649
- this.buffer.slice(placeholder.end);
1650
- // Update placeholder tracking
1651
- placeholder.placeholder = newPlaceholder;
1652
- placeholder.end = placeholder.start + newPlaceholder.length;
1653
- // Shift other placeholders
1654
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1655
- this.scheduleRender();
1656
- return true;
1657
- }
1658
- buildExpandedPlaceholder(ph) {
1659
- const lines = ph.content.split('\n');
1660
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1661
- const lastLines = lines.length > 5
1662
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1663
- : '';
1664
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1665
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1666
- }
1667
1450
  deletePlaceholder(placeholder) {
1668
1451
  const length = placeholder.end - placeholder.start;
1669
1452
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1671,7 +1454,11 @@ export class TerminalInput extends EventEmitter {
1671
1454
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1672
1455
  this.cursor = placeholder.start;
1673
1456
  }
1674
- 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
+ }
1675
1462
  if (value === null || !Number.isFinite(value)) {
1676
1463
  this.contextUsage = null;
1677
1464
  }
@@ -1698,6 +1485,22 @@ export class TerminalInput extends EventEmitter {
1698
1485
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1699
1486
  this.setEditMode(next);
1700
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
+ }
1701
1504
  scheduleRender() {
1702
1505
  if (!this.canRender())
1703
1506
  return;