From 5f19dd62c731dbac094de67324e059e1bf2c92d4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Nov 2025 00:13:46 +0100 Subject: [PATCH] Fix terminal rendering and add improvements - Enable bracketed paste mode for handling large pastes - Fix editor duplicate line rendering bug at terminal width - Add padding support to Markdown component (paddingX/Y) - Add Spacer component for vertical spacing - Update chat-simple with spacers between messages --- packages/tui/src/components-new/editor.ts | 78 ++++++++++++--- packages/tui/src/components-new/markdown.ts | 101 ++++++++++++++------ packages/tui/src/terminal.ts | 6 ++ packages/tui/test/chat-simple.ts | 19 +--- packages/tui/test/virtual-terminal.ts | 5 +- 5 files changed, 148 insertions(+), 61 deletions(-) diff --git a/packages/tui/src/components-new/editor.ts b/packages/tui/src/components-new/editor.ts index 187cae09..f5f6a429 100644 --- a/packages/tui/src/components-new/editor.ts +++ b/packages/tui/src/components-new/editor.ts @@ -38,6 +38,10 @@ export class Editor implements Component { private pastes: Map = new Map(); private pasteCounter: number = 0; + // Bracketed paste mode buffering + private pasteBuffer: string = ""; + private isInPaste: boolean = false; + public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; @@ -84,11 +88,23 @@ export class Editor implements Component { displayText = before + cursor + restAfter; // visibleLength stays the same - we're replacing, not adding } else { - // Cursor is at the end - add highlighted space - const cursor = "\x1b[7m \x1b[0m"; - displayText = before + cursor; - // visibleLength increases by 1 - we're adding a space - visibleLength = layoutLine.text.length + 1; + // Cursor is at the end - check if we have room for the space + if (layoutLine.text.length < width) { + // We have room - add highlighted space + const cursor = "\x1b[7m \x1b[0m"; + displayText = before + cursor; + // visibleLength increases by 1 - we're adding a space + visibleLength = layoutLine.text.length + 1; + } else { + // Line is at full width - use reverse video on last character if possible + // or just show cursor at the end without adding space + if (before.length > 0) { + const lastChar = before[before.length - 1]; + const cursor = `\x1b[7m${lastChar}\x1b[0m`; + displayText = before.slice(0, -1) + cursor; + } + // visibleLength stays the same + } } } @@ -112,6 +128,49 @@ export class Editor implements Component { } handleInput(data: string): void { + // Handle bracketed paste mode + // Start of paste: \x1b[200~ + // End of paste: \x1b[201~ + + // Check if we're starting a bracketed paste + if (data.includes("\x1b[200~")) { + this.isInPaste = true; + this.pasteBuffer = ""; + // Remove the start marker and keep the rest + data = data.replace("\x1b[200~", ""); + } + + // If we're in a paste, buffer the data + if (this.isInPaste) { + // Append data to buffer first (end marker could be split across chunks) + this.pasteBuffer += data; + + // Check if the accumulated buffer contains the end marker + const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + if (endIndex !== -1) { + // Extract content before the end marker + const pasteContent = this.pasteBuffer.substring(0, endIndex); + + // Process the complete paste + this.handlePaste(pasteContent); + + // Reset paste state + this.isInPaste = false; + + // Process any remaining data after the end marker + const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + this.pasteBuffer = ""; + + if (remaining.length > 0) { + this.handleInput(remaining); + } + return; + } else { + // Still accumulating, wait for more data + return; + } + } + // Handle special key combinations first // Ctrl+C - Exit (let parent handle this) @@ -119,13 +178,6 @@ export class Editor implements Component { return; } - // Handle paste - detect when we get a lot of text at once - const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n")); - if (isPaste) { - this.handlePaste(data); - return; - } - // Handle autocomplete special keys first (but don't block other input) if (this.isAutocompleting && this.autocompleteList) { // Escape - cancel autocomplete @@ -321,7 +373,7 @@ export class Editor implements Component { const chunkStart = chunkIndex * maxLineLength; const chunkEnd = chunkStart + chunk.length; const cursorPos = this.state.cursorCol; - const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd; + const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos <= chunkEnd; if (hasCursorInChunk) { layoutLines.push({ diff --git a/packages/tui/src/components-new/markdown.ts b/packages/tui/src/components-new/markdown.ts index d751dbfe..2a46578e 100644 --- a/packages/tui/src/components-new/markdown.ts +++ b/packages/tui/src/components-new/markdown.ts @@ -28,17 +28,28 @@ export class Markdown implements Component { private bgColor?: Color; private fgColor?: Color; private customBgRgb?: { r: number; g: number; b: number }; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding // Cache for rendered output private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; - constructor(text: string = "", bgColor?: Color, fgColor?: Color, customBgRgb?: { r: number; g: number; b: number }) { + constructor( + text: string = "", + bgColor?: Color, + fgColor?: Color, + customBgRgb?: { r: number; g: number; b: number }, + paddingX: number = 1, + paddingY: number = 1, + ) { this.text = text; this.bgColor = bgColor; this.fgColor = fgColor; this.customBgRgb = customBgRgb; + this.paddingX = paddingX; + this.paddingY = paddingY; } setText(text: string): void { @@ -71,6 +82,9 @@ export class Markdown implements Component { return this.cachedLines; } + // Calculate available width for content (subtract horizontal padding) + const contentWidth = Math.max(1, width - this.paddingX * 2); + // Parse markdown to HTML-like tokens const tokens = marked.lexer(this.text); @@ -80,54 +94,79 @@ export class Markdown implements Component { for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const nextToken = tokens[i + 1]; - const tokenLines = this.renderToken(token, width, nextToken?.type); + const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); renderedLines.push(...tokenLines); } - // Wrap lines to fit width + // Wrap lines to fit content width const wrappedLines: string[] = []; for (const line of renderedLines) { - wrappedLines.push(...this.wrapLine(line, width)); + wrappedLines.push(...this.wrapLine(line, contentWidth)); } - // Apply background and foreground colors if specified - let result: string[]; - if (this.bgColor || this.fgColor || this.customBgRgb) { - const coloredLines: string[] = []; - for (const line of wrappedLines) { - // Calculate visible length (strip ANSI codes) - const visibleLength = stripVTControlCharacters(line).length; - const padding = " ".repeat(Math.max(0, width - visibleLength)); + // Add padding and apply colors + const leftPad = " ".repeat(this.paddingX); + const paddedLines: string[] = []; - // Apply colors - let coloredLine = line + padding; + for (const line of wrappedLines) { + // Calculate visible length (strip ANSI codes) + const visibleLength = stripVTControlCharacters(line).length; + // Right padding to fill to width (accounting for left padding) + const rightPadLength = Math.max(0, width - visibleLength - this.paddingX * 2); + const rightPad = " ".repeat(rightPadLength); - // Apply foreground color first if specified - if (this.fgColor) { - coloredLine = (chalk as any)[this.fgColor](coloredLine); - } + // Add left padding, content, and right padding + let paddedLine = leftPad + line + rightPad; - // Apply background color if specified - if (this.customBgRgb) { - // Use custom RGB background - coloredLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(coloredLine); - } else if (this.bgColor) { - coloredLine = (chalk as any)[this.bgColor](coloredLine); - } - - coloredLines.push(coloredLine); + // Apply foreground color if specified + if (this.fgColor) { + paddedLine = (chalk as any)[this.fgColor](paddedLine); } - result = coloredLines.length > 0 ? coloredLines : [""]; - } else { - result = wrappedLines.length > 0 ? wrappedLines : [""]; + + // Apply background color if specified + if (this.customBgRgb) { + paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine); + } else if (this.bgColor) { + paddedLine = (chalk as any)[this.bgColor](paddedLine); + } + + paddedLines.push(paddedLine); } + // Add top padding (empty lines) + const emptyLine = " ".repeat(width); + const topPadding: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + let emptyPaddedLine = emptyLine; + if (this.customBgRgb) { + emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine); + } else if (this.bgColor) { + emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine); + } + topPadding.push(emptyPaddedLine); + } + + // Add bottom padding (empty lines) + const bottomPadding: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + let emptyPaddedLine = emptyLine; + if (this.customBgRgb) { + emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine); + } else if (this.bgColor) { + emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine); + } + bottomPadding.push(emptyPaddedLine); + } + + // Combine top padding, content, and bottom padding + const result = [...topPadding, ...paddedLines, ...bottomPadding]; + // Update cache this.cachedText = this.text; this.cachedWidth = width; this.cachedLines = result; - return result; + return result.length > 0 ? result : [""]; } private renderToken(token: Token, width: number, nextTokenType?: string): string[] { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index fa13fab1..68c4d4e7 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -48,12 +48,18 @@ export class ProcessTerminal implements Terminal { process.stdin.setEncoding("utf8"); process.stdin.resume(); + // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ + process.stdout.write("\x1b[?2004h"); + // Set up event handlers process.stdin.on("data", this.inputHandler); process.stdout.on("resize", this.resizeHandler); } stop(): void { + // Disable bracketed paste mode + process.stdout.write("\x1b[?2004l"); + // Remove event handlers if (this.inputHandler) { process.stdin.removeListener("data", this.inputHandler); diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 74f079d1..3fdec7e9 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -84,16 +84,12 @@ editor.onSubmit = (value: string) => { // Insert before the editor (which is last) const children = tui.children; children.splice(children.length - 1, 0, userMessage); - - // Add spacer after user message children.splice(children.length - 1, 0, new Spacer()); // Add loader const loader = new Loader(tui, "Thinking..."); - children.splice(children.length - 1, 0, loader); - - // Add spacer after loader const loaderSpacer = new Spacer(); + children.splice(children.length - 1, 0, loader); children.splice(children.length - 1, 0, loaderSpacer); tui.requestRender(); @@ -101,15 +97,8 @@ editor.onSubmit = (value: string) => { // Simulate a 1 second delay setTimeout(() => { // Remove loader and its spacer - const loaderIndex = children.indexOf(loader); - if (loaderIndex !== -1) { - children.splice(loaderIndex, 1); - loader.stop(); - } - const loaderSpacerIndex = children.indexOf(loaderSpacer); - if (loaderSpacerIndex !== -1) { - children.splice(loaderSpacerIndex, 1); - } + tui.removeChild(loader); + tui.removeChild(loaderSpacer); // Simulate a response const responses = [ @@ -127,8 +116,6 @@ editor.onSubmit = (value: string) => { // Add assistant message with no background (transparent) const botMessage = new Markdown(randomResponse); children.splice(children.length - 1, 0, botMessage); - - // Add spacer after assistant message children.splice(children.length - 1, 0, new Spacer()); // Re-enable submit diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 74ccbbfc..08673d3f 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -32,10 +32,13 @@ export class VirtualTerminal implements Terminal { start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; this.resizeHandler = onResize; - // No need for raw mode in virtual terminal + // Enable bracketed paste mode for consistency with ProcessTerminal + this.xterm.write("\x1b[?2004h"); } stop(): void { + // Disable bracketed paste mode + this.xterm.write("\x1b[?2004l"); this.inputHandler = undefined; this.resizeHandler = undefined; }