Improve mobile terminal input, selection, and resizing (#1607)

* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
Mohamed Boudra
2026-08-03 19:01:34 +08:00
committed by GitHub
parent 2948a435ed
commit 04fe30a3e2
119 changed files with 9843 additions and 1032 deletions
@@ -0,0 +1,341 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from "@xterm/headless";
import { expect, test } from "vitest";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const BYTE_DONE_MARKER = "__PASEO_BYTE_PACKAGE_LOCK_DONE__";
const BYTE_TEST_SIZE = { rows: 24, cols: 100 };
interface PackageLockTerminalCwd {
path: string;
gatePath: string;
}
interface CreatedTerminal {
id: string;
}
test("byte-stream headless terminal matches daemon state after high-output attach and restore", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
const terminalCwd = await createPackageLockTerminalCwd();
try {
await client.connect();
const terminal = await createPackageLockTerminal({
client,
cwd: terminalCwd.path,
gatePath: terminalCwd.gatePath,
});
const liveHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stopLive = pipeTerminalStreamToHeadless({
client,
terminalId: terminal.id,
headless: liveHeadless,
});
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot");
await subscribeTerminal(client, terminal.id);
await initialSnapshot;
client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE });
liveHeadless.resize(BYTE_TEST_SIZE);
await startPackageLockOutput(terminalCwd.gatePath);
const capturedViewport = await waitForCapturedViewport({
client,
terminalId: terminal.id,
text: BYTE_DONE_MARKER,
rows: BYTE_TEST_SIZE.rows,
});
const daemonSnapshot = await readTerminalSnapshot({
port: daemon.port,
terminalId: terminal.id,
});
await liveHeadless.flush();
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot));
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines);
client.unsubscribeTerminal(terminal.id);
stopLive();
const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stopRestore = pipeTerminalStreamToHeadless({
client,
terminalId: terminal.id,
headless: restoreHeadless,
});
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore");
await subscribeTerminal(client, terminal.id, {
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
});
await restoreFrame;
await restoreHeadless.flush();
expect(restoreHeadless.visibleLines()).toEqual(capturedViewport.lines);
expect(restoreHeadless.cursor()).toEqual(daemonSnapshot.cursor);
stopRestore();
} finally {
await client.close();
await daemon.close();
await rm(terminalCwd.path, { recursive: true, force: true });
}
}, 30_000);
class ClientHeadlessTerminal {
private readonly terminal: HeadlessTerminal;
private readonly decoder = new TextDecoder();
private pendingWrite = Promise.resolve();
constructor(input: { rows: number; cols: number }) {
this.terminal = new HeadlessTerminal({
rows: input.rows,
cols: input.cols,
scrollback: 10_000,
allowProposedApi: true,
});
}
resize(input: { rows: number; cols: number }): void {
this.terminal.resize(input.cols, input.rows);
}
applyEvent(event: TerminalStreamEvent): void {
if (event.type === "snapshot") {
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true });
return;
}
if (event.type === "restore") {
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
return;
}
this.enqueueText(this.decoder.decode(event.data, { stream: true }));
}
async flush(): Promise<void> {
await this.pendingWrite;
}
visibleLines(): string[] {
const baseY = this.terminal.buffer.active.baseY;
const cell = this.terminal.buffer.active.getNullCell();
const lines: string[] = [];
for (let row = 0; row < this.terminal.rows; row += 1) {
lines.push(bufferLineText(this.terminal.buffer.active.getLine(baseY + row), cell));
}
return lines;
}
cursor(): TerminalState["cursor"] {
return {
row: this.terminal.buffer.active.cursorY,
col: this.terminal.buffer.active.cursorX,
};
}
private enqueueText(text: string, options?: { resetDecoder?: boolean }): void {
if (options?.resetDecoder) {
this.decoder.decode();
this.terminal.reset();
}
this.pendingWrite = this.pendingWrite.then(
() =>
new Promise<void>((resolve) => {
this.terminal.write(text, () => resolve());
}),
);
}
}
function pipeTerminalStreamToHeadless(input: {
client: DaemonClient;
terminalId: string;
headless: ClientHeadlessTerminal;
}): () => void {
return input.client.onTerminalStreamEvent((event) => {
if (event.terminalId !== input.terminalId) {
return;
}
input.headless.applyEvent(event);
});
}
async function subscribeTerminal(
client: DaemonClient,
terminalId: string,
options?: Parameters<DaemonClient["subscribeTerminal"]>[1],
): Promise<void> {
const response = await client.subscribeTerminal(terminalId, options);
if (response.error) {
throw new Error(response.error);
}
}
async function readTerminalSnapshot(input: {
port: number;
terminalId: string;
}): Promise<TerminalState> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${input.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
try {
await client.connect();
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot");
await subscribeTerminal(client, input.terminalId);
const event = await snapshot;
return event.state;
} finally {
await client.close();
}
}
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>(
client: DaemonClient,
terminalId: string,
type: TType,
): Promise<Extract<TerminalStreamEvent, { type: TType }>> {
return client.waitForTerminalStreamEvent(
(event) => event.terminalId === terminalId && event.type === type,
10_000,
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
}
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> {
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-byte-package-lock-"));
return {
path: cwd,
gatePath: path.join(cwd, "start-package-lock-output"),
};
}
async function createPackageLockTerminal(input: {
client: DaemonClient;
cwd: string;
gatePath: string;
}): Promise<CreatedTerminal> {
const opened = await input.client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open workspace for ${input.cwd}`);
}
const response = await input.client.createTerminal(input.cwd, "byte-package-lock", undefined, {
command: process.execPath,
args: [
"-e",
packageLockStreamScript(),
repoPackageLockPath(),
input.gatePath,
BYTE_DONE_MARKER,
],
workspaceId: opened.workspace.id,
});
if (response.error || !response.terminal) {
throw new Error(response.error ?? "Terminal was not created");
}
return { id: response.terminal.id };
}
async function startPackageLockOutput(gatePath: string): Promise<void> {
await writeFile(gatePath, "go");
}
async function waitForCapturedViewport(input: {
client: DaemonClient;
terminalId: string;
text: string;
rows: number;
}): Promise<{ lines: string[] }> {
return waitForCondition(async () => {
const capture = await input.client.captureTerminal(input.terminalId, {
start: -input.rows,
stripAnsi: true,
});
return capture.lines.join("\n").includes(input.text) ? { lines: capture.lines } : null;
}, 15_000);
}
async function waitForCondition<T>(
predicate: () => Promise<T | null> | T | null,
timeoutMs: number,
): Promise<T> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const value = await predicate();
if (value) {
return value;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`);
}
function visibleTerminalLines(state: TerminalState): string[] {
return state.grid.map(terminalRowText);
}
function terminalRowText(row: TerminalState["grid"][number]): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
const cells: TerminalCell[] = [];
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
const cell = line?.getCell(col, reusableCell);
cells.push({ char: cell?.getChars() || " " });
}
return terminalRowText(cells);
}
function packageLockStreamScript(): string {
return `
const fs = require("node:fs");
const packageLockPath = process.argv[1];
const gatePath = process.argv[2];
const marker = process.argv[3];
function waitForGate() {
if (!fs.existsSync(gatePath)) {
setTimeout(waitForGate, 10);
return;
}
process.stdout.write(fs.readFileSync(packageLockPath, "utf8"));
process.stdout.write("\\n" + marker + "\\n");
}
waitForGate();
setInterval(() => {}, 1000);
`;
}
function repoPackageLockPath(): string {
const start = path.dirname(fileURLToPath(import.meta.url));
let current = start;
while (current !== path.dirname(current)) {
const candidate = path.join(current, "package-lock.json");
if (existsSync(candidate)) {
return candidate;
}
current = path.dirname(current);
}
throw new Error(`Could not find package-lock.json from ${start}`);
}
@@ -1264,6 +1264,68 @@ test("resize updates server dimensions without sending a live snapshot", async (
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("the latest resize from any attached client owns the PTY size", async () => {
const cwd = tmpCwd();
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
const mobileClient = await connectClient(
ctx.daemon.port,
`terminal-mobile-resize-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const readSize = () => {
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
return state ? { rows: state.rows, cols: state.cols } : null;
};
const expectSize = async (size: { rows: number; cols: number }) => {
await expect.poll(readSize, { timeout: 5_000 }).toEqual(size);
};
try {
await ctx.client.subscribeTerminal(terminalId);
await mobileClient.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "resize",
rows: 56,
cols: 200,
});
await expectSize({ rows: 56, cols: 200 });
mobileClient.sendTerminalInput(terminalId, {
type: "resize",
rows: 42,
cols: 54,
});
await expectSize({ rows: 42, cols: 54 });
// Staying attached and receiving output cannot implicitly take ownership back.
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf 'passive-client-output\\n'\r",
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(readSize()).toEqual({ rows: 42, cols: 54 });
ctx.client.sendTerminalInput(terminalId, {
type: "resize",
rows: 38,
cols: 81,
});
await expectSize({ rows: 38, cols: 81 });
mobileClient.sendTerminalInput(terminalId, {
type: "resize",
rows: 42,
cols: 54,
});
await expectSize({ rows: 42, cols: 54 });
} finally {
await mobileClient.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("resize does not stall streamed output for an attached client", async () => {
const cwd = tmpCwd();
const created = await createTerminalInWorkspace(ctx.client, { cwd });
@@ -933,6 +933,8 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
await server.close();
});
@@ -1531,6 +1531,10 @@ export class VoiceAssistantWebSocketServer {
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
"terminal-input-mode-replay": true,
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
"terminal-size-ownership": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: true,
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";
import {
TerminalStreamOpcode,
encodeTerminalResizePayload,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { SessionOutboundMessage, TerminalState } from "@getpaseo/protocol/messages";
import type pino from "pino";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js";
import type { TerminalSession } from "./terminal.js";
function createFixture(): {
terminal: TerminalSession;
terminalManager: TerminalManager;
appliedSizes: string[];
} {
const appliedSizes: string[] = [];
let size = { rows: 24, cols: 80 };
const state = (): TerminalState => ({
rows: size.rows,
cols: size.cols,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
});
const terminal = {
id: "terminal-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "workspace-1",
getSize: () => size,
send: (message: { type: string; rows?: number; cols?: number }) => {
if (message.type !== "resize" || message.rows === undefined || message.cols === undefined) {
return;
}
size = { rows: message.rows, cols: message.cols };
appliedSizes.push(`${message.cols}x${message.rows}`);
},
subscribe: () => () => {},
onExit: () => () => {},
} as unknown as TerminalSession;
const terminalManager = {
getTerminal: () => terminal,
getTerminalState: async () => ({ state: state(), revision: 0 }),
} as unknown as TerminalManager;
return { terminal, terminalManager, appliedSizes };
}
function createController(
terminalManager: TerminalManager,
outbound: SessionOutboundMessage[] = [],
): TerminalSessionController {
return new TerminalSessionController({
terminalManager,
emit: (message) => outbound.push(message),
emitBinary: () => {},
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: { warn: () => {}, error: () => {} } as unknown as pino.Logger,
});
}
function resizeFrame(input: {
rows: number;
cols: number;
intent?: "claim" | "update";
}): TerminalStreamFrame {
return {
opcode: TerminalStreamOpcode.Resize,
slot: 0,
payload: encodeTerminalResizePayload(input),
};
}
describe("terminal session controller size ownership", () => {
test("routes JSON, binary, and attach sizes through one connection-owned arbiter", async () => {
const { terminalManager, appliedSizes } = createFixture();
const controllerA = createController(terminalManager);
const controllerB = createController(terminalManager);
controllerA.dispatch({
type: "terminal_input",
terminalId: "terminal-1",
message: { type: "resize", rows: 30, cols: 100, intent: "claim" },
});
controllerA.dispatch({
type: "terminal_input",
terminalId: "terminal-1",
message: { type: "resize", rows: 31, cols: 101, intent: "update" },
});
controllerB.dispatch({
type: "terminal_input",
terminalId: "terminal-1",
message: { type: "resize", rows: 32, cols: 102, intent: "update" },
});
await controllerB.dispatch({
type: "subscribe_terminal_request",
terminalId: "terminal-1",
requestId: "subscribe-b",
restore: { mode: "live" },
});
controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" }));
controllerA.dispatch({
type: "terminal_input",
terminalId: "terminal-1",
message: { type: "resize", rows: 34, cols: 104, intent: "update" },
});
await controllerA.dispatch({
type: "subscribe_terminal_request",
terminalId: "terminal-1",
requestId: "subscribe-a",
restore: { mode: "live", size: { rows: 35, cols: 105 } },
});
controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" }));
expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]);
});
});
@@ -74,7 +74,7 @@ describe("terminal-session-controller restore", () => {
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "",
getReplayPreamble: () => "\x1b[?1h\x1b[?2004h",
getTitle: () => undefined,
getActivity: () => null,
setActivity: vi.fn(),
@@ -146,7 +146,9 @@ describe("terminal-session-controller restore", () => {
TerminalStreamOpcode.Output,
]);
expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe(
"\x1b[?1h\x1b[?2004hrestore-after\n",
);
});
});
@@ -33,6 +33,7 @@ import {
} from "./terminal-restore.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { applyTerminalSize } from "./terminal-size-ownership.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
@@ -130,6 +131,7 @@ export class TerminalSessionController {
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
private readonly clientSupportsWrapReflow: () => boolean;
private readonly getClientBufferedAmount: () => number | null;
private readonly terminalSizeOwner = {};
// A subscription is scoped to a (cwd, workspaceId) pair, keyed by
// terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub
@@ -239,7 +241,7 @@ export class TerminalSessionController {
if (!resize) {
return;
}
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
applyTerminalSize(terminal, this.terminalSizeOwner, resize);
return;
}
@@ -660,17 +662,10 @@ export class TerminalSessionController {
this.ensureExitSubscription(session);
if (msg.restore?.size) {
const currentSize = session.getSize();
if (
currentSize.rows !== msg.restore.size.rows ||
currentSize.cols !== msg.restore.size.cols
) {
session.send({
type: "resize",
rows: msg.restore.size.rows,
cols: msg.restore.size.cols,
});
}
applyTerminalSize(session, this.terminalSizeOwner, {
...msg.restore.size,
intent: "claim",
});
}
const slot = this.bindActiveStream(session, { restore: msg.restore });
@@ -725,10 +720,8 @@ export class TerminalSessionController {
this.ensureExitSubscription(session);
if (msg.message.type === "resize") {
const currentSize = session.getSize();
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
return;
}
applyTerminalSize(session, this.terminalSizeOwner, msg.message);
return;
}
session.send(msg.message);
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import type { TerminalSession } from "./terminal.js";
import { applyTerminalSize } from "./terminal-size-ownership.js";
function createTerminal(): { terminal: TerminalSession; appliedSizes: string[] } {
const appliedSizes: string[] = [];
let size = { rows: 24, cols: 80 };
const terminal = {
getSize: () => size,
send: (message: { type: "resize"; rows: number; cols: number }) => {
size = { rows: message.rows, cols: message.cols };
appliedSizes.push(`${message.cols}x${message.rows}`);
},
} as TerminalSession;
return { terminal, appliedSizes };
}
describe("terminal size ownership", () => {
test("only the latest claimant can update a terminal size", () => {
const { terminal, appliedSizes } = createTerminal();
const ownerA = {};
const ownerB = {};
expect(applyTerminalSize(terminal, ownerA, { rows: 30, cols: 100, intent: "claim" })).toBe(
true,
);
expect(applyTerminalSize(terminal, ownerA, { rows: 31, cols: 101, intent: "update" })).toBe(
true,
);
expect(applyTerminalSize(terminal, ownerB, { rows: 32, cols: 102, intent: "update" })).toBe(
false,
);
expect(applyTerminalSize(terminal, ownerB, { rows: 33, cols: 103, intent: "claim" })).toBe(
true,
);
expect(applyTerminalSize(terminal, ownerA, { rows: 34, cols: 104, intent: "update" })).toBe(
false,
);
expect(applyTerminalSize(terminal, ownerA, { rows: 35, cols: 105 })).toBe(true);
expect(applyTerminalSize(terminal, ownerB, { rows: 36, cols: 106, intent: "update" })).toBe(
false,
);
expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]);
});
test("transfers ownership when a new claimant reports the current size", () => {
const { terminal, appliedSizes } = createTerminal();
const ownerA = {};
const ownerB = {};
applyTerminalSize(terminal, ownerA, { rows: 30, cols: 100, intent: "claim" });
applyTerminalSize(terminal, ownerB, { rows: 30, cols: 100, intent: "claim" });
applyTerminalSize(terminal, ownerA, { rows: 31, cols: 101, intent: "update" });
applyTerminalSize(terminal, ownerB, { rows: 32, cols: 102, intent: "update" });
expect(appliedSizes).toEqual(["100x30", "102x32"]);
});
});
@@ -0,0 +1,38 @@
import type { TerminalSession } from "./terminal.js";
interface TerminalSizeRequest {
rows: number;
cols: number;
intent?: "claim" | "update";
}
const terminalSizeOwners = new WeakMap<TerminalSession, object>();
export function applyTerminalSize(
terminal: TerminalSession,
owner: object,
request: TerminalSizeRequest,
): boolean {
const intent = resolveTerminalSizeIntent(request.intent);
if (intent === "update" && terminalSizeOwners.get(terminal) !== owner) {
return false;
}
if (intent === "claim") {
terminalSizeOwners.set(terminal, owner);
}
const currentSize = terminal.getSize();
if (currentSize.rows !== request.rows || currentSize.cols !== request.cols) {
terminal.send({ type: "resize", rows: request.rows, cols: request.cols });
}
return true;
}
function resolveTerminalSizeIntent(intent: TerminalSizeRequest["intent"]): "claim" | "update" {
if (intent) {
return intent;
}
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove after 2027-02-02 once the client floor sends resize intent.
return "claim";
}
@@ -130,21 +130,27 @@ function watchTerminal(session: TerminalSession): void {
});
outputCoalescerByTerminalId.set(session.id, outputCoalescer);
const unsubscribeMessage = session.subscribe((message) => {
if (message.type === "output") {
pendingOutputRevision = message.revision;
outputCoalescer.handle(message.data);
return;
}
// Non-output messages (snapshot/snapshotReady/titleChange) must not jump
// ahead of buffered output: flush the coalescer first, then forward.
outputCoalescer.flush();
sendToParent({
type: "terminalMessage",
terminalId: session.id,
message,
});
});
const unsubscribeMessage = session.subscribe(
(message) => {
if (message.type === "output") {
pendingOutputRevision = message.revision;
outputCoalescer.handle(message.data);
return;
}
// Non-output messages (snapshot/snapshotReady/titleChange) must not jump
// ahead of buffered output: flush the coalescer first, then forward.
outputCoalescer.flush();
sendToParent({
type: "terminalMessage",
terminalId: session.id,
message,
});
},
// Creation already sends an authoritative state in terminalCreated. A second
// asynchronous state snapshot can arrive after the create response and
// overwrite a resize issued immediately by the caller.
{ initialSnapshot: "ready" },
);
const unsubscribeExit = session.onExit((info) => {
outputCoalescer.flush();
clearTerminalSubscriptions(session.id);
@@ -352,12 +352,10 @@ it("refreshes cached terminal size after worker resize", async () => {
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
session.send({ type: "resize", rows: 10, cols: 40 });
const snapshot = await manager.getTerminalState(session.id);
await waitForCondition(() => {
const size = session.getSize();
return size.rows === 10 && size.cols === 40;
}, 10000);
expect(snapshot?.state.rows).toBe(10);
expect(snapshot?.state.cols).toBe(40);
expect(session.getState().rows).toBe(10);
expect(session.getState().cols).toBe(40);
});