Skip to content
Merged
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
32 changes: 30 additions & 2 deletions src/renderer/components/terminal/XTermSurface.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof vi.fn>>,
terminalOptions: null as null | Record<string, unknown>,
fitSize: null as null | { cols: number; rows: number },
eventListeners: [] as Array<(e: SupervisorEvent) => void>,
isMac: false,
bridge: {
readTerminalScrollback: vi.fn<() => Promise<string>>().mockResolvedValue(""),
writeTerminal: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
resizeTerminal: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
resizeTerminal: vi.fn<(input: unknown) => Promise<void>>().mockResolvedValue(undefined),
openExternal: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
openExternalNative: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
onSupervisorEvent: vi.fn<(listener: (e: SupervisorEvent) => void) => () => void>(),
Expand Down Expand Up @@ -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);
}
});
},
}));

Expand Down Expand Up @@ -174,6 +180,7 @@ describe("XTermSurface", () => {
beforeEach(() => {
state.terminal = null;
state.terminalOptions = null;
state.fitSize = null;
state.eventListeners = [];
state.isMac = false;
vi.clearAllMocks();
Expand All @@ -187,6 +194,7 @@ describe("XTermSurface", () => {
});

afterEach(() => {
vi.restoreAllMocks();
state.eventListeners = [];
});

Expand Down Expand Up @@ -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(<XTermSurface terminalId="test-1" />);
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("");

Expand Down
14 changes: 8 additions & 6 deletions src/renderer/components/terminal/XTermSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions src/shared/contracts/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof terminalSizeSchema>;

Expand Down