erosolar-cli 1.7.231 → 1.7.232

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 (59) 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/enhancedErrorRecovery.d.ts +100 -0
  15. package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
  16. package/dist/core/enhancedErrorRecovery.js +345 -0
  17. package/dist/core/enhancedErrorRecovery.js.map +1 -0
  18. package/dist/mcp/sseClient.d.ts.map +1 -1
  19. package/dist/mcp/sseClient.js +18 -9
  20. package/dist/mcp/sseClient.js.map +1 -1
  21. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  22. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  23. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  24. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  25. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  26. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  27. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  28. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  29. package/dist/shell/inputQueueManager.d.ts +144 -0
  30. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  31. package/dist/shell/inputQueueManager.js +290 -0
  32. package/dist/shell/inputQueueManager.js.map +1 -0
  33. package/dist/shell/interactiveShell.d.ts +2 -2
  34. package/dist/shell/interactiveShell.d.ts.map +1 -1
  35. package/dist/shell/interactiveShell.js +14 -13
  36. package/dist/shell/interactiveShell.js.map +1 -1
  37. package/dist/shell/streamingOutputManager.d.ts +115 -0
  38. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  39. package/dist/shell/streamingOutputManager.js +225 -0
  40. package/dist/shell/streamingOutputManager.js.map +1 -0
  41. package/dist/shell/terminalInput.d.ts +65 -21
  42. package/dist/shell/terminalInput.d.ts.map +1 -1
  43. package/dist/shell/terminalInput.js +460 -209
  44. package/dist/shell/terminalInput.js.map +1 -1
  45. package/dist/shell/terminalInputAdapter.d.ts +14 -6
  46. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  47. package/dist/shell/terminalInputAdapter.js +20 -6
  48. package/dist/shell/terminalInputAdapter.js.map +1 -1
  49. package/dist/ui/persistentPrompt.d.ts +50 -0
  50. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  51. package/dist/ui/persistentPrompt.js +92 -0
  52. package/dist/ui/persistentPrompt.js.map +1 -0
  53. package/dist/ui/theme.d.ts.map +1 -1
  54. package/dist/ui/theme.js +8 -6
  55. package/dist/ui/theme.js.map +1 -1
  56. package/dist/ui/unified/layout.d.ts.map +1 -1
  57. package/dist/ui/unified/layout.js +26 -13
  58. package/dist/ui/unified/layout.js.map +1 -1
  59. package/package.json +1 -1
@@ -3,14 +3,13 @@
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
14
  import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
15
  import { isStreamingMode } from '../ui/globalWriteLock.js';
@@ -68,9 +67,6 @@ export class TerminalInput extends EventEmitter {
68
67
  statusMessage = null;
69
68
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
70
69
  streamingLabel = null; // Streaming progress indicator
71
- metaElapsedSeconds = null; // Optional elapsed time for header line
72
- metaTokensUsed = null; // Optional token usage
73
- metaTokenLimit = null; // Optional token window
74
70
  reservedLines = 2;
75
71
  scrollRegionActive = false;
76
72
  lastRenderContent = '';
@@ -78,6 +74,13 @@ export class TerminalInput extends EventEmitter {
78
74
  renderDirty = false;
79
75
  isRendering = false;
80
76
  pinnedTopRows = 0;
77
+ inlineAnchorRow = null;
78
+ inlineLayout = false;
79
+ anchorProvider = null;
80
+ // Flow mode: render input immediately after content instead of at absolute bottom
81
+ flowMode = true;
82
+ // Track how many lines we rendered in flow mode for proper re-rendering
83
+ flowModeRenderedLines = 0;
81
84
  // Lifecycle
82
85
  disposed = false;
83
86
  enabled = true;
@@ -92,6 +95,10 @@ export class TerminalInput extends EventEmitter {
92
95
  // Streaming render throttle
93
96
  lastStreamingRender = 0;
94
97
  streamingRenderInterval = 250; // ms between renders during streaming
98
+ // Metrics tracking for status bar
99
+ streamingStartTime = null;
100
+ tokensUsed = 0;
101
+ thinkingEnabled = true;
95
102
  constructor(writeStream = process.stdout, config = {}) {
96
103
  super();
97
104
  this.out = writeStream;
@@ -179,6 +186,11 @@ export class TerminalInput extends EventEmitter {
179
186
  if (handled)
180
187
  return;
181
188
  }
189
+ // Handle '?' for help hint (if buffer is empty)
190
+ if (str === '?' && this.buffer.length === 0) {
191
+ this.emit('showHelp');
192
+ return;
193
+ }
182
194
  // Insert printable characters
183
195
  if (str && !key?.ctrl && !key?.meta) {
184
196
  this.insertText(str);
@@ -187,24 +199,75 @@ export class TerminalInput extends EventEmitter {
187
199
  /**
188
200
  * Set the input mode
189
201
  *
190
- * Streaming keeps the scroll region active so the prompt/status stay pinned
191
- * below the streaming output. When streaming ends, we refresh the input area.
202
+ * Streaming mode disables scroll region and lets content flow naturally.
203
+ * The input area will be re-rendered after streaming ends at wherever
204
+ * the cursor is (below the streamed content).
192
205
  */
193
206
  setMode(mode) {
194
207
  const prevMode = this.mode;
195
208
  this.mode = mode;
196
209
  if (mode === 'streaming' && prevMode !== 'streaming') {
197
- // Keep scroll region active so status/prompt stay pinned while streaming
198
- this.enableScrollRegion();
210
+ // Track streaming start time for elapsed display
211
+ this.streamingStartTime = Date.now();
212
+ // Disable scroll region - let content flow naturally from current position
213
+ // This means content appears right after where cursor currently is
214
+ // (which should be after the banner)
215
+ this.disableScrollRegion();
216
+ // Reset flow mode rendered lines - content will flow naturally during streaming
217
+ this.flowModeRenderedLines = 0;
218
+ // Hide cursor during streaming to avoid racing chars
219
+ this.write(ESC.HIDE);
199
220
  this.renderDirty = true;
200
- this.render();
201
221
  }
202
222
  else if (mode !== 'streaming' && prevMode === 'streaming') {
203
- // Streaming ended - render the input area
204
- this.enableScrollRegion();
223
+ // Reset streaming time
224
+ this.streamingStartTime = null;
225
+ // Show cursor again
226
+ this.write(ESC.SHOW);
227
+ // Add a newline to separate content from input area
228
+ this.write('\n');
229
+ // Re-render the input area below the content
205
230
  this.forceRender();
206
231
  }
207
232
  }
233
+ /**
234
+ * Enable or disable flow mode.
235
+ * In flow mode, the input renders immediately after content (wherever cursor is).
236
+ * When disabled, input renders at the absolute bottom of terminal.
237
+ */
238
+ setFlowMode(enabled) {
239
+ if (this.flowMode === enabled)
240
+ return;
241
+ this.flowMode = enabled;
242
+ this.renderDirty = true;
243
+ this.scheduleRender();
244
+ }
245
+ /**
246
+ * Check if flow mode is enabled.
247
+ */
248
+ isFlowMode() {
249
+ return this.flowMode;
250
+ }
251
+ /**
252
+ * Update token count for metrics display
253
+ */
254
+ setTokensUsed(tokens) {
255
+ this.tokensUsed = tokens;
256
+ }
257
+ /**
258
+ * Toggle thinking/reasoning mode
259
+ */
260
+ toggleThinking() {
261
+ this.thinkingEnabled = !this.thinkingEnabled;
262
+ this.emit('thinkingToggle', this.thinkingEnabled);
263
+ this.scheduleRender();
264
+ }
265
+ /**
266
+ * Get thinking enabled state
267
+ */
268
+ isThinkingEnabled() {
269
+ return this.thinkingEnabled;
270
+ }
208
271
  /**
209
272
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
210
273
  */
@@ -217,6 +280,42 @@ export class TerminalInput extends EventEmitter {
217
280
  }
218
281
  }
219
282
  }
283
+ /**
284
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
285
+ * restore the default bottom-aligned layout.
286
+ */
287
+ setInlineAnchor(row) {
288
+ if (row === null || row === undefined) {
289
+ this.inlineAnchorRow = null;
290
+ this.inlineLayout = false;
291
+ this.renderDirty = true;
292
+ this.render();
293
+ return;
294
+ }
295
+ const { rows } = this.getSize();
296
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
297
+ this.inlineAnchorRow = clamped;
298
+ this.inlineLayout = true;
299
+ this.renderDirty = true;
300
+ this.render();
301
+ }
302
+ /**
303
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
304
+ * output by re-evaluating the anchor before each render.
305
+ */
306
+ setInlineAnchorProvider(provider) {
307
+ this.anchorProvider = provider;
308
+ if (!provider) {
309
+ this.inlineLayout = false;
310
+ this.inlineAnchorRow = null;
311
+ this.renderDirty = true;
312
+ this.render();
313
+ return;
314
+ }
315
+ this.inlineLayout = true;
316
+ this.renderDirty = true;
317
+ this.render();
318
+ }
220
319
  /**
221
320
  * Get current mode
222
321
  */
@@ -326,29 +425,6 @@ export class TerminalInput extends EventEmitter {
326
425
  this.streamingLabel = next;
327
426
  this.scheduleRender();
328
427
  }
329
- /**
330
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
331
- */
332
- setMetaStatus(meta) {
333
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
334
- ? Math.floor(meta.elapsedSeconds)
335
- : null;
336
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
337
- ? Math.floor(meta.tokensUsed)
338
- : null;
339
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
340
- ? Math.floor(meta.tokenLimit)
341
- : null;
342
- if (this.metaElapsedSeconds === nextElapsed &&
343
- this.metaTokensUsed === nextTokens &&
344
- this.metaTokenLimit === nextLimit) {
345
- return;
346
- }
347
- this.metaElapsedSeconds = nextElapsed;
348
- this.metaTokensUsed = nextTokens;
349
- this.metaTokenLimit = nextLimit;
350
- this.scheduleRender();
351
- }
352
428
  /**
353
429
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
354
430
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -417,100 +493,18 @@ export class TerminalInput extends EventEmitter {
417
493
  // Use write lock during render to prevent interleaved output
418
494
  writeLock.lock('terminalInput.render');
419
495
  try {
420
- if (!this.scrollRegionActive) {
421
- this.enableScrollRegion();
422
- }
423
- const { rows, cols } = this.getSize();
424
- const maxWidth = Math.max(8, cols - 4);
425
- // Wrap buffer into display lines
426
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
427
- const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
428
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
429
- const displayLines = Math.min(lines.length, maxVisible);
430
- const metaLine = this.buildMetaLine(cols - 2);
431
- // Reserved lines: optional meta(1) + separator(1) + input lines + controls(1)
432
- this.updateReservedLines(displayLines + 2 + (metaLine ? 1 : 0));
433
- // Calculate display window (keep cursor visible)
434
- let startLine = 0;
435
- if (lines.length > displayLines) {
436
- startLine = Math.max(0, cursorLine - displayLines + 1);
437
- startLine = Math.min(startLine, lines.length - displayLines);
496
+ // No scroll regions - we render the input area directly at the bottom
497
+ // Content flows naturally above it
498
+ if (this.scrollRegionActive) {
499
+ this.disableScrollRegion();
438
500
  }
439
- const visibleLines = lines.slice(startLine, startLine + displayLines);
440
- const adjustedCursorLine = cursorLine - startLine;
441
- // Render
442
- this.write(ESC.HIDE);
443
- this.write(ESC.RESET);
444
- const startRow = Math.max(1, rows - this.reservedLines + 1);
445
- let currentRow = startRow;
446
- // Clear the reserved block to avoid stale meta/status lines
447
- this.clearReservedArea(startRow, this.reservedLines, cols);
448
- // Meta/status header (elapsed, tokens/context)
449
- if (metaLine) {
450
- this.write(ESC.TO(currentRow, 1));
451
- this.write(ESC.CLEAR_LINE);
452
- this.write(metaLine);
453
- currentRow += 1;
501
+ // Use flow mode rendering (inline after content) for better UX
502
+ if (this.flowMode) {
503
+ this.renderFlowMode();
454
504
  }
455
- // Separator line
456
- this.write(ESC.TO(currentRow, 1));
457
- this.write(ESC.CLEAR_LINE);
458
- const divider = renderDivider(cols - 2);
459
- this.write(divider);
460
- currentRow += 1;
461
- // Render input lines
462
- let finalRow = currentRow;
463
- let finalCol = 3;
464
- for (let i = 0; i < visibleLines.length; i++) {
465
- const rowNum = currentRow + i;
466
- this.write(ESC.TO(rowNum, 1));
467
- this.write(ESC.CLEAR_LINE);
468
- const line = visibleLines[i] ?? '';
469
- const absoluteLineIdx = startLine + i;
470
- const isFirstLine = absoluteLineIdx === 0;
471
- const isCursorLine = i === adjustedCursorLine;
472
- // Background
473
- this.write(ESC.BG_DARK);
474
- // Prompt prefix
475
- this.write(ESC.DIM);
476
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
477
- this.write(ESC.RESET);
478
- this.write(ESC.BG_DARK);
479
- if (isCursorLine) {
480
- // Render with block cursor
481
- const col = Math.min(cursorCol, line.length);
482
- const before = line.slice(0, col);
483
- const at = col < line.length ? line[col] : ' ';
484
- const after = col < line.length ? line.slice(col + 1) : '';
485
- this.write(before);
486
- this.write(ESC.REVERSE + ESC.BOLD);
487
- this.write(at);
488
- this.write(ESC.RESET + ESC.BG_DARK);
489
- this.write(after);
490
- finalRow = rowNum;
491
- finalCol = this.config.promptChar.length + col + 1;
492
- }
493
- else {
494
- this.write(line);
495
- }
496
- // Pad to edge for clean look
497
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
498
- const padding = Math.max(0, cols - lineLen - 1);
499
- if (padding > 0)
500
- this.write(' '.repeat(padding));
501
- this.write(ESC.RESET);
505
+ else {
506
+ this.renderBottomPinned();
502
507
  }
503
- // Mode controls line (Claude Code style)
504
- const controlRow = currentRow + visibleLines.length;
505
- this.write(ESC.TO(controlRow, 1));
506
- this.write(ESC.CLEAR_LINE);
507
- this.write(this.buildModeControls(cols));
508
- // Position cursor
509
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
510
- this.write(ESC.SHOW);
511
- // Update state
512
- this.lastRenderContent = this.buffer;
513
- this.lastRenderCursor = this.cursor;
514
508
  }
515
509
  finally {
516
510
  writeLock.unlock();
@@ -518,83 +512,290 @@ export class TerminalInput extends EventEmitter {
518
512
  }
519
513
  }
520
514
  /**
521
- * Build a compact meta line above the divider (elapsed, context usage, queue size).
515
+ * Render in flow mode - input area renders inline at current cursor position
516
+ * This creates a unified look where input flows naturally with content
522
517
  */
523
- buildMetaLine(width) {
524
- const parts = [];
525
- if (this.metaElapsedSeconds !== null) {
526
- parts.push({ text: `elapsed ${this.metaElapsedSeconds}s`, tone: 'muted' });
527
- }
528
- if (this.metaTokensUsed !== null) {
529
- const limitText = this.metaTokenLimit ? `/${this.metaTokenLimit}` : '';
530
- parts.push({ text: `tokens ${this.metaTokensUsed}${limitText}`, tone: 'muted' });
531
- }
532
- if (this.contextUsage !== null) {
533
- const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
534
- parts.push({ text: `used ${this.contextUsage}%`, tone });
518
+ renderFlowMode() {
519
+ const { cols } = this.getSize();
520
+ const maxWidth = Math.max(8, cols - 4);
521
+ // Wrap buffer into display lines
522
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
523
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, 10));
524
+ const displayLines = Math.min(lines.length, maxVisible);
525
+ // Calculate display window (keep cursor visible)
526
+ let startLine = 0;
527
+ if (lines.length > displayLines) {
528
+ startLine = Math.max(0, cursorLine - displayLines + 1);
529
+ startLine = Math.min(startLine, lines.length - displayLines);
530
+ }
531
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
532
+ const adjustedCursorLine = cursorLine - startLine;
533
+ this.write(ESC.HIDE);
534
+ this.write(ESC.RESET);
535
+ // If we've previously rendered in flow mode, clear those lines first
536
+ if (this.flowModeRenderedLines > 0) {
537
+ // Move up to the start of our rendered content
538
+ this.write(`\x1b[${this.flowModeRenderedLines}A`);
539
+ // Clear each line we previously rendered
540
+ for (let i = 0; i < this.flowModeRenderedLines; i++) {
541
+ this.write(ESC.CLEAR_LINE);
542
+ if (i < this.flowModeRenderedLines - 1) {
543
+ this.write('\x1b[1B'); // Move down
544
+ }
545
+ }
546
+ // Move back up to where we'll start rendering
547
+ if (this.flowModeRenderedLines > 1) {
548
+ this.write(`\x1b[${this.flowModeRenderedLines - 1}A`);
549
+ }
535
550
  }
536
- if (this.queue.length > 0) {
537
- parts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
551
+ // Track total lines we'll render: status(1) + topDiv(1) + input(N) + bottomDiv(1) + controls(1)
552
+ const totalLinesToRender = visibleLines.length + 4;
553
+ // Flow layout: render inline from current position
554
+ // [status bar] [divider] [input...] [divider] [controls]
555
+ // Status bar
556
+ this.write(ESC.CLEAR_LINE);
557
+ this.write(this.buildStatusBar(cols));
558
+ // Top divider
559
+ this.write('\n');
560
+ this.write(ESC.CLEAR_LINE);
561
+ const divider = renderDivider(cols - 2);
562
+ this.write(divider);
563
+ // Input lines
564
+ let cursorRow = 0;
565
+ let cursorColPos = 3;
566
+ for (let i = 0; i < visibleLines.length; i++) {
567
+ this.write('\n');
568
+ this.write(ESC.CLEAR_LINE);
569
+ const line = visibleLines[i] ?? '';
570
+ const absoluteLineIdx = startLine + i;
571
+ const isFirstLine = absoluteLineIdx === 0;
572
+ const isCursorLine = i === adjustedCursorLine;
573
+ // Background
574
+ this.write(ESC.BG_DARK);
575
+ // Prompt prefix
576
+ this.write(ESC.DIM);
577
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
578
+ this.write(ESC.RESET);
579
+ this.write(ESC.BG_DARK);
580
+ if (isCursorLine) {
581
+ // Render with block cursor
582
+ const col = Math.min(cursorCol, line.length);
583
+ const before = line.slice(0, col);
584
+ const at = col < line.length ? line[col] : ' ';
585
+ const after = col < line.length ? line.slice(col + 1) : '';
586
+ this.write(before);
587
+ this.write(ESC.REVERSE + ESC.BOLD);
588
+ this.write(at);
589
+ this.write(ESC.RESET + ESC.BG_DARK);
590
+ this.write(after);
591
+ cursorRow = i;
592
+ cursorColPos = this.config.promptChar.length + col + 1;
593
+ }
594
+ else {
595
+ this.write(line);
596
+ }
597
+ // Pad to edge
598
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
599
+ const padding = Math.max(0, cols - lineLen - 1);
600
+ if (padding > 0)
601
+ this.write(' '.repeat(padding));
602
+ this.write(ESC.RESET);
538
603
  }
539
- return parts.length ? renderStatusLine(parts, width) : '';
604
+ // Bottom divider
605
+ this.write('\n');
606
+ this.write(ESC.CLEAR_LINE);
607
+ this.write(divider);
608
+ // Mode controls
609
+ this.write('\n');
610
+ this.write(ESC.CLEAR_LINE);
611
+ this.write(this.buildModeControls(cols));
612
+ // Remember how many lines we rendered for next re-render
613
+ this.flowModeRenderedLines = totalLinesToRender;
614
+ // Move cursor back to input area
615
+ // We're now at the last line (controls), need to go up to the input line
616
+ // Input starts at line 2 (after status and top divider), cursor is at cursorRow within input
617
+ const linesToGoUp = (visibleLines.length - cursorRow - 1) + 2; // +2 for bottom divider and controls
618
+ if (linesToGoUp > 0) {
619
+ this.write(`\x1b[${linesToGoUp}A`); // Move up
620
+ }
621
+ this.write(ESC.TO_COL(Math.min(cursorColPos, cols)));
622
+ this.write(ESC.SHOW);
623
+ // Update state
624
+ this.lastRenderContent = this.buffer;
625
+ this.lastRenderCursor = this.cursor;
540
626
  }
541
627
  /**
542
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
628
+ * Render in bottom-pinned mode - input area renders at absolute bottom
629
+ * Used when explicit bottom positioning is needed
543
630
  */
544
- clearReservedArea(startRow, reservedLines, cols) {
545
- const width = Math.max(1, cols);
546
- for (let i = 0; i < reservedLines; i++) {
547
- const row = startRow + i;
548
- this.write(ESC.TO(row, 1));
549
- this.write(' '.repeat(width));
631
+ renderBottomPinned() {
632
+ const { rows, cols } = this.getSize();
633
+ const maxWidth = Math.max(8, cols - 4);
634
+ // Wrap buffer into display lines
635
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
636
+ const availableForContent = Math.max(1, rows - 3);
637
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
638
+ const displayLines = Math.min(lines.length, maxVisible);
639
+ // Reserved lines: separator(1) + controls(1) + input lines
640
+ this.updateReservedLines(displayLines + 2);
641
+ // Calculate display window (keep cursor visible)
642
+ let startLine = 0;
643
+ if (lines.length > displayLines) {
644
+ startLine = Math.max(0, cursorLine - displayLines + 1);
645
+ startLine = Math.min(startLine, lines.length - displayLines);
646
+ }
647
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
648
+ const adjustedCursorLine = cursorLine - startLine;
649
+ this.write(ESC.HIDE);
650
+ this.write(ESC.RESET);
651
+ // Calculate positions from absolute bottom
652
+ const modeControlRow = rows;
653
+ const bottomSepRow = rows - 1;
654
+ const inputEndRow = rows - 2;
655
+ const inputStartRow = inputEndRow - visibleLines.length + 1;
656
+ const topSepRow = inputStartRow - 1;
657
+ const statusBarRow = topSepRow - 1;
658
+ // Reserved lines: status(1) + topSep(1) + input + bottomSep(1) + controls(1)
659
+ this.updateReservedLines(visibleLines.length + 4);
660
+ // Status bar
661
+ this.write(ESC.TO(statusBarRow, 1));
662
+ this.write(ESC.CLEAR_LINE);
663
+ this.write(this.buildStatusBar(cols));
664
+ // Top separator
665
+ this.write(ESC.TO(topSepRow, 1));
666
+ this.write(ESC.CLEAR_LINE);
667
+ const divider = renderDivider(cols - 2);
668
+ this.write(divider);
669
+ // Render input lines
670
+ let finalRow = inputStartRow;
671
+ let finalCol = 3;
672
+ for (let i = 0; i < visibleLines.length; i++) {
673
+ const rowNum = inputStartRow + i;
674
+ this.write(ESC.TO(rowNum, 1));
675
+ this.write(ESC.CLEAR_LINE);
676
+ const line = visibleLines[i] ?? '';
677
+ const absoluteLineIdx = startLine + i;
678
+ const isFirstLine = absoluteLineIdx === 0;
679
+ const isCursorLine = i === adjustedCursorLine;
680
+ // Background
681
+ this.write(ESC.BG_DARK);
682
+ // Prompt prefix
683
+ this.write(ESC.DIM);
684
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
685
+ this.write(ESC.RESET);
686
+ this.write(ESC.BG_DARK);
687
+ if (isCursorLine) {
688
+ const col = Math.min(cursorCol, line.length);
689
+ const before = line.slice(0, col);
690
+ const at = col < line.length ? line[col] : ' ';
691
+ const after = col < line.length ? line.slice(col + 1) : '';
692
+ this.write(before);
693
+ this.write(ESC.REVERSE + ESC.BOLD);
694
+ this.write(at);
695
+ this.write(ESC.RESET + ESC.BG_DARK);
696
+ this.write(after);
697
+ finalRow = rowNum;
698
+ finalCol = this.config.promptChar.length + col + 1;
699
+ }
700
+ else {
701
+ this.write(line);
702
+ }
703
+ // Pad to edge
704
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
705
+ const padding = Math.max(0, cols - lineLen - 1);
706
+ if (padding > 0)
707
+ this.write(' '.repeat(padding));
708
+ this.write(ESC.RESET);
550
709
  }
710
+ // Bottom separator
711
+ this.write(ESC.TO(bottomSepRow, 1));
712
+ this.write(ESC.CLEAR_LINE);
713
+ this.write(divider);
714
+ // Mode controls
715
+ this.write(ESC.TO(modeControlRow, 1));
716
+ this.write(ESC.CLEAR_LINE);
717
+ this.write(this.buildModeControls(cols));
718
+ // Position cursor in input area
719
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
720
+ this.write(ESC.SHOW);
721
+ // Update state
722
+ this.lastRenderContent = this.buffer;
723
+ this.lastRenderCursor = this.cursor;
551
724
  }
552
725
  /**
553
- * Build Claude Code style mode controls line.
554
- * Combines streaming label + override status + main status for simultaneous display.
726
+ * Build status bar showing streaming/ready status, elapsed time, and token count.
727
+ * This is the TOP line above the input area.
555
728
  */
556
- buildModeControls(cols) {
729
+ buildStatusBar(cols) {
557
730
  const parts = [];
558
- if (this.streamingLabel) {
559
- parts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
731
+ // Streaming/Ready status with elapsed time
732
+ if (this.mode === 'streaming') {
733
+ let statusText = '● Streaming';
734
+ if (this.streamingStartTime) {
735
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
736
+ const mins = Math.floor(elapsed / 60);
737
+ const secs = elapsed % 60;
738
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
739
+ }
740
+ parts.push({ text: statusText, tone: 'success' });
560
741
  }
561
- if (this.overrideStatusMessage) {
562
- parts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
742
+ else {
743
+ parts.push({ text: '○ Ready', tone: 'muted' });
563
744
  }
564
- if (this.statusMessage) {
565
- parts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
745
+ // Token count (context usage)
746
+ if (this.tokensUsed > 0) {
747
+ const tokenStr = this.tokensUsed >= 1000
748
+ ? `${(this.tokensUsed / 1000).toFixed(1)}k`
749
+ : `${this.tokensUsed}`;
750
+ parts.push({ text: `${tokenStr} tokens`, tone: 'info' });
566
751
  }
567
- const verifyLabel = this.verificationEnabled ? 'verify+build on' : 'verify+build off';
752
+ // Context window remaining
753
+ if (this.contextUsage !== null) {
754
+ const pct = Math.max(0, 100 - this.contextUsage);
755
+ parts.push({ text: `ctx ${pct}%`, tone: pct < 25 ? 'warn' : 'muted' });
756
+ }
757
+ // Paste indicator
758
+ if (this.pastePlaceholders.length > 0) {
759
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
760
+ parts.push({ text: `📋 ${totalLines}L`, tone: 'info' });
761
+ }
762
+ return renderStatusLine(parts, cols - 2);
763
+ }
764
+ /**
765
+ * Build mode controls line showing toggles and shortcuts.
766
+ * This is the BOTTOM line below the input area.
767
+ */
768
+ buildModeControls(cols) {
769
+ const parts = [];
770
+ // Thinking mode toggle
771
+ parts.push({
772
+ text: this.thinkingEnabled ? '💭 on (tab)' : '💭 off (tab)',
773
+ tone: this.thinkingEnabled ? 'info' : 'muted',
774
+ });
775
+ // Verification toggle
568
776
  parts.push({
569
- text: `${verifyLabel} (${this.verificationHotkey.toLowerCase()})`,
777
+ text: this.verificationEnabled ? `✓ verify (${this.verificationHotkey})` : `✗ verify (${this.verificationHotkey})`,
570
778
  tone: this.verificationEnabled ? 'success' : 'muted',
571
779
  });
572
- const continueLabel = this.autoContinueEnabled ? 'auto-continue on' : 'auto-continue off';
780
+ // Edit mode
573
781
  parts.push({
574
- text: `${continueLabel} (${this.autoContinueHotkey.toLowerCase()})`,
575
- tone: this.autoContinueEnabled ? 'info' : 'muted',
782
+ text: this.editMode === 'display-edits' ? 'auto-edit (⇧⇥)' : 'ask-first (⇧⇥)',
783
+ tone: 'muted',
576
784
  });
577
- const editLabel = this.editMode === 'display-edits' ? 'auto-edit' : 'ask first';
578
- parts.push({ text: `${editLabel} (⇧⇥)`, tone: 'muted' });
579
- if (this.contextUsage !== null) {
580
- const pct = Math.max(0, 100 - this.contextUsage);
581
- const tone = pct < 25 ? 'warn' : 'muted';
582
- parts.push({ text: `context ${pct}%`, tone });
785
+ // Override/warning status
786
+ if (this.overrideStatusMessage) {
787
+ parts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
583
788
  }
584
- if (this.queue.length > 0 && this.mode !== 'streaming') {
585
- parts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
789
+ // Queue indicator during streaming
790
+ if (this.mode === 'streaming' && this.queue.length > 0) {
791
+ parts.push({ text: `queued: ${this.queue.length}`, tone: 'info' });
586
792
  }
793
+ // Multi-line indicator
587
794
  if (this.buffer.includes('\n')) {
588
- const lineCount = this.buffer.split('\n').length;
589
- parts.push({ text: `${lineCount} lines`, tone: 'muted' });
590
- }
591
- if (this.pastePlaceholders.length > 0) {
592
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
593
- parts.push({
594
- text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
595
- tone: 'info',
596
- });
795
+ parts.push({ text: `${this.buffer.split('\n').length}L`, tone: 'muted' });
597
796
  }
797
+ // Shortcuts hint (at the end)
798
+ parts.push({ text: '? · esc', tone: 'muted' });
598
799
  return renderStatusLine(parts, cols - 2);
599
800
  }
600
801
  /**
@@ -630,6 +831,9 @@ export class TerminalInput extends EventEmitter {
630
831
  * Register with display's output interceptor to position cursor correctly.
631
832
  * When scroll region is active, output needs to go to the scroll region,
632
833
  * not the protected bottom area where the input is rendered.
834
+ *
835
+ * NOTE: With scroll region properly set, content naturally stays within
836
+ * the region boundaries - no cursor manipulation needed per-write.
633
837
  */
634
838
  registerOutputInterceptor(display) {
635
839
  if (this.outputInterceptorCleanup) {
@@ -637,20 +841,11 @@ export class TerminalInput extends EventEmitter {
637
841
  }
638
842
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
639
843
  beforeWrite: () => {
640
- // When the scroll region is active, temporarily move the cursor into
641
- // the scrollable area so streamed output lands above the pinned prompt.
642
- if (this.scrollRegionActive) {
643
- const { rows } = this.getSize();
644
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
645
- this.write(ESC.SAVE);
646
- this.write(ESC.TO(scrollBottom, 1));
647
- }
844
+ // Scroll region handles content containment automatically
845
+ // No per-write cursor manipulation needed
648
846
  },
649
847
  afterWrite: () => {
650
- // Restore cursor back to the pinned prompt after output completes.
651
- if (this.scrollRegionActive) {
652
- this.write(ESC.RESTORE);
653
- }
848
+ // No cursor manipulation needed
654
849
  },
655
850
  });
656
851
  }
@@ -772,7 +967,22 @@ export class TerminalInput extends EventEmitter {
772
967
  this.toggleEditMode();
773
968
  return true;
774
969
  }
775
- this.insertText(' ');
970
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
971
+ if (this.findPlaceholderAt(this.cursor)) {
972
+ this.togglePasteExpansion();
973
+ }
974
+ else {
975
+ this.toggleThinking();
976
+ }
977
+ return true;
978
+ case 'escape':
979
+ // Esc: interrupt if streaming, otherwise clear buffer
980
+ if (this.mode === 'streaming') {
981
+ this.emit('interrupt');
982
+ }
983
+ else if (this.buffer.length > 0) {
984
+ this.clear();
985
+ }
776
986
  return true;
777
987
  }
778
988
  return false;
@@ -1063,9 +1273,7 @@ export class TerminalInput extends EventEmitter {
1063
1273
  if (available <= 0)
1064
1274
  return;
1065
1275
  const chunk = clean.slice(0, available);
1066
- const isMultiline = isMultilinePaste(chunk);
1067
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1068
- if (isMultiline && !isShortMultiline) {
1276
+ if (isMultilinePaste(chunk)) {
1069
1277
  this.insertPastePlaceholder(chunk);
1070
1278
  }
1071
1279
  else {
@@ -1085,7 +1293,6 @@ export class TerminalInput extends EventEmitter {
1085
1293
  return;
1086
1294
  this.applyScrollRegion();
1087
1295
  this.scrollRegionActive = true;
1088
- this.forceRender();
1089
1296
  }
1090
1297
  disableScrollRegion() {
1091
1298
  if (!this.scrollRegionActive)
@@ -1236,19 +1443,17 @@ export class TerminalInput extends EventEmitter {
1236
1443
  this.shiftPlaceholders(position, text.length);
1237
1444
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1238
1445
  }
1239
- shouldInlineMultiline(content) {
1240
- const lines = content.split('\n').length;
1241
- const maxInlineLines = 4;
1242
- const maxInlineChars = 240;
1243
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1244
- }
1245
1446
  findPlaceholderAt(position) {
1246
1447
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1247
1448
  }
1248
- buildPlaceholder(lineCount) {
1449
+ buildPlaceholder(summary) {
1249
1450
  const id = ++this.pasteCounter;
1250
- const plural = lineCount === 1 ? '' : 's';
1251
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1451
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1452
+ // Show first line preview (truncated)
1453
+ const preview = summary.preview.length > 30
1454
+ ? `${summary.preview.slice(0, 30)}...`
1455
+ : summary.preview;
1456
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1252
1457
  return { id, placeholder };
1253
1458
  }
1254
1459
  insertPastePlaceholder(content) {
@@ -1256,21 +1461,67 @@ export class TerminalInput extends EventEmitter {
1256
1461
  if (available <= 0)
1257
1462
  return;
1258
1463
  const cleanContent = content.slice(0, available);
1259
- const lineCount = cleanContent.split('\n').length;
1260
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1464
+ const summary = generatePasteSummary(cleanContent);
1465
+ // For short pastes (< 5 lines), show full content instead of placeholder
1466
+ if (summary.lineCount < 5) {
1467
+ const placeholder = this.findPlaceholderAt(this.cursor);
1468
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1469
+ this.insertPlainText(cleanContent, insertPos);
1470
+ this.cursor = insertPos + cleanContent.length;
1471
+ return;
1472
+ }
1473
+ const { id, placeholder } = this.buildPlaceholder(summary);
1261
1474
  const insertPos = this.cursor;
1262
1475
  this.shiftPlaceholders(insertPos, placeholder.length);
1263
1476
  this.pastePlaceholders.push({
1264
1477
  id,
1265
1478
  content: cleanContent,
1266
- lineCount,
1479
+ lineCount: summary.lineCount,
1267
1480
  placeholder,
1268
1481
  start: insertPos,
1269
1482
  end: insertPos + placeholder.length,
1483
+ summary,
1484
+ expanded: false,
1270
1485
  });
1271
1486
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1272
1487
  this.cursor = insertPos + placeholder.length;
1273
1488
  }
1489
+ /**
1490
+ * Toggle expansion of a paste placeholder at the current cursor position.
1491
+ * When expanded, shows first 3 and last 2 lines of the content.
1492
+ */
1493
+ togglePasteExpansion() {
1494
+ const placeholder = this.findPlaceholderAt(this.cursor);
1495
+ if (!placeholder)
1496
+ return false;
1497
+ placeholder.expanded = !placeholder.expanded;
1498
+ // Update the placeholder text in buffer
1499
+ const newPlaceholder = placeholder.expanded
1500
+ ? this.buildExpandedPlaceholder(placeholder)
1501
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1502
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1503
+ // Update buffer
1504
+ this.buffer =
1505
+ this.buffer.slice(0, placeholder.start) +
1506
+ newPlaceholder +
1507
+ this.buffer.slice(placeholder.end);
1508
+ // Update placeholder tracking
1509
+ placeholder.placeholder = newPlaceholder;
1510
+ placeholder.end = placeholder.start + newPlaceholder.length;
1511
+ // Shift other placeholders
1512
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1513
+ this.scheduleRender();
1514
+ return true;
1515
+ }
1516
+ buildExpandedPlaceholder(ph) {
1517
+ const lines = ph.content.split('\n');
1518
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1519
+ const lastLines = lines.length > 5
1520
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1521
+ : '';
1522
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1523
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1524
+ }
1274
1525
  deletePlaceholder(placeholder) {
1275
1526
  const length = placeholder.end - placeholder.start;
1276
1527
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);