erosolar-cli 1.7.252 → 1.7.253

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 (81) 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 +183 -31
  25. package/dist/shell/interactiveShell.js.map +1 -1
  26. package/dist/shell/terminalInput.d.ts +54 -124
  27. package/dist/shell/terminalInput.d.ts.map +1 -1
  28. package/dist/shell/terminalInput.js +356 -616
  29. package/dist/shell/terminalInput.js.map +1 -1
  30. package/dist/shell/terminalInputAdapter.d.ts +12 -15
  31. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  32. package/dist/shell/terminalInputAdapter.js +8 -22
  33. package/dist/shell/terminalInputAdapter.js.map +1 -1
  34. package/dist/ui/display.d.ts +19 -0
  35. package/dist/ui/display.d.ts.map +1 -1
  36. package/dist/ui/display.js +133 -2
  37. package/dist/ui/display.js.map +1 -1
  38. package/dist/ui/theme.d.ts.map +1 -1
  39. package/dist/ui/theme.js +6 -8
  40. package/dist/ui/theme.js.map +1 -1
  41. package/dist/ui/unified/layout.d.ts +1 -0
  42. package/dist/ui/unified/layout.d.ts.map +1 -1
  43. package/dist/ui/unified/layout.js +15 -25
  44. package/dist/ui/unified/layout.js.map +1 -1
  45. package/package.json +1 -1
  46. package/dist/core/aiFlowOptimizer.d.ts +0 -26
  47. package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
  48. package/dist/core/aiFlowOptimizer.js +0 -31
  49. package/dist/core/aiFlowOptimizer.js.map +0 -1
  50. package/dist/core/aiOptimizationEngine.d.ts +0 -158
  51. package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
  52. package/dist/core/aiOptimizationEngine.js +0 -428
  53. package/dist/core/aiOptimizationEngine.js.map +0 -1
  54. package/dist/core/aiOptimizationIntegration.d.ts +0 -93
  55. package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
  56. package/dist/core/aiOptimizationIntegration.js +0 -250
  57. package/dist/core/aiOptimizationIntegration.js.map +0 -1
  58. package/dist/core/enhancedErrorRecovery.d.ts +0 -100
  59. package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
  60. package/dist/core/enhancedErrorRecovery.js +0 -345
  61. package/dist/core/enhancedErrorRecovery.js.map +0 -1
  62. package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
  63. package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
  64. package/dist/shell/claudeCodeStreamHandler.js +0 -322
  65. package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
  66. package/dist/shell/inputQueueManager.d.ts +0 -144
  67. package/dist/shell/inputQueueManager.d.ts.map +0 -1
  68. package/dist/shell/inputQueueManager.js +0 -290
  69. package/dist/shell/inputQueueManager.js.map +0 -1
  70. package/dist/shell/streamingOutputManager.d.ts +0 -115
  71. package/dist/shell/streamingOutputManager.d.ts.map +0 -1
  72. package/dist/shell/streamingOutputManager.js +0 -225
  73. package/dist/shell/streamingOutputManager.js.map +0 -1
  74. package/dist/ui/persistentPrompt.d.ts +0 -50
  75. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  76. package/dist/ui/persistentPrompt.js +0 -92
  77. package/dist/ui/persistentPrompt.js.map +0 -1
  78. package/dist/ui/terminalUISchema.d.ts +0 -195
  79. package/dist/ui/terminalUISchema.d.ts.map +0 -1
  80. package/dist/ui/terminalUISchema.js +0 -113
  81. 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,22 +81,12 @@ 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;
@@ -97,22 +94,19 @@ export class TerminalInput extends EventEmitter {
97
94
  autoContinueHotkey = 'alt+c';
98
95
  // Output interceptor cleanup
99
96
  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)
97
+ // Streaming render throttle
98
+ lastStreamingRender = 0;
99
+ streamingRenderInterval = 250; // ms between renders during streaming
105
100
  streamingRenderTimer = null;
106
101
  constructor(writeStream = process.stdout, config = {}) {
107
102
  super();
108
103
  this.out = writeStream;
109
- // Use schema defaults for configuration consistency
110
104
  this.config = {
111
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
112
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
105
+ maxLines: config.maxLines ?? 1000,
106
+ maxLength: config.maxLength ?? 10000,
113
107
  maxQueueSize: config.maxQueueSize ?? 100,
114
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
115
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
108
+ promptChar: config.promptChar ?? '> ',
109
+ continuationChar: config.continuationChar ?? '│ ',
116
110
  };
117
111
  }
118
112
  // ===========================================================================
@@ -191,11 +185,6 @@ export class TerminalInput extends EventEmitter {
191
185
  if (handled)
192
186
  return;
193
187
  }
194
- // Handle '?' for help hint (if buffer is empty)
195
- if (str === '?' && this.buffer.length === 0) {
196
- this.emit('showHelp');
197
- return;
198
- }
199
188
  // Insert printable characters
200
189
  if (str && !key?.ctrl && !key?.meta) {
201
190
  this.insertText(str);
@@ -204,225 +193,38 @@ export class TerminalInput extends EventEmitter {
204
193
  /**
205
194
  * Set the input mode
206
195
  *
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).
196
+ * Streaming keeps the scroll region active so the prompt/status stay pinned
197
+ * below the streaming output. When streaming ends, we refresh the input area.
210
198
  */
211
199
  setMode(mode) {
212
200
  const prevMode = this.mode;
213
201
  this.mode = mode;
214
202
  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 takes ~7 lines, compact streaming footer takes 2 lines
219
- const bannerLines = 7;
220
- const streamingFooterLines = 2;
221
- this.pinnedTopRows = bannerLines;
222
- this.reservedLines = streamingFooterLines;
223
- // Clear entire screen below banner
224
- for (let i = bannerLines + 1; i <= rows; i++) {
225
- this.write(ESC.TO(i, 1));
226
- this.write(ESC.CLEAR_LINE);
227
- }
228
- // Enable scroll region: from below banner to above streaming footer
203
+ // Keep scroll region active so status/prompt stay pinned while streaming
204
+ this.resetStreamingRenderThrottle();
229
205
  this.enableScrollRegion();
230
- // Render compact streaming footer at absolute bottom
231
- this.renderStreamingFooter();
232
- // Position cursor right after banner for streaming content
233
- this.write(ESC.TO(bannerLines + 1, 1));
234
- // Start timer to update streaming footer (every 1 second)
235
- this.streamingRenderTimer = setInterval(() => {
236
- if (this.mode === 'streaming') {
237
- this.renderStreamingFooter();
238
- }
239
- }, 1000);
240
206
  this.renderDirty = true;
207
+ this.render();
241
208
  }
242
209
  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
210
+ // Streaming ended - render the input area
211
+ this.resetStreamingRenderThrottle();
212
+ this.enableScrollRegion();
263
213
  this.forceRender();
264
214
  }
265
215
  }
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
216
  /**
379
217
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
380
218
  */
381
219
  setPinnedHeaderLines(count) {
382
- // Set pinned header rows (banner area that scroll region excludes)
383
- if (this.pinnedTopRows !== count) {
384
- this.pinnedTopRows = count;
220
+ // No pinned header rows anymore; keep everything in the scroll region.
221
+ if (this.pinnedTopRows !== 0) {
222
+ this.pinnedTopRows = 0;
385
223
  if (this.scrollRegionActive) {
386
224
  this.applyScrollRegion();
387
225
  }
388
226
  }
389
227
  }
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
228
  /**
427
229
  * Get current mode
428
230
  */
@@ -532,6 +334,37 @@ export class TerminalInput extends EventEmitter {
532
334
  this.streamingLabel = next;
533
335
  this.scheduleRender();
534
336
  }
337
+ /**
338
+ * Surface meta status just above the divider (e.g., elapsed time or token usage).
339
+ */
340
+ setMetaStatus(meta) {
341
+ const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
342
+ ? Math.floor(meta.elapsedSeconds)
343
+ : null;
344
+ const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
345
+ ? Math.floor(meta.tokensUsed)
346
+ : null;
347
+ const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
348
+ ? Math.floor(meta.tokenLimit)
349
+ : null;
350
+ const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
351
+ ? Math.floor(meta.thinkingMs)
352
+ : null;
353
+ const nextThinkingHasContent = !!meta.thinkingHasContent;
354
+ if (this.metaElapsedSeconds === nextElapsed &&
355
+ this.metaTokensUsed === nextTokens &&
356
+ this.metaTokenLimit === nextLimit &&
357
+ this.metaThinkingMs === nextThinking &&
358
+ this.metaThinkingHasContent === nextThinkingHasContent) {
359
+ return;
360
+ }
361
+ this.metaElapsedSeconds = nextElapsed;
362
+ this.metaTokensUsed = nextTokens;
363
+ this.metaTokenLimit = nextLimit;
364
+ this.metaThinkingMs = nextThinking;
365
+ this.metaThinkingHasContent = nextThinkingHasContent;
366
+ this.scheduleRender();
367
+ }
535
368
  /**
536
369
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
537
370
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -541,16 +374,19 @@ export class TerminalInput extends EventEmitter {
541
374
  const nextAutoContinue = !!options.autoContinueEnabled;
542
375
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
543
376
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
377
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
544
378
  if (this.verificationEnabled === nextVerification &&
545
379
  this.autoContinueEnabled === nextAutoContinue &&
546
380
  this.verificationHotkey === nextVerifyHotkey &&
547
- this.autoContinueHotkey === nextAutoHotkey) {
381
+ this.autoContinueHotkey === nextAutoHotkey &&
382
+ this.thinkingModeLabel === nextThinkingLabel) {
548
383
  return;
549
384
  }
550
385
  this.verificationEnabled = nextVerification;
551
386
  this.autoContinueEnabled = nextAutoContinue;
552
387
  this.verificationHotkey = nextVerifyHotkey;
553
388
  this.autoContinueHotkey = nextAutoHotkey;
389
+ this.thinkingModeLabel = nextThinkingLabel;
554
390
  this.scheduleRender();
555
391
  }
556
392
  /**
@@ -565,187 +401,91 @@ export class TerminalInput extends EventEmitter {
565
401
  /**
566
402
  * Render the input area - Claude Code style with mode controls
567
403
  *
568
- * During streaming, we skip this and use renderStreamingFooter() instead
569
- * to avoid cursor positioning conflicts with streaming content.
404
+ * During streaming we keep the scroll region active and repaint only the
405
+ * pinned status/input block (throttled) so streamed content can scroll
406
+ * naturally above while elapsed time and status stay fresh.
570
407
  */
571
408
  render() {
572
409
  if (!this.canRender())
573
410
  return;
574
411
  if (this.isRendering)
575
412
  return;
576
- // During streaming, use compact footer rendered by timer - don't interfere
577
- if (this.mode === 'streaming') {
578
- return;
413
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
414
+ // During streaming we still render the pinned input/status region, but throttle
415
+ // to avoid fighting with the streamed content flow.
416
+ if (streamingActive && this.lastStreamingRender > 0) {
417
+ const elapsed = Date.now() - this.lastStreamingRender;
418
+ const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
419
+ if (waitMs > 0) {
420
+ this.renderDirty = true;
421
+ this.scheduleStreamingRender(waitMs);
422
+ return;
423
+ }
579
424
  }
580
425
  const shouldSkip = !this.renderDirty &&
581
426
  this.buffer === this.lastRenderContent &&
582
427
  this.cursor === this.lastRenderCursor;
583
428
  this.renderDirty = false;
584
- // Skip if nothing changed (unless explicitly forced)
429
+ // Skip if nothing changed and no explicit refresh requested
585
430
  if (shouldSkip) {
586
431
  return;
587
432
  }
588
- // If write lock is held, defer render
433
+ // If write lock is held, defer render to avoid race conditions
589
434
  if (writeLock.isLocked()) {
590
435
  writeLock.safeWrite(() => this.render());
591
436
  return;
592
437
  }
593
438
  this.isRendering = true;
439
+ // Use write lock during render to prevent interleaved output
594
440
  writeLock.lock('terminalInput.render');
595
441
  try {
596
- // Render input area at bottom (outside scroll region)
597
- this.renderBottomPinned();
598
- }
599
- finally {
600
- writeLock.unlock();
601
- this.isRendering = false;
602
- }
603
- }
604
- /**
605
- * Render in flow mode - delegates to bottom-pinned for stability.
606
- *
607
- * Flow mode attempted inline rendering but caused duplicate renders
608
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
609
- */
610
- renderFlowMode() {
611
- // Use stable bottom-pinned approach
612
- this.renderBottomPinned();
613
- }
614
- /**
615
- * Render in bottom-pinned mode - Claude Code style with suggestions
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
- // Wrap buffer into display lines
634
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
635
- const availableForContent = Math.max(1, rows - 3);
636
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
637
- const displayLines = Math.min(lines.length, maxVisible);
638
- // Calculate display window (keep cursor visible)
639
- let startLine = 0;
640
- if (lines.length > displayLines) {
641
- startLine = Math.max(0, cursorLine - displayLines + 1);
642
- startLine = Math.min(startLine, lines.length - displayLines);
643
- }
644
- const visibleLines = lines.slice(startLine, startLine + displayLines);
645
- const adjustedCursorLine = cursorLine - startLine;
646
- // Calculate suggestion display
647
- const suggestionsToShow = this.showSuggestions
648
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
649
- : [];
650
- const suggestionLines = suggestionsToShow.length;
651
- this.write(ESC.HIDE);
652
- this.write(ESC.RESET);
653
- const divider = renderDivider(cols - 2);
654
- // Calculate positions from absolute bottom
655
- let currentRow;
656
- if (suggestionLines > 0) {
657
- // With suggestions: input area + dividers + suggestions
658
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
659
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
660
- currentRow = Math.max(1, rows - totalHeight + 1);
661
- this.updateReservedLines(totalHeight);
662
- // Top divider
663
- this.write(ESC.TO(currentRow, 1));
664
- this.write(ESC.CLEAR_LINE);
665
- this.write(divider);
666
- currentRow++;
667
- // Input lines
668
- let finalRow = currentRow;
669
- let finalCol = 3;
670
- for (let i = 0; i < visibleLines.length; i++) {
671
- this.write(ESC.TO(currentRow, 1));
672
- this.write(ESC.CLEAR_LINE);
673
- const line = visibleLines[i] ?? '';
674
- const absoluteLineIdx = startLine + i;
675
- const isFirstLine = absoluteLineIdx === 0;
676
- const isCursorLine = i === adjustedCursorLine;
677
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
678
- if (isCursorLine) {
679
- const col = Math.min(cursorCol, line.length);
680
- this.write(line.slice(0, col));
681
- this.write(ESC.REVERSE);
682
- this.write(col < line.length ? line[col] : ' ');
683
- this.write(ESC.RESET);
684
- this.write(line.slice(col + 1));
685
- finalRow = currentRow;
686
- finalCol = this.config.promptChar.length + col + 1;
687
- }
688
- else {
689
- this.write(line);
690
- }
691
- currentRow++;
442
+ if (!this.scrollRegionActive) {
443
+ this.enableScrollRegion();
692
444
  }
693
- // Bottom divider
694
- this.write(ESC.TO(currentRow, 1));
695
- this.write(ESC.CLEAR_LINE);
696
- this.write(divider);
697
- currentRow++;
698
- // Suggestions (Claude Code style)
699
- for (let i = 0; i < suggestionsToShow.length; i++) {
445
+ const { rows, cols } = this.getSize();
446
+ const maxWidth = Math.max(8, cols - 4);
447
+ // Wrap buffer into display lines
448
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
449
+ const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
450
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
451
+ const displayLines = Math.min(lines.length, maxVisible);
452
+ const metaLines = this.buildMetaLines(cols - 2);
453
+ // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
454
+ this.updateReservedLines(displayLines + 2 + metaLines.length);
455
+ // Calculate display window (keep cursor visible)
456
+ let startLine = 0;
457
+ if (lines.length > displayLines) {
458
+ startLine = Math.max(0, cursorLine - displayLines + 1);
459
+ startLine = Math.min(startLine, lines.length - displayLines);
460
+ }
461
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
462
+ const adjustedCursorLine = cursorLine - startLine;
463
+ // Render
464
+ this.write(ESC.HIDE);
465
+ this.write(ESC.RESET);
466
+ const startRow = Math.max(1, rows - this.reservedLines + 1);
467
+ let currentRow = startRow;
468
+ // Clear the reserved block to avoid stale meta/status lines
469
+ this.clearReservedArea(startRow, this.reservedLines, cols);
470
+ // Meta/status header (elapsed, tokens/context)
471
+ for (const metaLine of metaLines) {
700
472
  this.write(ESC.TO(currentRow, 1));
701
473
  this.write(ESC.CLEAR_LINE);
702
- const suggestion = suggestionsToShow[i];
703
- const isSelected = i === this.selectedSuggestionIndex;
704
- // Indent and highlight selected
705
- this.write(' ');
706
- if (isSelected) {
707
- this.write(ESC.REVERSE);
708
- this.write(ESC.BOLD);
709
- }
710
- this.write(suggestion.command);
711
- if (isSelected) {
712
- this.write(ESC.RESET);
713
- }
714
- // Description (dimmed)
715
- const descSpace = cols - suggestion.command.length - 8;
716
- if (descSpace > 10 && suggestion.description) {
717
- const desc = suggestion.description.slice(0, descSpace);
718
- this.write(ESC.RESET);
719
- this.write(ESC.DIM);
720
- this.write(' ');
721
- this.write(desc);
722
- this.write(ESC.RESET);
723
- }
724
- currentRow++;
474
+ this.write(metaLine);
475
+ currentRow += 1;
725
476
  }
726
- // Position cursor in input area
727
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
728
- }
729
- else {
730
- // Without suggestions: normal layout with status bar and controls
731
- const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
732
- currentRow = Math.max(1, rows - totalHeight + 1);
733
- this.updateReservedLines(totalHeight);
734
- // Status bar
735
- this.write(ESC.TO(currentRow, 1));
736
- this.write(ESC.CLEAR_LINE);
737
- this.write(this.buildStatusBar(cols));
738
- currentRow++;
739
- // Top divider
477
+ // Separator line
740
478
  this.write(ESC.TO(currentRow, 1));
741
479
  this.write(ESC.CLEAR_LINE);
480
+ const divider = renderDivider(cols - 2);
742
481
  this.write(divider);
743
- currentRow++;
744
- // Input lines
482
+ currentRow += 1;
483
+ // Render input lines
745
484
  let finalRow = currentRow;
746
485
  let finalCol = 3;
747
486
  for (let i = 0; i < visibleLines.length; i++) {
748
- this.write(ESC.TO(currentRow, 1));
487
+ const rowNum = currentRow + i;
488
+ this.write(ESC.TO(rowNum, 1));
749
489
  this.write(ESC.CLEAR_LINE);
750
490
  const line = visibleLines[i] ?? '';
751
491
  const absoluteLineIdx = startLine + i;
@@ -759,6 +499,7 @@ export class TerminalInput extends EventEmitter {
759
499
  this.write(ESC.RESET);
760
500
  this.write(ESC.BG_DARK);
761
501
  if (isCursorLine) {
502
+ // Render with block cursor
762
503
  const col = Math.min(cursorCol, line.length);
763
504
  const before = line.slice(0, col);
764
505
  const at = col < line.length ? line[col] : ' ';
@@ -768,189 +509,219 @@ export class TerminalInput extends EventEmitter {
768
509
  this.write(at);
769
510
  this.write(ESC.RESET + ESC.BG_DARK);
770
511
  this.write(after);
771
- finalRow = currentRow;
512
+ finalRow = rowNum;
772
513
  finalCol = this.config.promptChar.length + col + 1;
773
514
  }
774
515
  else {
775
516
  this.write(line);
776
517
  }
777
- // Pad to edge
518
+ // Pad to edge for clean look
778
519
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
779
520
  const padding = Math.max(0, cols - lineLen - 1);
780
521
  if (padding > 0)
781
522
  this.write(' '.repeat(padding));
782
523
  this.write(ESC.RESET);
783
- currentRow++;
784
524
  }
785
- // Bottom divider
786
- this.write(ESC.TO(currentRow, 1));
787
- this.write(ESC.CLEAR_LINE);
788
- this.write(divider);
789
- currentRow++;
790
- // Mode controls
791
- this.write(ESC.TO(currentRow, 1));
525
+ // Mode controls line (Claude Code style)
526
+ const controlRow = currentRow + visibleLines.length;
527
+ this.write(ESC.TO(controlRow, 1));
792
528
  this.write(ESC.CLEAR_LINE);
793
529
  this.write(this.buildModeControls(cols));
794
- // Position cursor in input area
530
+ // Position cursor
795
531
  this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
532
+ this.write(ESC.SHOW);
533
+ // Update state
534
+ this.lastRenderContent = this.buffer;
535
+ this.lastRenderCursor = this.cursor;
536
+ if (streamingActive) {
537
+ this.lastStreamingRender = Date.now();
538
+ }
539
+ else {
540
+ this.lastStreamingRender = 0;
541
+ }
542
+ if (this.streamingRenderTimer) {
543
+ clearTimeout(this.streamingRenderTimer);
544
+ this.streamingRenderTimer = null;
545
+ }
546
+ }
547
+ finally {
548
+ writeLock.unlock();
549
+ this.isRendering = false;
796
550
  }
797
- this.write(ESC.SHOW);
798
- // Update state
799
- this.lastRenderContent = this.buffer;
800
- this.lastRenderCursor = this.cursor;
801
551
  }
802
552
  /**
803
- * Render compact streaming footer - 2 lines at absolute bottom.
804
- * Line 1: Divider
805
- * Line 2: Streaming stats (elapsed, tokens/sec, mode icons, context)
553
+ * Build one or more compact meta lines above the divider (thinking, status, usage).
806
554
  */
807
- renderStreamingFooter() {
808
- if (!this.isTTY())
809
- return;
810
- const { rows, cols } = this.getSize();
811
- const { green: GREEN, cyan: CYAN, yellow: YELLOW, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
812
- // Save cursor position (so streaming can continue)
813
- this.write(ESC.SAVE);
814
- // Row 1: Divider at rows - 1
815
- const dividerRow = rows - 1;
816
- this.write(ESC.TO(dividerRow, 1));
817
- this.write(ESC.CLEAR_LINE);
818
- this.write(renderDivider(cols - 2));
819
- // Row 2: Streaming stats line at rows
820
- this.write(ESC.TO(rows, 1));
821
- this.write(ESC.CLEAR_LINE);
822
- // Build streaming stats: elapsed | mode icons | context
823
- let elapsed = '0s';
824
- if (this.streamingStartTime) {
825
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
826
- const mins = Math.floor(secs / 60);
827
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
828
- }
829
- const leftPart = `${GREEN}● Streaming${R} ${elapsed}`;
830
- // Queue indicator
831
- const queuePart = this.queue.length > 0 ? ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}` : '';
832
- // Mode icons (compact)
833
- const modeIcons = [
834
- this.editMode === 'display-edits' ? `${GREEN}⏵⏵${R}` : `${YELLOW}⏸⏸${R}`,
835
- this.thinkingEnabled ? `${CYAN}💭${R}` : `${DIM}○${R}`,
836
- this.verificationEnabled ? `${GREEN}✓${R}` : `${DIM}○${R}`,
837
- this.autoContinueEnabled ? `${MAGENTA}↻${R}` : `${DIM}○${R}`,
838
- ].join(' ');
839
- // Context usage
840
- let ctxPart = '';
555
+ buildMetaLines(width) {
556
+ const lines = [];
557
+ if (this.metaThinkingMs !== null) {
558
+ const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
559
+ lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
560
+ }
561
+ const statusParts = [];
562
+ const statusLabel = this.statusMessage ?? this.streamingLabel;
563
+ if (statusLabel) {
564
+ statusParts.push({ text: statusLabel, tone: 'info' });
565
+ }
566
+ if (this.mode === 'streaming' || isStreamingMode()) {
567
+ statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
568
+ }
569
+ if (this.metaElapsedSeconds !== null) {
570
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
571
+ }
572
+ const tokensRemaining = this.computeTokensRemaining();
573
+ if (tokensRemaining !== null) {
574
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
575
+ }
576
+ if (statusParts.length) {
577
+ lines.push(renderStatusLine(statusParts, width));
578
+ }
579
+ const usageParts = [];
580
+ if (this.metaTokensUsed !== null) {
581
+ const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
582
+ const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
583
+ usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
584
+ }
841
585
  if (this.contextUsage !== null) {
842
- const rem = Math.max(0, 100 - this.contextUsage);
843
- if (rem < 10)
844
- ctxPart = `${RED}ctx:${rem}%${R}`;
845
- else if (rem < 25)
846
- ctxPart = `${YELLOW}ctx:${rem}%${R}`;
847
- else
848
- ctxPart = `${DIM}ctx:${rem}%${R}`;
849
- }
850
- const rightPart = modeIcons + (ctxPart ? ` ${DIM}·${R} ${ctxPart}` : '');
851
- // Calculate spacing
852
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
853
- const leftLen = strip(leftPart + queuePart).length;
854
- const rightLen = strip(rightPart).length;
855
- const space = Math.max(2, cols - leftLen - rightLen - 2);
856
- this.write(leftPart + queuePart + ' '.repeat(space) + rightPart);
857
- // Restore cursor position to continue streaming
858
- this.write(ESC.RESTORE);
586
+ const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
587
+ const left = Math.max(0, 100 - this.contextUsage);
588
+ usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
589
+ }
590
+ if (this.queue.length > 0) {
591
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
592
+ }
593
+ if (usageParts.length) {
594
+ lines.push(renderStatusLine(usageParts, width));
595
+ }
596
+ return lines;
859
597
  }
860
598
  /**
861
- * Build status bar showing streaming/ready status and key info.
862
- * This is the TOP line above the input area - minimal Claude Code style.
599
+ * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
863
600
  */
864
- buildStatusBar(cols) {
865
- const maxWidth = cols - 2;
866
- const parts = [];
867
- // Streaming status with elapsed time (left side)
868
- if (this.mode === 'streaming') {
869
- let statusText = ' Streaming';
870
- if (this.streamingStartTime) {
871
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
872
- const mins = Math.floor(elapsed / 60);
873
- const secs = elapsed % 60;
874
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
875
- }
876
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
877
- }
878
- // Queue indicator during streaming
879
- if (this.mode === 'streaming' && this.queue.length > 0) {
880
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
601
+ clearReservedArea(startRow, reservedLines, cols) {
602
+ const width = Math.max(1, cols);
603
+ for (let i = 0; i < reservedLines; i++) {
604
+ const row = startRow + i;
605
+ this.write(ESC.TO(row, 1));
606
+ this.write(' '.repeat(width));
881
607
  }
882
- // Paste indicator
883
- if (this.pastePlaceholders.length > 0) {
884
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
885
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
608
+ }
609
+ /**
610
+ * Build Claude Code style mode controls line.
611
+ * Combines streaming label + override status + main status for simultaneous display.
612
+ */
613
+ buildModeControls(cols) {
614
+ const width = Math.max(8, cols - 2);
615
+ const leftParts = [];
616
+ const rightParts = [];
617
+ if (this.streamingLabel) {
618
+ leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
886
619
  }
887
- // Override/warning status
888
620
  if (this.overrideStatusMessage) {
889
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
621
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
890
622
  }
891
- // If idle with empty buffer, show quick shortcuts
892
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
893
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
623
+ if (this.statusMessage) {
624
+ leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
625
+ }
626
+ const editLabel = this.editMode === 'display-edits' ? 'accept edits on' : 'ask before edits';
627
+ const editIcon = this.editMode === 'display-edits' ? '⏵⏵' : '🛡';
628
+ leftParts.push({
629
+ text: `${editIcon} ${editLabel} (shift+tab to cycle)`,
630
+ tone: this.editMode === 'display-edits' ? 'success' : 'muted',
631
+ });
632
+ const verifyLabel = this.verificationEnabled ? 'verify+build on' : 'verify+build off';
633
+ leftParts.push({
634
+ text: `${verifyLabel} (${this.verificationHotkey.toLowerCase()})`,
635
+ tone: this.verificationEnabled ? 'success' : 'muted',
636
+ });
637
+ const continueLabel = this.autoContinueEnabled ? 'auto-continue on' : 'auto-continue off';
638
+ leftParts.push({
639
+ text: `${continueLabel} (${this.autoContinueHotkey.toLowerCase()})`,
640
+ tone: this.autoContinueEnabled ? 'info' : 'muted',
641
+ });
642
+ if (this.queue.length > 0 && this.mode !== 'streaming') {
643
+ leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
894
644
  }
895
- // Multi-line indicator
896
645
  if (this.buffer.includes('\n')) {
897
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
646
+ const lineCount = this.buffer.split('\n').length;
647
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
648
+ }
649
+ if (this.pastePlaceholders.length > 0) {
650
+ const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
651
+ leftParts.push({
652
+ text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
653
+ tone: 'info',
654
+ });
655
+ }
656
+ const contextRemaining = this.computeContextRemaining();
657
+ if (this.thinkingModeLabel) {
658
+ rightParts.push({ text: `thinking ${this.thinkingModeLabel} (/thinking)`, tone: 'info' });
659
+ }
660
+ if (contextRemaining !== null) {
661
+ const tone = contextRemaining <= 10 ? 'warn' : 'muted';
662
+ const label = contextRemaining === 0 && this.contextUsage !== null
663
+ ? 'Context auto-compact imminent'
664
+ : `Context left until auto-compact: ${contextRemaining}%`;
665
+ rightParts.push({ text: label, tone });
898
666
  }
899
- if (parts.length === 0) {
900
- return ''; // Empty status bar when idle
667
+ if (!rightParts.length || width < 60) {
668
+ const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
669
+ return renderStatusLine(merged, width);
670
+ }
671
+ const leftWidth = Math.max(12, Math.floor(width * 0.6));
672
+ const rightWidth = Math.max(14, width - leftWidth - 1);
673
+ const leftText = renderStatusLine(leftParts, leftWidth);
674
+ const rightText = renderStatusLine(rightParts, rightWidth);
675
+ const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
676
+ return `${leftText}${' '.repeat(spacing)}${rightText}`;
677
+ }
678
+ computeContextRemaining() {
679
+ if (this.contextUsage === null) {
680
+ return null;
681
+ }
682
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
683
+ }
684
+ computeTokensRemaining() {
685
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
686
+ return null;
901
687
  }
902
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
903
- return joined.slice(0, maxWidth);
688
+ const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
689
+ return this.formatTokenCount(remaining);
690
+ }
691
+ formatElapsedLabel(seconds) {
692
+ if (seconds < 60) {
693
+ return `${seconds}s`;
694
+ }
695
+ const mins = Math.floor(seconds / 60);
696
+ const secs = seconds % 60;
697
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
698
+ }
699
+ formatTokenCount(value) {
700
+ if (!Number.isFinite(value)) {
701
+ return `${value}`;
702
+ }
703
+ if (value >= 1_000_000) {
704
+ return `${(value / 1_000_000).toFixed(1)}M`;
705
+ }
706
+ if (value >= 1_000) {
707
+ return `${(value / 1_000).toFixed(1)}k`;
708
+ }
709
+ return `${Math.round(value)}`;
710
+ }
711
+ visibleLength(value) {
712
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
713
+ return value.replace(ansiPattern, '').length;
904
714
  }
905
715
  /**
906
- * Build mode controls line showing toggles and context info.
907
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
908
- *
909
- * Layout: [toggles on left] ... [context info on right]
716
+ * Debug-only snapshot used by tests to assert rendered strings without
717
+ * needing a TTY. Not used by production code.
910
718
  */
911
- buildModeControls(cols) {
912
- const maxWidth = cols - 2;
913
- // Use schema-defined colors for consistency
914
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
915
- // Mode toggles with colors (following ModeControlsSchema)
916
- const toggles = [];
917
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
918
- if (this.editMode === 'display-edits') {
919
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
920
- }
921
- else {
922
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
923
- }
924
- // Thinking mode (cyan when on) - per schema.thinkingMode
925
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
926
- // Verification (green when on) - per schema.verificationMode
927
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
928
- // Auto-continue (magenta when on) - per schema.autoContinueMode
929
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
930
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
931
- // Context usage with color - per schema.contextUsage thresholds
932
- let rightPart = '';
933
- if (this.contextUsage !== null) {
934
- const rem = Math.max(0, 100 - this.contextUsage);
935
- // Thresholds: critical < 10%, warning < 25%
936
- if (rem < 10)
937
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
938
- else if (rem < 25)
939
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
940
- else
941
- rightPart = `${DIM}ctx: ${rem}%${R}`;
942
- }
943
- // Calculate visible lengths (strip ANSI)
944
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
945
- const leftLen = strip(leftPart).length;
946
- const rightLen = strip(rightPart).length;
947
- if (leftLen + rightLen < maxWidth - 4) {
948
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
949
- }
950
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
951
- return `${leftPart} ${rightPart}`;
952
- }
953
- return leftPart;
719
+ getDebugUiSnapshot(width) {
720
+ const cols = Math.max(8, width ?? this.getSize().cols);
721
+ return {
722
+ meta: this.buildMetaLines(cols - 2),
723
+ controls: this.buildModeControls(cols),
724
+ };
954
725
  }
955
726
  /**
956
727
  * Force a re-render
@@ -973,17 +744,19 @@ export class TerminalInput extends EventEmitter {
973
744
  handleResize() {
974
745
  this.lastRenderContent = '';
975
746
  this.lastRenderCursor = -1;
747
+ this.resetStreamingRenderThrottle();
976
748
  // Re-clamp pinned header rows to the new terminal height
977
749
  this.setPinnedHeaderLines(this.pinnedTopRows);
750
+ if (this.scrollRegionActive) {
751
+ this.disableScrollRegion();
752
+ this.enableScrollRegion();
753
+ }
978
754
  this.scheduleRender();
979
755
  }
980
756
  /**
981
757
  * Register with display's output interceptor to position cursor correctly.
982
758
  * When scroll region is active, output needs to go to the scroll region,
983
759
  * not the protected bottom area where the input is rendered.
984
- *
985
- * NOTE: With scroll region properly set, content naturally stays within
986
- * the region boundaries - no cursor manipulation needed per-write.
987
760
  */
988
761
  registerOutputInterceptor(display) {
989
762
  if (this.outputInterceptorCleanup) {
@@ -991,11 +764,20 @@ export class TerminalInput extends EventEmitter {
991
764
  }
992
765
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
993
766
  beforeWrite: () => {
994
- // Scroll region handles content containment automatically
995
- // No per-write cursor manipulation needed
767
+ // When the scroll region is active, temporarily move the cursor into
768
+ // the scrollable area so streamed output lands above the pinned prompt.
769
+ if (this.scrollRegionActive) {
770
+ const { rows } = this.getSize();
771
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
772
+ this.write(ESC.SAVE);
773
+ this.write(ESC.TO(scrollBottom, 1));
774
+ }
996
775
  },
997
776
  afterWrite: () => {
998
- // No cursor manipulation needed
777
+ // Restore cursor back to the pinned prompt after output completes.
778
+ if (this.scrollRegionActive) {
779
+ this.write(ESC.RESTORE);
780
+ }
999
781
  },
1000
782
  });
1001
783
  }
@@ -1005,11 +787,6 @@ export class TerminalInput extends EventEmitter {
1005
787
  dispose() {
1006
788
  if (this.disposed)
1007
789
  return;
1008
- // Clean up streaming render timer
1009
- if (this.streamingRenderTimer) {
1010
- clearInterval(this.streamingRenderTimer);
1011
- this.streamingRenderTimer = null;
1012
- }
1013
790
  // Clean up output interceptor
1014
791
  if (this.outputInterceptorCleanup) {
1015
792
  this.outputInterceptorCleanup();
@@ -1017,6 +794,7 @@ export class TerminalInput extends EventEmitter {
1017
794
  }
1018
795
  this.disposed = true;
1019
796
  this.enabled = false;
797
+ this.resetStreamingRenderThrottle();
1020
798
  this.disableScrollRegion();
1021
799
  this.disableBracketedPaste();
1022
800
  this.buffer = '';
@@ -1122,22 +900,7 @@ export class TerminalInput extends EventEmitter {
1122
900
  this.toggleEditMode();
1123
901
  return true;
1124
902
  }
1125
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1126
- if (this.findPlaceholderAt(this.cursor)) {
1127
- this.togglePasteExpansion();
1128
- }
1129
- else {
1130
- this.toggleThinking();
1131
- }
1132
- return true;
1133
- case 'escape':
1134
- // Esc: interrupt if streaming, otherwise clear buffer
1135
- if (this.mode === 'streaming') {
1136
- this.emit('interrupt');
1137
- }
1138
- else if (this.buffer.length > 0) {
1139
- this.clear();
1140
- }
903
+ this.insertText(' ');
1141
904
  return true;
1142
905
  }
1143
906
  return false;
@@ -1155,7 +918,6 @@ export class TerminalInput extends EventEmitter {
1155
918
  this.insertPlainText(chunk, insertPos);
1156
919
  this.cursor = insertPos + chunk.length;
1157
920
  this.emit('change', this.buffer);
1158
- this.updateSuggestions();
1159
921
  this.scheduleRender();
1160
922
  }
1161
923
  insertNewline() {
@@ -1180,7 +942,6 @@ export class TerminalInput extends EventEmitter {
1180
942
  this.cursor = Math.max(0, this.cursor - 1);
1181
943
  }
1182
944
  this.emit('change', this.buffer);
1183
- this.updateSuggestions();
1184
945
  this.scheduleRender();
1185
946
  }
1186
947
  deleteForward() {
@@ -1430,7 +1191,9 @@ export class TerminalInput extends EventEmitter {
1430
1191
  if (available <= 0)
1431
1192
  return;
1432
1193
  const chunk = clean.slice(0, available);
1433
- if (isMultilinePaste(chunk)) {
1194
+ const isMultiline = isMultilinePaste(chunk);
1195
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1196
+ if (isMultiline && !isShortMultiline) {
1434
1197
  this.insertPastePlaceholder(chunk);
1435
1198
  }
1436
1199
  else {
@@ -1450,6 +1213,7 @@ export class TerminalInput extends EventEmitter {
1450
1213
  return;
1451
1214
  this.applyScrollRegion();
1452
1215
  this.scrollRegionActive = true;
1216
+ this.forceRender();
1453
1217
  }
1454
1218
  disableScrollRegion() {
1455
1219
  if (!this.scrollRegionActive)
@@ -1600,17 +1364,19 @@ export class TerminalInput extends EventEmitter {
1600
1364
  this.shiftPlaceholders(position, text.length);
1601
1365
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1602
1366
  }
1367
+ shouldInlineMultiline(content) {
1368
+ const lines = content.split('\n').length;
1369
+ const maxInlineLines = 4;
1370
+ const maxInlineChars = 240;
1371
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1372
+ }
1603
1373
  findPlaceholderAt(position) {
1604
1374
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1605
1375
  }
1606
- buildPlaceholder(summary) {
1376
+ buildPlaceholder(lineCount) {
1607
1377
  const id = ++this.pasteCounter;
1608
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1609
- // Show first line preview (truncated)
1610
- const preview = summary.preview.length > 30
1611
- ? `${summary.preview.slice(0, 30)}...`
1612
- : summary.preview;
1613
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1378
+ const plural = lineCount === 1 ? '' : 's';
1379
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1614
1380
  return { id, placeholder };
1615
1381
  }
1616
1382
  insertPastePlaceholder(content) {
@@ -1618,67 +1384,21 @@ export class TerminalInput extends EventEmitter {
1618
1384
  if (available <= 0)
1619
1385
  return;
1620
1386
  const cleanContent = content.slice(0, available);
1621
- const summary = generatePasteSummary(cleanContent);
1622
- // For short pastes (< 5 lines), show full content instead of placeholder
1623
- if (summary.lineCount < 5) {
1624
- const placeholder = this.findPlaceholderAt(this.cursor);
1625
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1626
- this.insertPlainText(cleanContent, insertPos);
1627
- this.cursor = insertPos + cleanContent.length;
1628
- return;
1629
- }
1630
- const { id, placeholder } = this.buildPlaceholder(summary);
1387
+ const lineCount = cleanContent.split('\n').length;
1388
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1631
1389
  const insertPos = this.cursor;
1632
1390
  this.shiftPlaceholders(insertPos, placeholder.length);
1633
1391
  this.pastePlaceholders.push({
1634
1392
  id,
1635
1393
  content: cleanContent,
1636
- lineCount: summary.lineCount,
1394
+ lineCount,
1637
1395
  placeholder,
1638
1396
  start: insertPos,
1639
1397
  end: insertPos + placeholder.length,
1640
- summary,
1641
- expanded: false,
1642
1398
  });
1643
1399
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1644
1400
  this.cursor = insertPos + placeholder.length;
1645
1401
  }
1646
- /**
1647
- * Toggle expansion of a paste placeholder at the current cursor position.
1648
- * When expanded, shows first 3 and last 2 lines of the content.
1649
- */
1650
- togglePasteExpansion() {
1651
- const placeholder = this.findPlaceholderAt(this.cursor);
1652
- if (!placeholder)
1653
- return false;
1654
- placeholder.expanded = !placeholder.expanded;
1655
- // Update the placeholder text in buffer
1656
- const newPlaceholder = placeholder.expanded
1657
- ? this.buildExpandedPlaceholder(placeholder)
1658
- : this.buildPlaceholder(placeholder.summary).placeholder;
1659
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1660
- // Update buffer
1661
- this.buffer =
1662
- this.buffer.slice(0, placeholder.start) +
1663
- newPlaceholder +
1664
- this.buffer.slice(placeholder.end);
1665
- // Update placeholder tracking
1666
- placeholder.placeholder = newPlaceholder;
1667
- placeholder.end = placeholder.start + newPlaceholder.length;
1668
- // Shift other placeholders
1669
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1670
- this.scheduleRender();
1671
- return true;
1672
- }
1673
- buildExpandedPlaceholder(ph) {
1674
- const lines = ph.content.split('\n');
1675
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1676
- const lastLines = lines.length > 5
1677
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1678
- : '';
1679
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1680
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1681
- }
1682
1402
  deletePlaceholder(placeholder) {
1683
1403
  const length = placeholder.end - placeholder.start;
1684
1404
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1686,7 +1406,11 @@ export class TerminalInput extends EventEmitter {
1686
1406
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1687
1407
  this.cursor = placeholder.start;
1688
1408
  }
1689
- updateContextUsage(value) {
1409
+ updateContextUsage(value, autoCompactThreshold) {
1410
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1411
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1412
+ this.contextAutoCompactThreshold = boundedThreshold;
1413
+ }
1690
1414
  if (value === null || !Number.isFinite(value)) {
1691
1415
  this.contextUsage = null;
1692
1416
  }
@@ -1713,6 +1437,22 @@ export class TerminalInput extends EventEmitter {
1713
1437
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1714
1438
  this.setEditMode(next);
1715
1439
  }
1440
+ scheduleStreamingRender(delayMs) {
1441
+ if (this.streamingRenderTimer)
1442
+ return;
1443
+ const wait = Math.max(16, delayMs);
1444
+ this.streamingRenderTimer = setTimeout(() => {
1445
+ this.streamingRenderTimer = null;
1446
+ this.render();
1447
+ }, wait);
1448
+ }
1449
+ resetStreamingRenderThrottle() {
1450
+ if (this.streamingRenderTimer) {
1451
+ clearTimeout(this.streamingRenderTimer);
1452
+ this.streamingRenderTimer = null;
1453
+ }
1454
+ this.lastStreamingRender = 0;
1455
+ }
1716
1456
  scheduleRender() {
1717
1457
  if (!this.canRender())
1718
1458
  return;