erosolar-cli 1.7.245 → 1.7.246

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