diff --git a/ui-tui/src/__tests__/terminalDimensions.test.ts b/ui-tui/src/__tests__/terminalDimensions.test.ts new file mode 100644 index 00000000000..773c30f0b0d --- /dev/null +++ b/ui-tui/src/__tests__/terminalDimensions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { + clampStdoutDimensions, + DEFAULT_COLUMNS, + DEFAULT_ROWS, + MAX_COLUMNS, + MAX_ROWS, + sanitizeDimension, + sanitizeTerminalSize +} from '../lib/terminalDimensions.js' + +describe('sanitizeDimension', () => { + it('passes through an in-range value', () => { + expect(sanitizeDimension(120, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(120) + }) + + it('floors fractional values', () => { + expect(sanitizeDimension(80.9, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(80) + }) + + it('clamps an absurd width to the max, not the fallback', () => { + expect(sanitizeDimension(131072, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(MAX_COLUMNS) + }) + + it('falls back when value is zero', () => { + expect(sanitizeDimension(0, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) + + it('falls back when value is negative', () => { + expect(sanitizeDimension(-5, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) + + it('falls back on NaN / undefined / non-number', () => { + expect(sanitizeDimension(NaN, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension(undefined, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension('80', 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension(Infinity, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) +}) + +describe('sanitizeTerminalSize', () => { + it('sanitizes the WSL 131072x1 report', () => { + // 131072 cols is absurd → clamp to max; 1 row is a valid (degenerate) TTY → keep. + expect(sanitizeTerminalSize(131072, 1)).toEqual({ columns: MAX_COLUMNS, rows: 1 }) + }) + + it('passes a normal terminal through unchanged', () => { + expect(sanitizeTerminalSize(120, 40)).toEqual({ columns: 120, rows: 40 }) + }) + + it('falls back when both dimensions are missing', () => { + expect(sanitizeTerminalSize(undefined, undefined)).toEqual({ + columns: DEFAULT_COLUMNS, + rows: DEFAULT_ROWS + }) + }) + + it('clamps an oversized height', () => { + expect(sanitizeTerminalSize(80, 99999)).toEqual({ columns: 80, rows: MAX_ROWS }) + }) +}) + +describe('clampStdoutDimensions', () => { + it('clamps a bogus columns getter on a live stream', () => { + let raw = 131072 + const stream: { columns?: number; rows?: number } = {} + Object.defineProperty(stream, 'columns', { configurable: true, get: () => raw }) + Object.defineProperty(stream, 'rows', { configurable: true, get: () => 1 }) + + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + expect(stream.rows).toBe(1) + + // Live resize still propagates through the original getter, clamped. + raw = 100 + expect(stream.columns).toBe(100) + + raw = 0 + expect(stream.columns).toBe(DEFAULT_COLUMNS) + }) + + it('clamps a bogus plain-value columns property', () => { + const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 } + + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + expect(stream.rows).toBe(24) + }) + + it('is idempotent', () => { + const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 } + + clampStdoutDimensions(stream) + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + }) + + it('does not crash on a non-configurable property', () => { + const stream: { columns?: number; rows?: number } = {} + Object.defineProperty(stream, 'columns', { configurable: false, value: 131072 }) + + expect(() => clampStdoutDimensions(stream)).not.toThrow() + }) +}) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index effde40fef9..787f738f9f3 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -11,6 +11,7 @@ import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js' import { openExternalUrl } from './lib/openExternalUrl.js' +import { clampStdoutDimensions } from './lib/terminalDimensions.js' import { resetTerminalModes } from './lib/terminalModes.js' if (!process.stdin.isTTY) { @@ -18,6 +19,12 @@ if (!process.stdin.isTTY) { process.exit(0) } +// Some hosts (notably WSL) report bogus window sizes such as 131072x1. Clamp +// `process.stdout.columns`/`rows` at the source so the Ink renderer, its +// resize handler, and every component read see sane values. Must run before +// `ink.render` constructs the renderer. +clampStdoutDimensions() + // Start from a clean slate. If a previous TUI crashed or was kill -9'd, the // terminal tab can still have mouse/focus/paste modes enabled. resetTerminalModes() diff --git a/ui-tui/src/lib/terminalDimensions.ts b/ui-tui/src/lib/terminalDimensions.ts new file mode 100644 index 00000000000..e6fabcd15eb --- /dev/null +++ b/ui-tui/src/lib/terminalDimensions.ts @@ -0,0 +1,143 @@ +/** + * Sanitize terminal dimensions reported by the host. + * + * Some environments report bogus window sizes. The motivating case (WSL, + * reported by @northframe_17) is `columns=131072, rows=1` — a width that + * overflows any sane layout and a height of one row that makes the TUI + * unusable. Node's own `stdout.columns || 80` fallback only catches + * `0`/`NaN`/`undefined`, so a positive-but-absurd value sails straight into + * the Ink renderer, which then allocates a 131072-cell-wide screen buffer. + * + * We clamp each dimension independently to a sane range. Out-of-range or + * non-finite values fall back to the conventional 80x24 default rather than + * the raw garbage. + */ + +export const DEFAULT_COLUMNS = 80 +export const DEFAULT_ROWS = 24 + +// Upper bounds are generous (ultrawide multi-monitor terminals, tmux panes +// spanning huge displays) but well below the WSL garbage value. Anything +// beyond these is treated as a broken probe. +export const MAX_COLUMNS = 2000 +export const MAX_ROWS = 1000 +export const MIN_COLUMNS = 1 +export const MIN_ROWS = 1 + +/** + * Clamp a single reported dimension into `[min, max]`. + * + * Returns `fallback` when the value is non-finite or `<= 0` (the classic + * "no size yet" signal). A positive value above `max` is clamped to `max`, + * not replaced by the fallback — an oversized-but-finite report is more + * likely a real-but-large terminal than a missing one, and clamping keeps + * the layout sane either way. + */ +export function sanitizeDimension(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback + } + + const rounded = Math.floor(value) + + if (rounded < min) { + return fallback + } + + if (rounded > max) { + return max + } + + return rounded +} + +export interface SanitizedTerminalSize { + columns: number + rows: number +} + +/** Sanitize a (columns, rows) pair using the TUI's bounds. */ +export function sanitizeTerminalSize(columns: unknown, rows: unknown): SanitizedTerminalSize { + return { + columns: sanitizeDimension(columns, MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS), + rows: sanitizeDimension(rows, MIN_ROWS, MAX_ROWS, DEFAULT_ROWS) + } +} + +interface ClampableStream { + columns?: number + rows?: number +} + +const PATCHED = Symbol.for('hermes.tui.clampedDimensions') + +/** + * Install clamping getters on `process.stdout` (or a provided stream) so every + * downstream reader — the Ink renderer's root layout, its `resize` handler, + * and our React components' `stdout.columns ?? 80` reads — sees sanitized + * values. Must run before `ink.render`. + * + * Idempotent: re-installing on an already-patched stream is a no-op. The raw + * values are read through the original property descriptor on each access, so + * live resizes still propagate (just clamped). + */ +export function clampStdoutDimensions(stream: ClampableStream = process.stdout): void { + const target = stream as ClampableStream & { [PATCHED]?: boolean } + + if (target[PATCHED]) { + return + } + + // Capture the original descriptors so we read the live host value on every + // access rather than freezing a single snapshot. + const colsDesc = findDescriptor(target, 'columns') + const rowsDesc = findDescriptor(target, 'rows') + + const readCols = () => (colsDesc ? readValue(target, colsDesc) : target.columns) + const readRows = () => (rowsDesc ? readValue(target, rowsDesc) : target.rows) + + try { + Object.defineProperty(target, 'columns', { + configurable: true, + enumerable: true, + get() { + return sanitizeDimension(readCols(), MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS) + } + }) + Object.defineProperty(target, 'rows', { + configurable: true, + enumerable: true, + get() { + return sanitizeDimension(readRows(), MIN_ROWS, MAX_ROWS, DEFAULT_ROWS) + } + }) + target[PATCHED] = true + } catch { + // Non-configurable property on an exotic stream — leave it alone rather + // than crashing startup. Components still have their own `?? 80` guard. + } +} + +function findDescriptor(obj: object, key: string): PropertyDescriptor | undefined { + let cur: object | null = obj + + while (cur) { + const desc = Object.getOwnPropertyDescriptor(cur, key) + + if (desc) { + return desc + } + + cur = Object.getPrototypeOf(cur) as object | null + } + + return undefined +} + +function readValue(target: object, desc: PropertyDescriptor): unknown { + if (desc.get) { + return desc.get.call(target) + } + + return desc.value +}