mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
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:
parent
985f955ea0
commit
c5083bb7cb
16 changed files with 429 additions and 372 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
12
packages/tui/src/utils.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue