fix(terminal): create PTYs at the client viewport size, not 80x24 (#2023)

New terminals were always spawned by the daemon at the hardcoded 80x24
default and only resized once the client's first resize arrived. On a
slow first mount that leaves a window -- or a stuck state -- where a new
terminal renders far smaller than its pane (e.g. vim in a corner).

Seed the PTY with the client's measured viewport size at creation time
so it is born at the right size and the race window is gone.

- protocol: optional `size` on create_terminal_request. Old daemons
  ignore it and keep 80x24; old clients send nothing. Fully backward
  compatible, no capability gate.
- client: a small last-measured-size cache keyed by serverId+cwd (every
  terminal in a workspace shares the pane) with a most-recent fallback;
  written on fit, read at create.
- server: thread rows/cols through the controller, terminal manager, and
  the worker-thread boundary into the existing size-aware createTerminal.

Tests: cache logic, protocol back-compat parse, controller forwarding.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-07-13 03:26:52 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ec93ca866e
commit 2658132384
11 changed files with 232 additions and 1 deletions
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
estimateTerminalViewportSize,
rememberTerminalViewportSize,
resetTerminalViewportSizeCacheForTests,
} from "./terminal-size-cache";
describe("terminal-size-cache", () => {
beforeEach(() => {
resetTerminalViewportSizeCacheForTests();
});
it("returns null before any size has been measured", () => {
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toBeNull();
});
it("returns the last measured size for the same workspace", () => {
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 55, cols: 136 } });
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({
rows: 55,
cols: 136,
});
});
it("falls back to the most recent size from another workspace", () => {
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } });
// No terminal has been measured in /repo-b yet — the estimate uses the global most-recent size.
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-b" })).toEqual({
rows: 40,
cols: 100,
});
});
it("prefers the same-workspace size over the global most-recent", () => {
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } });
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-b", size: { rows: 55, cols: 136 } });
// /repo-a keeps its own measured size even though /repo-b was measured more recently.
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-a" })).toEqual({
rows: 40,
cols: 100,
});
});
it("keys by server + cwd so different hosts do not collide", () => {
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 40, cols: 100 } });
rememberTerminalViewportSize({ serverId: "s2", cwd: "/repo", size: { rows: 55, cols: 136 } });
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({
rows: 40,
cols: 100,
});
expect(estimateTerminalViewportSize({ serverId: "s2", cwd: "/repo" })).toEqual({
rows: 55,
cols: 136,
});
});
});
@@ -0,0 +1,50 @@
export interface TerminalViewportSize {
rows: number;
cols: number;
}
function cacheKey(input: { serverId: string; cwd: string }): string {
// JSON-encode the pair so a `:` inside serverId or cwd (e.g. a Windows `C:\` path) can't
// make two different (serverId, cwd) pairs collide onto the same key.
return JSON.stringify([input.serverId, input.cwd]);
}
const sizeByWorkspace = new Map<string, TerminalViewportSize>();
let mostRecentSize: TerminalViewportSize | null = null;
/**
* Remember the latest measured terminal viewport size for a workspace. Every terminal in a
* workspace renders into the same pane, so this is the best estimate of the size a *new*
* terminal in that workspace will render at — used to seed the PTY at creation time instead
* of the daemon's 80x24 default (which otherwise shows briefly, or sticks, until the first
* resize lands).
*/
export function rememberTerminalViewportSize(input: {
serverId: string;
cwd: string;
size: TerminalViewportSize;
}): void {
const size: TerminalViewportSize = { rows: input.size.rows, cols: input.size.cols };
sizeByWorkspace.set(cacheKey(input), size);
mostRecentSize = size;
}
/**
* Best estimate of the viewport size a new terminal in this workspace will render at:
* the last measured size for the same workspace, else the most recently measured size
* anywhere (panes are usually the same size across workspaces on one device), else null
* when nothing has been measured yet this session — in which case the daemon keeps its
* 80x24 default and the first resize corrects it as before.
*/
export function estimateTerminalViewportSize(input: {
serverId: string;
cwd: string;
}): TerminalViewportSize | null {
return sizeByWorkspace.get(cacheKey(input)) ?? mostRecentSize;
}
/** Test-only: clear all remembered sizes so cases don't leak into each other. */
export function resetTerminalViewportSizeCacheForTests(): void {
sizeByWorkspace.clear();
mostRecentSize = null;
}