erosolar-cli 1.7.185 → 1.7.188

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.
@@ -317,6 +317,8 @@ export class PinnedChatBox {
317
317
  enableScrollRegion() {
318
318
  if (this.scrollRegionActive || !this.supportsRendering())
319
319
  return;
320
+ // Make sure reserved height matches current content/terminal width
321
+ this.updateReservedLinesForContent();
320
322
  const rows = this.writeStream.rows || 24;
321
323
  const scrollBottom = rows - this.reservedLines;
322
324
  // Step 1: Set scroll region (excludes bottom reserved lines)
@@ -415,8 +417,7 @@ export class PinnedChatBox {
415
417
  return;
416
418
  this.isRendering = true;
417
419
  try {
418
- const rows = this.writeStream.rows || 24;
419
- const cols = Math.max(this.writeStream.columns || 80, 40);
420
+ const { rows, cols, maxInputWidth } = this.getRenderDimensions();
420
421
  // ANSI codes - keep simple, no theme functions
421
422
  const HIDE_CURSOR = '\x1b[?25l';
422
423
  const SHOW_CURSOR = '\x1b[?25h';
@@ -426,44 +427,25 @@ export class PinnedChatBox {
426
427
  // Hide cursor during render
427
428
  this.safeWrite(HIDE_CURSOR);
428
429
  this.safeWrite(RESET);
429
- // Split input into lines for multi-line support
430
- const inputLines = this.inputBuffer.split('\n');
431
- const totalInputLines = inputLines.length;
430
+ // Wrap input into terminal-safe lines so long text doesn't overflow
431
+ const { lines: wrappedLines, cursorLine: cursorLineIndex, cursorCol: cursorColInLine } = this.wrapInputBuffer(maxInputWidth);
432
+ const totalInputLines = Math.max(1, wrappedLines.length);
432
433
  const displayLineCount = Math.min(totalInputLines, this.maxDisplayLines);
434
+ // Keep reserved height in sync with what we need to show
435
+ this.updateReservedLinesForContent(maxInputWidth, totalInputLines);
433
436
  // 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
- }
450
- }
451
- // Calculate display window to keep cursor visible
452
437
  let displayStartLine = 0;
453
438
  if (totalInputLines > displayLineCount) {
454
- if (cursorLineIndex >= displayLineCount - 1) {
455
- displayStartLine = cursorLineIndex - displayLineCount + 2;
456
- }
439
+ displayStartLine = Math.max(0, cursorLineIndex - displayLineCount + 1);
457
440
  displayStartLine = Math.min(displayStartLine, totalInputLines - displayLineCount);
458
- displayStartLine = Math.max(0, displayStartLine);
459
441
  }
460
- const linesToShow = inputLines.slice(displayStartLine, displayStartLine + displayLineCount);
442
+ const linesToShow = wrappedLines.slice(displayStartLine, displayStartLine + displayLineCount);
461
443
  // Move to the start of reserved area
462
444
  const reservedStart = rows - this.reservedLines + 1;
463
445
  this.safeWrite(ANSI.CURSOR_TO_BOTTOM(reservedStart));
464
446
  this.safeWrite(ANSI.CLEAR_LINE);
465
447
  // Line 1: Separator
466
- const separatorWidth = Math.min(cols - 2, 60);
448
+ const separatorWidth = Math.max(1, Math.min(cols - 2, 60));
467
449
  this.safeWrite(DIM);
468
450
  this.safeWrite('─'.repeat(separatorWidth));
469
451
  this.safeWrite(RESET);
@@ -481,7 +463,6 @@ export class PinnedChatBox {
481
463
  this.safeWrite(' ' + hints.join(' • '));
482
464
  this.safeWrite(RESET);
483
465
  }
484
- const maxInputWidth = cols - 5;
485
466
  let finalCursorRow = reservedStart + 1;
486
467
  let finalCursorCol = 3;
487
468
  // Render each input line
@@ -497,21 +478,12 @@ export class PinnedChatBox {
497
478
  this.safeWrite(isFirstLine ? '>' : '│');
498
479
  this.safeWrite(RESET);
499
480
  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
481
  if (isCursorLine) {
510
482
  // 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);
483
+ const displayCursorCol = Math.max(0, cursorColInLine);
484
+ const beforeCursor = line.slice(0, displayCursorCol);
485
+ const atCursor = line[displayCursorCol] ?? ' ';
486
+ const afterCursor = line.slice(displayCursorCol + 1);
515
487
  this.safeWrite(beforeCursor);
516
488
  this.safeWrite(REVERSE_VIDEO);
517
489
  this.safeWrite(atCursor);
@@ -523,12 +495,12 @@ export class PinnedChatBox {
523
495
  }
524
496
  else {
525
497
  // Render line without cursor
526
- this.safeWrite(displayLine);
498
+ this.safeWrite(line);
527
499
  }
528
500
  }
529
501
  // Position terminal cursor and show it
530
502
  this.safeWrite(ANSI.CURSOR_TO_BOTTOM(finalCursorRow));
531
- this.safeWrite(ANSI.MOVE_TO_COL(finalCursorCol));
503
+ this.safeWrite(ANSI.MOVE_TO_COL(Math.max(1, Math.min(finalCursorCol, cols))));
532
504
  this.safeWrite(SHOW_CURSOR);
533
505
  // Update rendered state for deduplication
534
506
  this.updateRenderedState();
@@ -594,10 +566,13 @@ export class PinnedChatBox {
594
566
  return withoutAnsi.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
595
567
  }
596
568
  /**
597
- * Sanitize queued command text (trimmed, single line)
569
+ * Sanitize queued command text (trimmed, preserves newlines)
598
570
  */
599
571
  sanitizeCommandText(text) {
600
- return this.sanitizeInlineText(text).trim();
572
+ if (!text)
573
+ return '';
574
+ const clean = this.sanitizeMultilineForDisplay(text);
575
+ return clean.slice(0, this.maxInputLength).trim();
601
576
  }
602
577
  /**
603
578
  * Sanitize status message (single line, bounded length)
@@ -630,6 +605,12 @@ export class PinnedChatBox {
630
605
  this.updateReservedLinesForContent();
631
606
  this.scheduleRender();
632
607
  }
608
+ /**
609
+ * Treat spaces, tabs, and newlines as whitespace for navigation helpers.
610
+ */
611
+ isWhitespace(char) {
612
+ return char === ' ' || char === '\t' || char === '\n';
613
+ }
633
614
  /**
634
615
  * Update cursor position while keeping it in bounds and re-rendering.
635
616
  */
@@ -637,51 +618,6 @@ export class PinnedChatBox {
637
618
  this.cursorPosition = Math.max(0, Math.min(position, this.inputBuffer.length));
638
619
  this.scheduleRender();
639
620
  }
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
652
- }
653
- // Fallback to last line
654
- const lastLine = Math.max(0, lines.length - 1);
655
- return { lines, lineIndex: lastLine, column: (lines[lastLine] ?? '').length };
656
- }
657
- /**
658
- * Convert line/column back to a single cursor index.
659
- */
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
664
- }
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;
675
- }
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
621
  /**
686
622
  * Insert multi-line content directly into the buffer (manual typing).
687
623
  */
@@ -797,7 +733,8 @@ export class PinnedChatBox {
797
733
  }
798
734
  // Sanitize and truncate command text
799
735
  const truncated = sanitizedText.slice(0, this.maxInputLength);
800
- const preview = truncated.length > 60 ? `${truncated.slice(0, 57)}...` : truncated;
736
+ const previewSource = this.sanitizeInlineText(truncated);
737
+ const preview = previewSource.length > 60 ? `${previewSource.slice(0, 57)}...` : previewSource;
801
738
  const cmd = {
802
739
  id: `cmd-${++this.commandIdCounter}`,
803
740
  text: truncated,
@@ -853,14 +790,25 @@ export class PinnedChatBox {
853
790
  * Handle character input with validation
854
791
  * Detects multiline paste and stores full content while showing summary
855
792
  */
856
- handleInput(char) {
793
+ handleInput(char, options = {}) {
857
794
  if (!this.isEnabled || this.isDisposed)
858
795
  return;
859
796
  if (typeof char !== 'string')
860
797
  return;
861
- // Detect multiline paste (content with newlines)
862
- if (char.includes('\n') || char.includes('\r')) {
863
- this.handleMultilinePaste(char);
798
+ const allowNewlines = options.allowNewlines ?? options.isPaste ?? false;
799
+ const hasNewline = char.includes('\n') || char.includes('\r');
800
+ const shouldHandleAsPaste = options.isPaste || (allowNewlines && hasNewline);
801
+ // Detect paste explicitly or any newline-containing input when allowed
802
+ if (shouldHandleAsPaste) {
803
+ if (options.isPaste) {
804
+ this.handleMultilinePaste(char);
805
+ return;
806
+ }
807
+ this.insertMultilineText(char);
808
+ return;
809
+ }
810
+ // Ignore unexpected newlines unless explicitly allowed
811
+ if (hasNewline) {
864
812
  return;
865
813
  }
866
814
  const sanitized = this.sanitizeInlineText(char);
@@ -874,13 +822,10 @@ export class PinnedChatBox {
874
822
  if (!chunk)
875
823
  return;
876
824
  // Insert character at cursor position
877
- this.inputBuffer =
878
- this.inputBuffer.slice(0, this.cursorPosition) +
879
- chunk +
880
- this.inputBuffer.slice(this.cursorPosition);
881
- this.cursorPosition = Math.min(this.cursorPosition + chunk.length, this.inputBuffer.length);
882
- this.state.currentInput = this.inputBuffer;
883
- this.scheduleRender();
825
+ this.startManualEdit();
826
+ this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
827
+ chunk +
828
+ this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + chunk.length);
884
829
  }
885
830
  /**
886
831
  * Handle multiline paste - store full content and display all lines
@@ -928,37 +873,134 @@ export class PinnedChatBox {
928
873
  */
929
874
  clearPastedBlockState() {
930
875
  this.clearPastedBlock();
931
- this.inputBuffer = '';
932
- this.cursorPosition = 0;
933
- this.state.currentInput = '';
934
- this.resetReservedLines();
876
+ this.applyBufferChange('', 0);
935
877
  }
936
878
  /**
937
- * Calculate and update reserved lines based on current input content.
938
- * Ensures multi-line content is fully visible within maxDisplayLines limit.
879
+ * Get terminal dimensions and a safe width for rendering input content.
939
880
  */
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));
881
+ getRenderDimensions() {
882
+ const rows = this.writeStream.rows || 24;
883
+ const cols = Math.max(10, this.writeStream.columns ?? 80);
884
+ // Leave a small gutter for prefix + padding so we never wrap unexpectedly,
885
+ // but respect the actual terminal width instead of forcing a wide minimum.
886
+ const maxInputWidth = Math.max(8, cols - 5);
887
+ return { rows, cols, maxInputWidth };
888
+ }
889
+ /**
890
+ * Wrap the current input buffer to the provided width and locate the cursor.
891
+ *
892
+ * ROBUST CURSOR TRACKING:
893
+ * - Uses cursorFound flag to ensure cursor is explicitly located
894
+ * - Falls back to direct calculation if normal tracking fails
895
+ * - Handles edge cases: empty input, cursor at end, cursor at newlines
896
+ */
897
+ wrapInputBuffer(maxInputWidth) {
898
+ const width = Math.max(1, maxInputWidth);
899
+ const wrappedLines = [];
900
+ const lineStarts = [];
901
+ const targetCursor = Math.max(0, Math.min(this.cursorPosition, this.inputBuffer.length));
902
+ // Handle empty buffer case explicitly
903
+ if (this.inputBuffer.length === 0) {
904
+ return { lines: [''], cursorLine: 0, cursorCol: 0, lineStarts: [0] };
905
+ }
906
+ let cursorLine = 0;
907
+ let cursorCol = 0;
908
+ let cursorFound = false;
909
+ let absoluteIndex = 0;
910
+ const rawLines = this.inputBuffer.split('\n');
911
+ for (let i = 0; i < rawLines.length; i++) {
912
+ const raw = rawLines[i] ?? '';
913
+ if (raw.length === 0) {
914
+ // Preserve empty lines (from newlines)
915
+ wrappedLines.push('');
916
+ lineStarts.push(absoluteIndex);
917
+ // Check if cursor is at this empty line position
918
+ if (!cursorFound && targetCursor === absoluteIndex) {
919
+ cursorLine = wrappedLines.length - 1;
920
+ cursorCol = 0;
921
+ cursorFound = true;
922
+ }
923
+ }
924
+ else {
925
+ // Wrap long lines at width boundary
926
+ for (let start = 0; start < raw.length; start += width) {
927
+ const segment = raw.slice(start, start + width);
928
+ const segmentStart = absoluteIndex + start;
929
+ const segmentEnd = segmentStart + segment.length;
930
+ wrappedLines.push(segment);
931
+ lineStarts.push(segmentStart);
932
+ // Check if cursor falls within this segment (inclusive on both ends)
933
+ if (!cursorFound && targetCursor >= segmentStart && targetCursor <= segmentEnd) {
934
+ cursorLine = wrappedLines.length - 1;
935
+ cursorCol = targetCursor - segmentStart;
936
+ cursorFound = true;
937
+ }
938
+ }
939
+ }
940
+ absoluteIndex += raw.length;
941
+ // Account for newline character between raw lines
942
+ if (i < rawLines.length - 1) {
943
+ // Check if cursor is at the newline position (start of next line)
944
+ if (!cursorFound && targetCursor === absoluteIndex) {
945
+ cursorLine = wrappedLines.length; // Will be the next line index
946
+ cursorCol = 0;
947
+ cursorFound = true;
948
+ }
949
+ absoluteIndex += 1; // Account for '\n'
950
+ }
951
951
  }
952
+ // Fallback: if cursor wasn't found, calculate directly from position
953
+ if (!cursorFound && wrappedLines.length > 0) {
954
+ // Find which wrapped line contains the cursor by checking lineStarts
955
+ for (let i = lineStarts.length - 1; i >= 0; i--) {
956
+ const lineStart = lineStarts[i] ?? 0;
957
+ if (targetCursor >= lineStart) {
958
+ cursorLine = i;
959
+ cursorCol = targetCursor - lineStart;
960
+ break;
961
+ }
962
+ }
963
+ }
964
+ // Safety: ensure wrappedLines has at least one entry
965
+ if (wrappedLines.length === 0) {
966
+ wrappedLines.push('');
967
+ lineStarts.push(0);
968
+ cursorLine = 0;
969
+ cursorCol = 0;
970
+ }
971
+ // Safety bounds checks
972
+ cursorLine = Math.max(0, Math.min(cursorLine, wrappedLines.length - 1));
973
+ const lineLen = wrappedLines[cursorLine]?.length ?? 0;
974
+ cursorCol = Math.max(0, Math.min(cursorCol, lineLen));
975
+ return { lines: wrappedLines, cursorLine, cursorCol, lineStarts };
952
976
  }
953
977
  /**
954
- * Reset reserved lines to base value (for single-line input)
978
+ * Convert a wrapped line/column back to an absolute cursor index.
955
979
  */
956
- resetReservedLines() {
957
- this.reservedLines = this.baseReservedLines;
980
+ getWrappedCursorIndex(lineStarts, lineIndex, column) {
981
+ const start = lineStarts[lineIndex] ?? 0;
982
+ const safeColumn = Math.max(0, column);
983
+ return Math.max(0, Math.min(start + safeColumn, this.inputBuffer.length));
984
+ }
985
+ /**
986
+ * Calculate and update reserved lines based on current input content.
987
+ * Ensures multi-line content is fully visible within maxDisplayLines limit.
988
+ */
989
+ updateReservedLinesForContent(maxInputWidth, wrappedLineCount) {
990
+ const { rows, maxInputWidth: derivedWidth } = this.getRenderDimensions();
991
+ const width = Math.max(1, maxInputWidth ?? derivedWidth);
992
+ const totalLines = wrappedLineCount ?? this.wrapInputBuffer(width).lines.length;
993
+ const lineCount = Math.max(1, totalLines);
994
+ // Calculate needed lines: 1 for separator + lineCount for input (clamped to maxDisplayLines)
995
+ const inputLines = Math.min(lineCount, this.maxDisplayLines);
996
+ const calculated = 1 + inputLines; // 1 separator + input lines
997
+ // Ensure we keep at least one row for output scrolling
998
+ const maxAllowed = Math.max(1, rows - 1);
999
+ const desired = Math.max(this.baseReservedLines, calculated);
1000
+ this.reservedLines = Math.min(desired, maxAllowed);
958
1001
  // If we have a scroll region active, update it
959
1002
  if (this.scrollRegionActive) {
960
- const rows = this.writeStream.rows || 24;
961
- const scrollBottom = rows - this.reservedLines;
1003
+ const scrollBottom = Math.max(1, rows - this.reservedLines);
962
1004
  this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
963
1005
  }
964
1006
  }
@@ -997,10 +1039,9 @@ export class PinnedChatBox {
997
1039
  handleCursorLeft() {
998
1040
  if (this.isDisposed)
999
1041
  return;
1000
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
1001
- if (this.cursorPosition > 0) {
1002
- this.cursorPosition--;
1003
- this.scheduleRender();
1042
+ const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
1043
+ if (bounded > 0) {
1044
+ this.setCursorPosition(bounded - 1);
1004
1045
  }
1005
1046
  }
1006
1047
  /**
@@ -1009,10 +1050,9 @@ export class PinnedChatBox {
1009
1050
  handleCursorRight() {
1010
1051
  if (this.isDisposed)
1011
1052
  return;
1012
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
1013
- if (this.cursorPosition < this.inputBuffer.length) {
1014
- this.cursorPosition++;
1015
- this.scheduleRender();
1053
+ const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
1054
+ if (bounded < this.inputBuffer.length) {
1055
+ this.setCursorPosition(bounded + 1);
1016
1056
  }
1017
1057
  }
1018
1058
  /**
@@ -1021,15 +1061,11 @@ export class PinnedChatBox {
1021
1061
  handleHome() {
1022
1062
  if (this.isDisposed)
1023
1063
  return;
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;
1032
- this.scheduleRender();
1064
+ // In multi-line mode, move to start of the current wrapped line
1065
+ const { maxInputWidth } = this.getRenderDimensions();
1066
+ const { lineStarts, cursorLine } = this.wrapInputBuffer(maxInputWidth);
1067
+ const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine, 0);
1068
+ this.setCursorPosition(newPos);
1033
1069
  }
1034
1070
  /**
1035
1071
  * Handle end key (Ctrl+E) - move to end of current line (not end of buffer)
@@ -1037,17 +1073,12 @@ export class PinnedChatBox {
1037
1073
  handleEnd() {
1038
1074
  if (this.isDisposed)
1039
1075
  return;
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;
1050
- this.scheduleRender();
1076
+ // In multi-line mode, move to end of the current wrapped line
1077
+ const { maxInputWidth } = this.getRenderDimensions();
1078
+ const { lines: wrappedLines, lineStarts, cursorLine } = this.wrapInputBuffer(maxInputWidth);
1079
+ const currentLine = wrappedLines[cursorLine] ?? '';
1080
+ const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine, currentLine.length);
1081
+ this.setCursorPosition(newPos);
1051
1082
  }
1052
1083
  /**
1053
1084
  * Handle inserting a newline character at cursor position (Shift+Enter or Option+Enter)
@@ -1058,40 +1089,10 @@ export class PinnedChatBox {
1058
1089
  // Respect max input length
1059
1090
  if (this.inputBuffer.length >= this.maxInputLength)
1060
1091
  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
- };
1092
+ this.startManualEdit();
1093
+ this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
1094
+ '\n' +
1095
+ this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + 1);
1095
1096
  }
1096
1097
  /**
1097
1098
  * Handle up arrow - move cursor up in multi-line content, or navigate history if on first line
@@ -1099,21 +1100,14 @@ export class PinnedChatBox {
1099
1100
  handleHistoryUp() {
1100
1101
  if (this.isDisposed)
1101
1102
  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();
1103
+ // For multi-line content (including wrapped lines), move cursor up within content first
1104
+ const { maxInputWidth } = this.getRenderDimensions();
1105
+ const { lines: wrappedLines, lineStarts, cursorLine, cursorCol } = this.wrapInputBuffer(maxInputWidth);
1106
+ if (cursorLine > 0) {
1107
+ const prevLine = wrappedLines[cursorLine - 1] ?? '';
1108
+ const newCol = Math.min(cursorCol, prevLine.length);
1109
+ const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine - 1, newCol);
1110
+ this.setCursorPosition(newPos);
1117
1111
  return;
1118
1112
  }
1119
1113
  // On first line - navigate history
@@ -1126,10 +1120,9 @@ export class PinnedChatBox {
1126
1120
  // Move up in history
1127
1121
  if (this.historyIndex < this.inputHistory.length - 1) {
1128
1122
  this.historyIndex++;
1129
- this.inputBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1130
- this.cursorPosition = this.inputBuffer.length;
1131
- this.state.currentInput = this.inputBuffer;
1132
- this.scheduleRender();
1123
+ const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1124
+ this.startManualEdit();
1125
+ this.applyBufferChange(nextBuffer, nextBuffer.length);
1133
1126
  }
1134
1127
  }
1135
1128
  /**
@@ -1138,39 +1131,30 @@ export class PinnedChatBox {
1138
1131
  handleHistoryDown() {
1139
1132
  if (this.isDisposed)
1140
1133
  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();
1134
+ // For multi-line content (including wrapped lines), move cursor down within content first
1135
+ const { maxInputWidth } = this.getRenderDimensions();
1136
+ const { lines: wrappedLines, lineStarts, cursorLine, cursorCol } = this.wrapInputBuffer(maxInputWidth);
1137
+ if (cursorLine < wrappedLines.length - 1) {
1138
+ const nextLine = wrappedLines[cursorLine + 1] ?? '';
1139
+ const newCol = Math.min(cursorCol, nextLine.length);
1140
+ const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine + 1, newCol);
1141
+ this.setCursorPosition(newPos);
1156
1142
  return;
1157
1143
  }
1158
1144
  // On last line - navigate history
1159
1145
  if (this.historyIndex > 0) {
1160
1146
  // Move down in history
1161
1147
  this.historyIndex--;
1162
- this.inputBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1163
- this.cursorPosition = this.inputBuffer.length;
1164
- this.state.currentInput = this.inputBuffer;
1165
- this.scheduleRender();
1148
+ const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1149
+ this.startManualEdit();
1150
+ this.applyBufferChange(nextBuffer, nextBuffer.length);
1166
1151
  }
1167
1152
  else if (this.historyIndex === 0) {
1168
1153
  // Return to current input
1169
1154
  this.historyIndex = -1;
1170
- this.inputBuffer = this.tempCurrentInput;
1171
- this.cursorPosition = this.inputBuffer.length;
1172
- this.state.currentInput = this.inputBuffer;
1173
- this.scheduleRender();
1155
+ const restored = this.tempCurrentInput;
1156
+ this.startManualEdit();
1157
+ this.applyBufferChange(restored, restored.length);
1174
1158
  }
1175
1159
  }
1176
1160
  /**
@@ -1181,10 +1165,9 @@ export class PinnedChatBox {
1181
1165
  return;
1182
1166
  if (this.cursorPosition === 0)
1183
1167
  return;
1184
- this.inputBuffer = this.inputBuffer.slice(this.cursorPosition);
1185
- this.cursorPosition = 0;
1186
- this.state.currentInput = this.inputBuffer;
1187
- this.scheduleRender();
1168
+ this.startManualEdit();
1169
+ const newBuffer = this.inputBuffer.slice(this.cursorPosition);
1170
+ this.applyBufferChange(newBuffer, 0);
1188
1171
  }
1189
1172
  /**
1190
1173
  * Handle Ctrl+K - delete from cursor to end of line
@@ -1194,9 +1177,9 @@ export class PinnedChatBox {
1194
1177
  return;
1195
1178
  if (this.cursorPosition >= this.inputBuffer.length)
1196
1179
  return;
1197
- this.inputBuffer = this.inputBuffer.slice(0, this.cursorPosition);
1198
- this.state.currentInput = this.inputBuffer;
1199
- this.scheduleRender();
1180
+ this.startManualEdit();
1181
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition);
1182
+ this.applyBufferChange(newBuffer, this.cursorPosition);
1200
1183
  }
1201
1184
  /**
1202
1185
  * Handle Ctrl+W - delete word before cursor
@@ -1209,17 +1192,16 @@ export class PinnedChatBox {
1209
1192
  // Find the start of the word (skip trailing spaces, then find word boundary)
1210
1193
  let pos = this.cursorPosition;
1211
1194
  // Skip any spaces before cursor
1212
- while (pos > 0 && this.inputBuffer[pos - 1] === ' ') {
1195
+ while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
1213
1196
  pos--;
1214
1197
  }
1215
1198
  // Find start of word
1216
- while (pos > 0 && this.inputBuffer[pos - 1] !== ' ') {
1199
+ while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
1217
1200
  pos--;
1218
1201
  }
1219
- this.inputBuffer = this.inputBuffer.slice(0, pos) + this.inputBuffer.slice(this.cursorPosition);
1220
- this.cursorPosition = pos;
1221
- this.state.currentInput = this.inputBuffer;
1222
- this.scheduleRender();
1202
+ const newBuffer = this.inputBuffer.slice(0, pos) + this.inputBuffer.slice(this.cursorPosition);
1203
+ this.startManualEdit();
1204
+ this.applyBufferChange(newBuffer, pos);
1223
1205
  }
1224
1206
  /**
1225
1207
  * Handle Alt+Left - move cursor to previous word
@@ -1231,15 +1213,14 @@ export class PinnedChatBox {
1231
1213
  return;
1232
1214
  let pos = this.cursorPosition;
1233
1215
  // Skip any spaces before cursor
1234
- while (pos > 0 && this.inputBuffer[pos - 1] === ' ') {
1216
+ while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
1235
1217
  pos--;
1236
1218
  }
1237
1219
  // Find start of word
1238
- while (pos > 0 && this.inputBuffer[pos - 1] !== ' ') {
1220
+ while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
1239
1221
  pos--;
1240
1222
  }
1241
- this.cursorPosition = pos;
1242
- this.scheduleRender();
1223
+ this.setCursorPosition(pos);
1243
1224
  }
1244
1225
  /**
1245
1226
  * Handle Alt+Right - move cursor to next word
@@ -1251,15 +1232,14 @@ export class PinnedChatBox {
1251
1232
  return;
1252
1233
  let pos = this.cursorPosition;
1253
1234
  // Skip current word
1254
- while (pos < this.inputBuffer.length && this.inputBuffer[pos] !== ' ') {
1235
+ while (pos < this.inputBuffer.length && !this.isWhitespace(this.inputBuffer[pos])) {
1255
1236
  pos++;
1256
1237
  }
1257
1238
  // Skip any spaces
1258
- while (pos < this.inputBuffer.length && this.inputBuffer[pos] === ' ') {
1239
+ while (pos < this.inputBuffer.length && this.isWhitespace(this.inputBuffer[pos])) {
1259
1240
  pos++;
1260
1241
  }
1261
- this.cursorPosition = pos;
1262
- this.scheduleRender();
1242
+ this.setCursorPosition(pos);
1263
1243
  }
1264
1244
  /**
1265
1245
  * Add input to history (call after successful submit)
@@ -1326,15 +1306,12 @@ export class PinnedChatBox {
1326
1306
  clearInput() {
1327
1307
  if (this.isDisposed)
1328
1308
  return;
1329
- this.inputBuffer = '';
1330
- this.cursorPosition = 0;
1331
- this.state.currentInput = '';
1332
1309
  // Reset history navigation
1333
1310
  this.historyIndex = -1;
1334
1311
  this.tempCurrentInput = '';
1335
1312
  // Clear paste state
1336
1313
  this.clearPastedBlock();
1337
- this.scheduleRender();
1314
+ this.applyBufferChange('', 0);
1338
1315
  }
1339
1316
  /**
1340
1317
  * Set input text and optionally cursor position (for readline sync)
@@ -1343,13 +1320,14 @@ export class PinnedChatBox {
1343
1320
  setInput(text, cursorPos) {
1344
1321
  if (this.isDisposed)
1345
1322
  return;
1346
- const normalized = this.sanitizeInlineText(text).slice(0, this.maxInputLength);
1323
+ const normalized = this.sanitizeMultilineForDisplay(text).slice(0, this.maxInputLength);
1347
1324
  this.inputBuffer = normalized;
1348
1325
  // Use provided cursor position, or default to end of input
1349
1326
  this.cursorPosition = typeof cursorPos === 'number'
1350
1327
  ? Math.max(0, Math.min(cursorPos, normalized.length))
1351
1328
  : normalized.length;
1352
1329
  this.state.currentInput = normalized;
1330
+ this.updateReservedLinesForContent();
1353
1331
  // Do NOT schedule render here - readline handles display during input
1354
1332
  // render() will only work when isProcessing=true anyway
1355
1333
  }
@@ -1484,10 +1462,16 @@ export class PinnedChatBox {
1484
1462
  handleResize() {
1485
1463
  // Invalidate render state to force re-render with new dimensions
1486
1464
  this.invalidateRenderedState();
1487
- if (this.scrollRegionActive) {
1488
- // Reset and re-enable scroll region with new dimensions
1465
+ const wasActive = this.scrollRegionActive;
1466
+ if (wasActive) {
1467
+ // Reset scroll region before recalculating dimensions
1489
1468
  this.safeWrite(ANSI.RESET_SCROLL_REGION);
1490
1469
  this.scrollRegionActive = false;
1470
+ }
1471
+ // Recompute reserved lines for the new terminal size
1472
+ this.updateReservedLinesForContent();
1473
+ if (wasActive) {
1474
+ // Re-enable scroll region with the updated height
1491
1475
  this.enableScrollRegion();
1492
1476
  }
1493
1477
  this.scheduleRender();