diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index d8c95fcc703f7..22c46468570bc 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -75,6 +75,7 @@ import { startSelection, updateSelection } from './selection.js' +import { safeColumns, safeRows, safeTerminalSize } from './terminal-dimensions.js' import { needsAltScreenResizeScrollbackClear, supportsExtendedKeys, @@ -332,8 +333,9 @@ export default class Ink { stdout: options.stdout, stderr: options.stderr } - this.terminalColumns = options.stdout.columns || 80 - this.terminalRows = options.stdout.rows || 24 + const { columns, rows } = safeTerminalSize(options.stdout) + this.terminalColumns = columns + this.terminalRows = rows this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) this.stylePool = new StylePool() this.charPool = new CharPool() @@ -486,8 +488,7 @@ export default class Ink { // one microtask per burst: vscode fires many SIGWINCHes per panel // drag, each ~80ms uncoalesced = event loop visibly locks up. private handleResize = () => { - const cols = this.options.stdout.columns || 80 - const rows = this.options.stdout.rows || 24 + const { columns: cols, rows } = safeTerminalSize(this.options.stdout) const dimsChanged = cols !== this.terminalColumns || rows !== this.terminalRows // Terminals often emit 2+ resize events for one user action @@ -709,8 +710,8 @@ export default class Ink { // an extra React re-render cycle. flushInteractionTime() const renderStart = performance.now() - const terminalWidth = this.options.stdout.columns || 80 - const terminalRows = this.options.stdout.rows || 24 + const terminalWidth = safeColumns(this.options.stdout) + const terminalRows = safeRows(this.options.stdout) const frame = this.renderer({ frontFrame: this.frontFrame, diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.test.ts new file mode 100644 index 0000000000000..582a039f13c09 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { safeColumns, safeRows, safeTerminalSize } from './terminal-dimensions.js' + +describe('Ink terminal dimension readers', () => { + it('passes through normal dimensions', () => { + expect(safeColumns({ columns: 120 })).toBe(120) + expect(safeRows({ rows: 40 })).toBe(40) + }) + + it('clamps absurd host dimensions without mutating stdout', () => { + const stdout = { columns: 131072, rows: 99999 } + + expect(safeTerminalSize(stdout)).toEqual({ columns: 2000, rows: 1000 }) + expect(stdout).toEqual({ columns: 131072, rows: 99999 }) + }) + + it('uses defaults for missing or invalid dimensions', () => { + expect(safeColumns({ columns: 0 })).toBe(80) + expect(safeRows({ rows: Number.NaN })).toBe(24) + expect(safeTerminalSize(null)).toEqual({ columns: 80, rows: 24 }) + }) + + it('falls back when a terminal wrapper throws while reading dimensions', () => { + const stdout = { + get columns() { + throw new Error('lol terminal cursed') + }, + get rows() { + throw new Error('lol terminal cursed') + } + } + + expect(safeColumns(stdout)).toBe(80) + expect(safeRows(stdout)).toBe(24) + }) + + it('sanitizes caller-provided fallbacks', () => { + expect(safeColumns({ columns: undefined }, Number.NaN)).toBe(1) + expect(safeColumns({ columns: undefined }, 999999)).toBe(2000) + expect(safeRows({ rows: undefined }, -1)).toBe(1) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.ts new file mode 100644 index 0000000000000..853c0266ae37b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.ts @@ -0,0 +1,58 @@ +const DEFAULT_COLUMNS = 80 +const DEFAULT_ROWS = 24 +const MAX_COLUMNS = 2000 +const MAX_ROWS = 1000 +const MIN_COLUMNS = 1 +const MIN_ROWS = 1 + +// Keep this package-local helper in sync with ui-tui/src/lib/terminalDimensions.ts. +type TerminalSizeSource = { + columns?: number + rows?: number +} + +function sanitizeDimension(value: unknown, min: number, max: number, fallback: number): number { + const safeFallback = + typeof fallback === 'number' && Number.isFinite(fallback) && fallback > 0 + ? Math.min(max, Math.max(min, Math.floor(fallback))) + : min + + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return safeFallback + } + + const rounded = Math.floor(value) + + if (rounded < min) { + return safeFallback + } + + if (rounded > max) { + return max + } + + return rounded +} + +function readDimension(stream: null | TerminalSizeSource | undefined, key: keyof TerminalSizeSource): unknown { + try { + return stream?.[key] + } catch { + return undefined + } +} + +export function safeColumns(stream?: null | TerminalSizeSource, fallback = DEFAULT_COLUMNS): number { + return sanitizeDimension(readDimension(stream, 'columns'), MIN_COLUMNS, MAX_COLUMNS, fallback) +} + +export function safeRows(stream?: null | TerminalSizeSource, fallback = DEFAULT_ROWS): number { + return sanitizeDimension(readDimension(stream, 'rows'), MIN_ROWS, MAX_ROWS, fallback) +} + +export function safeTerminalSize(stream?: null | TerminalSizeSource): { columns: number; rows: number } { + return { + columns: safeColumns(stream), + rows: safeRows(stream) + } +} diff --git a/ui-tui/src/__tests__/terminalDimensions.test.ts b/ui-tui/src/__tests__/terminalDimensions.test.ts new file mode 100644 index 0000000000000..fc49cfb7ddda5 --- /dev/null +++ b/ui-tui/src/__tests__/terminalDimensions.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_COLUMNS, + DEFAULT_ROWS, + MAX_COLUMNS, + MAX_ROWS, + safeColumns, + safeRows, + safeTerminalSize, + 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('safe terminal dimension readers', () => { + it('passes through normal dimensions', () => { + expect(safeColumns({ columns: 120 })).toBe(120) + expect(safeRows({ rows: 40 })).toBe(40) + }) + + it('uses defaults for missing streams and properties', () => { + expect(safeColumns(null)).toBe(DEFAULT_COLUMNS) + expect(safeRows(null)).toBe(DEFAULT_ROWS) + expect(safeRows({})).toBe(DEFAULT_ROWS) + }) + + it('sanitizes invalid and fractional values', () => { + expect(safeColumns({ columns: NaN })).toBe(DEFAULT_COLUMNS) + expect(safeRows({ rows: Infinity })).toBe(DEFAULT_ROWS) + expect(safeColumns({ columns: -1 })).toBe(DEFAULT_COLUMNS) + expect(safeColumns({ columns: 90.9 })).toBe(90) + }) + + it('clamps a bogus columns getter on a live stream without patching it', () => { + 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 }) + const descriptor = Object.getOwnPropertyDescriptor(stream, 'columns') + + expect(safeColumns(stream)).toBe(MAX_COLUMNS) + expect(safeRows(stream)).toBe(1) + expect(Object.getOwnPropertyDescriptor(stream, 'columns')).toStrictEqual(descriptor) + + // Live resize still propagates through the original getter, clamped. + raw = 100 + expect(safeColumns(stream)).toBe(100) + + raw = 0 + expect(safeColumns(stream)).toBe(DEFAULT_COLUMNS) + }) + + it('clamps bogus plain-value properties without mutating them', () => { + const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 99999 } + + expect(safeColumns(stream)).toBe(MAX_COLUMNS) + expect(safeRows(stream)).toBe(MAX_ROWS) + expect(stream.columns).toBe(131072) + expect(stream.rows).toBe(99999) + }) + + it('sanitizes a stream as a dimension pair', () => { + expect(safeTerminalSize({ columns: 131072, rows: 99999 })).toEqual({ + columns: MAX_COLUMNS, + rows: MAX_ROWS + }) + }) + + it('does not crash on a non-configurable property', () => { + const stream: { columns?: number; rows?: number } = {} + Object.defineProperty(stream, 'columns', { configurable: false, value: 131072 }) + + expect(() => safeColumns(stream)).not.toThrow() + expect(safeColumns(stream)).toBe(MAX_COLUMNS) + }) + + it('falls back when a terminal wrapper throws while reading dimensions', () => { + const stream = { + get columns() { + throw new Error('lol terminal cursed') + }, + get rows() { + throw new Error('lol terminal cursed') + } + } + + expect(safeColumns(stream)).toBe(DEFAULT_COLUMNS) + expect(safeRows(stream)).toBe(DEFAULT_ROWS) + }) + + it('sanitizes caller-provided fallbacks', () => { + expect(safeColumns({ columns: undefined }, NaN)).toBe(1) + expect(safeColumns({ columns: undefined }, 999999)).toBe(MAX_COLUMNS) + expect(safeRows({ rows: undefined }, -1)).toBe(1) + }) +}) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2cbb745b8fe2e..a3cde895e81ce 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -12,6 +12,7 @@ import type { } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' +import { safeRows } from '../lib/terminalDimensions.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' @@ -85,7 +86,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const overlay = useStore($overlayState) const isBlocked = useStore($isBlocked) - const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) + const pagerPageSize = Math.max(5, safeRows(terminal.stdout) - 6) const scrollIdleTimer = useRef>(null) // Wheel accel ported from claude-code: inter-event timing drives step size, @@ -402,7 +403,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (key.pageUp || key.pageDown) { // Half-viewport keeps 50% continuity and stays under Ink's // `delta < innerHeight` DECSTBM fast-path threshold. - const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8) + const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, safeRows(terminal.stdout) - 8) const step = Math.max(4, Math.floor(viewport / 2)) return scrollTranscript(key.pageUp ? -step : step) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 43e8a2ed628c2..d5818e9da543a 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -23,6 +23,7 @@ import { composerPromptWidth } from '../lib/inputMetrics.js' import { appendTranscriptMessage } from '../lib/messages.js' import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { safeColumns } from '../lib/terminalDimensions.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' @@ -137,14 +138,14 @@ export async function startPromptLiveSession({ export function useMainApp(gw: GatewayClient) { const { exit } = useApp() const { stdout } = useStdout() - const [cols, setCols] = useState(stdout?.columns ?? 80) + const [cols, setCols] = useState(safeColumns(stdout)) useEffect(() => { if (!stdout) { return } - const sync = () => setCols(stdout.columns ?? 80) + const sync = () => setCols(safeColumns(stdout)) stdout.on('resize', sync) @@ -555,7 +556,7 @@ export function useMainApp(gw: GatewayClient) { scrollRef.current.scrollToBottom() } - void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + void rpc('terminal.resize', { cols: safeColumns(stdout), session_id: ui.sid }) }, 100) } diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index f158b24a44db7..51afd69f1c899 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -5,6 +5,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { safeColumns } from '../lib/terminalDimensions.js' import type { Theme } from '../theme.js' import { ModelPicker } from './modelPicker.js' @@ -252,7 +253,7 @@ export function ActiveSessionSwitcher({ const [closingId, setClosingId] = useState('') const initialSelectionAppliedRef = useRef(false) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, safeColumns(stdout) - 6)) const promptColumns = Math.max(20, width - 11) const load = useCallback( diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index 497230c393460..1480ffd077530 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -29,6 +29,7 @@ import { treeTotals, widthByDepth } from '../lib/subagentTree.js' +import { safeColumns, safeRows } from '../lib/terminalDimensions.js' import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' import type { SubagentNode, SubagentProgress } from '../types.js' @@ -735,8 +736,8 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent const selected = rows[cursor] ?? null - const cols = stdout?.columns ?? 80 - const rowsH = Math.max(8, (stdout?.rows ?? 24) - 10) + const cols = safeColumns(stdout) + const rowsH = Math.max(8, safeRows(stdout) - 10) const listWindowStart = Math.max(0, cursor - Math.floor(rowsH / 2)) // ── Effects ──────────────────────────────────────────────────────── diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 4f2bbb5eae54a..d18719a125f80 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' +import { safeColumns } from '../lib/terminalDimensions.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' @@ -83,7 +84,7 @@ function CompactBanner({ cols, t }: { cols: number; t: Theme }) { } export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { - const term = useStdout().stdout?.columns ?? 80 + const term = safeColumns(useStdout().stdout) const cols = Math.max(1, Math.min(term, maxWidth ?? term)) if (cols < HIDE_BELOW) { @@ -158,7 +159,7 @@ const SKILLS_MAX = 8 const TOOLSETS_MAX = 8 export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { - const term = useStdout().stdout?.columns ?? 100 + const term = safeColumns(useStdout().stdout, 100) const cols = Math.max(20, Math.min(term, maxWidth ?? term)) const heroLines = caduceus(t.color, t.bannerHero || undefined) const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 07e3f22b9c8c4..c6036a1aca6ce 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -6,6 +6,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { safeColumns } from '../lib/terminalDimensions.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js' @@ -34,7 +35,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, // to-fit with alignSelf="flex-start") doesn't resize as long provider / // model names scroll into view, and so `wrap="truncate-end"` on each row // has an actual constraint to truncate against. - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, safeColumns(stdout) - 6)) useEffect(() => { gw.request('model.options', sessionId ? { session_id: sessionId } : {}) diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index e836e59852f8b..a6af44acf716b 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' import type { SessionDeleteResponse, SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { safeColumns } from '../lib/terminalDimensions.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js' @@ -37,7 +38,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const [deleting, setDeleting] = useState(false) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, safeColumns(stdout) - 6)) useOverlayKeys({ onClose: onCancel }) diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 941ee0b275295..9fdc3900fd13b 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' import { rpcErrorMessage } from '../lib/rpc.js' +import { safeColumns } from '../lib/terminalDimensions.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' @@ -23,7 +24,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [loading, setLoading] = useState(true) const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, safeColumns(stdout) - 6)) useEffect(() => { gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) diff --git a/ui-tui/src/lib/terminalDimensions.ts b/ui-tui/src/lib/terminalDimensions.ts new file mode 100644 index 0000000000000..28a8af8eb5d1c --- /dev/null +++ b/ui-tui/src/lib/terminalDimensions.ts @@ -0,0 +1,101 @@ +/** + * 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 a sanitized 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 { + const safeFallback = + typeof fallback === 'number' && Number.isFinite(fallback) && fallback > 0 + ? Math.min(max, Math.max(min, Math.floor(fallback))) + : min + + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return safeFallback + } + + const rounded = Math.floor(value) + + if (rounded < min) { + return safeFallback + } + + 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) + } +} + +export interface TerminalSizeSource { + columns?: number + rows?: number +} + +function readDimension(stream: null | TerminalSizeSource | undefined, key: keyof TerminalSizeSource): unknown { + try { + return stream?.[key] + } catch { + return undefined + } +} + +/** Read a sanitized terminal width without mutating the host stream. */ +export function safeColumns(stream?: null | TerminalSizeSource, fallback = DEFAULT_COLUMNS): number { + return sanitizeDimension(readDimension(stream, 'columns'), MIN_COLUMNS, MAX_COLUMNS, fallback) +} + +/** Read a sanitized terminal height without mutating the host stream. */ +export function safeRows(stream?: null | TerminalSizeSource, fallback = DEFAULT_ROWS): number { + return sanitizeDimension(readDimension(stream, 'rows'), MIN_ROWS, MAX_ROWS, fallback) +} + +/** Read sanitized terminal dimensions without mutating the host stream. */ +export function safeTerminalSize(stream?: null | TerminalSizeSource): SanitizedTerminalSize { + return { + columns: safeColumns(stream), + rows: safeRows(stream) + } +}