erosolar-cli 1.7.231 → 1.7.233

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 +68 -21
  42. package/dist/shell/terminalInput.d.ts.map +1 -1
  43. package/dist/shell/terminalInput.js +449 -210
  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,278 @@ 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.
517
+ *
518
+ * Uses terminal save/restore cursor position for more stable re-renders.
519
+ * Layout: [status] [divider] [input...] [divider] [controls]
522
520
  */
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 });
535
- }
536
- if (this.queue.length > 0) {
537
- parts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
521
+ renderFlowMode() {
522
+ const { cols } = this.getSize();
523
+ const maxWidth = Math.max(8, cols - 4);
524
+ // Wrap buffer into display lines
525
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
526
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, 10));
527
+ const displayLines = Math.min(lines.length, maxVisible);
528
+ // Calculate display window (keep cursor visible)
529
+ let startLine = 0;
530
+ if (lines.length > displayLines) {
531
+ startLine = Math.max(0, cursorLine - displayLines + 1);
532
+ startLine = Math.min(startLine, lines.length - displayLines);
533
+ }
534
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
535
+ const adjustedCursorLine = cursorLine - startLine;
536
+ // Total lines: status(1) + topDiv(1) + input(N) + bottomDiv(1) + controls(1)
537
+ const totalLines = visibleLines.length + 4;
538
+ this.write(ESC.HIDE);
539
+ this.write(ESC.RESET);
540
+ // If we've previously rendered, go back to the start of our rendered area
541
+ if (this.flowModeRenderedLines > 0) {
542
+ // Move cursor up to where our content started
543
+ this.write(`\x1b[${this.flowModeRenderedLines}A`);
544
+ this.write('\r'); // Go to column 1
545
+ }
546
+ // Save this position as our anchor point
547
+ this.write('\x1b7'); // Save cursor position (DEC)
548
+ const divider = renderDivider(cols - 2);
549
+ // Status bar
550
+ this.write(ESC.CLEAR_LINE);
551
+ this.write(this.buildStatusBar(cols));
552
+ this.write('\r\n');
553
+ // Top divider
554
+ this.write(ESC.CLEAR_LINE);
555
+ this.write(divider);
556
+ this.write('\r\n');
557
+ // Input lines
558
+ let cursorLineOffset = 2; // After status and top divider
559
+ for (let i = 0; i < visibleLines.length; i++) {
560
+ this.write(ESC.CLEAR_LINE);
561
+ const line = visibleLines[i] ?? '';
562
+ const absoluteLineIdx = startLine + i;
563
+ const isFirstLine = absoluteLineIdx === 0;
564
+ const isCursorLine = i === adjustedCursorLine;
565
+ // Background
566
+ this.write(ESC.BG_DARK);
567
+ // Prompt prefix
568
+ this.write(ESC.DIM);
569
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
570
+ this.write(ESC.RESET);
571
+ this.write(ESC.BG_DARK);
572
+ if (isCursorLine) {
573
+ cursorLineOffset = 2 + i; // Position within our rendered block
574
+ }
575
+ this.write(line);
576
+ // Pad to edge for clean look
577
+ const lineLen = this.config.promptChar.length + line.length;
578
+ const padding = Math.max(0, cols - lineLen - 1);
579
+ if (padding > 0)
580
+ this.write(' '.repeat(padding));
581
+ this.write(ESC.RESET);
582
+ this.write('\r\n');
583
+ }
584
+ // Bottom divider
585
+ this.write(ESC.CLEAR_LINE);
586
+ this.write(divider);
587
+ this.write('\r\n');
588
+ // Mode controls
589
+ this.write(ESC.CLEAR_LINE);
590
+ this.write(this.buildModeControls(cols));
591
+ // Clear any leftover lines from previous renders with more input lines
592
+ if (this.flowModeRenderedLines > totalLines) {
593
+ const extraLines = this.flowModeRenderedLines - totalLines;
594
+ for (let i = 0; i < extraLines; i++) {
595
+ this.write('\r\n');
596
+ this.write(ESC.CLEAR_LINE);
597
+ }
538
598
  }
539
- return parts.length ? renderStatusLine(parts, width) : '';
599
+ // Remember how many lines we rendered
600
+ this.flowModeRenderedLines = totalLines;
601
+ // Restore cursor to anchor point, then move to input position
602
+ this.write('\x1b8'); // Restore cursor position (DEC)
603
+ // Move down to the input line and to cursor column
604
+ if (cursorLineOffset > 0) {
605
+ this.write(`\x1b[${cursorLineOffset}B`); // Move down
606
+ }
607
+ const col = Math.min(cursorCol, (visibleLines[adjustedCursorLine] ?? '').length);
608
+ const cursorColPos = this.config.promptChar.length + col + 1;
609
+ this.write(ESC.TO_COL(Math.min(cursorColPos, cols)));
610
+ this.write(ESC.SHOW);
611
+ // Update state
612
+ this.lastRenderContent = this.buffer;
613
+ this.lastRenderCursor = this.cursor;
540
614
  }
541
615
  /**
542
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
616
+ * Render in bottom-pinned mode - input area renders at absolute bottom
617
+ * Used when explicit bottom positioning is needed
543
618
  */
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));
619
+ renderBottomPinned() {
620
+ const { rows, cols } = this.getSize();
621
+ const maxWidth = Math.max(8, cols - 4);
622
+ // Wrap buffer into display lines
623
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
624
+ const availableForContent = Math.max(1, rows - 3);
625
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
626
+ const displayLines = Math.min(lines.length, maxVisible);
627
+ // Reserved lines: separator(1) + controls(1) + input lines
628
+ this.updateReservedLines(displayLines + 2);
629
+ // Calculate display window (keep cursor visible)
630
+ let startLine = 0;
631
+ if (lines.length > displayLines) {
632
+ startLine = Math.max(0, cursorLine - displayLines + 1);
633
+ startLine = Math.min(startLine, lines.length - displayLines);
634
+ }
635
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
636
+ const adjustedCursorLine = cursorLine - startLine;
637
+ this.write(ESC.HIDE);
638
+ this.write(ESC.RESET);
639
+ // Calculate positions from absolute bottom
640
+ const modeControlRow = rows;
641
+ const bottomSepRow = rows - 1;
642
+ const inputEndRow = rows - 2;
643
+ const inputStartRow = inputEndRow - visibleLines.length + 1;
644
+ const topSepRow = inputStartRow - 1;
645
+ const statusBarRow = topSepRow - 1;
646
+ // Reserved lines: status(1) + topSep(1) + input + bottomSep(1) + controls(1)
647
+ this.updateReservedLines(visibleLines.length + 4);
648
+ // Status bar
649
+ this.write(ESC.TO(statusBarRow, 1));
650
+ this.write(ESC.CLEAR_LINE);
651
+ this.write(this.buildStatusBar(cols));
652
+ // Top separator
653
+ this.write(ESC.TO(topSepRow, 1));
654
+ this.write(ESC.CLEAR_LINE);
655
+ const divider = renderDivider(cols - 2);
656
+ this.write(divider);
657
+ // Render input lines
658
+ let finalRow = inputStartRow;
659
+ let finalCol = 3;
660
+ for (let i = 0; i < visibleLines.length; i++) {
661
+ const rowNum = inputStartRow + i;
662
+ this.write(ESC.TO(rowNum, 1));
663
+ this.write(ESC.CLEAR_LINE);
664
+ const line = visibleLines[i] ?? '';
665
+ const absoluteLineIdx = startLine + i;
666
+ const isFirstLine = absoluteLineIdx === 0;
667
+ const isCursorLine = i === adjustedCursorLine;
668
+ // Background
669
+ this.write(ESC.BG_DARK);
670
+ // Prompt prefix
671
+ this.write(ESC.DIM);
672
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
673
+ this.write(ESC.RESET);
674
+ this.write(ESC.BG_DARK);
675
+ if (isCursorLine) {
676
+ const col = Math.min(cursorCol, line.length);
677
+ const before = line.slice(0, col);
678
+ const at = col < line.length ? line[col] : ' ';
679
+ const after = col < line.length ? line.slice(col + 1) : '';
680
+ this.write(before);
681
+ this.write(ESC.REVERSE + ESC.BOLD);
682
+ this.write(at);
683
+ this.write(ESC.RESET + ESC.BG_DARK);
684
+ this.write(after);
685
+ finalRow = rowNum;
686
+ finalCol = this.config.promptChar.length + col + 1;
687
+ }
688
+ else {
689
+ this.write(line);
690
+ }
691
+ // Pad to edge
692
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
693
+ const padding = Math.max(0, cols - lineLen - 1);
694
+ if (padding > 0)
695
+ this.write(' '.repeat(padding));
696
+ this.write(ESC.RESET);
550
697
  }
698
+ // Bottom separator
699
+ this.write(ESC.TO(bottomSepRow, 1));
700
+ this.write(ESC.CLEAR_LINE);
701
+ this.write(divider);
702
+ // Mode controls
703
+ this.write(ESC.TO(modeControlRow, 1));
704
+ this.write(ESC.CLEAR_LINE);
705
+ this.write(this.buildModeControls(cols));
706
+ // Position cursor in input area
707
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
708
+ this.write(ESC.SHOW);
709
+ // Update state
710
+ this.lastRenderContent = this.buffer;
711
+ this.lastRenderCursor = this.cursor;
551
712
  }
552
713
  /**
553
- * Build Claude Code style mode controls line.
554
- * Combines streaming label + override status + main status for simultaneous display.
714
+ * Build status bar showing streaming/ready status, elapsed time, and token count.
715
+ * This is the TOP line above the input area.
555
716
  */
556
- buildModeControls(cols) {
717
+ buildStatusBar(cols) {
557
718
  const parts = [];
558
- if (this.streamingLabel) {
559
- parts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
719
+ // Streaming/Ready status with elapsed time
720
+ if (this.mode === 'streaming') {
721
+ let statusText = '● Streaming';
722
+ if (this.streamingStartTime) {
723
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
724
+ const mins = Math.floor(elapsed / 60);
725
+ const secs = elapsed % 60;
726
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
727
+ }
728
+ parts.push({ text: statusText, tone: 'success' });
560
729
  }
561
- if (this.overrideStatusMessage) {
562
- parts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
730
+ else {
731
+ parts.push({ text: '○ Ready', tone: 'muted' });
732
+ }
733
+ // Token count (context usage)
734
+ if (this.tokensUsed > 0) {
735
+ const tokenStr = this.tokensUsed >= 1000
736
+ ? `${(this.tokensUsed / 1000).toFixed(1)}k`
737
+ : `${this.tokensUsed}`;
738
+ parts.push({ text: `${tokenStr} tokens`, tone: 'info' });
563
739
  }
564
- if (this.statusMessage) {
565
- parts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
740
+ // Context window remaining
741
+ if (this.contextUsage !== null) {
742
+ const pct = Math.max(0, 100 - this.contextUsage);
743
+ parts.push({ text: `ctx ${pct}%`, tone: pct < 25 ? 'warn' : 'muted' });
744
+ }
745
+ // Paste indicator
746
+ if (this.pastePlaceholders.length > 0) {
747
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
748
+ parts.push({ text: `📋 ${totalLines}L`, tone: 'info' });
566
749
  }
567
- const verifyLabel = this.verificationEnabled ? 'verify+build on' : 'verify+build off';
750
+ return renderStatusLine(parts, cols - 2);
751
+ }
752
+ /**
753
+ * Build mode controls line showing toggles and shortcuts.
754
+ * This is the BOTTOM line below the input area.
755
+ */
756
+ buildModeControls(cols) {
757
+ const parts = [];
758
+ // Thinking mode toggle
759
+ parts.push({
760
+ text: this.thinkingEnabled ? '💭 on (tab)' : '💭 off (tab)',
761
+ tone: this.thinkingEnabled ? 'info' : 'muted',
762
+ });
763
+ // Verification toggle
568
764
  parts.push({
569
- text: `${verifyLabel} (${this.verificationHotkey.toLowerCase()})`,
765
+ text: this.verificationEnabled ? `✓ verify (${this.verificationHotkey})` : `✗ verify (${this.verificationHotkey})`,
570
766
  tone: this.verificationEnabled ? 'success' : 'muted',
571
767
  });
572
- const continueLabel = this.autoContinueEnabled ? 'auto-continue on' : 'auto-continue off';
768
+ // Edit mode
573
769
  parts.push({
574
- text: `${continueLabel} (${this.autoContinueHotkey.toLowerCase()})`,
575
- tone: this.autoContinueEnabled ? 'info' : 'muted',
770
+ text: this.editMode === 'display-edits' ? 'auto-edit (⇧⇥)' : 'ask-first (⇧⇥)',
771
+ tone: 'muted',
576
772
  });
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 });
773
+ // Override/warning status
774
+ if (this.overrideStatusMessage) {
775
+ parts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
583
776
  }
584
- if (this.queue.length > 0 && this.mode !== 'streaming') {
585
- parts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
777
+ // Queue indicator during streaming
778
+ if (this.mode === 'streaming' && this.queue.length > 0) {
779
+ parts.push({ text: `queued: ${this.queue.length}`, tone: 'info' });
586
780
  }
781
+ // Multi-line indicator
587
782
  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
- });
783
+ parts.push({ text: `${this.buffer.split('\n').length}L`, tone: 'muted' });
597
784
  }
785
+ // Shortcuts hint (at the end)
786
+ parts.push({ text: '? · esc', tone: 'muted' });
598
787
  return renderStatusLine(parts, cols - 2);
599
788
  }
600
789
  /**
@@ -630,6 +819,9 @@ export class TerminalInput extends EventEmitter {
630
819
  * Register with display's output interceptor to position cursor correctly.
631
820
  * When scroll region is active, output needs to go to the scroll region,
632
821
  * not the protected bottom area where the input is rendered.
822
+ *
823
+ * NOTE: With scroll region properly set, content naturally stays within
824
+ * the region boundaries - no cursor manipulation needed per-write.
633
825
  */
634
826
  registerOutputInterceptor(display) {
635
827
  if (this.outputInterceptorCleanup) {
@@ -637,20 +829,11 @@ export class TerminalInput extends EventEmitter {
637
829
  }
638
830
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
639
831
  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
- }
832
+ // Scroll region handles content containment automatically
833
+ // No per-write cursor manipulation needed
648
834
  },
649
835
  afterWrite: () => {
650
- // Restore cursor back to the pinned prompt after output completes.
651
- if (this.scrollRegionActive) {
652
- this.write(ESC.RESTORE);
653
- }
836
+ // No cursor manipulation needed
654
837
  },
655
838
  });
656
839
  }
@@ -772,7 +955,22 @@ export class TerminalInput extends EventEmitter {
772
955
  this.toggleEditMode();
773
956
  return true;
774
957
  }
775
- this.insertText(' ');
958
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
959
+ if (this.findPlaceholderAt(this.cursor)) {
960
+ this.togglePasteExpansion();
961
+ }
962
+ else {
963
+ this.toggleThinking();
964
+ }
965
+ return true;
966
+ case 'escape':
967
+ // Esc: interrupt if streaming, otherwise clear buffer
968
+ if (this.mode === 'streaming') {
969
+ this.emit('interrupt');
970
+ }
971
+ else if (this.buffer.length > 0) {
972
+ this.clear();
973
+ }
776
974
  return true;
777
975
  }
778
976
  return false;
@@ -1063,9 +1261,7 @@ export class TerminalInput extends EventEmitter {
1063
1261
  if (available <= 0)
1064
1262
  return;
1065
1263
  const chunk = clean.slice(0, available);
1066
- const isMultiline = isMultilinePaste(chunk);
1067
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1068
- if (isMultiline && !isShortMultiline) {
1264
+ if (isMultilinePaste(chunk)) {
1069
1265
  this.insertPastePlaceholder(chunk);
1070
1266
  }
1071
1267
  else {
@@ -1085,7 +1281,6 @@ export class TerminalInput extends EventEmitter {
1085
1281
  return;
1086
1282
  this.applyScrollRegion();
1087
1283
  this.scrollRegionActive = true;
1088
- this.forceRender();
1089
1284
  }
1090
1285
  disableScrollRegion() {
1091
1286
  if (!this.scrollRegionActive)
@@ -1236,19 +1431,17 @@ export class TerminalInput extends EventEmitter {
1236
1431
  this.shiftPlaceholders(position, text.length);
1237
1432
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1238
1433
  }
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
1434
  findPlaceholderAt(position) {
1246
1435
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1247
1436
  }
1248
- buildPlaceholder(lineCount) {
1437
+ buildPlaceholder(summary) {
1249
1438
  const id = ++this.pasteCounter;
1250
- const plural = lineCount === 1 ? '' : 's';
1251
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1439
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1440
+ // Show first line preview (truncated)
1441
+ const preview = summary.preview.length > 30
1442
+ ? `${summary.preview.slice(0, 30)}...`
1443
+ : summary.preview;
1444
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1252
1445
  return { id, placeholder };
1253
1446
  }
1254
1447
  insertPastePlaceholder(content) {
@@ -1256,21 +1449,67 @@ export class TerminalInput extends EventEmitter {
1256
1449
  if (available <= 0)
1257
1450
  return;
1258
1451
  const cleanContent = content.slice(0, available);
1259
- const lineCount = cleanContent.split('\n').length;
1260
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1452
+ const summary = generatePasteSummary(cleanContent);
1453
+ // For short pastes (< 5 lines), show full content instead of placeholder
1454
+ if (summary.lineCount < 5) {
1455
+ const placeholder = this.findPlaceholderAt(this.cursor);
1456
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1457
+ this.insertPlainText(cleanContent, insertPos);
1458
+ this.cursor = insertPos + cleanContent.length;
1459
+ return;
1460
+ }
1461
+ const { id, placeholder } = this.buildPlaceholder(summary);
1261
1462
  const insertPos = this.cursor;
1262
1463
  this.shiftPlaceholders(insertPos, placeholder.length);
1263
1464
  this.pastePlaceholders.push({
1264
1465
  id,
1265
1466
  content: cleanContent,
1266
- lineCount,
1467
+ lineCount: summary.lineCount,
1267
1468
  placeholder,
1268
1469
  start: insertPos,
1269
1470
  end: insertPos + placeholder.length,
1471
+ summary,
1472
+ expanded: false,
1270
1473
  });
1271
1474
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1272
1475
  this.cursor = insertPos + placeholder.length;
1273
1476
  }
1477
+ /**
1478
+ * Toggle expansion of a paste placeholder at the current cursor position.
1479
+ * When expanded, shows first 3 and last 2 lines of the content.
1480
+ */
1481
+ togglePasteExpansion() {
1482
+ const placeholder = this.findPlaceholderAt(this.cursor);
1483
+ if (!placeholder)
1484
+ return false;
1485
+ placeholder.expanded = !placeholder.expanded;
1486
+ // Update the placeholder text in buffer
1487
+ const newPlaceholder = placeholder.expanded
1488
+ ? this.buildExpandedPlaceholder(placeholder)
1489
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1490
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1491
+ // Update buffer
1492
+ this.buffer =
1493
+ this.buffer.slice(0, placeholder.start) +
1494
+ newPlaceholder +
1495
+ this.buffer.slice(placeholder.end);
1496
+ // Update placeholder tracking
1497
+ placeholder.placeholder = newPlaceholder;
1498
+ placeholder.end = placeholder.start + newPlaceholder.length;
1499
+ // Shift other placeholders
1500
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1501
+ this.scheduleRender();
1502
+ return true;
1503
+ }
1504
+ buildExpandedPlaceholder(ph) {
1505
+ const lines = ph.content.split('\n');
1506
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1507
+ const lastLines = lines.length > 5
1508
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1509
+ : '';
1510
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1511
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1512
+ }
1274
1513
  deletePlaceholder(placeholder) {
1275
1514
  const length = placeholder.end - placeholder.start;
1276
1515
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);