erosolar-cli 1.7.244 → 1.7.245

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 (77) 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 +9 -2
  23. package/dist/shell/interactiveShell.d.ts.map +1 -1
  24. package/dist/shell/interactiveShell.js +135 -20
  25. package/dist/shell/interactiveShell.js.map +1 -1
  26. package/dist/shell/terminalInput.d.ts +48 -116
  27. package/dist/shell/terminalInput.d.ts.map +1 -1
  28. package/dist/shell/terminalInput.js +317 -522
  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 +108 -0
  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 +52 -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
@@ -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 { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
15
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,10 +94,9 @@ 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;
97
+ // Streaming render throttle
98
+ lastStreamingRender = 0;
99
+ streamingRenderInterval = 250; // ms between renders during streaming
104
100
  constructor(writeStream = process.stdout, config = {}) {
105
101
  super();
106
102
  this.out = writeStream;
@@ -188,11 +184,6 @@ export class TerminalInput extends EventEmitter {
188
184
  if (handled)
189
185
  return;
190
186
  }
191
- // Handle '?' for help hint (if buffer is empty)
192
- if (str === '?' && this.buffer.length === 0) {
193
- this.emit('showHelp');
194
- return;
195
- }
196
187
  // Insert printable characters
197
188
  if (str && !key?.ctrl && !key?.meta) {
198
189
  this.insertText(str);
@@ -201,149 +192,24 @@ export class TerminalInput extends EventEmitter {
201
192
  /**
202
193
  * Set the input mode
203
194
  *
204
- * Streaming mode disables scroll region and lets content flow naturally.
205
- * The input area will be re-rendered after streaming ends at wherever
206
- * the cursor is (below the streamed content).
195
+ * Streaming keeps the scroll region active so the prompt/status stay pinned
196
+ * below the streaming output. When streaming ends, we refresh the input area.
207
197
  */
208
198
  setMode(mode) {
209
199
  const prevMode = this.mode;
210
200
  this.mode = mode;
211
201
  if (mode === 'streaming' && prevMode !== 'streaming') {
212
- // Track streaming start time for elapsed display
213
- this.streamingStartTime = Date.now();
214
- // Disable scroll region - let content flow naturally from current position
215
- this.disableScrollRegion();
216
- // Hide cursor during streaming to avoid racing chars
217
- this.write(ESC.HIDE);
218
- // Reset flow mode tracking
219
- this.flowModeRenderedLines = 0;
202
+ // Keep scroll region active so status/prompt stay pinned while streaming
203
+ this.enableScrollRegion();
220
204
  this.renderDirty = true;
205
+ this.render();
221
206
  }
222
207
  else if (mode !== 'streaming' && prevMode === 'streaming') {
223
- // Reset streaming time
224
- this.streamingStartTime = null;
225
- // Show cursor again
226
- this.write(ESC.SHOW);
227
- // Add a newline to separate content from input area
228
- this.write('\n');
229
- // Reset flow mode tracking for fresh render
230
- this.flowModeRenderedLines = 0;
231
- // Re-render the input area below the content
208
+ // Streaming ended - render the input area
209
+ this.enableScrollRegion();
232
210
  this.forceRender();
233
211
  }
234
212
  }
235
- /**
236
- * Enable or disable flow mode.
237
- * In flow mode, the input renders immediately after content (wherever cursor is).
238
- * When disabled, input renders at the absolute bottom of terminal.
239
- */
240
- setFlowMode(enabled) {
241
- if (this.flowMode === enabled)
242
- return;
243
- this.flowMode = enabled;
244
- this.renderDirty = true;
245
- this.scheduleRender();
246
- }
247
- /**
248
- * Check if flow mode is enabled.
249
- */
250
- isFlowMode() {
251
- return this.flowMode;
252
- }
253
- /**
254
- * Set available slash commands for auto-complete suggestions.
255
- */
256
- setCommands(commands) {
257
- this.commandSuggestions = commands;
258
- this.updateSuggestions();
259
- }
260
- /**
261
- * Update filtered suggestions based on current input.
262
- */
263
- updateSuggestions() {
264
- const input = this.buffer.trim();
265
- // Only show suggestions when input starts with "/"
266
- if (!input.startsWith('/')) {
267
- this.showSuggestions = false;
268
- this.filteredSuggestions = [];
269
- this.selectedSuggestionIndex = 0;
270
- return;
271
- }
272
- const query = input.toLowerCase();
273
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
274
- cmd.command.toLowerCase().includes(query.slice(1)));
275
- // Show suggestions if we have matches
276
- this.showSuggestions = this.filteredSuggestions.length > 0;
277
- // Keep selection in bounds
278
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
279
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
280
- }
281
- }
282
- /**
283
- * Select next suggestion (arrow down / tab).
284
- */
285
- selectNextSuggestion() {
286
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
287
- return;
288
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
289
- this.renderDirty = true;
290
- this.scheduleRender();
291
- }
292
- /**
293
- * Select previous suggestion (arrow up / shift+tab).
294
- */
295
- selectPrevSuggestion() {
296
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
297
- return;
298
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
299
- ? this.filteredSuggestions.length - 1
300
- : this.selectedSuggestionIndex - 1;
301
- this.renderDirty = true;
302
- this.scheduleRender();
303
- }
304
- /**
305
- * Accept current suggestion and insert into buffer.
306
- */
307
- acceptSuggestion() {
308
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
309
- return false;
310
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
311
- if (!selected)
312
- return false;
313
- // Replace buffer with selected command
314
- this.buffer = selected.command + ' ';
315
- this.cursor = this.buffer.length;
316
- this.showSuggestions = false;
317
- this.renderDirty = true;
318
- this.scheduleRender();
319
- return true;
320
- }
321
- /**
322
- * Check if suggestions are visible.
323
- */
324
- areSuggestionsVisible() {
325
- return this.showSuggestions && this.filteredSuggestions.length > 0;
326
- }
327
- /**
328
- * Update token count for metrics display
329
- */
330
- setTokensUsed(tokens) {
331
- this.tokensUsed = tokens;
332
- }
333
- /**
334
- * Toggle thinking/reasoning mode
335
- */
336
- toggleThinking() {
337
- this.thinkingEnabled = !this.thinkingEnabled;
338
- this.emit('thinkingToggle', this.thinkingEnabled);
339
- this.scheduleRender();
340
- }
341
- /**
342
- * Get thinking enabled state
343
- */
344
- isThinkingEnabled() {
345
- return this.thinkingEnabled;
346
- }
347
213
  /**
348
214
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
349
215
  */
@@ -356,42 +222,6 @@ export class TerminalInput extends EventEmitter {
356
222
  }
357
223
  }
358
224
  }
359
- /**
360
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
361
- * restore the default bottom-aligned layout.
362
- */
363
- setInlineAnchor(row) {
364
- if (row === null || row === undefined) {
365
- this.inlineAnchorRow = null;
366
- this.inlineLayout = false;
367
- this.renderDirty = true;
368
- this.render();
369
- return;
370
- }
371
- const { rows } = this.getSize();
372
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
373
- this.inlineAnchorRow = clamped;
374
- this.inlineLayout = true;
375
- this.renderDirty = true;
376
- this.render();
377
- }
378
- /**
379
- * Provide a dynamic anchor callback. When set, the prompt will follow the
380
- * output by re-evaluating the anchor before each render.
381
- */
382
- setInlineAnchorProvider(provider) {
383
- this.anchorProvider = provider;
384
- if (!provider) {
385
- this.inlineLayout = false;
386
- this.inlineAnchorRow = null;
387
- this.renderDirty = true;
388
- this.render();
389
- return;
390
- }
391
- this.inlineLayout = true;
392
- this.renderDirty = true;
393
- this.render();
394
- }
395
225
  /**
396
226
  * Get current mode
397
227
  */
@@ -501,6 +331,37 @@ export class TerminalInput extends EventEmitter {
501
331
  this.streamingLabel = next;
502
332
  this.scheduleRender();
503
333
  }
334
+ /**
335
+ * Surface meta status just above the divider (e.g., elapsed time or token usage).
336
+ */
337
+ setMetaStatus(meta) {
338
+ const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
339
+ ? Math.floor(meta.elapsedSeconds)
340
+ : null;
341
+ const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
342
+ ? Math.floor(meta.tokensUsed)
343
+ : null;
344
+ const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
345
+ ? Math.floor(meta.tokenLimit)
346
+ : null;
347
+ const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
348
+ ? Math.floor(meta.thinkingMs)
349
+ : null;
350
+ const nextThinkingHasContent = !!meta.thinkingHasContent;
351
+ if (this.metaElapsedSeconds === nextElapsed &&
352
+ this.metaTokensUsed === nextTokens &&
353
+ this.metaTokenLimit === nextLimit &&
354
+ this.metaThinkingMs === nextThinking &&
355
+ this.metaThinkingHasContent === nextThinkingHasContent) {
356
+ return;
357
+ }
358
+ this.metaElapsedSeconds = nextElapsed;
359
+ this.metaTokensUsed = nextTokens;
360
+ this.metaTokenLimit = nextLimit;
361
+ this.metaThinkingMs = nextThinking;
362
+ this.metaThinkingHasContent = nextThinkingHasContent;
363
+ this.scheduleRender();
364
+ }
504
365
  /**
505
366
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
506
367
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -510,16 +371,19 @@ export class TerminalInput extends EventEmitter {
510
371
  const nextAutoContinue = !!options.autoContinueEnabled;
511
372
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
512
373
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
374
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
513
375
  if (this.verificationEnabled === nextVerification &&
514
376
  this.autoContinueEnabled === nextAutoContinue &&
515
377
  this.verificationHotkey === nextVerifyHotkey &&
516
- this.autoContinueHotkey === nextAutoHotkey) {
378
+ this.autoContinueHotkey === nextAutoHotkey &&
379
+ this.thinkingModeLabel === nextThinkingLabel) {
517
380
  return;
518
381
  }
519
382
  this.verificationEnabled = nextVerification;
520
383
  this.autoContinueEnabled = nextAutoContinue;
521
384
  this.verificationHotkey = nextVerifyHotkey;
522
385
  this.autoContinueHotkey = nextAutoHotkey;
386
+ this.thinkingModeLabel = nextThinkingLabel;
523
387
  this.scheduleRender();
524
388
  }
525
389
  /**
@@ -534,188 +398,88 @@ export class TerminalInput extends EventEmitter {
534
398
  /**
535
399
  * Render the input area - Claude Code style with mode controls
536
400
  *
537
- * During streaming, we skip rendering to avoid interfering with streamed
538
- * content. A fresh render happens after streaming ends.
401
+ * IMPORTANT: During streaming mode, we skip the full render to avoid
402
+ * interfering with streaming content. Streaming content writes naturally
403
+ * to stdout, and cursor positioning during streaming would cause garbled output.
404
+ * A full render is done when streaming ends via forceRender().
539
405
  */
540
406
  render() {
541
407
  if (!this.canRender())
542
408
  return;
543
409
  if (this.isRendering)
544
410
  return;
545
- // Skip rendering while streaming to avoid cursor repositioning conflicts
411
+ // CRITICAL: During streaming, skip full render to avoid interfering
412
+ // with streaming content. The streaming chunks need to flow naturally
413
+ // without cursor repositioning breaking them up.
414
+ // Check both local mode and global streaming mode flag
546
415
  if (this.mode === 'streaming' || isStreamingMode()) {
547
- this.renderDirty = true;
416
+ this.renderDirty = true; // Mark dirty so we render after streaming ends
548
417
  return;
549
418
  }
550
419
  const shouldSkip = !this.renderDirty &&
551
420
  this.buffer === this.lastRenderContent &&
552
421
  this.cursor === this.lastRenderCursor;
553
422
  this.renderDirty = false;
554
- // Skip if nothing changed (unless explicitly forced)
423
+ // Skip if nothing changed and no explicit refresh requested
555
424
  if (shouldSkip) {
556
425
  return;
557
426
  }
558
- // If write lock is held, defer render
427
+ // If write lock is held, defer render to avoid race conditions
559
428
  if (writeLock.isLocked()) {
560
429
  writeLock.safeWrite(() => this.render());
561
430
  return;
562
431
  }
563
432
  this.isRendering = true;
433
+ // Use write lock during render to prevent interleaved output
564
434
  writeLock.lock('terminalInput.render');
565
435
  try {
566
- // Render input area at bottom (outside scroll region)
567
- this.renderBottomPinned();
568
- }
569
- finally {
570
- writeLock.unlock();
571
- this.isRendering = false;
572
- }
573
- }
574
- /**
575
- * Render in flow mode - delegates to bottom-pinned for stability.
576
- *
577
- * Flow mode attempted inline rendering but caused duplicate renders
578
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
579
- */
580
- renderFlowMode() {
581
- // Use stable bottom-pinned approach
582
- this.renderBottomPinned();
583
- }
584
- /**
585
- * Render in bottom-pinned mode - Claude Code style with suggestions
586
- *
587
- * Layout when suggestions visible:
588
- * - Top divider
589
- * - Input line(s)
590
- * - Bottom divider
591
- * - Suggestions (command list)
592
- *
593
- * Layout when suggestions hidden:
594
- * - Status bar (Ready/Streaming)
595
- * - Top divider
596
- * - Input line(s)
597
- * - Bottom divider
598
- * - Mode controls
599
- */
600
- renderBottomPinned() {
601
- const { rows, cols } = this.getSize();
602
- const maxWidth = Math.max(8, cols - 4);
603
- // Wrap buffer into display lines
604
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
605
- const availableForContent = Math.max(1, rows - 3);
606
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
607
- const displayLines = Math.min(lines.length, maxVisible);
608
- // Calculate display window (keep cursor visible)
609
- let startLine = 0;
610
- if (lines.length > displayLines) {
611
- startLine = Math.max(0, cursorLine - displayLines + 1);
612
- startLine = Math.min(startLine, lines.length - displayLines);
613
- }
614
- const visibleLines = lines.slice(startLine, startLine + displayLines);
615
- const adjustedCursorLine = cursorLine - startLine;
616
- // Calculate suggestion display
617
- const suggestionsToShow = this.showSuggestions
618
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
619
- : [];
620
- const suggestionLines = suggestionsToShow.length;
621
- this.write(ESC.HIDE);
622
- this.write(ESC.RESET);
623
- const divider = renderDivider(cols - 2);
624
- // Calculate positions from absolute bottom
625
- let currentRow;
626
- if (suggestionLines > 0) {
627
- // With suggestions: input area + dividers + suggestions
628
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
629
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
630
- currentRow = Math.max(1, rows - totalHeight + 1);
631
- this.updateReservedLines(totalHeight);
632
- // Top divider
633
- this.write(ESC.TO(currentRow, 1));
634
- this.write(ESC.CLEAR_LINE);
635
- this.write(divider);
636
- currentRow++;
637
- // Input lines
638
- let finalRow = currentRow;
639
- let finalCol = 3;
640
- for (let i = 0; i < visibleLines.length; i++) {
641
- this.write(ESC.TO(currentRow, 1));
642
- this.write(ESC.CLEAR_LINE);
643
- const line = visibleLines[i] ?? '';
644
- const absoluteLineIdx = startLine + i;
645
- const isFirstLine = absoluteLineIdx === 0;
646
- const isCursorLine = i === adjustedCursorLine;
647
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
648
- if (isCursorLine) {
649
- const col = Math.min(cursorCol, line.length);
650
- this.write(line.slice(0, col));
651
- this.write(ESC.REVERSE);
652
- this.write(col < line.length ? line[col] : ' ');
653
- this.write(ESC.RESET);
654
- this.write(line.slice(col + 1));
655
- finalRow = currentRow;
656
- finalCol = this.config.promptChar.length + col + 1;
657
- }
658
- else {
659
- this.write(line);
660
- }
661
- currentRow++;
436
+ if (!this.scrollRegionActive) {
437
+ this.enableScrollRegion();
662
438
  }
663
- // Bottom divider
664
- this.write(ESC.TO(currentRow, 1));
665
- this.write(ESC.CLEAR_LINE);
666
- this.write(divider);
667
- currentRow++;
668
- // Suggestions (Claude Code style)
669
- for (let i = 0; i < suggestionsToShow.length; i++) {
439
+ const { rows, cols } = this.getSize();
440
+ const maxWidth = Math.max(8, cols - 4);
441
+ // Wrap buffer into display lines
442
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
443
+ const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
444
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
445
+ const displayLines = Math.min(lines.length, maxVisible);
446
+ const metaLines = this.buildMetaLines(cols - 2);
447
+ // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
448
+ this.updateReservedLines(displayLines + 2 + metaLines.length);
449
+ // Calculate display window (keep cursor visible)
450
+ let startLine = 0;
451
+ if (lines.length > displayLines) {
452
+ startLine = Math.max(0, cursorLine - displayLines + 1);
453
+ startLine = Math.min(startLine, lines.length - displayLines);
454
+ }
455
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
456
+ const adjustedCursorLine = cursorLine - startLine;
457
+ // Render
458
+ this.write(ESC.HIDE);
459
+ this.write(ESC.RESET);
460
+ const startRow = Math.max(1, rows - this.reservedLines + 1);
461
+ let currentRow = startRow;
462
+ // Clear the reserved block to avoid stale meta/status lines
463
+ this.clearReservedArea(startRow, this.reservedLines, cols);
464
+ // Meta/status header (elapsed, tokens/context)
465
+ for (const metaLine of metaLines) {
670
466
  this.write(ESC.TO(currentRow, 1));
671
467
  this.write(ESC.CLEAR_LINE);
672
- const suggestion = suggestionsToShow[i];
673
- const isSelected = i === this.selectedSuggestionIndex;
674
- // Indent and highlight selected
675
- this.write(' ');
676
- if (isSelected) {
677
- this.write(ESC.REVERSE);
678
- this.write(ESC.BOLD);
679
- }
680
- this.write(suggestion.command);
681
- if (isSelected) {
682
- this.write(ESC.RESET);
683
- }
684
- // Description (dimmed)
685
- const descSpace = cols - suggestion.command.length - 8;
686
- if (descSpace > 10 && suggestion.description) {
687
- const desc = suggestion.description.slice(0, descSpace);
688
- this.write(ESC.RESET);
689
- this.write(ESC.DIM);
690
- this.write(' ');
691
- this.write(desc);
692
- this.write(ESC.RESET);
693
- }
694
- currentRow++;
468
+ this.write(metaLine);
469
+ currentRow += 1;
695
470
  }
696
- // Position cursor in input area
697
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
698
- }
699
- else {
700
- // Without suggestions: normal layout with status bar and controls
701
- const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
702
- currentRow = Math.max(1, rows - totalHeight + 1);
703
- this.updateReservedLines(totalHeight);
704
- // Status bar
705
- this.write(ESC.TO(currentRow, 1));
706
- this.write(ESC.CLEAR_LINE);
707
- this.write(this.buildStatusBar(cols));
708
- currentRow++;
709
- // Top divider
471
+ // Separator line
710
472
  this.write(ESC.TO(currentRow, 1));
711
473
  this.write(ESC.CLEAR_LINE);
474
+ const divider = renderDivider(cols - 2);
712
475
  this.write(divider);
713
- currentRow++;
714
- // Input lines
476
+ currentRow += 1;
477
+ // Render input lines
715
478
  let finalRow = currentRow;
716
479
  let finalCol = 3;
717
480
  for (let i = 0; i < visibleLines.length; i++) {
718
- this.write(ESC.TO(currentRow, 1));
481
+ const rowNum = currentRow + i;
482
+ this.write(ESC.TO(rowNum, 1));
719
483
  this.write(ESC.CLEAR_LINE);
720
484
  const line = visibleLines[i] ?? '';
721
485
  const absoluteLineIdx = startLine + i;
@@ -729,6 +493,7 @@ export class TerminalInput extends EventEmitter {
729
493
  this.write(ESC.RESET);
730
494
  this.write(ESC.BG_DARK);
731
495
  if (isCursorLine) {
496
+ // Render with block cursor
732
497
  const col = Math.min(cursorCol, line.length);
733
498
  const before = line.slice(0, col);
734
499
  const at = col < line.length ? line[col] : ' ';
@@ -738,135 +503,209 @@ export class TerminalInput extends EventEmitter {
738
503
  this.write(at);
739
504
  this.write(ESC.RESET + ESC.BG_DARK);
740
505
  this.write(after);
741
- finalRow = currentRow;
506
+ finalRow = rowNum;
742
507
  finalCol = this.config.promptChar.length + col + 1;
743
508
  }
744
509
  else {
745
510
  this.write(line);
746
511
  }
747
- // Pad to edge
512
+ // Pad to edge for clean look
748
513
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
749
514
  const padding = Math.max(0, cols - lineLen - 1);
750
515
  if (padding > 0)
751
516
  this.write(' '.repeat(padding));
752
517
  this.write(ESC.RESET);
753
- currentRow++;
754
518
  }
755
- // Bottom divider
756
- this.write(ESC.TO(currentRow, 1));
757
- this.write(ESC.CLEAR_LINE);
758
- this.write(divider);
759
- currentRow++;
760
- // Mode controls
761
- this.write(ESC.TO(currentRow, 1));
519
+ // Mode controls line (Claude Code style)
520
+ const controlRow = currentRow + visibleLines.length;
521
+ this.write(ESC.TO(controlRow, 1));
762
522
  this.write(ESC.CLEAR_LINE);
763
523
  this.write(this.buildModeControls(cols));
764
- // Position cursor in input area
524
+ // Position cursor
765
525
  this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
526
+ this.write(ESC.SHOW);
527
+ // Update state
528
+ this.lastRenderContent = this.buffer;
529
+ this.lastRenderCursor = this.cursor;
530
+ }
531
+ finally {
532
+ writeLock.unlock();
533
+ this.isRendering = false;
766
534
  }
767
- this.write(ESC.SHOW);
768
- // Update state
769
- this.lastRenderContent = this.buffer;
770
- this.lastRenderCursor = this.cursor;
771
535
  }
772
536
  /**
773
- * Build status bar showing streaming/ready status and key info.
774
- * This is the TOP line above the input area - minimal Claude Code style.
537
+ * Build one or more compact meta lines above the divider (thinking, status, usage).
775
538
  */
776
- buildStatusBar(cols) {
777
- const maxWidth = cols - 2;
778
- const parts = [];
779
- // Streaming status with elapsed time (left side)
780
- if (this.mode === 'streaming') {
781
- let statusText = '● Streaming';
782
- if (this.streamingStartTime) {
783
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
784
- const mins = Math.floor(elapsed / 60);
785
- const secs = elapsed % 60;
786
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
787
- }
788
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
539
+ buildMetaLines(width) {
540
+ const lines = [];
541
+ if (this.metaThinkingMs !== null) {
542
+ const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
543
+ lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
789
544
  }
790
- // Queue indicator during streaming
791
- if (this.mode === 'streaming' && this.queue.length > 0) {
792
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
545
+ const statusParts = [];
546
+ const statusLabel = this.statusMessage ?? this.streamingLabel;
547
+ if (statusLabel) {
548
+ statusParts.push({ text: statusLabel, tone: 'info' });
793
549
  }
794
- // Paste indicator
795
- if (this.pastePlaceholders.length > 0) {
796
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
797
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
550
+ if (this.mode === 'streaming' || isStreamingMode()) {
551
+ statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
798
552
  }
799
- // Override/warning status
800
- if (this.overrideStatusMessage) {
801
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
553
+ if (this.metaElapsedSeconds !== null) {
554
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
802
555
  }
803
- // If idle with empty buffer, show quick shortcuts
804
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
805
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
556
+ const tokensRemaining = this.computeTokensRemaining();
557
+ if (tokensRemaining !== null) {
558
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
806
559
  }
807
- // Multi-line indicator
808
- if (this.buffer.includes('\n')) {
809
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
560
+ if (statusParts.length) {
561
+ lines.push(renderStatusLine(statusParts, width));
562
+ }
563
+ const usageParts = [];
564
+ if (this.metaTokensUsed !== null) {
565
+ const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
566
+ const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
567
+ usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
568
+ }
569
+ if (this.contextUsage !== null) {
570
+ const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
571
+ const left = Math.max(0, 100 - this.contextUsage);
572
+ usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
573
+ }
574
+ if (this.queue.length > 0) {
575
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
810
576
  }
811
- if (parts.length === 0) {
812
- return ''; // Empty status bar when idle
577
+ if (usageParts.length) {
578
+ lines.push(renderStatusLine(usageParts, width));
813
579
  }
814
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
815
- return joined.slice(0, maxWidth);
580
+ return lines;
816
581
  }
817
582
  /**
818
- * Build mode controls line showing toggles and context info.
819
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
820
- *
821
- * Layout: [toggles on left] ... [context info on right]
583
+ * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
584
+ */
585
+ clearReservedArea(startRow, reservedLines, cols) {
586
+ const width = Math.max(1, cols);
587
+ for (let i = 0; i < reservedLines; i++) {
588
+ const row = startRow + i;
589
+ this.write(ESC.TO(row, 1));
590
+ this.write(' '.repeat(width));
591
+ }
592
+ }
593
+ /**
594
+ * Build Claude Code style mode controls line.
595
+ * Combines streaming label + override status + main status for simultaneous display.
822
596
  */
823
597
  buildModeControls(cols) {
824
- const maxWidth = cols - 2;
825
- const GREEN = '\x1b[32m';
826
- const YELLOW = '\x1b[33m';
827
- const CYAN = '\x1b[36m';
828
- const MAGENTA = '\x1b[35m';
829
- const RED = '\x1b[31m';
830
- const DIM = '\x1b[2m';
831
- const R = '\x1b[0m';
832
- // Mode toggles with colors
833
- const toggles = [];
834
- // Edit mode (green=auto, yellow=ask)
835
- if (this.editMode === 'display-edits') {
836
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
598
+ const width = Math.max(8, cols - 2);
599
+ const leftParts = [];
600
+ const rightParts = [];
601
+ if (this.streamingLabel) {
602
+ leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
837
603
  }
838
- else {
839
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
840
- }
841
- // Thinking mode (cyan when on)
842
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
843
- // Verification (green when on)
844
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
845
- // Auto-continue (magenta when on)
846
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
847
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
848
- // Context usage with color
849
- let rightPart = '';
850
- if (this.contextUsage !== null) {
851
- const rem = Math.max(0, 100 - this.contextUsage);
852
- if (rem < 10)
853
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
854
- else if (rem < 25)
855
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
856
- else
857
- rightPart = `${DIM}ctx: ${rem}%${R}`;
858
- }
859
- // Calculate visible lengths (strip ANSI)
860
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
861
- const leftLen = strip(leftPart).length;
862
- const rightLen = strip(rightPart).length;
863
- if (leftLen + rightLen < maxWidth - 4) {
864
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
865
- }
866
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
867
- return `${leftPart} ${rightPart}`;
868
- }
869
- return leftPart;
604
+ if (this.overrideStatusMessage) {
605
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
606
+ }
607
+ if (this.statusMessage) {
608
+ leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
609
+ }
610
+ const editLabel = this.editMode === 'display-edits' ? 'accept edits on' : 'ask before edits';
611
+ const editIcon = this.editMode === 'display-edits' ? '⏵⏵' : '🛡';
612
+ leftParts.push({
613
+ text: `${editIcon} ${editLabel} (shift+tab to cycle)`,
614
+ tone: this.editMode === 'display-edits' ? 'success' : 'muted',
615
+ });
616
+ const verifyLabel = this.verificationEnabled ? 'verify+build on' : 'verify+build off';
617
+ leftParts.push({
618
+ text: `${verifyLabel} (${this.verificationHotkey.toLowerCase()})`,
619
+ tone: this.verificationEnabled ? 'success' : 'muted',
620
+ });
621
+ const continueLabel = this.autoContinueEnabled ? 'auto-continue on' : 'auto-continue off';
622
+ leftParts.push({
623
+ text: `${continueLabel} (${this.autoContinueHotkey.toLowerCase()})`,
624
+ tone: this.autoContinueEnabled ? 'info' : 'muted',
625
+ });
626
+ if (this.queue.length > 0 && this.mode !== 'streaming') {
627
+ leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
628
+ }
629
+ if (this.buffer.includes('\n')) {
630
+ const lineCount = this.buffer.split('\n').length;
631
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
632
+ }
633
+ if (this.pastePlaceholders.length > 0) {
634
+ const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
635
+ leftParts.push({
636
+ text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
637
+ tone: 'info',
638
+ });
639
+ }
640
+ const contextRemaining = this.computeContextRemaining();
641
+ if (this.thinkingModeLabel) {
642
+ rightParts.push({ text: `thinking ${this.thinkingModeLabel} (/thinking)`, tone: 'info' });
643
+ }
644
+ if (contextRemaining !== null) {
645
+ const tone = contextRemaining <= 10 ? 'warn' : 'muted';
646
+ const label = contextRemaining === 0 && this.contextUsage !== null
647
+ ? 'Context auto-compact imminent'
648
+ : `Context left until auto-compact: ${contextRemaining}%`;
649
+ rightParts.push({ text: label, tone });
650
+ }
651
+ if (!rightParts.length || width < 60) {
652
+ const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
653
+ return renderStatusLine(merged, width);
654
+ }
655
+ const leftWidth = Math.max(12, Math.floor(width * 0.6));
656
+ const rightWidth = Math.max(14, width - leftWidth - 1);
657
+ const leftText = renderStatusLine(leftParts, leftWidth);
658
+ const rightText = renderStatusLine(rightParts, rightWidth);
659
+ const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
660
+ return `${leftText}${' '.repeat(spacing)}${rightText}`;
661
+ }
662
+ computeContextRemaining() {
663
+ if (this.contextUsage === null) {
664
+ return null;
665
+ }
666
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
667
+ }
668
+ computeTokensRemaining() {
669
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
670
+ return null;
671
+ }
672
+ const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
673
+ return this.formatTokenCount(remaining);
674
+ }
675
+ formatElapsedLabel(seconds) {
676
+ if (seconds < 60) {
677
+ return `${seconds}s`;
678
+ }
679
+ const mins = Math.floor(seconds / 60);
680
+ const secs = seconds % 60;
681
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
682
+ }
683
+ formatTokenCount(value) {
684
+ if (!Number.isFinite(value)) {
685
+ return `${value}`;
686
+ }
687
+ if (value >= 1_000_000) {
688
+ return `${(value / 1_000_000).toFixed(1)}M`;
689
+ }
690
+ if (value >= 1_000) {
691
+ return `${(value / 1_000).toFixed(1)}k`;
692
+ }
693
+ return `${Math.round(value)}`;
694
+ }
695
+ visibleLength(value) {
696
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
697
+ return value.replace(ansiPattern, '').length;
698
+ }
699
+ /**
700
+ * Debug-only snapshot used by tests to assert rendered strings without
701
+ * needing a TTY. Not used by production code.
702
+ */
703
+ getDebugUiSnapshot(width) {
704
+ const cols = Math.max(8, width ?? this.getSize().cols);
705
+ return {
706
+ meta: this.buildMetaLines(cols - 2),
707
+ controls: this.buildModeControls(cols),
708
+ };
870
709
  }
871
710
  /**
872
711
  * Force a re-render
@@ -891,15 +730,16 @@ export class TerminalInput extends EventEmitter {
891
730
  this.lastRenderCursor = -1;
892
731
  // Re-clamp pinned header rows to the new terminal height
893
732
  this.setPinnedHeaderLines(this.pinnedTopRows);
733
+ if (this.scrollRegionActive) {
734
+ this.disableScrollRegion();
735
+ this.enableScrollRegion();
736
+ }
894
737
  this.scheduleRender();
895
738
  }
896
739
  /**
897
740
  * Register with display's output interceptor to position cursor correctly.
898
741
  * When scroll region is active, output needs to go to the scroll region,
899
742
  * not the protected bottom area where the input is rendered.
900
- *
901
- * NOTE: With scroll region properly set, content naturally stays within
902
- * the region boundaries - no cursor manipulation needed per-write.
903
743
  */
904
744
  registerOutputInterceptor(display) {
905
745
  if (this.outputInterceptorCleanup) {
@@ -907,11 +747,20 @@ export class TerminalInput extends EventEmitter {
907
747
  }
908
748
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
909
749
  beforeWrite: () => {
910
- // Scroll region handles content containment automatically
911
- // No per-write cursor manipulation needed
750
+ // When the scroll region is active, temporarily move the cursor into
751
+ // the scrollable area so streamed output lands above the pinned prompt.
752
+ if (this.scrollRegionActive) {
753
+ const { rows } = this.getSize();
754
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
755
+ this.write(ESC.SAVE);
756
+ this.write(ESC.TO(scrollBottom, 1));
757
+ }
912
758
  },
913
759
  afterWrite: () => {
914
- // No cursor manipulation needed
760
+ // Restore cursor back to the pinned prompt after output completes.
761
+ if (this.scrollRegionActive) {
762
+ this.write(ESC.RESTORE);
763
+ }
915
764
  },
916
765
  });
917
766
  }
@@ -1033,22 +882,7 @@ export class TerminalInput extends EventEmitter {
1033
882
  this.toggleEditMode();
1034
883
  return true;
1035
884
  }
1036
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1037
- if (this.findPlaceholderAt(this.cursor)) {
1038
- this.togglePasteExpansion();
1039
- }
1040
- else {
1041
- this.toggleThinking();
1042
- }
1043
- return true;
1044
- case 'escape':
1045
- // Esc: interrupt if streaming, otherwise clear buffer
1046
- if (this.mode === 'streaming') {
1047
- this.emit('interrupt');
1048
- }
1049
- else if (this.buffer.length > 0) {
1050
- this.clear();
1051
- }
885
+ this.insertText(' ');
1052
886
  return true;
1053
887
  }
1054
888
  return false;
@@ -1066,7 +900,6 @@ export class TerminalInput extends EventEmitter {
1066
900
  this.insertPlainText(chunk, insertPos);
1067
901
  this.cursor = insertPos + chunk.length;
1068
902
  this.emit('change', this.buffer);
1069
- this.updateSuggestions();
1070
903
  this.scheduleRender();
1071
904
  }
1072
905
  insertNewline() {
@@ -1091,7 +924,6 @@ export class TerminalInput extends EventEmitter {
1091
924
  this.cursor = Math.max(0, this.cursor - 1);
1092
925
  }
1093
926
  this.emit('change', this.buffer);
1094
- this.updateSuggestions();
1095
927
  this.scheduleRender();
1096
928
  }
1097
929
  deleteForward() {
@@ -1341,7 +1173,9 @@ export class TerminalInput extends EventEmitter {
1341
1173
  if (available <= 0)
1342
1174
  return;
1343
1175
  const chunk = clean.slice(0, available);
1344
- if (isMultilinePaste(chunk)) {
1176
+ const isMultiline = isMultilinePaste(chunk);
1177
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1178
+ if (isMultiline && !isShortMultiline) {
1345
1179
  this.insertPastePlaceholder(chunk);
1346
1180
  }
1347
1181
  else {
@@ -1361,6 +1195,7 @@ export class TerminalInput extends EventEmitter {
1361
1195
  return;
1362
1196
  this.applyScrollRegion();
1363
1197
  this.scrollRegionActive = true;
1198
+ this.forceRender();
1364
1199
  }
1365
1200
  disableScrollRegion() {
1366
1201
  if (!this.scrollRegionActive)
@@ -1511,17 +1346,19 @@ export class TerminalInput extends EventEmitter {
1511
1346
  this.shiftPlaceholders(position, text.length);
1512
1347
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1513
1348
  }
1349
+ shouldInlineMultiline(content) {
1350
+ const lines = content.split('\n').length;
1351
+ const maxInlineLines = 4;
1352
+ const maxInlineChars = 240;
1353
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1354
+ }
1514
1355
  findPlaceholderAt(position) {
1515
1356
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1516
1357
  }
1517
- buildPlaceholder(summary) {
1358
+ buildPlaceholder(lineCount) {
1518
1359
  const id = ++this.pasteCounter;
1519
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1520
- // Show first line preview (truncated)
1521
- const preview = summary.preview.length > 30
1522
- ? `${summary.preview.slice(0, 30)}...`
1523
- : summary.preview;
1524
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1360
+ const plural = lineCount === 1 ? '' : 's';
1361
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1525
1362
  return { id, placeholder };
1526
1363
  }
1527
1364
  insertPastePlaceholder(content) {
@@ -1529,67 +1366,21 @@ export class TerminalInput extends EventEmitter {
1529
1366
  if (available <= 0)
1530
1367
  return;
1531
1368
  const cleanContent = content.slice(0, available);
1532
- const summary = generatePasteSummary(cleanContent);
1533
- // For short pastes (< 5 lines), show full content instead of placeholder
1534
- if (summary.lineCount < 5) {
1535
- const placeholder = this.findPlaceholderAt(this.cursor);
1536
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1537
- this.insertPlainText(cleanContent, insertPos);
1538
- this.cursor = insertPos + cleanContent.length;
1539
- return;
1540
- }
1541
- const { id, placeholder } = this.buildPlaceholder(summary);
1369
+ const lineCount = cleanContent.split('\n').length;
1370
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1542
1371
  const insertPos = this.cursor;
1543
1372
  this.shiftPlaceholders(insertPos, placeholder.length);
1544
1373
  this.pastePlaceholders.push({
1545
1374
  id,
1546
1375
  content: cleanContent,
1547
- lineCount: summary.lineCount,
1376
+ lineCount,
1548
1377
  placeholder,
1549
1378
  start: insertPos,
1550
1379
  end: insertPos + placeholder.length,
1551
- summary,
1552
- expanded: false,
1553
1380
  });
1554
1381
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1555
1382
  this.cursor = insertPos + placeholder.length;
1556
1383
  }
1557
- /**
1558
- * Toggle expansion of a paste placeholder at the current cursor position.
1559
- * When expanded, shows first 3 and last 2 lines of the content.
1560
- */
1561
- togglePasteExpansion() {
1562
- const placeholder = this.findPlaceholderAt(this.cursor);
1563
- if (!placeholder)
1564
- return false;
1565
- placeholder.expanded = !placeholder.expanded;
1566
- // Update the placeholder text in buffer
1567
- const newPlaceholder = placeholder.expanded
1568
- ? this.buildExpandedPlaceholder(placeholder)
1569
- : this.buildPlaceholder(placeholder.summary).placeholder;
1570
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1571
- // Update buffer
1572
- this.buffer =
1573
- this.buffer.slice(0, placeholder.start) +
1574
- newPlaceholder +
1575
- this.buffer.slice(placeholder.end);
1576
- // Update placeholder tracking
1577
- placeholder.placeholder = newPlaceholder;
1578
- placeholder.end = placeholder.start + newPlaceholder.length;
1579
- // Shift other placeholders
1580
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1581
- this.scheduleRender();
1582
- return true;
1583
- }
1584
- buildExpandedPlaceholder(ph) {
1585
- const lines = ph.content.split('\n');
1586
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1587
- const lastLines = lines.length > 5
1588
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1589
- : '';
1590
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1591
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1592
- }
1593
1384
  deletePlaceholder(placeholder) {
1594
1385
  const length = placeholder.end - placeholder.start;
1595
1386
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1597,7 +1388,11 @@ export class TerminalInput extends EventEmitter {
1597
1388
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1598
1389
  this.cursor = placeholder.start;
1599
1390
  }
1600
- updateContextUsage(value) {
1391
+ updateContextUsage(value, autoCompactThreshold) {
1392
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1393
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1394
+ this.contextAutoCompactThreshold = boundedThreshold;
1395
+ }
1601
1396
  if (value === null || !Number.isFinite(value)) {
1602
1397
  this.contextUsage = null;
1603
1398
  }