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
6 changes: 4 additions & 2 deletions apps/web/src/stage-terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,8 @@

.terminal-surface {
--terminal-xterm-bg: #f5f1e8;
--terminal-xterm-gutter-left: 6px;
--terminal-xterm-gutter-right: 18px;

position: relative;
box-sizing: border-box;
Expand All @@ -499,11 +501,11 @@
}

.terminal-surface .xterm {
/* FitAddon subtracts .xterm padding when computing PTY columns. */
/* FitAddon subtracts this padding; TerminalPane reserves extra readable columns. */
box-sizing: border-box;
width: 100%;
height: 100% !important;
padding: 4px 6px 0;
padding: 4px var(--terminal-xterm-gutter-right) 0 var(--terminal-xterm-gutter-left);
}

.terminal-surface .xterm-viewport {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/terminal-pane-basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const xtermMocks = vi.hoisted(() => {
selectAll = vi.fn();
hasSelection = vi.fn(() => true);
getSelection = vi.fn(() => "selected text");
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols;
this.rows = rows;
});

constructor(options: Record<string, unknown>) {
this.options = options;
Expand All @@ -47,7 +51,7 @@ const xtermMocks = vi.hoisted(() => {

class FakeFitAddon {
static instances: FakeFitAddon[] = [];
fit = vi.fn();
proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 }));

constructor() {
FakeFitAddon.instances.push(this);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/terminal-pane-disconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const xtermMocks = vi.hoisted(() => {
selectAll = vi.fn();
hasSelection = vi.fn(() => true);
getSelection = vi.fn(() => "selected text");
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols;
this.rows = rows;
});

constructor(options: Record<string, unknown>) {
this.options = options;
Expand All @@ -44,7 +48,7 @@ const xtermMocks = vi.hoisted(() => {

class FakeFitAddon {
static instances: FakeFitAddon[] = [];
fit = vi.fn();
proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 }));

constructor() {
FakeFitAddon.instances.push(this);
Expand Down
104 changes: 82 additions & 22 deletions apps/web/src/terminal-pane-resize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const xtermMocks = vi.hoisted(() => {
selectAll = vi.fn();
hasSelection = vi.fn(() => true);
getSelection = vi.fn(() => "selected text");
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols;
this.rows = rows;
});
private dataHandler: ((data: string) => void) | null = null;
private keyHandler: ((event: KeyboardEvent) => boolean) | null = null;
private selectionHandler: (() => void) | null = null;
Expand Down Expand Up @@ -60,7 +64,8 @@ const xtermMocks = vi.hoisted(() => {

class FakeFitAddon {
static instances: FakeFitAddon[] = [];
fit = vi.fn();
proposedDimensions: { cols: number; rows: number } | undefined = { cols: 80, rows: 24 };
proposeDimensions = vi.fn(() => this.proposedDimensions);

constructor() {
FakeFitAddon.instances.push(this);
Expand Down Expand Up @@ -203,39 +208,42 @@ describe("TerminalPane resize handling", () => {
observer.trigger();
window.dispatchEvent(new Event("resize"));
observer.trigger();
expect(fit.fit).not.toHaveBeenCalled();
expect(fit.proposeDimensions).not.toHaveBeenCalled();

flushAnimationFrames();

expect(fit.fit).toHaveBeenCalledTimes(1);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]);
expect(fit.proposeDimensions).toHaveBeenCalledTimes(1);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);
expect(term.resize).toHaveBeenCalledWith(78, 24);

observer.trigger();
window.dispatchEvent(new Event("resize"));
flushAnimationFrames();

expect(fit.fit).toHaveBeenCalledTimes(2);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]);
expect(fit.proposeDimensions).toHaveBeenCalledTimes(2);
expect(term.resize).toHaveBeenCalledTimes(1);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);

term.cols = 100;
term.rows = 32;
fit.proposedDimensions = { cols: 100, rows: 32 };
observer.trigger();
window.dispatchEvent(new Event("resize"));
flushAnimationFrames();

expect(fit.fit).toHaveBeenCalledTimes(3);
expect(fit.proposeDimensions).toHaveBeenCalledTimes(3);
expect(term.resize).toHaveBeenCalledWith(98, 32);
expect(term.resize).toHaveBeenCalledTimes(2);
expect(resizeMessages(ws)).toEqual([
{ type: "resize", cols: 80, rows: 24 },
{ type: "resize", cols: 100, rows: 32 },
{ type: "resize", cols: 78, rows: 24 },
{ type: "resize", cols: 98, rows: 32 },
]);
});

it("does not send invalid terminal dimensions", async () => {
await renderTerminal();
const ws = TerminalPaneWebSocketMock.instances[0];
const term = xtermMocks.FakeTerminal.instances[0];
const fit = xtermMocks.FakeFitAddon.instances[0];
const observer = FakeResizeObserver.instances[0];
if (!ws || !term || !observer) throw new Error("terminal rig missing");
if (!ws || !fit || !observer) throw new Error("terminal rig missing");

await flushReactUpdate(() => ws.open());

Expand All @@ -249,20 +257,72 @@ describe("TerminalPane resize handling", () => {
];

for (const [cols, rows] of invalidDimensions) {
term.cols = cols;
term.rows = rows;
fit.proposedDimensions = { cols, rows };
observer.trigger();
flushAnimationFrames();
}

expect(resizeMessages(ws)).toEqual([]);

term.cols = 90;
term.rows = 30;
fit.proposedDimensions = { cols: 90, rows: 30 };
observer.trigger();
flushAnimationFrames();

expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 88, rows: 30 }]);
});

it("does not repeatedly shrink when fit cannot propose dimensions", async () => {
await renderTerminal();
const ws = TerminalPaneWebSocketMock.instances[0];
const term = xtermMocks.FakeTerminal.instances[0];
const fit = xtermMocks.FakeFitAddon.instances[0];
const observer = FakeResizeObserver.instances[0];
if (!ws || !term || !fit || !observer) throw new Error("terminal rig missing");

await flushReactUpdate(() => ws.open());
flushAnimationFrames();
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);
expect(term.cols).toBe(78);

fit.proposedDimensions = undefined;
observer.trigger();
flushAnimationFrames();
observer.trigger();
flushAnimationFrames();

expect(term.cols).toBe(78);
expect(term.resize).toHaveBeenCalledTimes(1);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);
});

it("clamps readable terminal columns at the backend minimum", async () => {
await renderTerminal();
const ws = TerminalPaneWebSocketMock.instances[0];
const fit = xtermMocks.FakeFitAddon.instances[0];
const observer = FakeResizeObserver.instances[0];
if (!ws || !fit || !observer) throw new Error("terminal rig missing");

await flushReactUpdate(() => ws.open());
ws.sent = [];

const narrowSizes: Array<[number, number]> = [
[19, 12],
[20, 13],
[21, 14],
];
for (const [cols, rows] of narrowSizes) {
fit.proposedDimensions = { cols, rows };
observer.trigger();
flushAnimationFrames();
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 20, rows }]);
ws.sent = [];
}

fit.proposedDimensions = { cols: 23.9, rows: 15 };
observer.trigger();
flushAnimationFrames();

expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 90, rows: 30 }]);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 21, rows: 15 }]);
});

it("sends the first valid resize when the WebSocket opens after an earlier fit", async () => {
Expand All @@ -278,7 +338,7 @@ describe("TerminalPane resize handling", () => {
await flushReactUpdate(() => ws.open());
flushAnimationFrames();

expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]);
expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);
});

it("sends the first valid resize again after reconnect", async () => {
Expand All @@ -287,7 +347,7 @@ describe("TerminalPane resize handling", () => {
if (!first) throw new Error("missing first ws");
await flushReactUpdate(() => first.open());
flushAnimationFrames();
expect(resizeMessages(first)).toEqual([{ type: "resize", cols: 80, rows: 24 }]);
expect(resizeMessages(first)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);

await flushReactUpdate(() => {
getTerminalHandle("sess_1")?.reload();
Expand All @@ -298,7 +358,7 @@ describe("TerminalPane resize handling", () => {
await flushReactUpdate(() => second.open());
flushAnimationFrames();

expect(resizeMessages(second)).toEqual([{ type: "resize", cols: 80, rows: 24 }]);
expect(resizeMessages(second)).toEqual([{ type: "resize", cols: 78, rows: 24 }]);
});

it("cancels pending resize work on unmount", async () => {
Expand All @@ -314,7 +374,7 @@ describe("TerminalPane resize handling", () => {
untrackRoot(root);
flushAnimationFrames();

expect(fit.fit).not.toHaveBeenCalled();
expect(fit.proposeDimensions).not.toHaveBeenCalled();
expect(resizeMessages(ws)).toEqual([]);
});

Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/terminal-pane-wheel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ const xtermMocks = vi.hoisted(() => {
class FakeTerminal {
static instances: FakeTerminal[] = [];
options: Record<string, unknown>;
cols = 80;
rows = 24;
focus = vi.fn();
dispose = vi.fn();
refresh = vi.fn();
selectAll = vi.fn();
hasSelection = vi.fn(() => true);
getSelection = vi.fn(() => "selected text");
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols;
this.rows = rows;
});

constructor(options: Record<string, unknown>) {
this.options = options;
Expand All @@ -49,7 +55,7 @@ const xtermMocks = vi.hoisted(() => {

class FakeFitAddon {
static instances: FakeFitAddon[] = [];
fit = vi.fn();
proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 }));

constructor() {
FakeFitAddon.instances.push(this);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/terminal-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const xtermMocks = vi.hoisted(() => {
selectAll = vi.fn();
hasSelection = vi.fn(() => true);
getSelection = vi.fn(() => "selected text");
resize = vi.fn((cols: number, rows: number) => {
this.cols = cols;
this.rows = rows;
});
private dataHandler: ((data: string) => void) | null = null;
private keyHandler: ((event: KeyboardEvent) => boolean) | null = null;
private selectionHandler: (() => void) | null = null;
Expand Down Expand Up @@ -80,7 +84,7 @@ const xtermMocks = vi.hoisted(() => {

class FakeFitAddon {
static instances: FakeFitAddon[] = [];
fit = vi.fn();
proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 }));

constructor() {
FakeFitAddon.instances.push(this);
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/terminal-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const TERMINAL_MAX_SCROLL_LINES_PER_MESSAGE = 200;
const TERMINAL_AUTO_RETRY_LIMIT = 3;
const TERMINAL_AUTO_RETRY_BACKOFF_MS = 5_000;
const TERMINAL_PTY_INPUT_FLUSH_MS = 4;
const TERMINAL_RIGHT_EDGE_RESERVED_COLUMNS = 2;
const AUTO_RETRYABLE_TERMINAL_ERRORS = new Set(["terminal_disconnected", "terminal_socket_error"]);
const RUNTIME_MOUSE_EVENT_RUNTIMES = new Set(["claude-code"]);
export type TerminalPaneKey = typeof LINE_START_KEY | typeof LINE_END_KEY | typeof LINE_KILL_KEY;
Expand Down Expand Up @@ -234,14 +235,18 @@ export function TerminalPane(props: { session: WorkspaceSession; active?: boolea
const runResize = () => {
if (disposed) return;
if (!activeRef.current) return;
let fittedSize: ReturnType<FitAddon["proposeDimensions"]>;
try {
fit.fit();
fittedSize = fit.proposeDimensions();
} catch {
return;
}
const cols = terminal.cols;
const rows = terminal.rows;
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) return;
if (!fittedSize) return;
const fittedCols = fittedSize.cols;
const rows = fittedSize.rows;
if (!Number.isFinite(fittedCols) || !Number.isFinite(rows) || fittedCols <= 0 || rows <= 0) return;
const cols = terminalReadableCols(fittedCols);
if (terminal.cols !== cols || terminal.rows !== rows) terminal.resize(cols, rows);
if (lastSentResize?.cols === cols && lastSentResize.rows === rows) return;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
Expand Down Expand Up @@ -526,6 +531,10 @@ function guidanceFor(code: string) {
}
}

function terminalReadableCols(cols: number): number {
return Math.max(20, Math.trunc(cols) - TERMINAL_RIGHT_EDGE_RESERVED_COLUMNS);
}

function handleTerminalKeyEvent(
event: KeyboardEvent,
terminal: Terminal,
Expand Down
Loading
Loading