From 215c10664afeea01f376ab8b1e2bad5c2d404f6d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 13 Dec 2025 23:09:05 +0100 Subject: [PATCH] Fix image aspect ratio by querying terminal cell size - Add getCellDimensions/setCellDimensions to terminal-image.ts - TUI queries cell size on startup via CSI 16 t - Parse response and filter it from user input - Invalidate and re-render when cell dimensions received - Pass rows parameter to Kitty protocol for correct aspect ratio --- packages/tui/src/index.ts | 2 + packages/tui/src/terminal-image.ts | 16 ++++-- packages/tui/src/tui.ts | 84 +++++++++++++++++++++++++++--- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 21be0e08..5dfb1ced 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -27,6 +27,7 @@ export { encodeITerm2, encodeKitty, getCapabilities, + getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, @@ -38,6 +39,7 @@ export { imageFallback, renderImage, resetCapabilitiesCache, + setCellDimensions, type TerminalCapabilities, } from "./terminal-image.js"; export { type Component, Container, TUI } from "./tui.js"; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 8ad801da..88886e04 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -24,6 +24,17 @@ export interface ImageRenderOptions { let cachedCapabilities: TerminalCapabilities | null = null; +// Default cell dimensions - updated by TUI when terminal responds to query +let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }; + +export function getCellDimensions(): CellDimensions { + return cellDimensions; +} + +export function setCellDimensions(dims: CellDimensions): void { + cellDimensions = dims; +} + export function detectCapabilities(): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; @@ -307,11 +318,10 @@ export function renderImage( } const maxWidth = options.maxWidthCells ?? 80; - const cellDims: CellDimensions = { widthPx: 9, heightPx: 18 }; - const rows = calculateImageRows(imageDimensions, maxWidth, cellDims); + const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); if (caps.images === "kitty") { - const sequence = encodeKitty(base64Data, { columns: maxWidth }); + const sequence = encodeKitty(base64Data, { columns: maxWidth, rows }); return { sequence, rows }; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c3e0479a..955cd8f4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Terminal } from "./terminal.js"; -import { getCapabilities } from "./terminal-image.js"; +import { getCapabilities, setCellDimensions } from "./terminal-image.js"; import { visibleWidth } from "./utils.js"; /** @@ -80,6 +80,8 @@ export class TUI extends Container { private focusedComponent: Component | null = null; private renderRequested = false; private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) + private inputBuffer = ""; // Buffer for parsing terminal responses + private cellSizeQueryPending = false; constructor(terminal: Terminal) { super(); @@ -96,9 +98,17 @@ export class TUI extends Container { () => this.requestRender(), ); this.terminal.hideCursor(); + this.queryCellSize(); this.requestRender(); } + private queryCellSize(): void { + // Query terminal for cell size in pixels: CSI 16 t + // Response format: CSI 6 ; height ; width t + this.cellSizeQueryPending = true; + this.terminal.write("\x1b[16t"); + } + stop(): void { this.terminal.showCursor(); this.terminal.stop(); @@ -114,6 +124,14 @@ export class TUI extends Container { } private handleInput(data: string): void { + // If we're waiting for cell size response, buffer input and parse + if (this.cellSizeQueryPending) { + this.inputBuffer += data; + const filtered = this.parseCellSizeResponse(); + if (filtered.length === 0) return; + data = filtered; + } + // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { @@ -122,6 +140,52 @@ export class TUI extends Container { } } + private parseCellSizeResponse(): string { + // Response format: ESC [ 6 ; height ; width t + // Match the response pattern + const responsePattern = /\x1b\[6;(\d+);(\d+)t/; + const match = this.inputBuffer.match(responsePattern); + + if (match) { + const heightPx = parseInt(match[1], 10); + const widthPx = parseInt(match[2], 10); + + if (heightPx > 0 && widthPx > 0) { + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions + this.invalidate(); + this.requestRender(); + } + + // Remove the response from buffer + this.inputBuffer = this.inputBuffer.replace(responsePattern, ""); + this.cellSizeQueryPending = false; + } + + // Check if we have a partial response starting (wait for more data) + // ESC [ 6 ; ... could be incomplete + const partialPattern = /\x1b\[6;[\d;]*$/; + if (partialPattern.test(this.inputBuffer)) { + return ""; // Wait for more data + } + + // Check for any ESC that might be start of response + const escIndex = this.inputBuffer.lastIndexOf("\x1b"); + if (escIndex !== -1 && escIndex > this.inputBuffer.length - 10) { + // Might be incomplete escape sequence, wait a bit + // But return any data before it + const before = this.inputBuffer.substring(0, escIndex); + this.inputBuffer = this.inputBuffer.substring(escIndex); + return before; + } + + // No response found, return buffered data as user input + const result = this.inputBuffer; + this.inputBuffer = ""; + this.cellSizeQueryPending = false; // Give up waiting + return result; + } + private containsImage(line: string): boolean { return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); } @@ -187,12 +251,20 @@ export class TUI extends Container { return; } - // Check if we have images - they require special handling to avoid duplication - const hasImagesInPrevious = this.previousLines.some((line) => this.containsImage(line)); - const hasImagesInNew = newLines.some((line) => this.containsImage(line)); + // Check if image lines changed - they require special handling to avoid duplication + // Only force full re-render if image content actually changed, not just because images exist + const imageLineChanged = (() => { + for (let i = firstChanged; i < Math.max(newLines.length, this.previousLines.length); i++) { + const prevLine = this.previousLines[i] || ""; + const newLine = newLines[i] || ""; + if (this.containsImage(prevLine) || this.containsImage(newLine)) { + if (prevLine !== newLine) return true; + } + } + return false; + })(); - // If images are present and content changed, force full re-render - if (hasImagesInPrevious || hasImagesInNew) { + if (imageLineChanged) { let buffer = "\x1b[?2026h"; // Begin synchronized output // For Kitty protocol, delete all images before re-render if (getCapabilities().images === "kitty") {