mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 21:00:30 +00:00
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
This commit is contained in:
parent
fcad447f32
commit
215c10664a
3 changed files with 93 additions and 9 deletions
|
|
@ -27,6 +27,7 @@ export {
|
||||||
encodeITerm2,
|
encodeITerm2,
|
||||||
encodeKitty,
|
encodeKitty,
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
|
getCellDimensions,
|
||||||
getGifDimensions,
|
getGifDimensions,
|
||||||
getImageDimensions,
|
getImageDimensions,
|
||||||
getJpegDimensions,
|
getJpegDimensions,
|
||||||
|
|
@ -38,6 +39,7 @@ export {
|
||||||
imageFallback,
|
imageFallback,
|
||||||
renderImage,
|
renderImage,
|
||||||
resetCapabilitiesCache,
|
resetCapabilitiesCache,
|
||||||
|
setCellDimensions,
|
||||||
type TerminalCapabilities,
|
type TerminalCapabilities,
|
||||||
} from "./terminal-image.js";
|
} from "./terminal-image.js";
|
||||||
export { type Component, Container, TUI } from "./tui.js";
|
export { type Component, Container, TUI } from "./tui.js";
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,17 @@ export interface ImageRenderOptions {
|
||||||
|
|
||||||
let cachedCapabilities: TerminalCapabilities | null = null;
|
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 {
|
export function detectCapabilities(): TerminalCapabilities {
|
||||||
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
||||||
const term = process.env.TERM?.toLowerCase() || "";
|
const term = process.env.TERM?.toLowerCase() || "";
|
||||||
|
|
@ -307,11 +318,10 @@ export function renderImage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWidth = options.maxWidthCells ?? 80;
|
const maxWidth = options.maxWidthCells ?? 80;
|
||||||
const cellDims: CellDimensions = { widthPx: 9, heightPx: 18 };
|
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
|
||||||
const rows = calculateImageRows(imageDimensions, maxWidth, cellDims);
|
|
||||||
|
|
||||||
if (caps.images === "kitty") {
|
if (caps.images === "kitty") {
|
||||||
const sequence = encodeKitty(base64Data, { columns: maxWidth });
|
const sequence = encodeKitty(base64Data, { columns: maxWidth, rows });
|
||||||
return { sequence, rows };
|
return { sequence, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Terminal } from "./terminal.js";
|
import type { Terminal } from "./terminal.js";
|
||||||
import { getCapabilities } from "./terminal-image.js";
|
import { getCapabilities, setCellDimensions } from "./terminal-image.js";
|
||||||
import { visibleWidth } from "./utils.js";
|
import { visibleWidth } from "./utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,6 +80,8 @@ export class TUI extends Container {
|
||||||
private focusedComponent: Component | null = null;
|
private focusedComponent: Component | null = null;
|
||||||
private renderRequested = false;
|
private renderRequested = false;
|
||||||
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
|
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) {
|
constructor(terminal: Terminal) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -96,9 +98,17 @@ export class TUI extends Container {
|
||||||
() => this.requestRender(),
|
() => this.requestRender(),
|
||||||
);
|
);
|
||||||
this.terminal.hideCursor();
|
this.terminal.hideCursor();
|
||||||
|
this.queryCellSize();
|
||||||
this.requestRender();
|
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 {
|
stop(): void {
|
||||||
this.terminal.showCursor();
|
this.terminal.showCursor();
|
||||||
this.terminal.stop();
|
this.terminal.stop();
|
||||||
|
|
@ -114,6 +124,14 @@ export class TUI extends Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleInput(data: string): void {
|
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)
|
// Pass input to focused component (including Ctrl+C)
|
||||||
// The focused component can decide how to handle Ctrl+C
|
// The focused component can decide how to handle Ctrl+C
|
||||||
if (this.focusedComponent?.handleInput) {
|
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 {
|
private containsImage(line: string): boolean {
|
||||||
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
||||||
}
|
}
|
||||||
|
|
@ -187,12 +251,20 @@ export class TUI extends Container {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have images - they require special handling to avoid duplication
|
// Check if image lines changed - they require special handling to avoid duplication
|
||||||
const hasImagesInPrevious = this.previousLines.some((line) => this.containsImage(line));
|
// Only force full re-render if image content actually changed, not just because images exist
|
||||||
const hasImagesInNew = newLines.some((line) => this.containsImage(line));
|
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 (imageLineChanged) {
|
||||||
if (hasImagesInPrevious || hasImagesInNew) {
|
|
||||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||||
// For Kitty protocol, delete all images before re-render
|
// For Kitty protocol, delete all images before re-render
|
||||||
if (getCapabilities().images === "kitty") {
|
if (getCapabilities().images === "kitty") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue