diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7dd124c0a..3c9035c08 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added tmux support for Kitty graphics protocol using unicode placeholders, enabling inline images to render correctly inside tmux without getting stuck during screen redraws ([#908](https://github.com/badlogic/pi-mono/pull/908) by [@ogulcancelik](https://github.com/ogulcancelik)) + ## [0.50.1] - 2026-01-26 ## [0.50.0] - 2026-01-26 diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index ca76cddde..7d7c369c2 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -15,8 +15,6 @@ export interface ImageOptions { maxWidthCells?: number; maxHeightCells?: number; filename?: string; - /** Kitty image ID. If provided, reuses this ID (for animations/updates). */ - imageId?: number; } export class Image implements Component { @@ -25,10 +23,11 @@ export class Image implements Component { private dimensions: ImageDimensions; private theme: ImageTheme; private options: ImageOptions; - private imageId?: number; private cachedLines?: string[]; private cachedWidth?: number; + /** Track whether image data has been transmitted (tmux mode only) */ + private imageTransmitted = false; constructor( base64Data: string, @@ -42,17 +41,12 @@ export class Image implements Component { this.theme = theme; this.options = options; this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; - this.imageId = options.imageId; - } - - /** Get the Kitty image ID used by this image (if any). */ - getImageId(): number | undefined { - return this.imageId; } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; + this.imageTransmitted = false; } render(width: number): string[] { @@ -66,27 +60,38 @@ export class Image implements Component { let lines: string[]; if (caps.images) { - const result = renderImage(this.base64Data, this.dimensions, { - maxWidthCells: maxWidth, - imageId: this.imageId, - }); + const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth }); if (result) { - // Store the image ID for later cleanup - if (result.imageId) { - this.imageId = result.imageId; - } - - // Return `rows` lines so TUI accounts for image height - // First (rows-1) lines are empty (TUI clears them) - // Last line: move cursor back up, then output image sequence - lines = []; - for (let i = 0; i < result.rows - 1; i++) { - lines.push(""); + if (result.placeholderLines) { + // tmux mode: transmit image data once, then use placeholder lines + // Placeholders reference the image by ID encoded in foreground color + lines = []; + if (!this.imageTransmitted) { + // First render: transmit image data + first placeholder row + lines.push(result.sequence + result.placeholderLines[0]); + this.imageTransmitted = true; + } else { + // Subsequent renders: placeholders only (image already in terminal memory) + lines.push(result.placeholderLines[0]); + } + // Rest of the placeholder rows + for (let i = 1; i < result.placeholderLines.length; i++) { + lines.push(result.placeholderLines[i]); + } + } else { + // Direct placement mode (non-tmux) + // Return `rows` lines so TUI accounts for image height + // First (rows-1) lines are empty (TUI clears them) + // Last line: move cursor back up, then output image sequence + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + // Move cursor up to first row, then output image + const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""; + lines.push(moveUp + result.sequence); } - // Move cursor up to first row, then output image - const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""; - lines.push(moveUp + result.sequence); } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); lines = [this.theme.fallbackColor(fallback)]; diff --git a/packages/tui/src/diacritics.ts b/packages/tui/src/diacritics.ts new file mode 100644 index 000000000..9eb7da22a --- /dev/null +++ b/packages/tui/src/diacritics.ts @@ -0,0 +1,36 @@ +/** + * Diacritics used for encoding row/column values in Kitty graphics protocol Unicode placeholders. + * + * The index in this array IS the encoded value (0-296). + * These are Unicode combining characters (class 230, "above base") that were carefully + * chosen to avoid normalization issues. + * + * Source: https://sw.kovidgoyal.net/kitty/_downloads/f0a0de9ec8d9ff4456206db8e0814937/rowcolumn-diacritics.txt + * + * Used by terminals like Kitty, Ghostty, and WezTerm to decode row/column positions + * for virtual image placements in multiplexer-safe rendering mode. + */ +export const KITTY_DIACRITICS: readonly number[] = [ + 0x0305, 0x030d, 0x030e, 0x0310, 0x0312, 0x033d, 0x033e, 0x033f, 0x0346, 0x034a, 0x034b, 0x034c, 0x0350, 0x0351, + 0x0352, 0x0357, 0x035b, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, 0x036a, 0x036b, 0x036c, 0x036d, + 0x036e, 0x036f, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, + 0x059c, 0x059d, 0x059e, 0x059f, 0x05a0, 0x05a1, 0x05a8, 0x05a9, 0x05ab, 0x05ac, 0x05af, 0x05c4, 0x0610, 0x0611, + 0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065a, 0x065b, 0x065d, 0x065e, 0x06d6, + 0x06d7, 0x06d8, 0x06d9, 0x06da, 0x06db, 0x06dc, 0x06df, 0x06e0, 0x06e1, 0x06e2, 0x06e4, 0x06e7, 0x06e8, 0x06eb, + 0x06ec, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073a, 0x073d, 0x073f, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, + 0x0749, 0x074a, 0x07eb, 0x07ec, 0x07ed, 0x07ee, 0x07ef, 0x07f0, 0x07f1, 0x07f3, 0x0816, 0x0817, 0x0818, 0x0819, + 0x081b, 0x081c, 0x081d, 0x081e, 0x081f, 0x0820, 0x0821, 0x0822, 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082a, + 0x082b, 0x082c, 0x082d, 0x0951, 0x0953, 0x0954, 0x0f82, 0x0f83, 0x0f86, 0x0f87, 0x135d, 0x135e, 0x135f, 0x17dd, + 0x193a, 0x1a17, 0x1a75, 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, + 0x1b70, 0x1b71, 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, + 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, + 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, + 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, + 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, + 0x2deb, 0x2dec, 0x2ded, 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, + 0x2df9, 0x2dfa, 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, + 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, + 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, + 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, + 0x1d1ad, 0x1d242, 0x1d243, 0x1d244, +]; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 44665da85..71f11691f 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -51,29 +51,33 @@ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from " export { ProcessTerminal, type Terminal } from "./terminal.js"; // Terminal image support export { - allocateImageId, type CellDimensions, calculateImageRows, - deleteAllKittyImages, - deleteKittyImage, detectCapabilities, encodeITerm2, encodeKitty, + generatePlaceholderRows, getCapabilities, getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, + getNextImageId, getPngDimensions, getWebpDimensions, type ImageDimensions, type ImageProtocol, type ImageRenderOptions, + type ImageRenderResult, imageFallback, + isInsideTmux, + isTmuxPassthroughEnabled, renderImage, resetCapabilitiesCache, + resetTmuxPassthroughCache, setCellDimensions, type TerminalCapabilities, + wrapTmuxPassthrough, } from "./terminal-image.js"; export { type Component, diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index ef48e21b6..441899f3d 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -1,3 +1,6 @@ +import { execSync } from "node:child_process"; +import { KITTY_DIACRITICS } from "./diacritics.js"; + export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { @@ -20,8 +23,6 @@ export interface ImageRenderOptions { maxWidthCells?: number; maxHeightCells?: number; preserveAspectRatio?: boolean; - /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */ - imageId?: number; } let cachedCapabilities: TerminalCapabilities | null = null; @@ -82,13 +83,144 @@ export function resetCapabilitiesCache(): void { } /** - * Generate a random image ID for Kitty graphics protocol. - * Uses random IDs to avoid collisions between different module instances - * (e.g., main app vs extensions). + * Check if we're running inside tmux. + */ +export function isInsideTmux(): boolean { + return !!process.env.TMUX; +} + +/** + * Cache for tmux passthrough check. + * null = not checked yet, true/false = cached result */ -export function allocateImageId(): number { - // Use random ID in range [1, 0xffffffff] to avoid collisions - return Math.floor(Math.random() * 0xfffffffe) + 1; +let tmuxPassthroughEnabled: boolean | null = null; + +/** + * Check if tmux has allow-passthrough enabled. + * This is required for images to work in tmux. + * Result is cached after first check. + */ +export function isTmuxPassthroughEnabled(): boolean { + if (!isInsideTmux()) { + return false; + } + + if (tmuxPassthroughEnabled !== null) { + return tmuxPassthroughEnabled; + } + + try { + const result = execSync("tmux show-options -gv allow-passthrough 2>/dev/null", { + encoding: "utf-8", + timeout: 1000, + }).trim(); + // allow-passthrough can be "on", "all", or "off" + // "on" allows passthrough only for visible panes + // "all" allows passthrough for all panes including invisible ones + // Both "on" and "all" work for our purposes + tmuxPassthroughEnabled = result === "on" || result === "all"; + } catch { + // If tmux command fails, assume passthrough is not enabled + tmuxPassthroughEnabled = false; + } + + return tmuxPassthroughEnabled; +} + +/** + * Reset the tmux passthrough cache. + * Useful for testing or when tmux config might have changed. + */ +export function resetTmuxPassthroughCache(): void { + tmuxPassthroughEnabled = null; +} + +/** + * Wrap a sequence in tmux passthrough escapes. + * Inside tmux, escape sequences need to be wrapped so they pass through to the outer terminal. + * Format: \x1bPtmux;\x1b\\ + * Every \x1b inside the sequence must be doubled. + */ +export function wrapTmuxPassthrough(sequence: string): string { + // Double every ESC (\x1b) in the sequence + const escaped = sequence.replace(/\x1b/g, "\x1b\x1b"); + return `\x1bPtmux;${escaped}\x1b\\`; +} + +/** + * Unicode placeholder character for Kitty graphics protocol. + * This character is in the Unicode Private Use Area and is used by terminals + * that support the Kitty graphics protocol to mark where images should appear. + */ +const KITTY_PLACEHOLDER = "\u{10EEEE}"; + +/** + * Auto-incrementing image ID counter for Kitty graphics protocol. + * IDs must be non-zero, so we start at 1. + */ +let nextImageId = 1; + +/** + * Get the next available image ID. + */ +export function getNextImageId(): number { + const id = nextImageId; + nextImageId = (nextImageId % 0xffffff) + 1; // Wrap at 24 bits, skip 0 + return id; +} + +/** + * Encode image_id into RGB foreground color escape sequence. + * The image_id is encoded in the 24-bit RGB value. + */ +function encodeImageIdAsFgColor(imageId: number): string { + const r = (imageId >> 16) & 0xff; + const g = (imageId >> 8) & 0xff; + const b = imageId & 0xff; + return `\x1b[38;2;${r};${g};${b}m`; +} + +/** + * Maximum row/column value that can be encoded with diacritics. + * The KITTY_DIACRITICS array has 297 entries (indices 0-296). + */ +const MAX_DIACRITIC_VALUE = KITTY_DIACRITICS.length - 1; + +/** + * Get the diacritic codepoint for a given row/column value. + * Returns the character for the diacritic at the given index. + */ +function getDiacritic(value: number): string { + if (value < 0 || value > MAX_DIACRITIC_VALUE) { + // Clamp to valid range + value = Math.max(0, Math.min(value, MAX_DIACRITIC_VALUE)); + } + return String.fromCodePoint(KITTY_DIACRITICS[value]); +} + +/** + * Generate unicode placeholder rows for an image. + * Uses inference optimization: only the first cell of each row includes the row diacritic, + * subsequent cells are just the placeholder character (row and column inferred from left cell). + * The foreground color encodes the image_id. + */ +export function generatePlaceholderRows(imageId: number, columns: number, rows: number): string[] { + // Clamp to valid range: at least 1, at most diacritic limit + const clampedRows = Math.max(1, Math.min(rows, MAX_DIACRITIC_VALUE + 1)); + const clampedCols = Math.max(1, Math.min(columns, MAX_DIACRITIC_VALUE + 1)); + + const colorStart = encodeImageIdAsFgColor(imageId); + const colorEnd = "\x1b[39m"; // Reset foreground color + + const result: string[] = []; + for (let row = 0; row < clampedRows; row++) { + // First cell: placeholder + row diacritic (column 0 inferred) + // Subsequent cells: just placeholder (row and column inferred from left) + const firstCell = KITTY_PLACEHOLDER + getDiacritic(row); + const otherCells = clampedCols > 1 ? KITTY_PLACEHOLDER.repeat(clampedCols - 1) : ""; + result.push(`${colorStart}${firstCell}${otherCells}${colorEnd}`); + } + return result; } export function encodeKitty( @@ -97,18 +229,29 @@ export function encodeKitty( columns?: number; rows?: number; imageId?: number; + virtual?: boolean; // Use virtual placement (for tmux unicode placeholders) } = {}, ): string { const CHUNK_SIZE = 4096; - const params: string[] = ["a=T", "f=100", "q=2"]; + const params: string[] = ["f=100", "q=2"]; + + // a=T means transmit and display + // U=1 enables unicode placeholder mode (virtual placement) + params.unshift("a=T"); + if (options.virtual) { + params.push("U=1"); // Enable unicode placeholder mode + } if (options.columns) params.push(`c=${options.columns}`); if (options.rows) params.push(`r=${options.rows}`); if (options.imageId) params.push(`i=${options.imageId}`); + const inTmux = isInsideTmux(); + if (base64Data.length <= CHUNK_SIZE) { - return `\x1b_G${params.join(",")};${base64Data}\x1b\\`; + const seq = `\x1b_G${params.join(",")};${base64Data}\x1b\\`; + return inTmux ? wrapTmuxPassthrough(seq) : seq; } const chunks: string[] = []; @@ -119,35 +262,23 @@ export function encodeKitty( const chunk = base64Data.slice(offset, offset + CHUNK_SIZE); const isLast = offset + CHUNK_SIZE >= base64Data.length; + let seq: string; if (isFirst) { - chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`); + seq = `\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`; isFirst = false; } else if (isLast) { - chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); + seq = `\x1b_Gm=0;${chunk}\x1b\\`; } else { - chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); + seq = `\x1b_Gm=1;${chunk}\x1b\\`; } + chunks.push(seq); offset += CHUNK_SIZE; } - return chunks.join(""); -} - -/** - * Delete a Kitty graphics image by ID. - * Uses uppercase 'I' to also free the image data. - */ -export function deleteKittyImage(imageId: number): string { - return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`; -} - -/** - * Delete all visible Kitty graphics images. - * Uses uppercase 'A' to also free the image data. - */ -export function deleteAllKittyImages(): string { - return `\x1b_Ga=d,d=A\x1b\\`; + // Wrap all chunks in a single tmux passthrough frame (much faster than wrapping each chunk) + const result = chunks.join(""); + return inTmux ? wrapTmuxPassthrough(result) : result; } export function encodeITerm2( @@ -328,11 +459,18 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD return null; } +export interface ImageRenderResult { + sequence: string; + rows: number; + /** For tmux unicode placeholder mode: lines containing placeholder characters */ + placeholderLines?: string[]; +} + export function renderImage( base64Data: string, imageDimensions: ImageDimensions, options: ImageRenderOptions = {}, -): { sequence: string; rows: number; imageId?: number } | null { +): ImageRenderResult | null { const caps = getCapabilities(); if (!caps.images) { @@ -343,9 +481,33 @@ export function renderImage( const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); if (caps.images === "kitty") { - // Only use imageId if explicitly provided - static images don't need IDs - const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId: options.imageId }); - return { sequence, rows, imageId: options.imageId }; + const inTmux = isInsideTmux(); + + if (inTmux) { + // Check if passthrough is enabled - if not, fall back to text + if (!isTmuxPassthroughEnabled()) { + return null; // Will trigger fallback in Image component + } + + // Clamp dimensions to diacritic limits for tmux unicode placeholder mode + const tmuxMaxWidth = Math.max(1, Math.min(maxWidth, MAX_DIACRITIC_VALUE + 1)); + const tmuxRows = Math.max(1, Math.min(rows, MAX_DIACRITIC_VALUE + 1)); + + // Use virtual placement with unicode placeholders for tmux + const imageId = getNextImageId(); + const sequence = encodeKitty(base64Data, { + columns: tmuxMaxWidth, + rows: tmuxRows, + imageId, + virtual: true, + }); + const placeholderLines = generatePlaceholderRows(imageId, tmuxMaxWidth, tmuxRows); + return { sequence, rows: tmuxRows, placeholderLines }; + } else { + // Direct placement for non-tmux (no diacritic limits) + const sequence = encodeKitty(base64Data, { columns: maxWidth, rows }); + return { sequence, rows }; + } } if (caps.images === "iterm2") { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 686bfc7a0..2cae465d2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -7,7 +7,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { isKeyRelease, matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; -import { getCapabilities, setCellDimensions } from "./terminal-image.js"; +import { getCapabilities, isTmuxPassthroughEnabled, setCellDimensions, wrapTmuxPassthrough } from "./terminal-image.js"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; /** @@ -490,7 +490,8 @@ export class TUI extends Container { } private containsImage(line: string): boolean { - return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); + // Detect Kitty direct placement, iTerm2, or Kitty unicode placeholder (tmux mode) + return line.includes("\x1b_G") || line.includes("\x1b]1337;File=") || line.includes("\u{10EEEE}"); } /** @@ -801,6 +802,11 @@ export class TUI extends Container { return targetScreenRow - currentScreenRow; }; + // Synchronized output markers - wrap in tmux passthrough so outer terminal honors them + const tmuxPassthrough = isTmuxPassthroughEnabled(); + const syncBegin = tmuxPassthrough ? `${wrapTmuxPassthrough("\x1b[?2026h")}\x1b[?2026h` : "\x1b[?2026h"; + const syncEnd = tmuxPassthrough ? `\x1b[?2026l${wrapTmuxPassthrough("\x1b[?2026l")}` : "\x1b[?2026l"; + // Render all components to get new lines let newLines = this.render(width); @@ -820,13 +826,13 @@ export class TUI extends Container { // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { this.fullRedrawCount += 1; - let buffer = "\x1b[?2026h"; // Begin synchronized output + let buffer = syncBegin; if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; buffer += newLines[i]; } - buffer += "\x1b[?2026l"; // End synchronized output + buffer += syncEnd; this.terminal.write(buffer); this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = this.cursorRow; @@ -888,7 +894,7 @@ export class TUI extends Container { // All changes are in deleted lines (nothing to render, just clear) if (firstChanged >= newLines.length) { if (this.previousLines.length > newLines.length) { - let buffer = "\x1b[?2026h"; + let buffer = syncBegin; // Move to end of new content (clamp to 0 for empty content) const targetRow = Math.max(0, newLines.length - 1); const lineDiff = computeLineDiff(targetRow); @@ -911,7 +917,7 @@ export class TUI extends Container { if (extraLines > 0) { buffer += `\x1b[${extraLines}A`; } - buffer += "\x1b[?2026l"; + buffer += syncEnd; this.terminal.write(buffer); this.cursorRow = targetRow; this.hardwareCursorRow = targetRow; @@ -933,7 +939,7 @@ export class TUI extends Container { // Render from first changed line to end // Build buffer with all updates wrapped in synchronized output - let buffer = "\x1b[?2026h"; // Begin synchronized output + let buffer = syncBegin; const prevViewportBottom = prevViewportTop + height - 1; const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; if (moveTargetRow > prevViewportBottom) { @@ -1017,7 +1023,7 @@ export class TUI extends Container { buffer += `\x1b[${extraLines}A`; } - buffer += "\x1b[?2026l"; // End synchronized output + buffer += syncEnd; if (process.env.PI_TUI_DEBUG === "1") { const debugDir = "/tmp/tui";