From f1dd6dfd37bc93860173778a3e65c040a83fc3ed Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 23 Aug 2026 23:18:19 -0700 Subject: [PATCH] fix(terminal): clamp fitted sizes to PTY limits - Export shared terminal dimension limits - Clamp XTerm resize and repaint dimensions - Test oversized fitted terminal handling --- .../components/terminal/XTermSurface.test.tsx | 32 +++++++++++++++++-- .../components/terminal/XTermSurface.tsx | 14 ++++---- src/shared/contracts/thread.ts | 7 ++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/terminal/XTermSurface.test.tsx b/src/renderer/components/terminal/XTermSurface.test.tsx index ce0c89d4f..b22b74a5a 100644 --- a/src/renderer/components/terminal/XTermSurface.test.tsx +++ b/src/renderer/components/terminal/XTermSurface.test.tsx @@ -1,18 +1,20 @@ import { act } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import { resizeTerminalPayloadSchema } from "@/shared/contracts"; import type { SupervisorEvent } from "@/shared/ipc"; // ── Hoisted state shared between mock factories and test code ──── const { state } = vi.hoisted(() => ({ state: { terminal: null as null | Record>, terminalOptions: null as null | Record, + fitSize: null as null | { cols: number; rows: number }, eventListeners: [] as Array<(e: SupervisorEvent) => void>, isMac: false, bridge: { readTerminalScrollback: vi.fn<() => Promise>().mockResolvedValue(""), writeTerminal: vi.fn<() => Promise>().mockResolvedValue(undefined), - resizeTerminal: vi.fn<() => Promise>().mockResolvedValue(undefined), + resizeTerminal: vi.fn<(input: unknown) => Promise>().mockResolvedValue(undefined), openExternal: vi.fn<() => Promise>().mockResolvedValue(undefined), openExternalNative: vi.fn<() => Promise>().mockResolvedValue(undefined), onSupervisorEvent: vi.fn<(listener: (e: SupervisorEvent) => void) => () => void>(), @@ -70,7 +72,11 @@ vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/addon-fit", () => ({ FitAddon: class MockFitAddon { - fit = vi.fn<() => void>(); + fit = vi.fn<() => void>(() => { + if (state.terminal && state.fitSize) { + Object.assign(state.terminal, state.fitSize); + } + }); }, })); @@ -174,6 +180,7 @@ describe("XTermSurface", () => { beforeEach(() => { state.terminal = null; state.terminalOptions = null; + state.fitSize = null; state.eventListeners = []; state.isMac = false; vi.clearAllMocks(); @@ -187,6 +194,7 @@ describe("XTermSurface", () => { }); afterEach(() => { + vi.restoreAllMocks(); state.eventListeners = []; }); @@ -267,6 +275,26 @@ describe("XTermSurface", () => { }); }); + it("clamps a fitted terminal to the backing PTY size contract", async () => { + state.fitSize = { cols: 401, rows: 201 }; + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(4_000); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(2_000); + state.bridge.resizeTerminal.mockImplementation((input) => { + resizeTerminalPayloadSchema.parse(input); + return Promise.resolve(); + }); + + render(); + await flushFrame(); + + expect(state.terminal).toMatchObject({ cols: 401, rows: 201 }); + expect(state.bridge.resizeTerminal).toHaveBeenCalledWith({ + threadId: "test-1", + cols: 400, + rows: 200, + }); + }); + it("does not nudge on a fresh launch with no scrollback", async () => { state.bridge.readTerminalScrollback.mockResolvedValueOnce(""); diff --git a/src/renderer/components/terminal/XTermSurface.tsx b/src/renderer/components/terminal/XTermSurface.tsx index ef958a10c..215b219b4 100644 --- a/src/renderer/components/terminal/XTermSurface.tsx +++ b/src/renderer/components/terminal/XTermSurface.tsx @@ -21,7 +21,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, } from "react"; -import type { TerminalSize } from "@/shared/contracts"; +import { MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, type TerminalSize } from "@/shared/contracts"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useThreadOutputStore } from "@/renderer/state/threadOutputStore"; import { isMac, readBridge } from "@/renderer/bridge"; @@ -281,6 +281,10 @@ export const XTermSurface = forwardRef< resizeBackingTerminalRef.current ? resizeBackingTerminalRef.current(size) : readBridge().resizeTerminal({ threadId: terminalId, cols: size.cols, rows: size.rows }); + const backingTerminalSize = (): TerminalSize => ({ + cols: Math.min(terminal.cols, MAX_TERMINAL_COLS), + rows: Math.min(terminal.rows, MAX_TERMINAL_ROWS), + }); // Force the live agent to repaint a clean full frame. On reopen the PTY kept // running at the same winsize, so a fresh same-size fit issues a no-op @@ -292,8 +296,7 @@ export const XTermSurface = forwardRef< // (possibly stale / byte-sliced) replayed scrollback. const forceAgentRepaint = () => { if (!isActive) return; - const cols = terminal.cols; - const rows = terminal.rows; + const { cols, rows } = backingTerminalSize(); if (cols < 20 || rows < 5) return; // Pin our throttle bookkeeping to the REAL size so the next doFit doesn't // also fire a (now-redundant) resize for the same dimensions. @@ -430,8 +433,7 @@ export const XTermSurface = forwardRef< const flushPtyResize = () => { ptyResizeTimer = 0; if (!isActive) return; - const cols = terminal.cols; - const rows = terminal.rows; + const { cols, rows } = backingTerminalSize(); if (cols === lastCols && rows === lastRows) return; if (cols < 20 || rows < 5) return; @@ -508,7 +510,7 @@ export const XTermSurface = forwardRef< ptyResizeTimer = 0; } if (!resizeTerminalOnFit) { - onTerminalResizeRef.current?.({ cols: terminal.cols, rows: terminal.rows }); + onTerminalResizeRef.current?.(backingTerminalSize()); } else if (terminal.buffer.normal.length < RESIZE_DEBOUNCE_BUFFER_THRESHOLD) { flushPtyResize(); } else { diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index e2c9fce6b..e52dc23e6 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -90,9 +90,12 @@ export interface TerminalShellSnapshot { outputLength: number; } +export const MAX_TERMINAL_COLS = 400; +export const MAX_TERMINAL_ROWS = 200; + export const terminalSizeSchema = z.object({ - cols: z.number().int().min(20).max(400), - rows: z.number().int().min(5).max(200), + cols: z.number().int().min(20).max(MAX_TERMINAL_COLS), + rows: z.number().int().min(5).max(MAX_TERMINAL_ROWS), }); export type TerminalSize = z.infer;