fix(terminal): skip resize snapshots and standardize key encoding
This commit is contained in:
@@ -606,16 +606,10 @@ describe("daemon E2E terminal", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("resize sends a new snapshot with the updated dimensions", async () => {
|
||||
test("resize updates server dimensions without sending a live snapshot", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
const resizedSnapshot = waitForTerminalSnapshot(
|
||||
ctx.client,
|
||||
terminalId,
|
||||
(state) => state.rows === 10 && state.cols === 40,
|
||||
);
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
@@ -623,9 +617,11 @@ describe("daemon E2E terminal", () => {
|
||||
cols: 40,
|
||||
});
|
||||
|
||||
const snapshot = await resizedSnapshot;
|
||||
expect(snapshot.rows).toBe(10);
|
||||
expect(snapshot.cols).toBe(40);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
|
||||
expect(state?.rows).toBe(10);
|
||||
expect(state?.cols).toBe(40);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
@@ -7729,6 +7729,13 @@ export class Session {
|
||||
}
|
||||
this.ensureTerminalExitSubscription(session);
|
||||
|
||||
if (msg.message.type === "resize") {
|
||||
const currentSize = session.getSize();
|
||||
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.send(msg.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ describe("encodeTerminalKeyInput", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("encodes enter with other modifiers as plain carriage return", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\r");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b\r");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\r");
|
||||
it("encodes enter with modifiers using CSI u", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", meta: true })).toBe("\x1b[13;9u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\x1b[13;6u");
|
||||
});
|
||||
|
||||
it("returns empty string for unsupported keys", () => {
|
||||
|
||||
@@ -9,13 +9,14 @@ export interface TerminalKeyInput {
|
||||
function modifierParam(input: TerminalKeyInput): number {
|
||||
let value = 1;
|
||||
if (input.shift) value += 1;
|
||||
if (input.alt || input.meta) value += 2;
|
||||
if (input.alt) value += 2;
|
||||
if (input.ctrl) value += 4;
|
||||
if (input.meta) value += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
||||
return input.alt || input.meta ? `\x1b${sequence}` : sequence;
|
||||
return input.alt ? `\x1b${sequence}` : sequence;
|
||||
}
|
||||
|
||||
function encodePrintableKey(input: TerminalKeyInput): string {
|
||||
@@ -85,11 +86,13 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
||||
return "\x1b[13;2u";
|
||||
case "Enter": {
|
||||
const mod = modifierParam(input);
|
||||
if (mod > 1) {
|
||||
return `\x1b[13;${mod}u`;
|
||||
}
|
||||
return applyAltLikePrefix("\r", input);
|
||||
return "\r";
|
||||
}
|
||||
case "Tab":
|
||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
||||
return "\x1b[Z";
|
||||
|
||||
@@ -402,6 +402,25 @@ describe("Terminal", () => {
|
||||
expect(state.grid.length).toBe(10);
|
||||
expect(state.grid[0].length).toBe(40);
|
||||
});
|
||||
|
||||
it("exposes the current size without extracting full state", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(session.getSize()).toEqual({ rows: 24, cols: 80 });
|
||||
|
||||
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(session.getSize()).toEqual({ rows: 10, cols: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
@@ -427,7 +446,7 @@ describe("Terminal", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("receives output and snapshot messages on updates", async () => {
|
||||
it("receives output messages on updates without replay snapshots", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
@@ -443,6 +462,7 @@ describe("Terminal", () => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "input", data: "echo test\r" });
|
||||
@@ -450,7 +470,35 @@ describe("Terminal", () => {
|
||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||
|
||||
expect(messages.some((message) => message.type === "output")).toBe(true);
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(true);
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not emit snapshot messages for resize-only updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
const messages: Array<{ type: string }> = [];
|
||||
const unsubscribe = session.subscribe((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "resize", rows: 30, cols: 100 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
const { Terminal } = xterm;
|
||||
const require = createRequire(import.meta.url);
|
||||
let nodePtySpawnHelperChecked = false;
|
||||
const STATE_BROADCAST_INTERVAL_MS = 33;
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
@@ -27,6 +26,7 @@ export interface TerminalSession {
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
onExit(listener: () => void): () => void;
|
||||
getSize(): { rows: number; cols: number };
|
||||
getState(): TerminalState;
|
||||
kill(): void;
|
||||
}
|
||||
@@ -246,8 +246,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
let killed = false;
|
||||
let disposed = false;
|
||||
let exitEmitted = false;
|
||||
let stateBroadcastScheduled = false;
|
||||
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -301,38 +299,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
if (stateBroadcastTimer) {
|
||||
clearTimeout(stateBroadcastTimer);
|
||||
stateBroadcastTimer = null;
|
||||
}
|
||||
stateBroadcastScheduled = false;
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
exitListeners.clear();
|
||||
}
|
||||
|
||||
function emitStateToListeners(): void {
|
||||
stateBroadcastScheduled = false;
|
||||
if (disposed || killed || listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
const state = getState();
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "snapshot", state });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStateBroadcast(): void {
|
||||
if (disposed || killed || listeners.size === 0 || stateBroadcastScheduled) {
|
||||
return;
|
||||
}
|
||||
stateBroadcastScheduled = true;
|
||||
stateBroadcastTimer = setTimeout(() => {
|
||||
stateBroadcastTimer = null;
|
||||
emitStateToListeners();
|
||||
}, STATE_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
@@ -343,7 +314,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "output", data });
|
||||
}
|
||||
scheduleStateBroadcast();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,6 +332,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
cursor: extractCursorState(terminal),
|
||||
};
|
||||
}
|
||||
|
||||
function getSize(): { rows: number; cols: number } {
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
};
|
||||
}
|
||||
|
||||
function send(msg: ClientMessage): void {
|
||||
if (killed) return;
|
||||
|
||||
@@ -372,7 +350,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
case "resize":
|
||||
terminal.resize(msg.cols, msg.rows);
|
||||
ptyProcess.resize(msg.cols, msg.rows);
|
||||
scheduleStateBroadcast();
|
||||
break;
|
||||
case "mouse":
|
||||
// Mouse events can be sent as escape sequences if terminal supports it
|
||||
@@ -432,6 +409,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
getSize,
|
||||
getState,
|
||||
kill,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user