diff --git a/ui-tui/src/__tests__/terminalDimensions.test.ts b/ui-tui/src/__tests__/terminalDimensions.test.ts deleted file mode 100644 index 773c30f0b0d..00000000000 --- a/ui-tui/src/__tests__/terminalDimensions.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 45b9f564cf5..aa6cd9b9fea 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -12,7 +12,6 @@ import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory. import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js' import { openExternalUrl } from './lib/openExternalUrl.js' import { recordParentLifecycle } from './lib/parentLog.js' -import { clampStdoutDimensions } from './lib/terminalDimensions.js' import { resetTerminalModes } from './lib/terminalModes.js' if (!process.stdin.isTTY) { @@ -20,12 +19,6 @@ 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 deleted file mode 100644 index e6fabcd15eb..00000000000 --- a/ui-tui/src/lib/terminalDimensions.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * 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 -}