Fix markdown streaming duplication by splitting newlines first

- Added string-width library for proper terminal column width calculation
- Fixed wrapLine() to split by newlines before wrapping (like Text component)
- Fixed Loader interval leak by stopping before container removal
- Changed loader message from 'Loading...' to 'Working...'
This commit is contained in:
Mario Zechner 2025-11-11 19:27:58 +01:00
parent 985f955ea0
commit c5083bb7cb
16 changed files with 429 additions and 372 deletions

View file

@ -40,7 +40,8 @@
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
"mime-types": "^3.0.1",
"string-width": "^8.1.0"
},
"devDependencies": {
"@xterm/headless": "^5.5.0",

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
/**
* Input component - single-line text input with horizontal scrolling
@ -127,8 +127,8 @@ export class Input implements Component {
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + cursorChar + afterCursor;
// Calculate visual width (strip ANSI codes to measure actual displayed characters)
const visualLength = stripVTControlCharacters(textWithCursor).length;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = prompt + textWithCursor + padding;

View file

@ -1,7 +1,7 @@
import { stripVTControlCharacters } from "node:util";
import chalk from "chalk";
import { marked, type Token } from "marked";
import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
type Color =
| "black"
@ -109,8 +109,8 @@ export class Markdown implements Component {
const paddedLines: string[] = [];
for (const line of wrappedLines) {
// Calculate visible length (strip ANSI codes)
const visibleLength = stripVTControlCharacters(line).length;
// Calculate visible length
const visibleLength = visibleWidth(line);
// Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength);
@ -328,12 +328,26 @@ export class Markdown implements Component {
return [""];
}
// If line fits within width, return as-is
const visibleLength = stripVTControlCharacters(line).length;
if (visibleLength <= width) {
return [line];
// Split by newlines first - wrap each line individually
const splitLines = line.split("\n");
for (const splitLine of splitLines) {
const visibleLength = visibleWidth(splitLine);
if (visibleLength <= width) {
wrapped.push(splitLine);
continue;
}
// This line needs wrapping
wrapped.push(...this.wrapSingleLine(splitLine, width));
}
return wrapped.length > 0 ? wrapped : [""];
}
private wrapSingleLine(line: string, width: number): string[] {
const wrapped: string[] = [];
// Track active ANSI codes to preserve them across wrapped lines
const activeAnsiCodes: string[] = [];
let currentLine = "";
@ -381,8 +395,10 @@ export class Markdown implements Component {
}
currentLength = 0;
}
currentLine += line[i];
currentLength++;
const char = line[i];
currentLine += char;
// Count actual terminal column width, not string length
currentLength += visibleWidth(char);
i++;
}
}

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from "node:util";
import type { Component } from "../tui.js";
import { visibleWidth } from "../utils.js";
/**
* Text component - displays multi-line text with word wrapping
@ -50,7 +50,10 @@ export class Text implements Component {
const textLines = this.text.split("\n");
for (const line of textLines) {
if (line.length <= contentWidth) {
// Measure visible length (strip ANSI codes)
const visibleLineLength = visibleWidth(line);
if (visibleLineLength <= contentWidth) {
lines.push(line);
} else {
// Word wrap
@ -58,9 +61,12 @@ export class Text implements Component {
let currentLine = "";
for (const word of words) {
if (currentLine.length === 0) {
const currentVisible = visibleWidth(currentLine);
const wordVisible = visibleWidth(word);
if (currentVisible === 0) {
currentLine = word;
} else if (currentLine.length + 1 + word.length <= contentWidth) {
} else if (currentVisible + 1 + wordVisible <= contentWidth) {
currentLine += " " + word;
} else {
lines.push(currentLine);
@ -80,7 +86,7 @@ export class Text implements Component {
for (const line of lines) {
// Calculate visible length (strip ANSI codes)
const visibleLength = stripVTControlCharacters(line).length;
const visibleLength = visibleWidth(line);
// Right padding to fill to width (accounting for left padding and content)
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
const rightPad = " ".repeat(rightPadLength);

View file

@ -17,4 +17,6 @@ export { Spacer } from "./components/spacer.js";
export { Text } from "./components/text.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
export { Component, Container, TUI } from "./tui.js";
export { type Component, Container, TUI } from "./tui.js";
// Utilities
export { visibleWidth } from "./utils.js";

View file

@ -3,6 +3,7 @@
*/
import type { Terminal } from "./terminal.js";
import { visibleWidth } from "./utils.js";
/**
* Component interface - all components must implement this
@ -21,6 +22,8 @@ export interface Component {
handleInput?(data: string): void;
}
export { visibleWidth };
/**
* Container - a component that contains other components
*/
@ -211,6 +214,9 @@ export class TUI extends Container {
// Render from first changed line to end
for (let i = firstChanged; i < newLines.length; i++) {
if (i > firstChanged) buffer += "\r\n";
if (visibleWidth(newLines[i]) > width) {
throw new Error("Rendered line exceeds terminal width");
}
buffer += newLines[i];
}

12
packages/tui/src/utils.ts Normal file
View file

@ -0,0 +1,12 @@
import stringWidth from "string-width";
/**
* Calculate the visible width of a string in terminal columns.
* This correctly handles:
* - ANSI escape codes (ignored)
* - Emojis and wide characters (counted as 2 columns)
* - Combining characters (counted correctly)
*/
export function visibleWidth(str: string): number {
return stringWidth(str);
}