erosolar-cli 2.1.52 → 2.1.54

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.
@@ -13,7 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  import { homedir } from 'node:os';
14
14
  import { theme } from './theme.js';
15
15
  import { isPlainOutputMode } from './outputMode.js';
16
- import { renderDivider, renderStatusLines } from './unified/layout.js';
16
+ import { renderDivider } from './unified/layout.js';
17
17
  const ESC = {
18
18
  HIDE_CURSOR: '\x1b[?25l',
19
19
  SHOW_CURSOR: '\x1b[?25h',
@@ -38,6 +38,7 @@ export class UnifiedUIRenderer extends EventEmitter {
38
38
  interactive;
39
39
  rows = 24;
40
40
  cols = 80;
41
+ lastRenderWidth = null;
41
42
  eventQueue = [];
42
43
  isProcessingQueue = false;
43
44
  buffer = '';
@@ -69,20 +70,32 @@ export class UnifiedUIRenderer extends EventEmitter {
69
70
  promptHeight = 0;
70
71
  lastOverlayHeight = 0;
71
72
  lastPromptIndex = 0;
73
+ overlayBottomPadding = 1;
72
74
  inlinePanel = [];
73
75
  overlayInvalidated = false;
74
76
  hasConversationContent = false;
75
77
  isPromptActive = false;
76
78
  inputRenderOffset = 0;
79
+ plainPasteIdleMs = 24;
80
+ plainPasteWindowMs = 60;
81
+ plainPasteTriggerChars = 24;
82
+ plainPasteNewlineAssist = 3;
77
83
  cursorVisibleColumn = 1;
78
84
  inBracketedPaste = false;
79
85
  pasteBuffer = '';
86
+ inPlainPaste = false;
87
+ plainPasteBuffer = '';
88
+ plainPasteTimer = null;
89
+ pasteBurstWindowStart = 0;
90
+ pasteBurstCharCount = 0;
91
+ plainRecentChunks = [];
80
92
  lastRenderedEventKey = null;
81
93
  lastOutputEndedWithNewline = true;
82
94
  hasRenderedPrompt = false;
83
95
  hasEverRenderedOverlay = false; // Track if we've ever rendered to prevent first-render scrollback pollution
84
96
  lastOverlay = null;
85
97
  allowPromptRender = true;
98
+ inputCapture = null;
86
99
  constructor(output = process.stdout, input = process.stdin, options) {
87
100
  super();
88
101
  this.output = output;
@@ -131,6 +144,8 @@ export class UnifiedUIRenderer extends EventEmitter {
131
144
  this.renderPrompt();
132
145
  }
133
146
  cleanup() {
147
+ this.cancelInputCapture(new Error('Renderer disposed'));
148
+ this.cancelPlainPasteCapture();
134
149
  if (!this.interactive) {
135
150
  this.rl.close();
136
151
  return;
@@ -174,6 +189,14 @@ export class UnifiedUIRenderer extends EventEmitter {
174
189
  if (this.handleBracketedPaste(str, key)) {
175
190
  return;
176
191
  }
192
+ if (this.handlePlainPaste(str, key)) {
193
+ return;
194
+ }
195
+ if (this.inputCapture && key.ctrl && (key.name === 'c' || key.name === 'd')) {
196
+ this.cancelInputCapture(new Error('Input capture cancelled'));
197
+ this.clearBuffer();
198
+ return;
199
+ }
177
200
  if (key.ctrl && key.shift && key.name?.toLowerCase() === 'a') {
178
201
  this.emit('toggle-critical-approval');
179
202
  return;
@@ -319,6 +342,7 @@ export class UnifiedUIRenderer extends EventEmitter {
319
342
  if (sequence === '\x1b[200~') {
320
343
  this.inBracketedPaste = true;
321
344
  this.pasteBuffer = '';
345
+ this.cancelPlainPasteCapture();
322
346
  return true;
323
347
  }
324
348
  if (!this.inBracketedPaste) {
@@ -366,10 +390,141 @@ export class UnifiedUIRenderer extends EventEmitter {
366
390
  }
367
391
  this.inBracketedPaste = false;
368
392
  this.pasteBuffer = '';
393
+ this.cancelPlainPasteCapture();
394
+ }
395
+ handlePlainPaste(str, key) {
396
+ // Fallback paste capture when bracketed paste isn't supported
397
+ if (this.inBracketedPaste || key?.ctrl || key?.meta) {
398
+ this.resetPlainPasteBurst();
399
+ this.pruneRecentPlainChunks();
400
+ return false;
401
+ }
402
+ const sequence = key?.sequence ?? '';
403
+ const chunk = typeof str === 'string' && str.length > 0 ? str : sequence;
404
+ if (!chunk) {
405
+ this.resetPlainPasteBurst();
406
+ this.pruneRecentPlainChunks();
407
+ return false;
408
+ }
409
+ const now = Date.now();
410
+ this.trackPlainPasteBurst(chunk.length, now);
411
+ if (!this.inPlainPaste) {
412
+ this.recordRecentPlainChunk(chunk, now);
413
+ }
414
+ const chunkMultiple = chunk.length > 1;
415
+ const hasNewline = /[\r\n]/.test(chunk);
416
+ const burstActive = this.pasteBurstWindowStart > 0 && now - this.pasteBurstWindowStart <= this.plainPasteWindowMs;
417
+ const burstTrigger = burstActive && this.pasteBurstCharCount >= this.plainPasteTriggerChars;
418
+ const newlineTrigger = hasNewline && (this.inPlainPaste || chunkMultiple || this.pasteBurstCharCount >= this.plainPasteNewlineAssist);
419
+ const looksLikePaste = this.inPlainPaste || chunkMultiple || burstTrigger || newlineTrigger;
420
+ if (!looksLikePaste) {
421
+ this.pruneRecentPlainChunks();
422
+ return false;
423
+ }
424
+ let chunkAlreadyCaptured = false;
425
+ if (!this.inPlainPaste) {
426
+ this.inPlainPaste = true;
427
+ const reclaimed = this.reclaimRecentPlainChunks();
428
+ if (reclaimed.length > 0) {
429
+ const removeCount = Math.min(this.buffer.length, reclaimed.length);
430
+ const suffix = this.buffer.slice(-removeCount);
431
+ if (removeCount > 0 && suffix === reclaimed.slice(-removeCount)) {
432
+ this.buffer = this.buffer.slice(0, this.buffer.length - removeCount);
433
+ this.cursor = Math.max(0, this.buffer.length);
434
+ }
435
+ this.plainPasteBuffer = reclaimed;
436
+ chunkAlreadyCaptured = reclaimed.endsWith(chunk);
437
+ }
438
+ else {
439
+ this.plainPasteBuffer = '';
440
+ }
441
+ }
442
+ if (!chunkAlreadyCaptured) {
443
+ this.plainPasteBuffer += chunk;
444
+ }
445
+ this.schedulePlainPasteCommit();
446
+ return true;
447
+ }
448
+ trackPlainPasteBurst(length, now) {
449
+ if (!this.pasteBurstWindowStart || now - this.pasteBurstWindowStart > this.plainPasteWindowMs) {
450
+ this.pasteBurstWindowStart = now;
451
+ this.pasteBurstCharCount = 0;
452
+ }
453
+ this.pasteBurstCharCount += length;
454
+ }
455
+ resetPlainPasteBurst() {
456
+ this.pasteBurstWindowStart = 0;
457
+ this.pasteBurstCharCount = 0;
458
+ }
459
+ cancelPlainPasteCapture() {
460
+ if (this.plainPasteTimer) {
461
+ clearTimeout(this.plainPasteTimer);
462
+ this.plainPasteTimer = null;
463
+ }
464
+ this.inPlainPaste = false;
465
+ this.plainPasteBuffer = '';
466
+ this.plainRecentChunks = [];
467
+ this.resetPlainPasteBurst();
468
+ }
469
+ recordRecentPlainChunk(text, at) {
470
+ const windowStart = at - this.plainPasteWindowMs;
471
+ this.plainRecentChunks.push({ text, at });
472
+ this.plainRecentChunks = this.plainRecentChunks.filter(entry => entry.at >= windowStart);
473
+ }
474
+ pruneRecentPlainChunks() {
475
+ if (!this.plainRecentChunks.length)
476
+ return;
477
+ const now = Date.now();
478
+ const windowStart = now - this.plainPasteWindowMs;
479
+ this.plainRecentChunks = this.plainRecentChunks.filter(entry => entry.at >= windowStart);
480
+ }
481
+ reclaimRecentPlainChunks() {
482
+ if (!this.plainRecentChunks.length)
483
+ return '';
484
+ const combined = this.plainRecentChunks.map(entry => entry.text).join('');
485
+ this.plainRecentChunks = [];
486
+ return combined;
487
+ }
488
+ schedulePlainPasteCommit() {
489
+ if (this.plainPasteTimer) {
490
+ clearTimeout(this.plainPasteTimer);
491
+ }
492
+ this.plainPasteTimer = setTimeout(() => {
493
+ this.finalizePlainPaste();
494
+ }, this.plainPasteIdleMs);
495
+ }
496
+ finalizePlainPaste() {
497
+ if (!this.inPlainPaste)
498
+ return;
499
+ const content = this.plainPasteBuffer.replace(/\r\n?/g, '\n');
500
+ this.inPlainPaste = false;
501
+ this.plainPasteBuffer = '';
502
+ this.plainRecentChunks = [];
503
+ this.resetPlainPasteBurst();
504
+ if (this.plainPasteTimer) {
505
+ clearTimeout(this.plainPasteTimer);
506
+ this.plainPasteTimer = null;
507
+ }
508
+ if (!content)
509
+ return;
510
+ const lines = content.split('\n');
511
+ if (lines.length > 1 || content.length > 200) {
512
+ this.collapsedPaste = { text: content, lines: lines.length, chars: content.length };
513
+ this.buffer = '';
514
+ this.cursor = 0;
515
+ this.updateSuggestions();
516
+ this.renderPrompt();
517
+ this.emitInputChange();
518
+ return;
519
+ }
520
+ this.insertText(content);
369
521
  }
370
522
  insertText(text) {
371
523
  if (!text)
372
524
  return;
525
+ if (this.inPlainPaste) {
526
+ return;
527
+ }
373
528
  if (this.collapsedPaste) {
374
529
  this.expandCollapsedPaste();
375
530
  }
@@ -384,6 +539,24 @@ export class UnifiedUIRenderer extends EventEmitter {
384
539
  this.expandCollapsedPaste();
385
540
  return;
386
541
  }
542
+ if (this.inputCapture) {
543
+ const shouldTrim = this.inputCapture.options.trim;
544
+ const normalizedCapture = shouldTrim ? text.trim() : text;
545
+ if (!this.inputCapture.options.allowEmpty && !normalizedCapture) {
546
+ this.renderPrompt();
547
+ return;
548
+ }
549
+ const resolver = this.inputCapture;
550
+ this.inputCapture = null;
551
+ this.buffer = '';
552
+ this.cursor = 0;
553
+ this.inputRenderOffset = 0;
554
+ this.resetSuggestions();
555
+ this.renderPrompt();
556
+ this.emitInputChange();
557
+ resolver.resolve(normalizedCapture);
558
+ return;
559
+ }
387
560
  const normalized = text.trim();
388
561
  if (!normalized) {
389
562
  this.renderPrompt();
@@ -785,79 +958,59 @@ export class UnifiedUIRenderer extends EventEmitter {
785
958
  }
786
959
  // Rich mode: inline overlay anchored to current scrollback (no full-screen clear)
787
960
  this.updateTerminalSize();
961
+ const maxWidth = this.safeWidth();
962
+ if (this.lastRenderWidth !== null && maxWidth !== this.lastRenderWidth) {
963
+ // Terminal resized; force a clean anchor so the overlay doesn't jitter.
964
+ this.overlayInvalidated = true;
965
+ }
966
+ this.lastRenderWidth = maxWidth;
788
967
  const overlay = this.buildOverlayLines();
789
968
  if (!overlay.lines.length) {
790
969
  return;
791
970
  }
792
- const lines = overlay.lines;
793
- const promptIndex = Math.max(0, Math.min(overlay.promptIndex, lines.length - 1));
794
- const height = lines.length;
795
- const prevHeight = this.lastOverlay?.lines.length ?? this.lastOverlayHeight ?? 0;
796
- const prevPromptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex ?? promptIndex;
797
- const needsFreshAnchor = this.overlayInvalidated || !this.hasRenderedPrompt;
798
- // For first render, ensure we start on a fresh line
799
- if (needsFreshAnchor) {
800
- if (!this.lastOutputEndedWithNewline) {
801
- this.write('\n');
802
- this.lastOutputEndedWithNewline = true;
803
- }
804
- // Write blank lines to push the overlay below scrollback
805
- // This ensures the overlay doesn't mix with scrollback content
806
- for (let i = 0; i < height; i++) {
807
- this.write('\n');
808
- }
809
- // Move cursor back up to where we want to start the overlay
810
- if (height > 0) {
811
- this.write(`\x1b[${height}A`);
812
- }
813
- this.write('\r');
971
+ const renderedLines = overlay.lines.map(line => this.truncateLine(line, maxWidth));
972
+ if (!renderedLines.length) {
973
+ return;
814
974
  }
815
- else {
816
- // Move to the top of the previous overlay relative to the prompt row
817
- if (prevPromptIndex > 0) {
818
- this.write(`\x1b[${prevPromptIndex}A`);
819
- }
820
- this.write('\r');
821
- // Clear previous overlay footprint
822
- const rowsToClear = Math.max(prevHeight, height);
823
- for (let idx = 0; idx < rowsToClear; idx++) {
824
- this.write(ESC.CLEAR_LINE);
825
- if (idx < rowsToClear - 1) {
826
- this.write('\n');
827
- }
828
- }
829
- if (rowsToClear > 1) {
830
- this.write(`\x1b[${rowsToClear - 1}A`);
831
- }
832
- this.write('\r');
975
+ let promptIndex = Math.max(0, Math.min(overlay.promptIndex, renderedLines.length - 1));
976
+ let height = renderedLines.length;
977
+ // Keep at least one free line below the overlay so typing always has breathing room
978
+ const bottomPadding = this.overlayBottomPadding;
979
+ const totalRows = this.rows || 24;
980
+ const availableRows = Math.max(1, totalRows - bottomPadding);
981
+ if (height > availableRows) {
982
+ renderedLines.splice(availableRows);
983
+ height = renderedLines.length;
984
+ promptIndex = Math.max(0, Math.min(promptIndex, height - 1));
985
+ }
986
+ const startRow = Math.max(1, availableRows - height + 1);
987
+ const promptRow = startRow + promptIndex;
988
+ const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols || 80);
989
+ // Clear any previous overlay footprint (status, prompt, controls) to avoid leaking into scrollback
990
+ this.clearOverlayRows(height, startRow);
991
+ if (bottomPadding > 0 && startRow + height <= totalRows) {
992
+ this.write(ESC.TO(startRow + height, 1));
993
+ this.write(ESC.CLEAR_LINE);
833
994
  }
834
- // Write overlay lines in place (relative to current scrollback)
995
+ // Render overlay lines in place without pushing scrollback
835
996
  for (let idx = 0; idx < height; idx++) {
836
- const line = this.truncateLine(lines[idx] ?? '', this.safeWidth());
997
+ const row = startRow + idx;
998
+ const line = renderedLines[idx] ?? '';
999
+ this.write(ESC.TO(row, 1));
837
1000
  this.write(ESC.CLEAR_LINE);
838
- this.write(line);
839
- if (idx < height - 1) {
840
- this.write('\n');
1001
+ if (line) {
1002
+ this.write(line);
841
1003
  }
842
1004
  }
843
1005
  // Position cursor at prompt row/col
844
- const fromBottomToPrompt = height - 1 - promptIndex;
845
- if (fromBottomToPrompt > 0) {
846
- this.write(`\x1b[${fromBottomToPrompt}A`);
847
- }
848
- else if (fromBottomToPrompt < 0) {
849
- this.write(`\x1b[${-fromBottomToPrompt}B`);
850
- }
851
- const promptCol = Math.min(Math.max(1, 3 + this.cursor), this.cols);
852
- this.write('\r');
853
- this.write(ESC.TO_COL(promptCol));
1006
+ this.write(ESC.TO(promptRow, promptCol));
854
1007
  this.cursorVisibleColumn = promptCol;
855
1008
  this.hasRenderedPrompt = true;
856
1009
  this.hasEverRenderedOverlay = true; // Mark that we've rendered at least once
857
1010
  this.isPromptActive = true;
858
1011
  this.lastOverlayHeight = height;
859
1012
  this.lastPromptIndex = promptIndex;
860
- this.lastOverlay = { lines, promptIndex };
1013
+ this.lastOverlay = { lines: renderedLines, promptIndex };
861
1014
  this.overlayInvalidated = false;
862
1015
  this.lastOutputEndedWithNewline = true;
863
1016
  this.promptHeight = height;
@@ -865,6 +1018,8 @@ export class UnifiedUIRenderer extends EventEmitter {
865
1018
  buildOverlayLines() {
866
1019
  const lines = [];
867
1020
  const maxWidth = this.safeWidth();
1021
+ // Top border to frame status/meta block
1022
+ lines.push(this.truncateLine(renderDivider(Math.min(maxWidth, 96), 'status'), maxWidth));
868
1023
  const chromeLines = this.buildChromeLines();
869
1024
  for (const line of chromeLines) {
870
1025
  lines.push(this.truncateLine(line, maxWidth));
@@ -895,12 +1050,10 @@ export class UnifiedUIRenderer extends EventEmitter {
895
1050
  return { lines, promptIndex };
896
1051
  }
897
1052
  buildChromeLines() {
898
- const parts = [...this.buildStatusParts(), ...this.buildMetaParts()];
899
- if (parts.length === 0) {
900
- return [];
901
- }
902
1053
  const maxWidth = this.safeWidth();
903
- return renderStatusLines(parts, maxWidth).map((line) => this.truncateLine(line, maxWidth));
1054
+ const statusLines = this.buildStatusBlock(maxWidth);
1055
+ const metaLines = this.buildMetaBlock(maxWidth);
1056
+ return [...statusLines, ...metaLines];
904
1057
  }
905
1058
  abbreviatePath(pathValue) {
906
1059
  const home = homedir();
@@ -909,54 +1062,116 @@ export class UnifiedUIRenderer extends EventEmitter {
909
1062
  }
910
1063
  return pathValue;
911
1064
  }
912
- buildStatusParts() {
913
- const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
914
- const label = statuses.length > 0 ? statuses.join(' • ') : 'Ready for prompts';
915
- const isReady = label.toLowerCase().includes('ready');
916
- const tone = isReady ? 'success' : 'info';
917
- const parts = [{ text: `● ${label}`, tone }];
1065
+ buildStatusBlock(maxWidth) {
1066
+ const statusLabel = this.composeStatusLabel();
1067
+ if (!statusLabel) {
1068
+ return [];
1069
+ }
1070
+ const segments = [];
1071
+ segments.push(`${theme.ui.muted('status')} ${this.applyTone(statusLabel.text, statusLabel.tone)}`);
918
1072
  if (this.statusMeta.sessionTime) {
919
- parts.push({ text: `runtime ${this.statusMeta.sessionTime}`, tone: 'muted' });
1073
+ segments.push(`${theme.ui.muted('runtime')} ${theme.ui.muted(this.statusMeta.sessionTime)}`);
920
1074
  }
921
1075
  if (this.statusMeta.contextPercent !== undefined) {
922
1076
  const ctx = this.statusMeta.contextPercent;
923
- const ctxTone = ctx > 90 ? 'error' : ctx > 70 ? 'warn' : 'muted';
924
- parts.push({ text: `ctx ${ctx}%`, tone: ctxTone });
1077
+ const tone = ctx > 90 ? 'error' : ctx > 70 ? 'warn' : 'muted';
1078
+ const color = tone === 'error' ? theme.error : tone === 'warn' ? theme.warning : theme.ui.muted;
1079
+ segments.push(`${theme.ui.muted('ctx')} ${color(`${ctx}%`)}`);
925
1080
  }
926
- return parts;
1081
+ return this.wrapSegments(segments, maxWidth);
927
1082
  }
928
- buildMetaParts() {
929
- const parts = [];
1083
+ buildMetaBlock(maxWidth) {
1084
+ const segments = [];
930
1085
  if (this.statusMeta.profile) {
931
- parts.push({ text: `profile ${this.statusMeta.profile}`, tone: 'info' });
1086
+ segments.push(this.formatMetaSegment('profile', this.statusMeta.profile, 'info'));
932
1087
  }
933
1088
  const model = this.statusMeta.provider && this.statusMeta.model
934
- ? `${this.statusMeta.provider} · ${this.statusMeta.model}`
1089
+ ? `${this.statusMeta.provider} / ${this.statusMeta.model}`
935
1090
  : this.statusMeta.model || this.statusMeta.provider;
936
1091
  if (model) {
937
- parts.push({ text: `model ${model}`, tone: 'info' });
1092
+ segments.push(this.formatMetaSegment('model', model, 'info'));
938
1093
  }
939
1094
  const workspace = this.statusMeta.workspace || this.statusMeta.directory;
940
1095
  if (workspace) {
941
- parts.push({ text: `dir ${this.abbreviatePath(workspace)}`, tone: 'muted' });
1096
+ segments.push(this.formatMetaSegment('dir', this.abbreviatePath(workspace), 'muted'));
942
1097
  }
943
1098
  if (this.statusMeta.writes) {
944
- parts.push({ text: `writes ${this.statusMeta.writes}`, tone: 'muted' });
1099
+ segments.push(this.formatMetaSegment('writes', this.statusMeta.writes, 'muted'));
945
1100
  }
946
1101
  if (this.statusMeta.toolSummary) {
947
- parts.push({ text: this.statusMeta.toolSummary, tone: 'muted' });
1102
+ segments.push(this.formatMetaSegment('tools', this.statusMeta.toolSummary, 'muted'));
948
1103
  }
949
- const sessionLabel = this.statusMeta.sessionLabel;
950
- if (sessionLabel) {
951
- parts.push({ text: `session ${sessionLabel}`, tone: 'muted' });
1104
+ if (this.statusMeta.sessionLabel) {
1105
+ segments.push(this.formatMetaSegment('session', this.statusMeta.sessionLabel, 'muted'));
952
1106
  }
953
1107
  if (this.statusMeta.version) {
954
- parts.push({ text: `v${this.statusMeta.version}`, tone: 'muted' });
1108
+ segments.push(this.formatMetaSegment('build', `v${this.statusMeta.version}`, 'muted'));
955
1109
  }
956
- if (parts.length === 0) {
1110
+ if (segments.length === 0) {
957
1111
  return [];
958
1112
  }
959
- return parts;
1113
+ return this.wrapSegments(segments, maxWidth);
1114
+ }
1115
+ composeStatusLabel() {
1116
+ const statuses = [this.statusStreaming, this.statusOverride, this.statusMessage].filter((value, index, arr) => Boolean(value) && arr.indexOf(value) === index);
1117
+ const text = statuses.length > 0 ? statuses.join(' / ') : 'Ready for prompts';
1118
+ if (!text.trim()) {
1119
+ return null;
1120
+ }
1121
+ const normalized = text.toLowerCase();
1122
+ const tone = normalized.includes('ready') ? 'success' : 'info';
1123
+ return { text, tone };
1124
+ }
1125
+ formatMetaSegment(label, value, tone) {
1126
+ const colorizer = tone === 'success'
1127
+ ? theme.success
1128
+ : tone === 'warn'
1129
+ ? theme.warning
1130
+ : tone === 'error'
1131
+ ? theme.error
1132
+ : tone === 'muted'
1133
+ ? theme.ui.muted
1134
+ : theme.info;
1135
+ return `${theme.ui.muted(label)} ${colorizer(value)}`;
1136
+ }
1137
+ applyTone(text, tone) {
1138
+ switch (tone) {
1139
+ case 'success':
1140
+ return theme.success(text);
1141
+ case 'warn':
1142
+ return theme.warning(text);
1143
+ case 'error':
1144
+ return theme.error(text);
1145
+ case 'info':
1146
+ default:
1147
+ return theme.info(text);
1148
+ }
1149
+ }
1150
+ wrapSegments(segments, maxWidth) {
1151
+ const lines = [];
1152
+ const separator = theme.ui.muted(' | ');
1153
+ let current = '';
1154
+ for (const segment of segments) {
1155
+ const normalized = segment.trim();
1156
+ if (!normalized)
1157
+ continue;
1158
+ if (!current) {
1159
+ current = this.truncateLine(normalized, maxWidth);
1160
+ continue;
1161
+ }
1162
+ const candidate = `${current}${separator}${normalized}`;
1163
+ if (this.visibleLength(candidate) <= maxWidth) {
1164
+ current = candidate;
1165
+ }
1166
+ else {
1167
+ lines.push(this.truncateLine(current, maxWidth));
1168
+ current = this.truncateLine(normalized, maxWidth);
1169
+ }
1170
+ }
1171
+ if (current) {
1172
+ lines.push(this.truncateLine(current, maxWidth));
1173
+ }
1174
+ return lines;
960
1175
  }
961
1176
  buildControlLines() {
962
1177
  const lines = [];
@@ -1084,6 +1299,37 @@ export class UnifiedUIRenderer extends EventEmitter {
1084
1299
  this.renderPrompt();
1085
1300
  this.emitInputChange();
1086
1301
  }
1302
+ captureInput(options = {}) {
1303
+ if (this.inputCapture) {
1304
+ return Promise.reject(new Error('Input capture already in progress'));
1305
+ }
1306
+ if (options.resetBuffer) {
1307
+ this.buffer = '';
1308
+ this.cursor = 0;
1309
+ this.inputRenderOffset = 0;
1310
+ this.resetSuggestions();
1311
+ this.renderPrompt();
1312
+ this.emitInputChange();
1313
+ }
1314
+ return new Promise((resolve, reject) => {
1315
+ this.inputCapture = {
1316
+ resolve,
1317
+ reject,
1318
+ options: {
1319
+ trim: options.trim !== false,
1320
+ allowEmpty: options.allowEmpty ?? false,
1321
+ },
1322
+ };
1323
+ });
1324
+ }
1325
+ cancelInputCapture(reason) {
1326
+ if (!this.inputCapture) {
1327
+ return;
1328
+ }
1329
+ const capture = this.inputCapture;
1330
+ this.inputCapture = null;
1331
+ capture.reject?.(reason ?? new Error('Input capture cancelled'));
1332
+ }
1087
1333
  // ------------ Helpers ------------
1088
1334
  safeWidth() {
1089
1335
  const cols = this.output.isTTY ? this.cols || 80 : 80;
@@ -1139,6 +1385,17 @@ export class UnifiedUIRenderer extends EventEmitter {
1139
1385
  }
1140
1386
  return result;
1141
1387
  }
1388
+ clearOverlayRows(rows, startRow) {
1389
+ const totalRows = this.rows || 24;
1390
+ const limit = Math.max(0, Math.min(rows, totalRows));
1391
+ for (let idx = 0; idx < limit; idx++) {
1392
+ const row = startRow + idx;
1393
+ if (row < 1 || row > totalRows)
1394
+ continue;
1395
+ this.write(ESC.TO(row, 1));
1396
+ this.write(ESC.CLEAR_LINE);
1397
+ }
1398
+ }
1142
1399
  getBuffer() {
1143
1400
  return this.buffer;
1144
1401
  }
@@ -1154,6 +1411,7 @@ export class UnifiedUIRenderer extends EventEmitter {
1154
1411
  this.emitInputChange();
1155
1412
  }
1156
1413
  clearBuffer() {
1414
+ this.cancelPlainPasteCapture();
1157
1415
  this.buffer = '';
1158
1416
  this.cursor = 0;
1159
1417
  this.inputRenderOffset = 0;
@@ -1205,28 +1463,26 @@ export class UnifiedUIRenderer extends EventEmitter {
1205
1463
  this.addEvent('prompt', normalized);
1206
1464
  }
1207
1465
  clearPromptArea() {
1208
- const rows = Math.max(this.promptHeight, this.lastOverlay?.lines.length ?? 0);
1209
- if (rows === 0)
1466
+ const height = this.lastOverlay?.lines.length ?? this.promptHeight ?? 0;
1467
+ if (height === 0)
1210
1468
  return;
1211
- const promptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex ?? 0;
1212
- if (promptIndex > 0) {
1213
- this.write(`\x1b[${promptIndex}A`);
1214
- }
1215
- this.write('\r');
1216
- for (let i = 0; i < rows; i++) {
1469
+ this.updateTerminalSize();
1470
+ const totalRows = this.rows || 24;
1471
+ const startRow = Math.max(1, Math.max(1, totalRows - this.overlayBottomPadding) - height + 1);
1472
+ this.clearOverlayRows(height, startRow);
1473
+ // Keep the padding row clean as well
1474
+ const paddingRow = startRow + height;
1475
+ if (this.overlayBottomPadding > 0 && paddingRow <= totalRows) {
1476
+ this.write(ESC.TO(paddingRow, 1));
1217
1477
  this.write(ESC.CLEAR_LINE);
1218
- if (i < rows - 1) {
1219
- this.write('\n');
1220
- }
1221
1478
  }
1222
- if (rows > 1) {
1223
- this.write(`\x1b[${rows - 1}A`);
1224
- }
1225
- this.write('\r');
1226
- this.lastOverlayHeight = rows;
1227
- this.lastPromptIndex = promptIndex;
1479
+ // Move cursor to the bottom ready for new scrollback output
1480
+ this.write(ESC.TO(totalRows, 1));
1481
+ this.lastOverlayHeight = height;
1482
+ this.lastPromptIndex = this.lastOverlay?.promptIndex ?? this.lastPromptIndex;
1228
1483
  this.lastOverlay = null;
1229
1484
  this.overlayInvalidated = true;
1485
+ this.promptHeight = 0;
1230
1486
  }
1231
1487
  updateTerminalSize() {
1232
1488
  if (this.output.isTTY) {