erosolar-cli 1.7.183 → 1.7.185

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.
@@ -215,10 +215,12 @@ const ANSI = {
215
215
  export class PinnedChatBox {
216
216
  writeStream;
217
217
  state;
218
- reservedLines = 2; // Lines reserved at bottom for status bar (readline handles input)
218
+ baseReservedLines = 2; // Base lines reserved (separator + at least 1 input line)
219
+ reservedLines = 2; // Actual reserved lines (dynamically adjusted for multi-line)
219
220
  _lastRenderedHeight = 0;
220
221
  inputBuffer = '';
221
222
  cursorPosition = 0;
223
+ maxDisplayLines = 15; // Maximum lines to show in the input area
222
224
  commandIdCounter = 0;
223
225
  onCommandQueued;
224
226
  onInputSubmit;
@@ -232,7 +234,6 @@ export class PinnedChatBox {
232
234
  ansiPattern = /[\u001B\u009B][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
233
235
  oscPattern = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
234
236
  maxStatusMessageLength = 200;
235
- maxPastePreviewLength = 60;
236
237
  // Scroll region management for persistent bottom input
237
238
  scrollRegionActive = false;
238
239
  // Input history for up/down navigation
@@ -245,6 +246,12 @@ export class PinnedChatBox {
245
246
  pastedFullContent = '';
246
247
  /** Cleanup function for output interceptor registration */
247
248
  outputInterceptorCleanup;
249
+ // Render deduplication - prevent duplicate displays
250
+ lastRenderedContent = '';
251
+ lastRenderedCursor = -1;
252
+ lastRenderedRows = 0;
253
+ lastRenderedCols = 0;
254
+ isRendering = false; // Render lock to prevent concurrent renders
248
255
  constructor(writeStream, _promptText = '> ', // Unused - readline handles input display
249
256
  options = {}) {
250
257
  this.writeStream = writeStream;
@@ -314,6 +321,8 @@ export class PinnedChatBox {
314
321
  const scrollBottom = rows - this.reservedLines;
315
322
  // Step 1: Set scroll region (excludes bottom reserved lines)
316
323
  this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
324
+ // Invalidate render state to ensure fresh render in the new scroll region
325
+ this.invalidateRenderedState();
317
326
  // Step 2: Render the prompt in the protected bottom area
318
327
  this.renderPersistentInput();
319
328
  // Mark scroll region as active AFTER rendering
@@ -343,78 +352,190 @@ export class PinnedChatBox {
343
352
  // Move cursor to the end of the terminal
344
353
  this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows));
345
354
  }
355
+ /**
356
+ * Check if render is needed by comparing with last rendered state.
357
+ * Returns true if content, cursor, or terminal size has changed.
358
+ */
359
+ needsRender() {
360
+ const rows = this.writeStream.rows || 24;
361
+ const cols = this.writeStream.columns || 80;
362
+ // Check if anything changed that requires a re-render
363
+ if (this.inputBuffer !== this.lastRenderedContent)
364
+ return true;
365
+ if (this.cursorPosition !== this.lastRenderedCursor)
366
+ return true;
367
+ if (rows !== this.lastRenderedRows)
368
+ return true;
369
+ if (cols !== this.lastRenderedCols)
370
+ return true;
371
+ return false;
372
+ }
373
+ /**
374
+ * Update last rendered state after successful render.
375
+ */
376
+ updateRenderedState() {
377
+ this.lastRenderedContent = this.inputBuffer;
378
+ this.lastRenderedCursor = this.cursorPosition;
379
+ this.lastRenderedRows = this.writeStream.rows || 24;
380
+ this.lastRenderedCols = this.writeStream.columns || 80;
381
+ }
382
+ /**
383
+ * Reset rendered state to force next render.
384
+ */
385
+ invalidateRenderedState() {
386
+ this.lastRenderedContent = '';
387
+ this.lastRenderedCursor = -1;
388
+ this.lastRenderedRows = 0;
389
+ this.lastRenderedCols = 0;
390
+ }
346
391
  /**
347
392
  * Render the persistent input area at the bottom of the terminal.
348
393
  *
349
394
  * CLEAN CLAUDE CODE STYLE:
350
- * - Line 1: Simple separator with optional status on the right
351
- * - Line 2: Clean prompt "> " with input and visible block cursor
352
- * - Cursor is rendered as a highlighted block character
395
+ * - Line 1: Simple separator with optional queue count
396
+ * - Line 2+: Clean prompt "> " with input and visible block cursor
397
+ *
398
+ * Key for clean rendering:
399
+ * - Render deduplication via content/cursor comparison
400
+ * - Render lock to prevent concurrent renders
401
+ * - Hide cursor during render (prevents flicker)
402
+ * - Clear lines completely before writing
403
+ * - Reset styles explicitly
404
+ * - No theme functions (avoid extra ANSI codes)
405
+ * - Show cursor at correct position at end
353
406
  */
354
407
  renderPersistentInput() {
355
408
  if (!this.supportsRendering())
356
409
  return;
357
- const rows = this.writeStream.rows || 24;
358
- const cols = Math.max(this.writeStream.columns || 80, 40);
359
- // ANSI codes for cursor rendering
360
- const REVERSE_VIDEO = '\x1b[7m'; // Highlighted/inverted
361
- const RESET = '\x1b[0m';
362
- // Move to the bottom area (last 2 lines)
363
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows - 1));
364
- this.safeWrite(ANSI.CLEAR_LINE);
365
- // Line 1: Separator with optional status hint on right
366
- const queueCount = this.state.queuedCommands.length;
367
- let statusHint = '';
368
- if (this.state.isProcessing) {
369
- statusHint = queueCount > 0 ? `${queueCount} queued` : '';
370
- }
371
- const separatorWidth = Math.min(cols - 2, 60);
372
- this.safeWrite(theme.ui.border(''.repeat(separatorWidth)));
373
- // Add status hint after separator if there's room
374
- if (statusHint) {
375
- const remaining = cols - separatorWidth - 4;
376
- if (remaining > statusHint.length) {
377
- this.safeWrite(' ');
378
- this.safeWrite(theme.ui.muted(statusHint));
410
+ // Render lock - prevent concurrent renders that cause flicker
411
+ if (this.isRendering)
412
+ return;
413
+ // Deduplication - skip if nothing changed
414
+ if (!this.needsRender())
415
+ return;
416
+ this.isRendering = true;
417
+ try {
418
+ const rows = this.writeStream.rows || 24;
419
+ const cols = Math.max(this.writeStream.columns || 80, 40);
420
+ // ANSI codes - keep simple, no theme functions
421
+ const HIDE_CURSOR = '\x1b[?25l';
422
+ const SHOW_CURSOR = '\x1b[?25h';
423
+ const REVERSE_VIDEO = '\x1b[7m';
424
+ const RESET = '\x1b[0m';
425
+ const DIM = '\x1b[2m';
426
+ // Hide cursor during render
427
+ this.safeWrite(HIDE_CURSOR);
428
+ this.safeWrite(RESET);
429
+ // Split input into lines for multi-line support
430
+ const inputLines = this.inputBuffer.split('\n');
431
+ const totalInputLines = inputLines.length;
432
+ const displayLineCount = Math.min(totalInputLines, this.maxDisplayLines);
433
+ // Find cursor position: which line and column
434
+ let cursorLineIndex = 0;
435
+ let cursorColInLine = 0;
436
+ let charCount = 0;
437
+ for (let i = 0; i < inputLines.length; i++) {
438
+ const currentLine = inputLines[i] ?? '';
439
+ const lineLen = currentLine.length;
440
+ if (charCount + lineLen >= this.cursorPosition) {
441
+ cursorLineIndex = i;
442
+ cursorColInLine = this.cursorPosition - charCount;
443
+ break;
444
+ }
445
+ charCount += lineLen + 1; // +1 for newline
446
+ if (i === inputLines.length - 1) {
447
+ cursorLineIndex = i;
448
+ cursorColInLine = currentLine.length;
449
+ }
379
450
  }
380
- }
381
- // Line 2: Clean prompt with visible cursor
382
- this.safeWrite('\n');
383
- this.safeWrite(ANSI.CLEAR_LINE);
384
- const promptPrefix = '> ';
385
- const maxInputWidth = cols - 5; // Room for prompt + cursor + margin
386
- // Get input and cursor position
387
- const input = this.inputBuffer;
388
- const cursorPos = Math.min(this.cursorPosition, input.length);
389
- // Handle long input - scroll to keep cursor visible
390
- let displayStart = 0;
391
- let displayInput = input;
392
- if (input.length > maxInputWidth) {
393
- // Keep cursor visible by adjusting display window
394
- if (cursorPos > maxInputWidth - 5) {
395
- displayStart = cursorPos - maxInputWidth + 5;
451
+ // Calculate display window to keep cursor visible
452
+ let displayStartLine = 0;
453
+ if (totalInputLines > displayLineCount) {
454
+ if (cursorLineIndex >= displayLineCount - 1) {
455
+ displayStartLine = cursorLineIndex - displayLineCount + 2;
456
+ }
457
+ displayStartLine = Math.min(displayStartLine, totalInputLines - displayLineCount);
458
+ displayStartLine = Math.max(0, displayStartLine);
459
+ }
460
+ const linesToShow = inputLines.slice(displayStartLine, displayStartLine + displayLineCount);
461
+ // Move to the start of reserved area
462
+ const reservedStart = rows - this.reservedLines + 1;
463
+ this.safeWrite(ANSI.CURSOR_TO_BOTTOM(reservedStart));
464
+ this.safeWrite(ANSI.CLEAR_LINE);
465
+ // Line 1: Separator
466
+ const separatorWidth = Math.min(cols - 2, 60);
467
+ this.safeWrite(DIM);
468
+ this.safeWrite('─'.repeat(separatorWidth));
469
+ this.safeWrite(RESET);
470
+ // Status hints (line count, queue count)
471
+ const queueCount = this.state.queuedCommands.length;
472
+ const hints = [];
473
+ if (totalInputLines > 1) {
474
+ hints.push(`${totalInputLines} lines`);
475
+ }
476
+ if (this.state.isProcessing && queueCount > 0) {
477
+ hints.push(`${queueCount} queued`);
478
+ }
479
+ if (hints.length > 0) {
480
+ this.safeWrite(DIM);
481
+ this.safeWrite(' ' + hints.join(' • '));
482
+ this.safeWrite(RESET);
483
+ }
484
+ const maxInputWidth = cols - 5;
485
+ let finalCursorRow = reservedStart + 1;
486
+ let finalCursorCol = 3;
487
+ // Render each input line
488
+ for (let lineIdx = 0; lineIdx < linesToShow.length; lineIdx++) {
489
+ this.safeWrite('\n');
490
+ this.safeWrite(ANSI.CLEAR_LINE);
491
+ const line = linesToShow[lineIdx] ?? '';
492
+ const absoluteLineIdx = displayStartLine + lineIdx;
493
+ const isFirstLine = absoluteLineIdx === 0;
494
+ const isCursorLine = absoluteLineIdx === cursorLineIndex;
495
+ // Prompt prefix: ">" for first line, "│" for continuation lines
496
+ this.safeWrite(DIM);
497
+ this.safeWrite(isFirstLine ? '>' : '│');
498
+ this.safeWrite(RESET);
499
+ this.safeWrite(' ');
500
+ // Handle long lines - scroll horizontally to keep cursor visible
501
+ let displayStart = 0;
502
+ let displayLine = line;
503
+ if (line.length > maxInputWidth) {
504
+ if (isCursorLine && cursorColInLine > maxInputWidth - 5) {
505
+ displayStart = cursorColInLine - maxInputWidth + 5;
506
+ }
507
+ displayLine = line.slice(displayStart, displayStart + maxInputWidth);
508
+ }
509
+ if (isCursorLine) {
510
+ // Render line with cursor
511
+ const displayCursorCol = Math.max(0, cursorColInLine - displayStart);
512
+ const beforeCursor = displayLine.slice(0, displayCursorCol);
513
+ const atCursor = displayLine[displayCursorCol] ?? ' ';
514
+ const afterCursor = displayLine.slice(displayCursorCol + 1);
515
+ this.safeWrite(beforeCursor);
516
+ this.safeWrite(REVERSE_VIDEO);
517
+ this.safeWrite(atCursor);
518
+ this.safeWrite(RESET);
519
+ this.safeWrite(afterCursor);
520
+ // Remember cursor position for final positioning
521
+ finalCursorRow = reservedStart + 1 + lineIdx;
522
+ finalCursorCol = 3 + displayCursorCol;
523
+ }
524
+ else {
525
+ // Render line without cursor
526
+ this.safeWrite(displayLine);
527
+ }
396
528
  }
397
- displayInput = input.slice(displayStart, displayStart + maxInputWidth);
529
+ // Position terminal cursor and show it
530
+ this.safeWrite(ANSI.CURSOR_TO_BOTTOM(finalCursorRow));
531
+ this.safeWrite(ANSI.MOVE_TO_COL(finalCursorCol));
532
+ this.safeWrite(SHOW_CURSOR);
533
+ // Update rendered state for deduplication
534
+ this.updateRenderedState();
535
+ }
536
+ finally {
537
+ this.isRendering = false;
398
538
  }
399
- // Calculate cursor position in displayed text
400
- const displayCursorPos = cursorPos - displayStart;
401
- // Render: prompt + text before cursor + CURSOR + text after cursor
402
- this.safeWrite(promptPrefix);
403
- const beforeCursor = displayInput.slice(0, displayCursorPos);
404
- const atCursor = displayInput[displayCursorPos] || ' '; // Space if at end
405
- const afterCursor = displayInput.slice(displayCursorPos + 1);
406
- // Text before cursor
407
- this.safeWrite(beforeCursor);
408
- // Cursor character (reverse video block)
409
- this.safeWrite(REVERSE_VIDEO);
410
- this.safeWrite(atCursor);
411
- this.safeWrite(RESET);
412
- // Text after cursor
413
- this.safeWrite(afterCursor);
414
- // Position the terminal cursor at our visual cursor position
415
- // (This ensures the terminal's blinking cursor is also at the right spot)
416
- const terminalCursorCol = promptPrefix.length + displayCursorPos + 1;
417
- this.safeWrite(ANSI.MOVE_TO_COL(terminalCursorCol));
418
539
  }
419
540
  /**
420
541
  * Update the persistent input during streaming.
@@ -490,30 +611,93 @@ export class PinnedChatBox {
490
611
  return clean.slice(0, this.maxStatusMessageLength);
491
612
  }
492
613
  /**
493
- * Build a compact preview string for pasted content
614
+ * Clear paste-specific state when the user edits the buffer manually.
615
+ * Prevents us from sending stale paste content after edits.
494
616
  */
495
- buildPastePreview(firstLine) {
496
- const clean = firstLine.replace(/\s+/g, ' ').trim();
497
- if (!clean) {
498
- return '(empty)';
617
+ startManualEdit() {
618
+ if (this.isPastedBlock) {
619
+ this.clearPastedBlock();
499
620
  }
500
- if (clean.length > this.maxPastePreviewLength) {
501
- return `${clean.slice(0, this.maxPastePreviewLength - 1)}…`;
621
+ }
622
+ /**
623
+ * Apply a new buffer/cursor position and trigger downstream updates.
624
+ */
625
+ applyBufferChange(buffer, cursorPos) {
626
+ this.inputBuffer = buffer;
627
+ const nextCursor = typeof cursorPos === 'number' ? cursorPos : buffer.length;
628
+ this.cursorPosition = Math.max(0, Math.min(nextCursor, this.inputBuffer.length));
629
+ this.state.currentInput = this.inputBuffer;
630
+ this.updateReservedLinesForContent();
631
+ this.scheduleRender();
632
+ }
633
+ /**
634
+ * Update cursor position while keeping it in bounds and re-rendering.
635
+ */
636
+ setCursorPosition(position) {
637
+ this.cursorPosition = Math.max(0, Math.min(position, this.inputBuffer.length));
638
+ this.scheduleRender();
639
+ }
640
+ /**
641
+ * Convert the current cursor index into line/column info.
642
+ */
643
+ getCursorLineInfo() {
644
+ const lines = this.inputBuffer.split('\n');
645
+ let remaining = this.cursorPosition;
646
+ for (let i = 0; i < lines.length; i++) {
647
+ const line = lines[i] ?? '';
648
+ if (remaining <= line.length) {
649
+ return { lines, lineIndex: i, column: remaining };
650
+ }
651
+ remaining -= line.length + 1; // account for newline
502
652
  }
503
- return clean;
653
+ // Fallback to last line
654
+ const lastLine = Math.max(0, lines.length - 1);
655
+ return { lines, lineIndex: lastLine, column: (lines[lastLine] ?? '').length };
504
656
  }
505
657
  /**
506
- * Format character counts with compact units
658
+ * Convert line/column back to a single cursor index.
507
659
  */
508
- formatCharCount(charCount) {
509
- if (!Number.isFinite(charCount) || charCount <= 0) {
510
- return '0';
660
+ positionFromLineCol(lines, lineIndex, column) {
661
+ let pos = 0;
662
+ for (let i = 0; i < lineIndex; i++) {
663
+ pos += (lines[i] ?? '').length + 1; // +1 for newline
511
664
  }
512
- if (charCount >= 1000) {
513
- const rounded = (charCount / 1000).toFixed(charCount >= 10000 ? 0 : 1);
514
- return `${rounded}k`;
665
+ return pos + column;
666
+ }
667
+ /**
668
+ * Move cursor vertically within a multi-line buffer.
669
+ * Returns true if a movement occurred.
670
+ */
671
+ moveCursorVertical(direction) {
672
+ const info = this.getCursorLineInfo();
673
+ if (info.lines.length <= 1) {
674
+ return false;
515
675
  }
516
- return `${charCount}`;
676
+ const targetLine = info.lineIndex + direction;
677
+ if (targetLine < 0 || targetLine >= info.lines.length) {
678
+ return false;
679
+ }
680
+ const targetCol = Math.min(info.column, (info.lines[targetLine] ?? '').length);
681
+ const newPos = this.positionFromLineCol(info.lines, targetLine, targetCol);
682
+ this.setCursorPosition(newPos);
683
+ return true;
684
+ }
685
+ /**
686
+ * Insert multi-line content directly into the buffer (manual typing).
687
+ */
688
+ insertMultilineText(text) {
689
+ const sanitized = this.sanitizeMultilineForDisplay(text);
690
+ if (!sanitized)
691
+ return;
692
+ // Respect max input length
693
+ const availableSpace = this.maxInputLength - this.inputBuffer.length;
694
+ if (availableSpace <= 0)
695
+ return;
696
+ const chunk = sanitized.slice(0, availableSpace);
697
+ this.startManualEdit();
698
+ this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
699
+ chunk +
700
+ this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + chunk.length);
517
701
  }
518
702
  /**
519
703
  * Safely write to the output stream, swallowing non-fatal errors
@@ -699,20 +883,18 @@ export class PinnedChatBox {
699
883
  this.scheduleRender();
700
884
  }
701
885
  /**
702
- * Handle multiline paste - store full content but display summary
886
+ * Handle multiline paste - store full content and display all lines
703
887
  */
704
888
  handleMultilinePaste(content) {
705
- // Keep the original content for submission but sanitize what we display
889
+ // Keep the original content for submission
706
890
  const displaySafeContent = this.sanitizeMultilineForDisplay(content);
707
- const lineCount = displaySafeContent.split('\n').filter(line => line.trim().length > 0).length;
708
- this.pastedFullContent = content;
891
+ if (!displaySafeContent)
892
+ return;
893
+ const truncatedDisplay = displaySafeContent.slice(0, this.maxInputLength);
894
+ const truncatedOriginal = content.slice(0, this.maxInputLength);
895
+ this.pastedFullContent = truncatedOriginal;
709
896
  this.isPastedBlock = true;
710
- // Generate a compact summary for the input line
711
- const summary = this.generatePasteSummary(displaySafeContent, lineCount);
712
- this.inputBuffer = summary;
713
- this.cursorPosition = summary.length;
714
- this.state.currentInput = summary;
715
- this.scheduleRender();
897
+ this.applyBufferChange(truncatedDisplay, truncatedDisplay.length);
716
898
  }
717
899
  /**
718
900
  * Public helper for routing external multi-line pastes directly into the chat box
@@ -722,43 +904,6 @@ export class PinnedChatBox {
722
904
  return;
723
905
  this.handleMultilinePaste(content);
724
906
  }
725
- /**
726
- * Generate a short summary of pasted content for display
727
- */
728
- generatePasteSummary(content, lineCount) {
729
- const lines = content.split('\n');
730
- const firstLine = lines[0]?.trim() || '';
731
- const charCount = content.length;
732
- // Detect content type and get appropriate icon/label
733
- let typeLabel = 'Paste';
734
- let typeIcon = '📋';
735
- if (firstLine.match(/^(import|export|const|let|var|function|class|def |async |from |interface |type )/)) {
736
- typeIcon = '📝';
737
- typeLabel = 'Code';
738
- }
739
- else if (firstLine.match(/^[{[\]]/)) {
740
- typeIcon = '📊';
741
- typeLabel = 'JSON';
742
- }
743
- else if (firstLine.match(/^<[!?]?[a-zA-Z]/)) {
744
- typeIcon = '📄';
745
- typeLabel = 'HTML';
746
- }
747
- else if (firstLine.match(/^#|^\/\/|^\/\*|^\*|^--/)) {
748
- typeIcon = '💬';
749
- typeLabel = 'Text';
750
- }
751
- else if (firstLine.match(/^```|^~~~|^\s{4}/)) {
752
- typeIcon = '📖';
753
- typeLabel = 'Markdown';
754
- }
755
- // Create a compact preview
756
- const preview = this.buildPastePreview(firstLine);
757
- // Format size info compactly
758
- const sizeInfo = this.formatCharCount(charCount);
759
- const lineInfo = `${lineCount || 1}L`;
760
- return `${typeIcon} [${typeLabel}: ${lineInfo}/${sizeInfo}c] ${preview}`;
761
- }
762
907
  /**
763
908
  * Check if current input is a pasted block
764
909
  */
@@ -786,6 +931,36 @@ export class PinnedChatBox {
786
931
  this.inputBuffer = '';
787
932
  this.cursorPosition = 0;
788
933
  this.state.currentInput = '';
934
+ this.resetReservedLines();
935
+ }
936
+ /**
937
+ * Calculate and update reserved lines based on current input content.
938
+ * Ensures multi-line content is fully visible within maxDisplayLines limit.
939
+ */
940
+ updateReservedLinesForContent() {
941
+ const lines = this.inputBuffer.split('\n');
942
+ const lineCount = lines.length;
943
+ // Calculate needed lines: 1 for separator + lineCount for input (clamped to maxDisplayLines)
944
+ const inputLines = Math.min(lineCount, this.maxDisplayLines);
945
+ this.reservedLines = 1 + inputLines; // 1 separator + input lines
946
+ // If we have a scroll region active, update it
947
+ if (this.scrollRegionActive) {
948
+ const rows = this.writeStream.rows || 24;
949
+ const scrollBottom = rows - this.reservedLines;
950
+ this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
951
+ }
952
+ }
953
+ /**
954
+ * Reset reserved lines to base value (for single-line input)
955
+ */
956
+ resetReservedLines() {
957
+ this.reservedLines = this.baseReservedLines;
958
+ // If we have a scroll region active, update it
959
+ if (this.scrollRegionActive) {
960
+ const rows = this.writeStream.rows || 24;
961
+ const scrollBottom = rows - this.reservedLines;
962
+ this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
963
+ }
789
964
  }
790
965
  /**
791
966
  * Handle backspace
@@ -796,12 +971,11 @@ export class PinnedChatBox {
796
971
  this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
797
972
  if (this.cursorPosition === 0)
798
973
  return;
799
- this.inputBuffer =
800
- this.inputBuffer.slice(0, this.cursorPosition - 1) +
801
- this.inputBuffer.slice(this.cursorPosition);
802
- this.cursorPosition = Math.max(0, this.cursorPosition - 1);
803
- this.state.currentInput = this.inputBuffer;
804
- this.scheduleRender();
974
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition - 1) +
975
+ this.inputBuffer.slice(this.cursorPosition);
976
+ const newCursor = Math.max(0, this.cursorPosition - 1);
977
+ this.startManualEdit();
978
+ this.applyBufferChange(newBuffer, newCursor);
805
979
  }
806
980
  /**
807
981
  * Handle delete key
@@ -812,11 +986,10 @@ export class PinnedChatBox {
812
986
  this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
813
987
  if (this.cursorPosition >= this.inputBuffer.length)
814
988
  return;
815
- this.inputBuffer =
816
- this.inputBuffer.slice(0, this.cursorPosition) +
817
- this.inputBuffer.slice(this.cursorPosition + 1);
818
- this.state.currentInput = this.inputBuffer;
819
- this.scheduleRender();
989
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition) +
990
+ this.inputBuffer.slice(this.cursorPosition + 1);
991
+ this.startManualEdit();
992
+ this.applyBufferChange(newBuffer, this.cursorPosition);
820
993
  }
821
994
  /**
822
995
  * Handle cursor left
@@ -843,29 +1016,107 @@ export class PinnedChatBox {
843
1016
  }
844
1017
  }
845
1018
  /**
846
- * Handle home key
1019
+ * Handle home key - move to start of current line (not start of buffer)
847
1020
  */
848
1021
  handleHome() {
849
1022
  if (this.isDisposed)
850
1023
  return;
851
- this.cursorPosition = 0;
1024
+ // In multi-line mode, move to start of current line
1025
+ const { lineIndex, lines } = this.getCursorLinePosition();
1026
+ // Calculate position at start of current line
1027
+ let newPos = 0;
1028
+ for (let i = 0; i < lineIndex; i++) {
1029
+ newPos += (lines[i] ?? '').length + 1;
1030
+ }
1031
+ this.cursorPosition = newPos;
852
1032
  this.scheduleRender();
853
1033
  }
854
1034
  /**
855
- * Handle end key (Ctrl+E)
1035
+ * Handle end key (Ctrl+E) - move to end of current line (not end of buffer)
856
1036
  */
857
1037
  handleEnd() {
858
1038
  if (this.isDisposed)
859
1039
  return;
860
- this.cursorPosition = this.inputBuffer.length;
1040
+ // In multi-line mode, move to end of current line
1041
+ const { lineIndex, lines } = this.getCursorLinePosition();
1042
+ const currentLine = lines[lineIndex] ?? '';
1043
+ // Calculate position at end of current line
1044
+ let newPos = 0;
1045
+ for (let i = 0; i < lineIndex; i++) {
1046
+ newPos += (lines[i] ?? '').length + 1;
1047
+ }
1048
+ newPos += currentLine.length;
1049
+ this.cursorPosition = newPos;
861
1050
  this.scheduleRender();
862
1051
  }
863
1052
  /**
864
- * Handle up arrow - navigate to previous history entry
1053
+ * Handle inserting a newline character at cursor position (Shift+Enter or Option+Enter)
1054
+ */
1055
+ handleNewline() {
1056
+ if (!this.isEnabled || this.isDisposed)
1057
+ return;
1058
+ // Respect max input length
1059
+ if (this.inputBuffer.length >= this.maxInputLength)
1060
+ return;
1061
+ // Insert newline at cursor position
1062
+ this.inputBuffer =
1063
+ this.inputBuffer.slice(0, this.cursorPosition) +
1064
+ '\n' +
1065
+ this.inputBuffer.slice(this.cursorPosition);
1066
+ this.cursorPosition++;
1067
+ this.state.currentInput = this.inputBuffer;
1068
+ // Update reserved lines for the new content
1069
+ this.updateReservedLinesForContent();
1070
+ this.scheduleRender();
1071
+ }
1072
+ /**
1073
+ * Get cursor line and column position within multi-line content.
1074
+ */
1075
+ getCursorLinePosition() {
1076
+ const lines = this.inputBuffer.split('\n');
1077
+ let charCount = 0;
1078
+ for (let i = 0; i < lines.length; i++) {
1079
+ const lineLen = (lines[i] ?? '').length;
1080
+ if (charCount + lineLen >= this.cursorPosition) {
1081
+ return {
1082
+ lineIndex: i,
1083
+ colInLine: this.cursorPosition - charCount,
1084
+ lines,
1085
+ };
1086
+ }
1087
+ charCount += lineLen + 1; // +1 for newline
1088
+ }
1089
+ // Cursor at end
1090
+ return {
1091
+ lineIndex: lines.length - 1,
1092
+ colInLine: (lines[lines.length - 1] ?? '').length,
1093
+ lines,
1094
+ };
1095
+ }
1096
+ /**
1097
+ * Handle up arrow - move cursor up in multi-line content, or navigate history if on first line
865
1098
  */
866
1099
  handleHistoryUp() {
867
1100
  if (this.isDisposed)
868
1101
  return;
1102
+ // For multi-line content, move cursor up within content first
1103
+ const { lineIndex, colInLine, lines } = this.getCursorLinePosition();
1104
+ if (lineIndex > 0) {
1105
+ // Move cursor to previous line, keeping column position if possible
1106
+ const prevLine = lines[lineIndex - 1] ?? '';
1107
+ const newCol = Math.min(colInLine, prevLine.length);
1108
+ // Calculate new cursor position
1109
+ let newPos = 0;
1110
+ for (let i = 0; i < lineIndex - 1; i++) {
1111
+ newPos += (lines[i] ?? '').length + 1;
1112
+ }
1113
+ newPos += newCol;
1114
+ this.cursorPosition = newPos;
1115
+ this.state.currentInput = this.inputBuffer;
1116
+ this.scheduleRender();
1117
+ return;
1118
+ }
1119
+ // On first line - navigate history
869
1120
  if (this.inputHistory.length === 0)
870
1121
  return;
871
1122
  // If at current input, save it temporarily
@@ -882,11 +1133,29 @@ export class PinnedChatBox {
882
1133
  }
883
1134
  }
884
1135
  /**
885
- * Handle down arrow - navigate to next history entry
1136
+ * Handle down arrow - move cursor down in multi-line content, or navigate history if on last line
886
1137
  */
887
1138
  handleHistoryDown() {
888
1139
  if (this.isDisposed)
889
1140
  return;
1141
+ // For multi-line content, move cursor down within content first
1142
+ const { lineIndex, colInLine, lines } = this.getCursorLinePosition();
1143
+ if (lineIndex < lines.length - 1) {
1144
+ // Move cursor to next line, keeping column position if possible
1145
+ const nextLine = lines[lineIndex + 1] ?? '';
1146
+ const newCol = Math.min(colInLine, nextLine.length);
1147
+ // Calculate new cursor position
1148
+ let newPos = 0;
1149
+ for (let i = 0; i <= lineIndex; i++) {
1150
+ newPos += (lines[i] ?? '').length + 1;
1151
+ }
1152
+ newPos += newCol;
1153
+ this.cursorPosition = newPos;
1154
+ this.state.currentInput = this.inputBuffer;
1155
+ this.scheduleRender();
1156
+ return;
1157
+ }
1158
+ // On last line - navigate history
890
1159
  if (this.historyIndex > 0) {
891
1160
  // Move down in history
892
1161
  this.historyIndex--;
@@ -1213,6 +1482,8 @@ export class PinnedChatBox {
1213
1482
  * Handle terminal resize - update scroll region if active
1214
1483
  */
1215
1484
  handleResize() {
1485
+ // Invalidate render state to force re-render with new dimensions
1486
+ this.invalidateRenderedState();
1216
1487
  if (this.scrollRegionActive) {
1217
1488
  // Reset and re-enable scroll region with new dimensions
1218
1489
  this.safeWrite(ANSI.RESET_SCROLL_REGION);
@@ -1269,13 +1540,14 @@ export class PinnedChatBox {
1269
1540
  return !this.isDisposed && this.isEnabled;
1270
1541
  }
1271
1542
  /**
1272
- * Force immediate render (bypass throttling)
1543
+ * Force immediate render (bypass throttling and deduplication)
1273
1544
  */
1274
1545
  forceRender() {
1275
1546
  if (this.isDisposed)
1276
1547
  return;
1277
1548
  this.lastRenderTime = 0;
1278
1549
  this.renderScheduled = false;
1550
+ this.invalidateRenderedState(); // Force re-render even if content unchanged
1279
1551
  this.render();
1280
1552
  }
1281
1553
  /**
@@ -1294,6 +1566,7 @@ export class PinnedChatBox {
1294
1566
  statusMessage: null,
1295
1567
  isVisible: true,
1296
1568
  };
1569
+ this.invalidateRenderedState();
1297
1570
  this.scheduleRender();
1298
1571
  }
1299
1572
  }