Skip to content

Commit fe56bad

Browse files
committed
fix(inquirerer): prevent viewport scroll and soft-wrap issues
- Remove trailing newline after render to prevent scroll when at bottom row - Add truncateToWidth() to prevent soft-wrap which desyncs cursor position - Lines are now truncated to terminal width before rendering
1 parent a2a92f2 commit fe56bad

File tree

1 file changed

+57
-3
lines changed

1 file changed

+57
-3
lines changed

packages/inquirerer/src/ui/viewport.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,59 @@
1111
*/
1212

1313
import { Writable } from 'stream';
14+
import stringWidth from 'string-width';
15+
16+
/**
17+
* Truncate a string to fit within a given display width
18+
* Handles ANSI escape codes and wide characters correctly
19+
*/
20+
function truncateToWidth(str: string, maxWidth: number): string {
21+
// Quick check - if no ANSI codes and ASCII only, use simple truncation
22+
const hasAnsi = /\x1B\[/.test(str);
23+
if (!hasAnsi && stringWidth(str) <= maxWidth) {
24+
return str;
25+
}
26+
27+
// For strings with ANSI codes or wide chars, we need to be careful
28+
let result = '';
29+
let currentWidth = 0;
30+
let inEscape = false;
31+
let escapeBuffer = '';
32+
33+
for (const char of str) {
34+
if (inEscape) {
35+
escapeBuffer += char;
36+
// Check if escape sequence is complete (ends with letter)
37+
if (/[a-zA-Z]/.test(char)) {
38+
result += escapeBuffer;
39+
escapeBuffer = '';
40+
inEscape = false;
41+
}
42+
continue;
43+
}
44+
45+
if (char === '\x1B') {
46+
inEscape = true;
47+
escapeBuffer = char;
48+
continue;
49+
}
50+
51+
const charWidth = stringWidth(char);
52+
if (currentWidth + charWidth > maxWidth) {
53+
break;
54+
}
55+
56+
result += char;
57+
currentWidth += charWidth;
58+
}
59+
60+
// Reset any open ANSI styles at the end
61+
if (hasAnsi) {
62+
result += '\x1B[0m';
63+
}
64+
65+
return result;
66+
}
1467

1568
/**
1669
* ANSI escape codes for terminal control
@@ -194,16 +247,17 @@ export class ViewportRenderer {
194247
this.write(ANSI.cursorToStart);
195248

196249
// Clear and redraw each line
250+
// Truncate lines to terminal width to prevent soft-wrap which desyncs cursor position
197251
for (let i = 0; i < lines.length; i++) {
198252
this.write(ANSI.clearLine);
199-
this.write(lines[i]);
253+
this.write(truncateToWidth(lines[i], this.terminalWidth));
200254
if (i < lines.length - 1) {
201255
this.write('\n');
202256
}
203257
}
204258

205-
// Move cursor to end of viewport (for next render)
206-
this.write('\n');
259+
// Position cursor at end of last line (do NOT emit newline to avoid scroll)
260+
// The next render will move cursor back up anyway
207261

208262
return this;
209263
}

0 commit comments

Comments
 (0)