erosolar-cli 1.7.184 → 1.7.187

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.
@@ -246,6 +246,12 @@ export class PinnedChatBox {
246
246
  pastedFullContent = '';
247
247
  /** Cleanup function for output interceptor registration */
248
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
249
255
  constructor(writeStream, _promptText = '> ', // Unused - readline handles input display
250
256
  options = {}) {
251
257
  this.writeStream = writeStream;
@@ -311,10 +317,14 @@ export class PinnedChatBox {
311
317
  enableScrollRegion() {
312
318
  if (this.scrollRegionActive || !this.supportsRendering())
313
319
  return;
320
+ // Make sure reserved height matches current content/terminal width
321
+ this.updateReservedLinesForContent();
314
322
  const rows = this.writeStream.rows || 24;
315
323
  const scrollBottom = rows - this.reservedLines;
316
324
  // Step 1: Set scroll region (excludes bottom reserved lines)
317
325
  this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
326
+ // Invalidate render state to ensure fresh render in the new scroll region
327
+ this.invalidateRenderedState();
318
328
  // Step 2: Render the prompt in the protected bottom area
319
329
  this.renderPersistentInput();
320
330
  // Mark scroll region as active AFTER rendering
@@ -344,14 +354,52 @@ export class PinnedChatBox {
344
354
  // Move cursor to the end of the terminal
345
355
  this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows));
346
356
  }
357
+ /**
358
+ * Check if render is needed by comparing with last rendered state.
359
+ * Returns true if content, cursor, or terminal size has changed.
360
+ */
361
+ needsRender() {
362
+ const rows = this.writeStream.rows || 24;
363
+ const cols = this.writeStream.columns || 80;
364
+ // Check if anything changed that requires a re-render
365
+ if (this.inputBuffer !== this.lastRenderedContent)
366
+ return true;
367
+ if (this.cursorPosition !== this.lastRenderedCursor)
368
+ return true;
369
+ if (rows !== this.lastRenderedRows)
370
+ return true;
371
+ if (cols !== this.lastRenderedCols)
372
+ return true;
373
+ return false;
374
+ }
375
+ /**
376
+ * Update last rendered state after successful render.
377
+ */
378
+ updateRenderedState() {
379
+ this.lastRenderedContent = this.inputBuffer;
380
+ this.lastRenderedCursor = this.cursorPosition;
381
+ this.lastRenderedRows = this.writeStream.rows || 24;
382
+ this.lastRenderedCols = this.writeStream.columns || 80;
383
+ }
384
+ /**
385
+ * Reset rendered state to force next render.
386
+ */
387
+ invalidateRenderedState() {
388
+ this.lastRenderedContent = '';
389
+ this.lastRenderedCursor = -1;
390
+ this.lastRenderedRows = 0;
391
+ this.lastRenderedCols = 0;
392
+ }
347
393
  /**
348
394
  * Render the persistent input area at the bottom of the terminal.
349
395
  *
350
396
  * CLEAN CLAUDE CODE STYLE:
351
397
  * - Line 1: Simple separator with optional queue count
352
- * - Line 2: Clean prompt "> " with input and visible block cursor
398
+ * - Line 2+: Clean prompt "> " with input and visible block cursor
353
399
  *
354
400
  * Key for clean rendering:
401
+ * - Render deduplication via content/cursor comparison
402
+ * - Render lock to prevent concurrent renders
355
403
  * - Hide cursor during render (prevents flicker)
356
404
  * - Clear lines completely before writing
357
405
  * - Reset styles explicitly
@@ -361,63 +409,105 @@ export class PinnedChatBox {
361
409
  renderPersistentInput() {
362
410
  if (!this.supportsRendering())
363
411
  return;
364
- const rows = this.writeStream.rows || 24;
365
- const cols = Math.max(this.writeStream.columns || 80, 40);
366
- // ANSI codes - keep simple, no theme functions
367
- const HIDE_CURSOR = '\x1b[?25l';
368
- const SHOW_CURSOR = '\x1b[?25h';
369
- const REVERSE_VIDEO = '\x1b[7m';
370
- const RESET = '\x1b[0m';
371
- const DIM = '\x1b[2m';
372
- // Hide cursor during render
373
- this.safeWrite(HIDE_CURSOR);
374
- this.safeWrite(RESET);
375
- // Line 1: Separator (at row N-1)
376
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows - 1));
377
- this.safeWrite(ANSI.CLEAR_LINE);
378
- const separatorWidth = Math.min(cols - 2, 60);
379
- this.safeWrite(DIM);
380
- this.safeWrite('─'.repeat(separatorWidth));
381
- this.safeWrite(RESET);
382
- // Queue count hint if processing
383
- const queueCount = this.state.queuedCommands.length;
384
- if (this.state.isProcessing && queueCount > 0) {
412
+ // Render lock - prevent concurrent renders that cause flicker
413
+ if (this.isRendering)
414
+ return;
415
+ // Deduplication - skip if nothing changed
416
+ if (!this.needsRender())
417
+ return;
418
+ this.isRendering = true;
419
+ try {
420
+ const { rows, cols, maxInputWidth } = this.getRenderDimensions();
421
+ // ANSI codes - keep simple, no theme functions
422
+ const HIDE_CURSOR = '\x1b[?25l';
423
+ const SHOW_CURSOR = '\x1b[?25h';
424
+ const REVERSE_VIDEO = '\x1b[7m';
425
+ const RESET = '\x1b[0m';
426
+ const DIM = '\x1b[2m';
427
+ // Hide cursor during render
428
+ this.safeWrite(HIDE_CURSOR);
429
+ this.safeWrite(RESET);
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);
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);
436
+ // Find cursor position: which line and column
437
+ let displayStartLine = 0;
438
+ if (totalInputLines > displayLineCount) {
439
+ displayStartLine = Math.max(0, cursorLineIndex - displayLineCount + 1);
440
+ displayStartLine = Math.min(displayStartLine, totalInputLines - displayLineCount);
441
+ }
442
+ const linesToShow = wrappedLines.slice(displayStartLine, displayStartLine + displayLineCount);
443
+ // Move to the start of reserved area
444
+ const reservedStart = rows - this.reservedLines + 1;
445
+ this.safeWrite(ANSI.CURSOR_TO_BOTTOM(reservedStart));
446
+ this.safeWrite(ANSI.CLEAR_LINE);
447
+ // Line 1: Separator
448
+ const separatorWidth = Math.min(cols - 2, 60);
385
449
  this.safeWrite(DIM);
386
- this.safeWrite(` ${queueCount} queued`);
450
+ this.safeWrite('─'.repeat(separatorWidth));
387
451
  this.safeWrite(RESET);
388
- }
389
- // Line 2: Prompt (at row N)
390
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows));
391
- this.safeWrite(ANSI.CLEAR_LINE);
392
- const promptPrefix = '> ';
393
- const maxInputWidth = cols - 5;
394
- const input = this.inputBuffer;
395
- const cursorPos = Math.min(this.cursorPosition, input.length);
396
- // Handle long input
397
- let displayStart = 0;
398
- let displayInput = input;
399
- if (input.length > maxInputWidth) {
400
- if (cursorPos > maxInputWidth - 5) {
401
- displayStart = cursorPos - maxInputWidth + 5;
452
+ // Status hints (line count, queue count)
453
+ const queueCount = this.state.queuedCommands.length;
454
+ const hints = [];
455
+ if (totalInputLines > 1) {
456
+ hints.push(`${totalInputLines} lines`);
457
+ }
458
+ if (this.state.isProcessing && queueCount > 0) {
459
+ hints.push(`${queueCount} queued`);
460
+ }
461
+ if (hints.length > 0) {
462
+ this.safeWrite(DIM);
463
+ this.safeWrite(' ' + hints.join(' '));
464
+ this.safeWrite(RESET);
402
465
  }
403
- displayInput = input.slice(displayStart, displayStart + maxInputWidth);
466
+ let finalCursorRow = reservedStart + 1;
467
+ let finalCursorCol = 3;
468
+ // Render each input line
469
+ for (let lineIdx = 0; lineIdx < linesToShow.length; lineIdx++) {
470
+ this.safeWrite('\n');
471
+ this.safeWrite(ANSI.CLEAR_LINE);
472
+ const line = linesToShow[lineIdx] ?? '';
473
+ const absoluteLineIdx = displayStartLine + lineIdx;
474
+ const isFirstLine = absoluteLineIdx === 0;
475
+ const isCursorLine = absoluteLineIdx === cursorLineIndex;
476
+ // Prompt prefix: ">" for first line, "│" for continuation lines
477
+ this.safeWrite(DIM);
478
+ this.safeWrite(isFirstLine ? '>' : '│');
479
+ this.safeWrite(RESET);
480
+ this.safeWrite(' ');
481
+ if (isCursorLine) {
482
+ // Render line with cursor
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);
487
+ this.safeWrite(beforeCursor);
488
+ this.safeWrite(REVERSE_VIDEO);
489
+ this.safeWrite(atCursor);
490
+ this.safeWrite(RESET);
491
+ this.safeWrite(afterCursor);
492
+ // Remember cursor position for final positioning
493
+ finalCursorRow = reservedStart + 1 + lineIdx;
494
+ finalCursorCol = 3 + displayCursorCol;
495
+ }
496
+ else {
497
+ // Render line without cursor
498
+ this.safeWrite(line);
499
+ }
500
+ }
501
+ // Position terminal cursor and show it
502
+ this.safeWrite(ANSI.CURSOR_TO_BOTTOM(finalCursorRow));
503
+ this.safeWrite(ANSI.MOVE_TO_COL(finalCursorCol));
504
+ this.safeWrite(SHOW_CURSOR);
505
+ // Update rendered state for deduplication
506
+ this.updateRenderedState();
507
+ }
508
+ finally {
509
+ this.isRendering = false;
404
510
  }
405
- const displayCursorPos = cursorPos - displayStart;
406
- // Write prompt
407
- this.safeWrite(promptPrefix);
408
- // Text before cursor
409
- this.safeWrite(displayInput.slice(0, displayCursorPos));
410
- // Cursor block (reverse video)
411
- const atCursor = displayInput[displayCursorPos] || ' ';
412
- this.safeWrite(REVERSE_VIDEO);
413
- this.safeWrite(atCursor);
414
- this.safeWrite(RESET);
415
- // Text after cursor
416
- this.safeWrite(displayInput.slice(displayCursorPos + 1));
417
- // Position terminal cursor and show it
418
- const terminalCursorCol = promptPrefix.length + displayCursorPos + 1;
419
- this.safeWrite(ANSI.MOVE_TO_COL(terminalCursorCol));
420
- this.safeWrite(SHOW_CURSOR);
421
511
  }
422
512
  /**
423
513
  * Update the persistent input during streaming.
@@ -476,10 +566,13 @@ export class PinnedChatBox {
476
566
  return withoutAnsi.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
477
567
  }
478
568
  /**
479
- * Sanitize queued command text (trimmed, single line)
569
+ * Sanitize queued command text (trimmed, preserves newlines)
480
570
  */
481
571
  sanitizeCommandText(text) {
482
- return this.sanitizeInlineText(text).trim();
572
+ if (!text)
573
+ return '';
574
+ const clean = this.sanitizeMultilineForDisplay(text);
575
+ return clean.slice(0, this.maxInputLength).trim();
483
576
  }
484
577
  /**
485
578
  * Sanitize status message (single line, bounded length)
@@ -492,6 +585,56 @@ export class PinnedChatBox {
492
585
  return null;
493
586
  return clean.slice(0, this.maxStatusMessageLength);
494
587
  }
588
+ /**
589
+ * Clear paste-specific state when the user edits the buffer manually.
590
+ * Prevents us from sending stale paste content after edits.
591
+ */
592
+ startManualEdit() {
593
+ if (this.isPastedBlock) {
594
+ this.clearPastedBlock();
595
+ }
596
+ }
597
+ /**
598
+ * Apply a new buffer/cursor position and trigger downstream updates.
599
+ */
600
+ applyBufferChange(buffer, cursorPos) {
601
+ this.inputBuffer = buffer;
602
+ const nextCursor = typeof cursorPos === 'number' ? cursorPos : buffer.length;
603
+ this.cursorPosition = Math.max(0, Math.min(nextCursor, this.inputBuffer.length));
604
+ this.state.currentInput = this.inputBuffer;
605
+ this.updateReservedLinesForContent();
606
+ this.scheduleRender();
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
+ }
614
+ /**
615
+ * Update cursor position while keeping it in bounds and re-rendering.
616
+ */
617
+ setCursorPosition(position) {
618
+ this.cursorPosition = Math.max(0, Math.min(position, this.inputBuffer.length));
619
+ this.scheduleRender();
620
+ }
621
+ /**
622
+ * Insert multi-line content directly into the buffer (manual typing).
623
+ */
624
+ insertMultilineText(text) {
625
+ const sanitized = this.sanitizeMultilineForDisplay(text);
626
+ if (!sanitized)
627
+ return;
628
+ // Respect max input length
629
+ const availableSpace = this.maxInputLength - this.inputBuffer.length;
630
+ if (availableSpace <= 0)
631
+ return;
632
+ const chunk = sanitized.slice(0, availableSpace);
633
+ this.startManualEdit();
634
+ this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
635
+ chunk +
636
+ this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + chunk.length);
637
+ }
495
638
  /**
496
639
  * Safely write to the output stream, swallowing non-fatal errors
497
640
  */
@@ -590,7 +733,8 @@ export class PinnedChatBox {
590
733
  }
591
734
  // Sanitize and truncate command text
592
735
  const truncated = sanitizedText.slice(0, this.maxInputLength);
593
- 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;
594
738
  const cmd = {
595
739
  id: `cmd-${++this.commandIdCounter}`,
596
740
  text: truncated,
@@ -646,14 +790,25 @@ export class PinnedChatBox {
646
790
  * Handle character input with validation
647
791
  * Detects multiline paste and stores full content while showing summary
648
792
  */
649
- handleInput(char) {
793
+ handleInput(char, options = {}) {
650
794
  if (!this.isEnabled || this.isDisposed)
651
795
  return;
652
796
  if (typeof char !== 'string')
653
797
  return;
654
- // Detect multiline paste (content with newlines)
655
- if (char.includes('\n') || char.includes('\r')) {
656
- 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) {
657
812
  return;
658
813
  }
659
814
  const sanitized = this.sanitizeInlineText(char);
@@ -667,13 +822,10 @@ export class PinnedChatBox {
667
822
  if (!chunk)
668
823
  return;
669
824
  // Insert character at cursor position
670
- this.inputBuffer =
671
- this.inputBuffer.slice(0, this.cursorPosition) +
672
- chunk +
673
- this.inputBuffer.slice(this.cursorPosition);
674
- this.cursorPosition = Math.min(this.cursorPosition + chunk.length, this.inputBuffer.length);
675
- this.state.currentInput = this.inputBuffer;
676
- 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);
677
829
  }
678
830
  /**
679
831
  * Handle multiline paste - store full content and display all lines
@@ -681,15 +833,13 @@ export class PinnedChatBox {
681
833
  handleMultilinePaste(content) {
682
834
  // Keep the original content for submission
683
835
  const displaySafeContent = this.sanitizeMultilineForDisplay(content);
684
- this.pastedFullContent = content;
836
+ if (!displaySafeContent)
837
+ return;
838
+ const truncatedDisplay = displaySafeContent.slice(0, this.maxInputLength);
839
+ const truncatedOriginal = content.slice(0, this.maxInputLength);
840
+ this.pastedFullContent = truncatedOriginal;
685
841
  this.isPastedBlock = true;
686
- // Store the full content for display (not just a summary)
687
- this.inputBuffer = displaySafeContent;
688
- this.cursorPosition = displaySafeContent.length;
689
- this.state.currentInput = displaySafeContent;
690
- // Update reserved lines based on content
691
- this.updateReservedLinesForContent();
692
- this.scheduleRender();
842
+ this.applyBufferChange(truncatedDisplay, truncatedDisplay.length);
693
843
  }
694
844
  /**
695
845
  * Public helper for routing external multi-line pastes directly into the chat box
@@ -723,37 +873,88 @@ export class PinnedChatBox {
723
873
  */
724
874
  clearPastedBlockState() {
725
875
  this.clearPastedBlock();
726
- this.inputBuffer = '';
727
- this.cursorPosition = 0;
728
- this.state.currentInput = '';
729
- this.resetReservedLines();
876
+ this.applyBufferChange('', 0);
730
877
  }
731
878
  /**
732
- * Calculate and update reserved lines based on current input content.
733
- * Ensures multi-line content is fully visible within maxDisplayLines limit.
879
+ * Get terminal dimensions and a safe width for rendering input content.
734
880
  */
735
- updateReservedLinesForContent() {
736
- const lines = this.inputBuffer.split('\n');
737
- const lineCount = lines.length;
738
- // Calculate needed lines: 1 for separator + lineCount for input (clamped to maxDisplayLines)
739
- const inputLines = Math.min(lineCount, this.maxDisplayLines);
740
- this.reservedLines = 1 + inputLines; // 1 separator + input lines
741
- // If we have a scroll region active, update it
742
- if (this.scrollRegionActive) {
743
- const rows = this.writeStream.rows || 24;
744
- const scrollBottom = rows - this.reservedLines;
745
- this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
881
+ getRenderDimensions() {
882
+ const rows = this.writeStream.rows || 24;
883
+ const cols = Math.max(this.writeStream.columns || 80, 40);
884
+ // Leave a small gutter for prefix + padding so we never wrap unexpectedly
885
+ const maxInputWidth = Math.max(10, cols - 5);
886
+ return { rows, cols, maxInputWidth };
887
+ }
888
+ /**
889
+ * Wrap the current input buffer to the provided width and locate the cursor.
890
+ */
891
+ wrapInputBuffer(maxInputWidth) {
892
+ const width = Math.max(1, maxInputWidth);
893
+ const rawLines = this.inputBuffer.split('\n');
894
+ const wrappedLines = [];
895
+ const targetCursor = Math.max(0, Math.min(this.cursorPosition, this.inputBuffer.length));
896
+ let cursorLine = 0;
897
+ let cursorCol = 0;
898
+ let absoluteIndex = 0;
899
+ for (let i = 0; i < rawLines.length; i++) {
900
+ const raw = rawLines[i] ?? '';
901
+ if (raw.length === 0) {
902
+ // Preserve empty lines so vertical space stays accurate
903
+ wrappedLines.push('');
904
+ if (targetCursor === absoluteIndex) {
905
+ cursorLine = wrappedLines.length - 1;
906
+ cursorCol = 0;
907
+ }
908
+ }
909
+ else {
910
+ for (let start = 0; start < raw.length; start += width) {
911
+ const segment = raw.slice(start, start + width);
912
+ const segmentStart = absoluteIndex + start;
913
+ wrappedLines.push(segment);
914
+ if (targetCursor >= segmentStart && targetCursor <= segmentStart + segment.length) {
915
+ cursorLine = wrappedLines.length - 1;
916
+ cursorCol = targetCursor - segmentStart;
917
+ }
918
+ }
919
+ }
920
+ absoluteIndex += raw.length;
921
+ if (i < rawLines.length - 1) {
922
+ // Account for the newline character between raw lines
923
+ if (targetCursor === absoluteIndex) {
924
+ cursorLine = wrappedLines.length;
925
+ cursorCol = 0;
926
+ }
927
+ absoluteIndex += 1;
928
+ }
929
+ }
930
+ if (wrappedLines.length === 0) {
931
+ wrappedLines.push('');
932
+ cursorLine = 0;
933
+ cursorCol = 0;
746
934
  }
935
+ cursorLine = Math.min(cursorLine, wrappedLines.length - 1);
936
+ cursorCol = Math.min(cursorCol, wrappedLines[cursorLine]?.length ?? 0);
937
+ return { lines: wrappedLines, cursorLine, cursorCol };
747
938
  }
748
939
  /**
749
- * Reset reserved lines to base value (for single-line input)
940
+ * Calculate and update reserved lines based on current input content.
941
+ * Ensures multi-line content is fully visible within maxDisplayLines limit.
750
942
  */
751
- resetReservedLines() {
752
- this.reservedLines = this.baseReservedLines;
943
+ updateReservedLinesForContent(maxInputWidth, wrappedLineCount) {
944
+ const { rows, maxInputWidth: derivedWidth } = this.getRenderDimensions();
945
+ const width = Math.max(1, maxInputWidth ?? derivedWidth);
946
+ const totalLines = wrappedLineCount ?? this.wrapInputBuffer(width).lines.length;
947
+ const lineCount = Math.max(1, totalLines);
948
+ // Calculate needed lines: 1 for separator + lineCount for input (clamped to maxDisplayLines)
949
+ const inputLines = Math.min(lineCount, this.maxDisplayLines);
950
+ const calculated = 1 + inputLines; // 1 separator + input lines
951
+ // Ensure we keep at least one row for output scrolling
952
+ const maxAllowed = Math.max(1, rows - 1);
953
+ const desired = Math.max(this.baseReservedLines, calculated);
954
+ this.reservedLines = Math.min(desired, maxAllowed);
753
955
  // If we have a scroll region active, update it
754
956
  if (this.scrollRegionActive) {
755
- const rows = this.writeStream.rows || 24;
756
- const scrollBottom = rows - this.reservedLines;
957
+ const scrollBottom = Math.max(1, rows - this.reservedLines);
757
958
  this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
758
959
  }
759
960
  }
@@ -766,12 +967,11 @@ export class PinnedChatBox {
766
967
  this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
767
968
  if (this.cursorPosition === 0)
768
969
  return;
769
- this.inputBuffer =
770
- this.inputBuffer.slice(0, this.cursorPosition - 1) +
771
- this.inputBuffer.slice(this.cursorPosition);
772
- this.cursorPosition = Math.max(0, this.cursorPosition - 1);
773
- this.state.currentInput = this.inputBuffer;
774
- this.scheduleRender();
970
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition - 1) +
971
+ this.inputBuffer.slice(this.cursorPosition);
972
+ const newCursor = Math.max(0, this.cursorPosition - 1);
973
+ this.startManualEdit();
974
+ this.applyBufferChange(newBuffer, newCursor);
775
975
  }
776
976
  /**
777
977
  * Handle delete key
@@ -782,11 +982,10 @@ export class PinnedChatBox {
782
982
  this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
783
983
  if (this.cursorPosition >= this.inputBuffer.length)
784
984
  return;
785
- this.inputBuffer =
786
- this.inputBuffer.slice(0, this.cursorPosition) +
787
- this.inputBuffer.slice(this.cursorPosition + 1);
788
- this.state.currentInput = this.inputBuffer;
789
- this.scheduleRender();
985
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition) +
986
+ this.inputBuffer.slice(this.cursorPosition + 1);
987
+ this.startManualEdit();
988
+ this.applyBufferChange(newBuffer, this.cursorPosition);
790
989
  }
791
990
  /**
792
991
  * Handle cursor left
@@ -794,10 +993,9 @@ export class PinnedChatBox {
794
993
  handleCursorLeft() {
795
994
  if (this.isDisposed)
796
995
  return;
797
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
798
- if (this.cursorPosition > 0) {
799
- this.cursorPosition--;
800
- this.scheduleRender();
996
+ const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
997
+ if (bounded > 0) {
998
+ this.setCursorPosition(bounded - 1);
801
999
  }
802
1000
  }
803
1001
  /**
@@ -806,36 +1004,103 @@ export class PinnedChatBox {
806
1004
  handleCursorRight() {
807
1005
  if (this.isDisposed)
808
1006
  return;
809
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
810
- if (this.cursorPosition < this.inputBuffer.length) {
811
- this.cursorPosition++;
812
- this.scheduleRender();
1007
+ const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
1008
+ if (bounded < this.inputBuffer.length) {
1009
+ this.setCursorPosition(bounded + 1);
813
1010
  }
814
1011
  }
815
1012
  /**
816
- * Handle home key
1013
+ * Handle home key - move to start of current line (not start of buffer)
817
1014
  */
818
1015
  handleHome() {
819
1016
  if (this.isDisposed)
820
1017
  return;
821
- this.cursorPosition = 0;
822
- this.scheduleRender();
1018
+ // In multi-line mode, move to start of current line
1019
+ const { lineIndex, lines } = this.getCursorLinePosition();
1020
+ // Calculate position at start of current line
1021
+ let newPos = 0;
1022
+ for (let i = 0; i < lineIndex; i++) {
1023
+ newPos += (lines[i] ?? '').length + 1;
1024
+ }
1025
+ this.setCursorPosition(newPos);
823
1026
  }
824
1027
  /**
825
- * Handle end key (Ctrl+E)
1028
+ * Handle end key (Ctrl+E) - move to end of current line (not end of buffer)
826
1029
  */
827
1030
  handleEnd() {
828
1031
  if (this.isDisposed)
829
1032
  return;
830
- this.cursorPosition = this.inputBuffer.length;
831
- this.scheduleRender();
1033
+ // In multi-line mode, move to end of current line
1034
+ const { lineIndex, lines } = this.getCursorLinePosition();
1035
+ const currentLine = lines[lineIndex] ?? '';
1036
+ // Calculate position at end of current line
1037
+ let newPos = 0;
1038
+ for (let i = 0; i < lineIndex; i++) {
1039
+ newPos += (lines[i] ?? '').length + 1;
1040
+ }
1041
+ newPos += currentLine.length;
1042
+ this.setCursorPosition(newPos);
1043
+ }
1044
+ /**
1045
+ * Handle inserting a newline character at cursor position (Shift+Enter or Option+Enter)
1046
+ */
1047
+ handleNewline() {
1048
+ if (!this.isEnabled || this.isDisposed)
1049
+ return;
1050
+ // Respect max input length
1051
+ if (this.inputBuffer.length >= this.maxInputLength)
1052
+ return;
1053
+ this.startManualEdit();
1054
+ this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
1055
+ '\n' +
1056
+ this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + 1);
1057
+ }
1058
+ /**
1059
+ * Get cursor line and column position within multi-line content.
1060
+ */
1061
+ getCursorLinePosition() {
1062
+ const lines = this.inputBuffer.split('\n');
1063
+ let charCount = 0;
1064
+ for (let i = 0; i < lines.length; i++) {
1065
+ const lineLen = (lines[i] ?? '').length;
1066
+ if (charCount + lineLen >= this.cursorPosition) {
1067
+ return {
1068
+ lineIndex: i,
1069
+ colInLine: this.cursorPosition - charCount,
1070
+ lines,
1071
+ };
1072
+ }
1073
+ charCount += lineLen + 1; // +1 for newline
1074
+ }
1075
+ // Cursor at end
1076
+ return {
1077
+ lineIndex: lines.length - 1,
1078
+ colInLine: (lines[lines.length - 1] ?? '').length,
1079
+ lines,
1080
+ };
832
1081
  }
833
1082
  /**
834
- * Handle up arrow - navigate to previous history entry
1083
+ * Handle up arrow - move cursor up in multi-line content, or navigate history if on first line
835
1084
  */
836
1085
  handleHistoryUp() {
837
1086
  if (this.isDisposed)
838
1087
  return;
1088
+ // For multi-line content, move cursor up within content first
1089
+ const { lineIndex, colInLine, lines } = this.getCursorLinePosition();
1090
+ if (lineIndex > 0) {
1091
+ // Move cursor to previous line, keeping column position if possible
1092
+ const prevLine = lines[lineIndex - 1] ?? '';
1093
+ const newCol = Math.min(colInLine, prevLine.length);
1094
+ // Calculate new cursor position
1095
+ let newPos = 0;
1096
+ for (let i = 0; i < lineIndex - 1; i++) {
1097
+ newPos += (lines[i] ?? '').length + 1;
1098
+ }
1099
+ newPos += newCol;
1100
+ this.setCursorPosition(newPos);
1101
+ return;
1102
+ }
1103
+ // On first line - navigate history
839
1104
  if (this.inputHistory.length === 0)
840
1105
  return;
841
1106
  // If at current input, save it temporarily
@@ -845,33 +1110,46 @@ export class PinnedChatBox {
845
1110
  // Move up in history
846
1111
  if (this.historyIndex < this.inputHistory.length - 1) {
847
1112
  this.historyIndex++;
848
- this.inputBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
849
- this.cursorPosition = this.inputBuffer.length;
850
- this.state.currentInput = this.inputBuffer;
851
- this.scheduleRender();
1113
+ const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1114
+ this.startManualEdit();
1115
+ this.applyBufferChange(nextBuffer, nextBuffer.length);
852
1116
  }
853
1117
  }
854
1118
  /**
855
- * Handle down arrow - navigate to next history entry
1119
+ * Handle down arrow - move cursor down in multi-line content, or navigate history if on last line
856
1120
  */
857
1121
  handleHistoryDown() {
858
1122
  if (this.isDisposed)
859
1123
  return;
1124
+ // For multi-line content, move cursor down within content first
1125
+ const { lineIndex, colInLine, lines } = this.getCursorLinePosition();
1126
+ if (lineIndex < lines.length - 1) {
1127
+ // Move cursor to next line, keeping column position if possible
1128
+ const nextLine = lines[lineIndex + 1] ?? '';
1129
+ const newCol = Math.min(colInLine, nextLine.length);
1130
+ // Calculate new cursor position
1131
+ let newPos = 0;
1132
+ for (let i = 0; i <= lineIndex; i++) {
1133
+ newPos += (lines[i] ?? '').length + 1;
1134
+ }
1135
+ newPos += newCol;
1136
+ this.setCursorPosition(newPos);
1137
+ return;
1138
+ }
1139
+ // On last line - navigate history
860
1140
  if (this.historyIndex > 0) {
861
1141
  // Move down in history
862
1142
  this.historyIndex--;
863
- this.inputBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
864
- this.cursorPosition = this.inputBuffer.length;
865
- this.state.currentInput = this.inputBuffer;
866
- this.scheduleRender();
1143
+ const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1144
+ this.startManualEdit();
1145
+ this.applyBufferChange(nextBuffer, nextBuffer.length);
867
1146
  }
868
1147
  else if (this.historyIndex === 0) {
869
1148
  // Return to current input
870
1149
  this.historyIndex = -1;
871
- this.inputBuffer = this.tempCurrentInput;
872
- this.cursorPosition = this.inputBuffer.length;
873
- this.state.currentInput = this.inputBuffer;
874
- this.scheduleRender();
1150
+ const restored = this.tempCurrentInput;
1151
+ this.startManualEdit();
1152
+ this.applyBufferChange(restored, restored.length);
875
1153
  }
876
1154
  }
877
1155
  /**
@@ -882,10 +1160,9 @@ export class PinnedChatBox {
882
1160
  return;
883
1161
  if (this.cursorPosition === 0)
884
1162
  return;
885
- this.inputBuffer = this.inputBuffer.slice(this.cursorPosition);
886
- this.cursorPosition = 0;
887
- this.state.currentInput = this.inputBuffer;
888
- this.scheduleRender();
1163
+ this.startManualEdit();
1164
+ const newBuffer = this.inputBuffer.slice(this.cursorPosition);
1165
+ this.applyBufferChange(newBuffer, 0);
889
1166
  }
890
1167
  /**
891
1168
  * Handle Ctrl+K - delete from cursor to end of line
@@ -895,9 +1172,9 @@ export class PinnedChatBox {
895
1172
  return;
896
1173
  if (this.cursorPosition >= this.inputBuffer.length)
897
1174
  return;
898
- this.inputBuffer = this.inputBuffer.slice(0, this.cursorPosition);
899
- this.state.currentInput = this.inputBuffer;
900
- this.scheduleRender();
1175
+ this.startManualEdit();
1176
+ const newBuffer = this.inputBuffer.slice(0, this.cursorPosition);
1177
+ this.applyBufferChange(newBuffer, this.cursorPosition);
901
1178
  }
902
1179
  /**
903
1180
  * Handle Ctrl+W - delete word before cursor
@@ -910,17 +1187,16 @@ export class PinnedChatBox {
910
1187
  // Find the start of the word (skip trailing spaces, then find word boundary)
911
1188
  let pos = this.cursorPosition;
912
1189
  // Skip any spaces before cursor
913
- while (pos > 0 && this.inputBuffer[pos - 1] === ' ') {
1190
+ while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
914
1191
  pos--;
915
1192
  }
916
1193
  // Find start of word
917
- while (pos > 0 && this.inputBuffer[pos - 1] !== ' ') {
1194
+ while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
918
1195
  pos--;
919
1196
  }
920
- this.inputBuffer = this.inputBuffer.slice(0, pos) + this.inputBuffer.slice(this.cursorPosition);
921
- this.cursorPosition = pos;
922
- this.state.currentInput = this.inputBuffer;
923
- this.scheduleRender();
1197
+ const newBuffer = this.inputBuffer.slice(0, pos) + this.inputBuffer.slice(this.cursorPosition);
1198
+ this.startManualEdit();
1199
+ this.applyBufferChange(newBuffer, pos);
924
1200
  }
925
1201
  /**
926
1202
  * Handle Alt+Left - move cursor to previous word
@@ -932,15 +1208,14 @@ export class PinnedChatBox {
932
1208
  return;
933
1209
  let pos = this.cursorPosition;
934
1210
  // Skip any spaces before cursor
935
- while (pos > 0 && this.inputBuffer[pos - 1] === ' ') {
1211
+ while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
936
1212
  pos--;
937
1213
  }
938
1214
  // Find start of word
939
- while (pos > 0 && this.inputBuffer[pos - 1] !== ' ') {
1215
+ while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
940
1216
  pos--;
941
1217
  }
942
- this.cursorPosition = pos;
943
- this.scheduleRender();
1218
+ this.setCursorPosition(pos);
944
1219
  }
945
1220
  /**
946
1221
  * Handle Alt+Right - move cursor to next word
@@ -952,15 +1227,14 @@ export class PinnedChatBox {
952
1227
  return;
953
1228
  let pos = this.cursorPosition;
954
1229
  // Skip current word
955
- while (pos < this.inputBuffer.length && this.inputBuffer[pos] !== ' ') {
1230
+ while (pos < this.inputBuffer.length && !this.isWhitespace(this.inputBuffer[pos])) {
956
1231
  pos++;
957
1232
  }
958
1233
  // Skip any spaces
959
- while (pos < this.inputBuffer.length && this.inputBuffer[pos] === ' ') {
1234
+ while (pos < this.inputBuffer.length && this.isWhitespace(this.inputBuffer[pos])) {
960
1235
  pos++;
961
1236
  }
962
- this.cursorPosition = pos;
963
- this.scheduleRender();
1237
+ this.setCursorPosition(pos);
964
1238
  }
965
1239
  /**
966
1240
  * Add input to history (call after successful submit)
@@ -1027,15 +1301,12 @@ export class PinnedChatBox {
1027
1301
  clearInput() {
1028
1302
  if (this.isDisposed)
1029
1303
  return;
1030
- this.inputBuffer = '';
1031
- this.cursorPosition = 0;
1032
- this.state.currentInput = '';
1033
1304
  // Reset history navigation
1034
1305
  this.historyIndex = -1;
1035
1306
  this.tempCurrentInput = '';
1036
1307
  // Clear paste state
1037
1308
  this.clearPastedBlock();
1038
- this.scheduleRender();
1309
+ this.applyBufferChange('', 0);
1039
1310
  }
1040
1311
  /**
1041
1312
  * Set input text and optionally cursor position (for readline sync)
@@ -1044,13 +1315,14 @@ export class PinnedChatBox {
1044
1315
  setInput(text, cursorPos) {
1045
1316
  if (this.isDisposed)
1046
1317
  return;
1047
- const normalized = this.sanitizeInlineText(text).slice(0, this.maxInputLength);
1318
+ const normalized = this.sanitizeMultilineForDisplay(text).slice(0, this.maxInputLength);
1048
1319
  this.inputBuffer = normalized;
1049
1320
  // Use provided cursor position, or default to end of input
1050
1321
  this.cursorPosition = typeof cursorPos === 'number'
1051
1322
  ? Math.max(0, Math.min(cursorPos, normalized.length))
1052
1323
  : normalized.length;
1053
1324
  this.state.currentInput = normalized;
1325
+ this.updateReservedLinesForContent();
1054
1326
  // Do NOT schedule render here - readline handles display during input
1055
1327
  // render() will only work when isProcessing=true anyway
1056
1328
  }
@@ -1183,10 +1455,18 @@ export class PinnedChatBox {
1183
1455
  * Handle terminal resize - update scroll region if active
1184
1456
  */
1185
1457
  handleResize() {
1186
- if (this.scrollRegionActive) {
1187
- // Reset and re-enable scroll region with new dimensions
1458
+ // Invalidate render state to force re-render with new dimensions
1459
+ this.invalidateRenderedState();
1460
+ const wasActive = this.scrollRegionActive;
1461
+ if (wasActive) {
1462
+ // Reset scroll region before recalculating dimensions
1188
1463
  this.safeWrite(ANSI.RESET_SCROLL_REGION);
1189
1464
  this.scrollRegionActive = false;
1465
+ }
1466
+ // Recompute reserved lines for the new terminal size
1467
+ this.updateReservedLinesForContent();
1468
+ if (wasActive) {
1469
+ // Re-enable scroll region with the updated height
1190
1470
  this.enableScrollRegion();
1191
1471
  }
1192
1472
  this.scheduleRender();
@@ -1239,13 +1519,14 @@ export class PinnedChatBox {
1239
1519
  return !this.isDisposed && this.isEnabled;
1240
1520
  }
1241
1521
  /**
1242
- * Force immediate render (bypass throttling)
1522
+ * Force immediate render (bypass throttling and deduplication)
1243
1523
  */
1244
1524
  forceRender() {
1245
1525
  if (this.isDisposed)
1246
1526
  return;
1247
1527
  this.lastRenderTime = 0;
1248
1528
  this.renderScheduled = false;
1529
+ this.invalidateRenderedState(); // Force re-render even if content unchanged
1249
1530
  this.render();
1250
1531
  }
1251
1532
  /**
@@ -1264,6 +1545,7 @@ export class PinnedChatBox {
1264
1545
  statusMessage: null,
1265
1546
  isVisible: true,
1266
1547
  };
1548
+ this.invalidateRenderedState();
1267
1549
  this.scheduleRender();
1268
1550
  }
1269
1551
  }