erosolar-cli 1.7.243 → 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 +133 -17
  25. package/dist/shell/interactiveShell.js.map +1 -1
  26. package/dist/shell/terminalInput.d.ts +46 -117
  27. package/dist/shell/terminalInput.d.ts.map +1 -1
  28. package/dist/shell/terminalInput.js +312 -541
  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
@@ -55,8 +57,6 @@ export class TerminalInput extends EventEmitter {
55
57
  isPasting = false;
56
58
  pastePlaceholders = [];
57
59
  pasteCounter = 0;
58
- // Streaming render timer for periodic status updates
59
- streamingRenderTimer = null;
60
60
  // History
61
61
  history = [];
62
62
  historyIndex = -1;
@@ -69,6 +69,11 @@ export class TerminalInput extends EventEmitter {
69
69
  statusMessage = null;
70
70
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
71
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
72
77
  reservedLines = 2;
73
78
  scrollRegionActive = false;
74
79
  lastRenderContent = '';
@@ -76,22 +81,12 @@ export class TerminalInput extends EventEmitter {
76
81
  renderDirty = false;
77
82
  isRendering = false;
78
83
  pinnedTopRows = 0;
79
- inlineAnchorRow = null;
80
- inlineLayout = false;
81
- anchorProvider = null;
82
- // Flow mode: when true, renders inline after content (no absolute positioning)
83
- flowMode = true;
84
- flowModeRenderedLines = 0; // Track lines rendered for clearing
85
- // Command suggestions (Claude Code style auto-complete)
86
- commandSuggestions = [];
87
- filteredSuggestions = [];
88
- selectedSuggestionIndex = 0;
89
- showSuggestions = false;
90
- maxVisibleSuggestions = 10;
91
84
  // Lifecycle
92
85
  disposed = false;
93
86
  enabled = true;
94
87
  contextUsage = null;
88
+ contextAutoCompactThreshold = 90;
89
+ thinkingModeLabel = null;
95
90
  editMode = 'display-edits';
96
91
  verificationEnabled = true;
97
92
  autoContinueEnabled = false;
@@ -102,10 +97,6 @@ export class TerminalInput extends EventEmitter {
102
97
  // Streaming render throttle
103
98
  lastStreamingRender = 0;
104
99
  streamingRenderInterval = 250; // ms between renders during streaming
105
- // Metrics tracking for status bar
106
- streamingStartTime = null;
107
- tokensUsed = 0;
108
- thinkingEnabled = true;
109
100
  constructor(writeStream = process.stdout, config = {}) {
110
101
  super();
111
102
  this.out = writeStream;
@@ -193,11 +184,6 @@ export class TerminalInput extends EventEmitter {
193
184
  if (handled)
194
185
  return;
195
186
  }
196
- // Handle '?' for help hint (if buffer is empty)
197
- if (str === '?' && this.buffer.length === 0) {
198
- this.emit('showHelp');
199
- return;
200
- }
201
187
  // Insert printable characters
202
188
  if (str && !key?.ctrl && !key?.meta) {
203
189
  this.insertText(str);
@@ -206,158 +192,24 @@ export class TerminalInput extends EventEmitter {
206
192
  /**
207
193
  * Set the input mode
208
194
  *
209
- * Streaming mode disables scroll region and lets content flow naturally.
210
- * The input area will be re-rendered after streaming ends at wherever
211
- * 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.
212
197
  */
213
198
  setMode(mode) {
214
199
  const prevMode = this.mode;
215
200
  this.mode = mode;
216
201
  if (mode === 'streaming' && prevMode !== 'streaming') {
217
- // Track streaming start time for elapsed display
218
- this.streamingStartTime = Date.now();
219
- // Enable scroll region so content scrolls while input stays fixed
202
+ // Keep scroll region active so status/prompt stay pinned while streaming
220
203
  this.enableScrollRegion();
221
- // Position cursor in scroll region for content
222
- const { rows } = this.getSize();
223
- const scrollBottom = Math.max(1, rows - this.reservedLines);
224
- this.write(ESC.TO(scrollBottom, 1));
225
- // Mark dirty for continuous updates
226
204
  this.renderDirty = true;
227
- // Start periodic render timer for status updates (elapsed time, etc.)
228
- this.streamingRenderTimer = setInterval(() => {
229
- if (this.mode === 'streaming') {
230
- this.forceRender();
231
- }
232
- }, 1000);
233
- // Render the input area (it's outside scroll region, stays fixed)
234
- this.forceRender();
205
+ this.render();
235
206
  }
236
207
  else if (mode !== 'streaming' && prevMode === 'streaming') {
237
- // Stop streaming render timer
238
- if (this.streamingRenderTimer) {
239
- clearInterval(this.streamingRenderTimer);
240
- this.streamingRenderTimer = null;
241
- }
242
- // Reset streaming time
243
- this.streamingStartTime = null;
244
- // Keep scroll region active - consistent UI
245
- // Re-render the input area
208
+ // Streaming ended - render the input area
209
+ this.enableScrollRegion();
246
210
  this.forceRender();
247
211
  }
248
212
  }
249
- /**
250
- * Enable or disable flow mode.
251
- * In flow mode, the input renders immediately after content (wherever cursor is).
252
- * When disabled, input renders at the absolute bottom of terminal.
253
- */
254
- setFlowMode(enabled) {
255
- if (this.flowMode === enabled)
256
- return;
257
- this.flowMode = enabled;
258
- this.renderDirty = true;
259
- this.scheduleRender();
260
- }
261
- /**
262
- * Check if flow mode is enabled.
263
- */
264
- isFlowMode() {
265
- return this.flowMode;
266
- }
267
- /**
268
- * Set available slash commands for auto-complete suggestions.
269
- */
270
- setCommands(commands) {
271
- this.commandSuggestions = commands;
272
- this.updateSuggestions();
273
- }
274
- /**
275
- * Update filtered suggestions based on current input.
276
- */
277
- updateSuggestions() {
278
- const input = this.buffer.trim();
279
- // Only show suggestions when input starts with "/"
280
- if (!input.startsWith('/')) {
281
- this.showSuggestions = false;
282
- this.filteredSuggestions = [];
283
- this.selectedSuggestionIndex = 0;
284
- return;
285
- }
286
- const query = input.toLowerCase();
287
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
288
- cmd.command.toLowerCase().includes(query.slice(1)));
289
- // Show suggestions if we have matches
290
- this.showSuggestions = this.filteredSuggestions.length > 0;
291
- // Keep selection in bounds
292
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
293
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
294
- }
295
- }
296
- /**
297
- * Select next suggestion (arrow down / tab).
298
- */
299
- selectNextSuggestion() {
300
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
301
- return;
302
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
303
- this.renderDirty = true;
304
- this.scheduleRender();
305
- }
306
- /**
307
- * Select previous suggestion (arrow up / shift+tab).
308
- */
309
- selectPrevSuggestion() {
310
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
311
- return;
312
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
313
- ? this.filteredSuggestions.length - 1
314
- : this.selectedSuggestionIndex - 1;
315
- this.renderDirty = true;
316
- this.scheduleRender();
317
- }
318
- /**
319
- * Accept current suggestion and insert into buffer.
320
- */
321
- acceptSuggestion() {
322
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
323
- return false;
324
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
325
- if (!selected)
326
- return false;
327
- // Replace buffer with selected command
328
- this.buffer = selected.command + ' ';
329
- this.cursor = this.buffer.length;
330
- this.showSuggestions = false;
331
- this.renderDirty = true;
332
- this.scheduleRender();
333
- return true;
334
- }
335
- /**
336
- * Check if suggestions are visible.
337
- */
338
- areSuggestionsVisible() {
339
- return this.showSuggestions && this.filteredSuggestions.length > 0;
340
- }
341
- /**
342
- * Update token count for metrics display
343
- */
344
- setTokensUsed(tokens) {
345
- this.tokensUsed = tokens;
346
- }
347
- /**
348
- * Toggle thinking/reasoning mode
349
- */
350
- toggleThinking() {
351
- this.thinkingEnabled = !this.thinkingEnabled;
352
- this.emit('thinkingToggle', this.thinkingEnabled);
353
- this.scheduleRender();
354
- }
355
- /**
356
- * Get thinking enabled state
357
- */
358
- isThinkingEnabled() {
359
- return this.thinkingEnabled;
360
- }
361
213
  /**
362
214
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
363
215
  */
@@ -370,42 +222,6 @@ export class TerminalInput extends EventEmitter {
370
222
  }
371
223
  }
372
224
  }
373
- /**
374
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
375
- * restore the default bottom-aligned layout.
376
- */
377
- setInlineAnchor(row) {
378
- if (row === null || row === undefined) {
379
- this.inlineAnchorRow = null;
380
- this.inlineLayout = false;
381
- this.renderDirty = true;
382
- this.render();
383
- return;
384
- }
385
- const { rows } = this.getSize();
386
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
387
- this.inlineAnchorRow = clamped;
388
- this.inlineLayout = true;
389
- this.renderDirty = true;
390
- this.render();
391
- }
392
- /**
393
- * Provide a dynamic anchor callback. When set, the prompt will follow the
394
- * output by re-evaluating the anchor before each render.
395
- */
396
- setInlineAnchorProvider(provider) {
397
- this.anchorProvider = provider;
398
- if (!provider) {
399
- this.inlineLayout = false;
400
- this.inlineAnchorRow = null;
401
- this.renderDirty = true;
402
- this.render();
403
- return;
404
- }
405
- this.inlineLayout = true;
406
- this.renderDirty = true;
407
- this.render();
408
- }
409
225
  /**
410
226
  * Get current mode
411
227
  */
@@ -515,6 +331,37 @@ export class TerminalInput extends EventEmitter {
515
331
  this.streamingLabel = next;
516
332
  this.scheduleRender();
517
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
+ }
518
365
  /**
519
366
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
520
367
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -524,16 +371,19 @@ export class TerminalInput extends EventEmitter {
524
371
  const nextAutoContinue = !!options.autoContinueEnabled;
525
372
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
526
373
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
374
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
527
375
  if (this.verificationEnabled === nextVerification &&
528
376
  this.autoContinueEnabled === nextAutoContinue &&
529
377
  this.verificationHotkey === nextVerifyHotkey &&
530
- this.autoContinueHotkey === nextAutoHotkey) {
378
+ this.autoContinueHotkey === nextAutoHotkey &&
379
+ this.thinkingModeLabel === nextThinkingLabel) {
531
380
  return;
532
381
  }
533
382
  this.verificationEnabled = nextVerification;
534
383
  this.autoContinueEnabled = nextAutoContinue;
535
384
  this.verificationHotkey = nextVerifyHotkey;
536
385
  this.autoContinueHotkey = nextAutoHotkey;
386
+ this.thinkingModeLabel = nextThinkingLabel;
537
387
  this.scheduleRender();
538
388
  }
539
389
  /**
@@ -548,192 +398,88 @@ export class TerminalInput extends EventEmitter {
548
398
  /**
549
399
  * Render the input area - Claude Code style with mode controls
550
400
  *
551
- * Uses scroll region to keep input fixed while content scrolls above.
552
- * During streaming, we render periodically to show status updates.
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().
553
405
  */
554
406
  render() {
555
407
  if (!this.canRender())
556
408
  return;
557
409
  if (this.isRendering)
558
410
  return;
559
- const isStreaming = this.mode === 'streaming' || isStreamingMode();
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
415
+ if (this.mode === 'streaming' || isStreamingMode()) {
416
+ this.renderDirty = true; // Mark dirty so we render after streaming ends
417
+ return;
418
+ }
560
419
  const shouldSkip = !this.renderDirty &&
561
420
  this.buffer === this.lastRenderContent &&
562
421
  this.cursor === this.lastRenderCursor;
563
422
  this.renderDirty = false;
564
- // Skip if nothing changed (unless streaming, where we update status)
565
- if (shouldSkip && !isStreaming) {
423
+ // Skip if nothing changed and no explicit refresh requested
424
+ if (shouldSkip) {
566
425
  return;
567
426
  }
568
- // If write lock is held, defer render
427
+ // If write lock is held, defer render to avoid race conditions
569
428
  if (writeLock.isLocked()) {
570
429
  writeLock.safeWrite(() => this.render());
571
430
  return;
572
431
  }
573
432
  this.isRendering = true;
433
+ // Use write lock during render to prevent interleaved output
574
434
  writeLock.lock('terminalInput.render');
575
435
  try {
576
- // During streaming, save cursor position so content can continue
577
- if (isStreaming) {
578
- this.write('\x1b7'); // Save cursor (DECSC)
436
+ if (!this.scrollRegionActive) {
437
+ this.enableScrollRegion();
579
438
  }
580
- // Render input area at bottom (outside scroll region)
581
- this.renderBottomPinned();
582
- // During streaming, restore cursor to scroll region for content
583
- if (isStreaming) {
584
- this.write('\x1b8'); // Restore cursor (DECRC)
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);
585
454
  }
586
- }
587
- finally {
588
- writeLock.unlock();
589
- this.isRendering = false;
590
- }
591
- }
592
- /**
593
- * Render in flow mode - delegates to bottom-pinned for stability.
594
- *
595
- * Flow mode attempted inline rendering but caused duplicate renders
596
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
597
- */
598
- renderFlowMode() {
599
- // Use stable bottom-pinned approach
600
- this.renderBottomPinned();
601
- }
602
- /**
603
- * Render in bottom-pinned mode - Claude Code style with suggestions
604
- *
605
- * Layout when suggestions visible:
606
- * - Top divider
607
- * - Input line(s)
608
- * - Bottom divider
609
- * - Suggestions (command list)
610
- *
611
- * Layout when suggestions hidden:
612
- * - Status bar (Ready/Streaming)
613
- * - Top divider
614
- * - Input line(s)
615
- * - Bottom divider
616
- * - Mode controls
617
- */
618
- renderBottomPinned() {
619
- const { rows, cols } = this.getSize();
620
- const maxWidth = Math.max(8, cols - 4);
621
- // Wrap buffer into display lines
622
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
623
- const availableForContent = Math.max(1, rows - 3);
624
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
625
- const displayLines = Math.min(lines.length, maxVisible);
626
- // Calculate display window (keep cursor visible)
627
- let startLine = 0;
628
- if (lines.length > displayLines) {
629
- startLine = Math.max(0, cursorLine - displayLines + 1);
630
- startLine = Math.min(startLine, lines.length - displayLines);
631
- }
632
- const visibleLines = lines.slice(startLine, startLine + displayLines);
633
- const adjustedCursorLine = cursorLine - startLine;
634
- // Calculate suggestion display
635
- const suggestionsToShow = this.showSuggestions
636
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
637
- : [];
638
- const suggestionLines = suggestionsToShow.length;
639
- this.write(ESC.HIDE);
640
- this.write(ESC.RESET);
641
- const divider = renderDivider(cols - 2);
642
- // Calculate positions from absolute bottom
643
- let currentRow;
644
- if (suggestionLines > 0) {
645
- // With suggestions: input area + dividers + suggestions
646
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
647
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
648
- currentRow = Math.max(1, rows - totalHeight + 1);
649
- this.updateReservedLines(totalHeight);
650
- // Top divider
651
- this.write(ESC.TO(currentRow, 1));
652
- this.write(ESC.CLEAR_LINE);
653
- this.write(divider);
654
- currentRow++;
655
- // Input lines
656
- let finalRow = currentRow;
657
- let finalCol = 3;
658
- for (let i = 0; i < visibleLines.length; i++) {
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) {
659
466
  this.write(ESC.TO(currentRow, 1));
660
467
  this.write(ESC.CLEAR_LINE);
661
- const line = visibleLines[i] ?? '';
662
- const absoluteLineIdx = startLine + i;
663
- const isFirstLine = absoluteLineIdx === 0;
664
- const isCursorLine = i === adjustedCursorLine;
665
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
666
- if (isCursorLine) {
667
- const col = Math.min(cursorCol, line.length);
668
- this.write(line.slice(0, col));
669
- this.write(ESC.REVERSE);
670
- this.write(col < line.length ? line[col] : ' ');
671
- this.write(ESC.RESET);
672
- this.write(line.slice(col + 1));
673
- finalRow = currentRow;
674
- finalCol = this.config.promptChar.length + col + 1;
675
- }
676
- else {
677
- this.write(line);
678
- }
679
- currentRow++;
680
- }
681
- // Bottom divider
682
- this.write(ESC.TO(currentRow, 1));
683
- this.write(ESC.CLEAR_LINE);
684
- this.write(divider);
685
- currentRow++;
686
- // Suggestions (Claude Code style)
687
- for (let i = 0; i < suggestionsToShow.length; i++) {
688
- this.write(ESC.TO(currentRow, 1));
689
- this.write(ESC.CLEAR_LINE);
690
- const suggestion = suggestionsToShow[i];
691
- const isSelected = i === this.selectedSuggestionIndex;
692
- // Indent and highlight selected
693
- this.write(' ');
694
- if (isSelected) {
695
- this.write(ESC.REVERSE);
696
- this.write(ESC.BOLD);
697
- }
698
- this.write(suggestion.command);
699
- if (isSelected) {
700
- this.write(ESC.RESET);
701
- }
702
- // Description (dimmed)
703
- const descSpace = cols - suggestion.command.length - 8;
704
- if (descSpace > 10 && suggestion.description) {
705
- const desc = suggestion.description.slice(0, descSpace);
706
- this.write(ESC.RESET);
707
- this.write(ESC.DIM);
708
- this.write(' ');
709
- this.write(desc);
710
- this.write(ESC.RESET);
711
- }
712
- currentRow++;
468
+ this.write(metaLine);
469
+ currentRow += 1;
713
470
  }
714
- // Position cursor in input area
715
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
716
- }
717
- else {
718
- // Without suggestions: normal layout with status bar and controls
719
- const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
720
- currentRow = Math.max(1, rows - totalHeight + 1);
721
- this.updateReservedLines(totalHeight);
722
- // Status bar
723
- this.write(ESC.TO(currentRow, 1));
724
- this.write(ESC.CLEAR_LINE);
725
- this.write(this.buildStatusBar(cols));
726
- currentRow++;
727
- // Top divider
471
+ // Separator line
728
472
  this.write(ESC.TO(currentRow, 1));
729
473
  this.write(ESC.CLEAR_LINE);
474
+ const divider = renderDivider(cols - 2);
730
475
  this.write(divider);
731
- currentRow++;
732
- // Input lines
476
+ currentRow += 1;
477
+ // Render input lines
733
478
  let finalRow = currentRow;
734
479
  let finalCol = 3;
735
480
  for (let i = 0; i < visibleLines.length; i++) {
736
- this.write(ESC.TO(currentRow, 1));
481
+ const rowNum = currentRow + i;
482
+ this.write(ESC.TO(rowNum, 1));
737
483
  this.write(ESC.CLEAR_LINE);
738
484
  const line = visibleLines[i] ?? '';
739
485
  const absoluteLineIdx = startLine + i;
@@ -747,6 +493,7 @@ export class TerminalInput extends EventEmitter {
747
493
  this.write(ESC.RESET);
748
494
  this.write(ESC.BG_DARK);
749
495
  if (isCursorLine) {
496
+ // Render with block cursor
750
497
  const col = Math.min(cursorCol, line.length);
751
498
  const before = line.slice(0, col);
752
499
  const at = col < line.length ? line[col] : ' ';
@@ -756,137 +503,214 @@ export class TerminalInput extends EventEmitter {
756
503
  this.write(at);
757
504
  this.write(ESC.RESET + ESC.BG_DARK);
758
505
  this.write(after);
759
- finalRow = currentRow;
506
+ finalRow = rowNum;
760
507
  finalCol = this.config.promptChar.length + col + 1;
761
508
  }
762
509
  else {
763
510
  this.write(line);
764
511
  }
765
- // Pad to edge
512
+ // Pad to edge for clean look
766
513
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
767
514
  const padding = Math.max(0, cols - lineLen - 1);
768
515
  if (padding > 0)
769
516
  this.write(' '.repeat(padding));
770
517
  this.write(ESC.RESET);
771
- currentRow++;
772
518
  }
773
- // Bottom divider
774
- this.write(ESC.TO(currentRow, 1));
775
- this.write(ESC.CLEAR_LINE);
776
- this.write(divider);
777
- currentRow++;
778
- // Mode controls
779
- 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));
780
522
  this.write(ESC.CLEAR_LINE);
781
523
  this.write(this.buildModeControls(cols));
782
- // Position cursor in input area
524
+ // Position cursor
783
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;
784
534
  }
785
- this.write(ESC.SHOW);
786
- // Update state
787
- this.lastRenderContent = this.buffer;
788
- this.lastRenderCursor = this.cursor;
789
535
  }
790
536
  /**
791
- * Build status bar showing streaming/ready status and key info.
792
- * 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).
793
538
  */
794
- buildStatusBar(cols) {
795
- const maxWidth = cols - 2;
796
- const parts = [];
797
- // Streaming status with elapsed time (left side)
798
- if (this.mode === 'streaming') {
799
- let statusText = '● Streaming';
800
- if (this.streamingStartTime) {
801
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
802
- const mins = Math.floor(elapsed / 60);
803
- const secs = elapsed % 60;
804
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
805
- }
806
- 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));
807
544
  }
808
- // Queue indicator during streaming
809
- if (this.mode === 'streaming' && this.queue.length > 0) {
810
- 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' });
811
549
  }
812
- // Paste indicator
813
- if (this.pastePlaceholders.length > 0) {
814
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
815
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
550
+ if (this.mode === 'streaming' || isStreamingMode()) {
551
+ statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
552
+ }
553
+ if (this.metaElapsedSeconds !== null) {
554
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
555
+ }
556
+ const tokensRemaining = this.computeTokensRemaining();
557
+ if (tokensRemaining !== null) {
558
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
559
+ }
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' });
576
+ }
577
+ if (usageParts.length) {
578
+ lines.push(renderStatusLine(usageParts, width));
579
+ }
580
+ return lines;
581
+ }
582
+ /**
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.
596
+ */
597
+ buildModeControls(cols) {
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' });
816
603
  }
817
- // Override/warning status
818
604
  if (this.overrideStatusMessage) {
819
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
605
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
820
606
  }
821
- // If idle with empty buffer, show quick shortcuts
822
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
823
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
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' });
824
628
  }
825
- // Multi-line indicator
826
629
  if (this.buffer.includes('\n')) {
827
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
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;
828
671
  }
829
- if (parts.length === 0) {
830
- return ''; // Empty status bar when idle
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`;
831
678
  }
832
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
833
- return joined.slice(0, maxWidth);
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;
834
698
  }
835
699
  /**
836
- * Build mode controls line showing toggles and context info.
837
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
838
- *
839
- * Layout: [toggles on left] ... [context info on right]
700
+ * Debug-only snapshot used by tests to assert rendered strings without
701
+ * needing a TTY. Not used by production code.
840
702
  */
841
- buildModeControls(cols) {
842
- const maxWidth = cols - 2;
843
- // Left side: Mode toggles (erosolar-cli features)
844
- const toggles = [];
845
- // Edit mode toggle (⏵⏵ = auto, ⏸⏸ = ask)
846
- const editIcon = this.editMode === 'display-edits' ? '⏵⏵' : '⏸⏸';
847
- const editLabel = this.editMode === 'display-edits' ? 'auto-edit' : 'ask-first';
848
- toggles.push(`${editIcon} ${editLabel}`);
849
- // Thinking mode toggle
850
- const thinkIcon = this.thinkingEnabled ? '💭' : '○';
851
- toggles.push(`${thinkIcon} think`);
852
- // Verification toggle
853
- const verifyIcon = this.verificationEnabled ? '✓' : '○';
854
- toggles.push(`${verifyIcon} verify`);
855
- // Auto-continue toggle
856
- const autoIcon = this.autoContinueEnabled ? '↻' : '○';
857
- toggles.push(`${autoIcon} auto`);
858
- const leftPart = toggles.join(' · ') + ' (⇧⇥)';
859
- // Right side: Context usage information
860
- let rightPart = '';
861
- if (this.contextUsage !== null) {
862
- const remaining = Math.max(0, 100 - this.contextUsage);
863
- const urgency = remaining < 10 ? '⚠ ' : remaining < 25 ? '! ' : '';
864
- rightPart = `${urgency}ctx: ${remaining}%`;
865
- }
866
- // Calculate spacing
867
- const leftLen = leftPart.length;
868
- const rightLen = rightPart.length;
869
- const totalLen = leftLen + rightLen;
870
- // If both fit with spacing, align right part to right edge
871
- if (totalLen < maxWidth - 4) {
872
- const spacing = maxWidth - totalLen;
873
- return `${ESC.DIM}${leftPart}${' '.repeat(spacing)}${rightPart}${ESC.RESET}`;
874
- }
875
- // If they don't fit, prioritize left and truncate right
876
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
877
- const availableForRight = maxWidth - leftLen - 4;
878
- const truncatedRight = rightPart.slice(0, availableForRight);
879
- return `${ESC.DIM}${leftPart} ${truncatedRight}${ESC.RESET}`;
880
- }
881
- // Just show left part
882
- return `${ESC.DIM}${leftPart.slice(0, maxWidth)}${ESC.RESET}`;
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
+ };
883
709
  }
884
710
  /**
885
711
  * Force a re-render
886
712
  */
887
713
  forceRender() {
888
- // Ensure scroll region is enabled for consistent UI
889
- this.enableScrollRegion();
890
714
  this.lastRenderContent = '';
891
715
  this.lastRenderCursor = -1;
892
716
  this.renderDirty = true;
@@ -916,9 +740,6 @@ export class TerminalInput extends EventEmitter {
916
740
  * Register with display's output interceptor to position cursor correctly.
917
741
  * When scroll region is active, output needs to go to the scroll region,
918
742
  * not the protected bottom area where the input is rendered.
919
- *
920
- * NOTE: With scroll region properly set, content naturally stays within
921
- * the region boundaries - no cursor manipulation needed per-write.
922
743
  */
923
744
  registerOutputInterceptor(display) {
924
745
  if (this.outputInterceptorCleanup) {
@@ -926,11 +747,20 @@ export class TerminalInput extends EventEmitter {
926
747
  }
927
748
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
928
749
  beforeWrite: () => {
929
- // Scroll region handles content containment automatically
930
- // 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
+ }
931
758
  },
932
759
  afterWrite: () => {
933
- // 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
+ }
934
764
  },
935
765
  });
936
766
  }
@@ -940,11 +770,6 @@ export class TerminalInput extends EventEmitter {
940
770
  dispose() {
941
771
  if (this.disposed)
942
772
  return;
943
- // Clean up streaming render timer
944
- if (this.streamingRenderTimer) {
945
- clearInterval(this.streamingRenderTimer);
946
- this.streamingRenderTimer = null;
947
- }
948
773
  // Clean up output interceptor
949
774
  if (this.outputInterceptorCleanup) {
950
775
  this.outputInterceptorCleanup();
@@ -1057,22 +882,7 @@ export class TerminalInput extends EventEmitter {
1057
882
  this.toggleEditMode();
1058
883
  return true;
1059
884
  }
1060
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1061
- if (this.findPlaceholderAt(this.cursor)) {
1062
- this.togglePasteExpansion();
1063
- }
1064
- else {
1065
- this.toggleThinking();
1066
- }
1067
- return true;
1068
- case 'escape':
1069
- // Esc: interrupt if streaming, otherwise clear buffer
1070
- if (this.mode === 'streaming') {
1071
- this.emit('interrupt');
1072
- }
1073
- else if (this.buffer.length > 0) {
1074
- this.clear();
1075
- }
885
+ this.insertText(' ');
1076
886
  return true;
1077
887
  }
1078
888
  return false;
@@ -1090,7 +900,6 @@ export class TerminalInput extends EventEmitter {
1090
900
  this.insertPlainText(chunk, insertPos);
1091
901
  this.cursor = insertPos + chunk.length;
1092
902
  this.emit('change', this.buffer);
1093
- this.updateSuggestions();
1094
903
  this.scheduleRender();
1095
904
  }
1096
905
  insertNewline() {
@@ -1115,7 +924,6 @@ export class TerminalInput extends EventEmitter {
1115
924
  this.cursor = Math.max(0, this.cursor - 1);
1116
925
  }
1117
926
  this.emit('change', this.buffer);
1118
- this.updateSuggestions();
1119
927
  this.scheduleRender();
1120
928
  }
1121
929
  deleteForward() {
@@ -1365,7 +1173,9 @@ export class TerminalInput extends EventEmitter {
1365
1173
  if (available <= 0)
1366
1174
  return;
1367
1175
  const chunk = clean.slice(0, available);
1368
- if (isMultilinePaste(chunk)) {
1176
+ const isMultiline = isMultilinePaste(chunk);
1177
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1178
+ if (isMultiline && !isShortMultiline) {
1369
1179
  this.insertPastePlaceholder(chunk);
1370
1180
  }
1371
1181
  else {
@@ -1385,6 +1195,7 @@ export class TerminalInput extends EventEmitter {
1385
1195
  return;
1386
1196
  this.applyScrollRegion();
1387
1197
  this.scrollRegionActive = true;
1198
+ this.forceRender();
1388
1199
  }
1389
1200
  disableScrollRegion() {
1390
1201
  if (!this.scrollRegionActive)
@@ -1535,17 +1346,19 @@ export class TerminalInput extends EventEmitter {
1535
1346
  this.shiftPlaceholders(position, text.length);
1536
1347
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1537
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
+ }
1538
1355
  findPlaceholderAt(position) {
1539
1356
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1540
1357
  }
1541
- buildPlaceholder(summary) {
1358
+ buildPlaceholder(lineCount) {
1542
1359
  const id = ++this.pasteCounter;
1543
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1544
- // Show first line preview (truncated)
1545
- const preview = summary.preview.length > 30
1546
- ? `${summary.preview.slice(0, 30)}...`
1547
- : summary.preview;
1548
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1360
+ const plural = lineCount === 1 ? '' : 's';
1361
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1549
1362
  return { id, placeholder };
1550
1363
  }
1551
1364
  insertPastePlaceholder(content) {
@@ -1553,67 +1366,21 @@ export class TerminalInput extends EventEmitter {
1553
1366
  if (available <= 0)
1554
1367
  return;
1555
1368
  const cleanContent = content.slice(0, available);
1556
- const summary = generatePasteSummary(cleanContent);
1557
- // For short pastes (< 5 lines), show full content instead of placeholder
1558
- if (summary.lineCount < 5) {
1559
- const placeholder = this.findPlaceholderAt(this.cursor);
1560
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1561
- this.insertPlainText(cleanContent, insertPos);
1562
- this.cursor = insertPos + cleanContent.length;
1563
- return;
1564
- }
1565
- const { id, placeholder } = this.buildPlaceholder(summary);
1369
+ const lineCount = cleanContent.split('\n').length;
1370
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1566
1371
  const insertPos = this.cursor;
1567
1372
  this.shiftPlaceholders(insertPos, placeholder.length);
1568
1373
  this.pastePlaceholders.push({
1569
1374
  id,
1570
1375
  content: cleanContent,
1571
- lineCount: summary.lineCount,
1376
+ lineCount,
1572
1377
  placeholder,
1573
1378
  start: insertPos,
1574
1379
  end: insertPos + placeholder.length,
1575
- summary,
1576
- expanded: false,
1577
1380
  });
1578
1381
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1579
1382
  this.cursor = insertPos + placeholder.length;
1580
1383
  }
1581
- /**
1582
- * Toggle expansion of a paste placeholder at the current cursor position.
1583
- * When expanded, shows first 3 and last 2 lines of the content.
1584
- */
1585
- togglePasteExpansion() {
1586
- const placeholder = this.findPlaceholderAt(this.cursor);
1587
- if (!placeholder)
1588
- return false;
1589
- placeholder.expanded = !placeholder.expanded;
1590
- // Update the placeholder text in buffer
1591
- const newPlaceholder = placeholder.expanded
1592
- ? this.buildExpandedPlaceholder(placeholder)
1593
- : this.buildPlaceholder(placeholder.summary).placeholder;
1594
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1595
- // Update buffer
1596
- this.buffer =
1597
- this.buffer.slice(0, placeholder.start) +
1598
- newPlaceholder +
1599
- this.buffer.slice(placeholder.end);
1600
- // Update placeholder tracking
1601
- placeholder.placeholder = newPlaceholder;
1602
- placeholder.end = placeholder.start + newPlaceholder.length;
1603
- // Shift other placeholders
1604
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1605
- this.scheduleRender();
1606
- return true;
1607
- }
1608
- buildExpandedPlaceholder(ph) {
1609
- const lines = ph.content.split('\n');
1610
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1611
- const lastLines = lines.length > 5
1612
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1613
- : '';
1614
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1615
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1616
- }
1617
1384
  deletePlaceholder(placeholder) {
1618
1385
  const length = placeholder.end - placeholder.start;
1619
1386
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1621,7 +1388,11 @@ export class TerminalInput extends EventEmitter {
1621
1388
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1622
1389
  this.cursor = placeholder.start;
1623
1390
  }
1624
- 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
+ }
1625
1396
  if (value === null || !Number.isFinite(value)) {
1626
1397
  this.contextUsage = null;
1627
1398
  }