Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions ui-tui/packages/hermes-ink/src/ink/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
startSelection,
updateSelection
} from './selection.js'
import { safeColumns, safeRows, safeTerminalSize } from './terminal-dimensions.js'
import {
needsAltScreenResizeScrollbackClear,
supportsExtendedKeys,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
58 changes: 58 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/terminal-dimensions.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
147 changes: 147 additions & 0 deletions ui-tui/src/__tests__/terminalDimensions.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
5 changes: 3 additions & 2 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 | ReturnType<typeof setTimeout>>(null)

// Wheel accel ported from claude-code: inter-event timing drives step size,
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -555,7 +556,7 @@ export function useMainApp(gw: GatewayClient) {
scrollRef.current.scrollToBottom()
}

void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
void rpc<TerminalResizeResponse>('terminal.resize', { cols: safeColumns(stdout), session_id: ui.sid })
}, 100)
}

Expand Down
3 changes: 2 additions & 1 deletion ui-tui/src/components/activeSessionSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions ui-tui/src/components/agentsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down
Loading