|
11 | 11 | */ |
12 | 12 |
|
13 | 13 | 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 | +} |
14 | 67 |
|
15 | 68 | /** |
16 | 69 | * ANSI escape codes for terminal control |
@@ -194,16 +247,17 @@ export class ViewportRenderer { |
194 | 247 | this.write(ANSI.cursorToStart); |
195 | 248 |
|
196 | 249 | // Clear and redraw each line |
| 250 | + // Truncate lines to terminal width to prevent soft-wrap which desyncs cursor position |
197 | 251 | for (let i = 0; i < lines.length; i++) { |
198 | 252 | this.write(ANSI.clearLine); |
199 | | - this.write(lines[i]); |
| 253 | + this.write(truncateToWidth(lines[i], this.terminalWidth)); |
200 | 254 | if (i < lines.length - 1) { |
201 | 255 | this.write('\n'); |
202 | 256 | } |
203 | 257 | } |
204 | 258 |
|
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 |
207 | 261 |
|
208 | 262 | return this; |
209 | 263 | } |
|
0 commit comments