fix(terminal): skip resize snapshots and standardize key encoding

This commit is contained in:
Mohamed Boudra
2026-03-23 14:47:27 +07:00
parent b862f7b72d
commit b873523eab
10 changed files with 167 additions and 86 deletions
@@ -175,6 +175,19 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId, paneId,
buildPaneContentModel, buildPaneContentModel,
}: MountedTabSlotProps) { }: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo( const content = useMemo(
() => () =>
buildPaneContentModel({ buildPaneContentModel({
@@ -1,7 +1,8 @@
"use dom"; "use dom";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState, type Ref } from "react";
import type { DOMProps } from "expo/dom"; import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm"; import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
@@ -13,6 +14,12 @@ import {
computeVerticalScrollbarGeometry, computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math"; } from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6; const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9; const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18; const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
@@ -64,13 +71,14 @@ function buildXtermThemeKey(theme: ITheme): string {
interface TerminalEmulatorProps { interface TerminalEmulatorProps {
dom?: DOMProps; dom?: DOMProps;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string; streamKey: string;
initialSnapshot: TerminalState | null;
testId?: string; testId?: string;
xtermTheme?: ITheme; xtermTheme?: ITheme;
swipeGesturesEnabled?: boolean; swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void; onSwipeLeft?: () => void;
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void; onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: { onTerminalKey?: (input: {
@@ -86,12 +94,6 @@ interface TerminalEmulatorProps {
resizeRequestToken?: number; resizeRequestToken?: number;
} }
export type TerminalEmulatorHandle = {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
};
declare global { declare global {
interface Window {} interface Window {}
} }
@@ -124,9 +126,9 @@ function ensureTerminalScrollbarStyle(): void {
document.head.appendChild(styleElement); document.head.appendChild(styleElement);
} }
const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProps>(function TerminalEmulator({ export default function TerminalEmulator({
ref,
streamKey, streamKey,
initialSnapshot,
testId = "terminal-surface", testId = "terminal-surface",
xtermTheme = { xtermTheme = {
background: "#0b0b0b", background: "#0b0b0b",
@@ -136,6 +138,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
swipeGesturesEnabled = false, swipeGesturesEnabled = false,
onSwipeLeft, onSwipeLeft,
onSwipeRight, onSwipeRight,
initialSnapshot = null,
onInput, onInput,
onResize, onResize,
onTerminalKey, onTerminalKey,
@@ -143,7 +146,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
pendingModifiers = { ctrl: false, shift: false, alt: false }, pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0, focusRequestToken = 0,
resizeRequestToken = 0, resizeRequestToken = 0,
}, ref) { }: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null); const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
@@ -165,6 +168,23 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
const [isScrollVisible, setIsScrollVisible] = useState(false); const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false); const [isScrollActive, setIsScrollActive] = useState(false);
useDOMImperativeHandle(
ref as Ref<DOMImperativeFactory>,
() =>
({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}) as unknown as DOMImperativeFactory,
[],
);
useEffect(() => { useEffect(() => {
mountedThemeRef.current = xtermTheme; mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme }); runtimeRef.current?.setTheme({ theme: xtermTheme });
@@ -338,22 +358,6 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
runtimeRef.current?.setPendingModifiers({ pendingModifiers }); runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]); }, [pendingModifiers]);
useImperativeHandle(
ref,
() => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}),
[],
);
useEffect(() => { useEffect(() => {
if (focusRequestToken <= 0) { if (focusRequestToken <= 0) {
return; return;
@@ -670,6 +674,4 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
) : null} ) : null}
</div> </div>
); );
}); }
export default TerminalEmulator;
@@ -107,13 +107,13 @@ export function TerminalPane({
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null); const streamControllerRef = useRef<TerminalStreamController | null>(null);
const emulatorRef = useRef<TerminalEmulatorHandle | null>(null);
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]); const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
const [isAttaching, setIsAttaching] = useState(false); const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null); const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS); const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0); const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId); const terminalIdRef = useRef<string>(terminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]); const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
@@ -560,10 +560,10 @@ export function TerminalPane({
contentInsetAdjustmentBehavior: "never", contentInsetAdjustmentBehavior: "never",
}} }}
streamKey={`${scopeKey}:${terminalId}`} streamKey={`${scopeKey}:${terminalId}`}
initialSnapshot={initialSnapshot}
testId="terminal-surface" testId="terminal-surface"
xtermTheme={xtermTheme} xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled} swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onSwipeRight={() => { onSwipeRight={() => {
if (!swipeGesturesEnabled) { if (!swipeGesturesEnabled) {
return; return;
@@ -40,6 +40,10 @@ export type TerminalStreamControllerOptions = {
const TERMINAL_EXITED_ERROR = "Terminal exited"; const TERMINAL_EXITED_ERROR = "Terminal exited";
function logTerminalStreamController(event: string, details: Record<string, unknown>): void {
console.log("[terminal-stream-controller]", event, details);
}
export class TerminalStreamController { export class TerminalStreamController {
private readonly decoder = new TextDecoder(); private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void; private readonly unsubscribeStreamEvents: () => void;
@@ -65,10 +69,19 @@ export class TerminalStreamController {
setTerminal(input: { terminalId: string | null }): void { setTerminal(input: { terminalId: string | null }): void {
if (this.disposed || input.terminalId === this.terminalId) { if (this.disposed || input.terminalId === this.terminalId) {
logTerminalStreamController("set_terminal_noop", {
disposed: this.disposed,
currentTerminalId: this.terminalId,
requestedTerminalId: input.terminalId,
});
return; return;
} }
const nextTerminalId = input.terminalId; const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId; const previousTerminalId = this.terminalId;
logTerminalStreamController("set_terminal", {
previousTerminalId,
nextTerminalId,
});
this.terminalId = nextTerminalId; this.terminalId = nextTerminalId;
this.decoder.decode(); this.decoder.decode();
if (previousTerminalId) { if (previousTerminalId) {
@@ -82,6 +95,11 @@ export class TerminalStreamController {
void this.options.client void this.options.client
.subscribeTerminal(nextTerminalId) .subscribeTerminal(nextTerminalId)
.then((payload) => { .then((payload) => {
logTerminalStreamController("subscribe_resolved", {
nextTerminalId,
payloadTerminalId: payload.terminalId,
error: payload.error ?? null,
});
if (this.disposed || this.terminalId !== nextTerminalId) { if (this.disposed || this.terminalId !== nextTerminalId) {
return; return;
} }
@@ -95,6 +113,10 @@ export class TerminalStreamController {
return; return;
} }
const preferredSize = this.options.getPreferredSize(); const preferredSize = this.options.getPreferredSize();
logTerminalStreamController("preferred_size_after_subscribe", {
nextTerminalId,
preferredSize,
});
if (preferredSize) { if (preferredSize) {
this.options.client.sendTerminalInput(nextTerminalId, { this.options.client.sendTerminalInput(nextTerminalId, {
type: "resize", type: "resize",
@@ -109,6 +131,10 @@ export class TerminalStreamController {
}); });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
logTerminalStreamController("subscribe_rejected", {
nextTerminalId,
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
});
if (this.disposed || this.terminalId !== nextTerminalId) { if (this.disposed || this.terminalId !== nextTerminalId) {
return; return;
} }
@@ -122,6 +148,10 @@ export class TerminalStreamController {
} }
handleTerminalExit(input: { terminalId: string }): void { handleTerminalExit(input: { terminalId: string }): void {
logTerminalStreamController("terminal_exit", {
currentTerminalId: this.terminalId,
exitedTerminalId: input.terminalId,
});
if (this.disposed || input.terminalId !== this.terminalId) { if (this.disposed || input.terminalId !== this.terminalId) {
return; return;
} }
@@ -138,6 +168,9 @@ export class TerminalStreamController {
if (this.disposed) { if (this.disposed) {
return; return;
} }
logTerminalStreamController("dispose", {
terminalId: this.terminalId,
});
this.disposed = true; this.disposed = true;
this.decoder.decode(); this.decoder.decode();
const terminalId = this.terminalId; const terminalId = this.terminalId;
@@ -606,16 +606,10 @@ describe("daemon E2E terminal", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 30000); }, 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 cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd); const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id; const terminalId = created.terminal!.id;
const resizedSnapshot = waitForTerminalSnapshot(
ctx.client,
terminalId,
(state) => state.rows === 10 && state.cols === 40,
);
await ctx.client.subscribeTerminal(terminalId); await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, { ctx.client.sendTerminalInput(terminalId, {
type: "resize", type: "resize",
@@ -623,9 +617,11 @@ describe("daemon E2E terminal", () => {
cols: 40, cols: 40,
}); });
const snapshot = await resizedSnapshot; await new Promise((resolve) => setTimeout(resolve, 300));
expect(snapshot.rows).toBe(10);
expect(snapshot.cols).toBe(40); 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 }); rmSync(cwd, { recursive: true, force: true });
}, 30000); }, 30000);
+7
View File
@@ -7729,6 +7729,13 @@ export class Session {
} }
this.ensureTerminalExitSubscription(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); session.send(msg.message);
} }
@@ -23,10 +23,11 @@ describe("encodeTerminalKeyInput", () => {
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u"); expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
}); });
it("encodes enter with other modifiers as plain carriage return", () => { it("encodes enter with modifiers using CSI u", () => {
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\r"); expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u");
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b\r"); expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u");
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\r"); 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", () => { it("returns empty string for unsupported keys", () => {
@@ -9,13 +9,14 @@ export interface TerminalKeyInput {
function modifierParam(input: TerminalKeyInput): number { function modifierParam(input: TerminalKeyInput): number {
let value = 1; let value = 1;
if (input.shift) 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.ctrl) value += 4;
if (input.meta) value += 8;
return value; return value;
} }
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string { 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 { function encodePrintableKey(input: TerminalKeyInput): string {
@@ -85,11 +86,13 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
} }
switch (key) { switch (key) {
case "Enter": case "Enter": {
if (input.shift && !input.ctrl && !input.alt && !input.meta) { const mod = modifierParam(input);
return "\x1b[13;2u"; if (mod > 1) {
return `\x1b[13;${mod}u`;
}
return "\r";
} }
return applyAltLikePrefix("\r", input);
case "Tab": case "Tab":
if (input.shift && !input.ctrl && !input.alt && !input.meta) { if (input.shift && !input.ctrl && !input.alt && !input.meta) {
return "\x1b[Z"; return "\x1b[Z";
+50 -2
View File
@@ -402,6 +402,25 @@ describe("Terminal", () => {
expect(state.grid.length).toBe(10); expect(state.grid.length).toBe(10);
expect(state.grid[0].length).toBe(40); 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", () => { describe("subscribe", () => {
@@ -427,7 +446,7 @@ describe("Terminal", () => {
unsubscribe(); unsubscribe();
}); });
it("receives output and snapshot messages on updates", async () => { it("receives output messages on updates without replay snapshots", async () => {
const session = trackSession( const session = trackSession(
await createTerminal({ await createTerminal({
cwd: "/tmp", cwd: "/tmp",
@@ -443,6 +462,7 @@ describe("Terminal", () => {
messages.push(msg); messages.push(msg);
}); });
await new Promise((resolve) => setTimeout(resolve, 100));
messages.length = 0; messages.length = 0;
session.send({ type: "input", data: "echo test\r" }); session.send({ type: "input", data: "echo test\r" });
@@ -450,7 +470,35 @@ describe("Terminal", () => {
await waitForLines(session, ["$ echo test", "test", "$"]); await waitForLines(session, ["$ echo test", "test", "$"]);
expect(messages.some((message) => message.type === "output")).toBe(true); 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(); unsubscribe();
}); });
+10 -32
View File
@@ -9,7 +9,6 @@ import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm; const { Terminal } = xterm;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false; let nodePtySpawnHelperChecked = false;
const STATE_BROADCAST_INTERVAL_MS = 33;
export type ClientMessage = export type ClientMessage =
| { type: "input"; data: string } | { type: "input"; data: string }
@@ -27,6 +26,7 @@ export interface TerminalSession {
send(msg: ClientMessage): void; send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void; subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void; onExit(listener: () => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState; getState(): TerminalState;
kill(): void; kill(): void;
} }
@@ -246,8 +246,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let killed = false; let killed = false;
let disposed = false; let disposed = false;
let exitEmitted = false; let exitEmitted = false;
let stateBroadcastScheduled = false;
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
// Create xterm.js headless terminal // Create xterm.js headless terminal
const terminal = new Terminal({ const terminal = new Terminal({
@@ -301,38 +299,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return; return;
} }
disposed = true; disposed = true;
if (stateBroadcastTimer) {
clearTimeout(stateBroadcastTimer);
stateBroadcastTimer = null;
}
stateBroadcastScheduled = false;
terminal.dispose(); terminal.dispose();
listeners.clear(); listeners.clear();
exitListeners.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 // Pipe PTY output to terminal emulator
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
if (killed) return; if (killed) return;
@@ -343,7 +314,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
for (const listener of listeners) { for (const listener of listeners) {
listener({ type: "output", data }); listener({ type: "output", data });
} }
scheduleStateBroadcast();
}); });
}); });
@@ -362,6 +332,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
cursor: extractCursorState(terminal), cursor: extractCursorState(terminal),
}; };
} }
function getSize(): { rows: number; cols: number } {
return {
rows: terminal.rows,
cols: terminal.cols,
};
}
function send(msg: ClientMessage): void { function send(msg: ClientMessage): void {
if (killed) return; if (killed) return;
@@ -372,7 +350,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
case "resize": case "resize":
terminal.resize(msg.cols, msg.rows); terminal.resize(msg.cols, msg.rows);
ptyProcess.resize(msg.cols, msg.rows); ptyProcess.resize(msg.cols, msg.rows);
scheduleStateBroadcast();
break; break;
case "mouse": case "mouse":
// Mouse events can be sent as escape sequences if terminal supports it // 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, send,
subscribe, subscribe,
onExit, onExit,
getSize,
getState, getState,
kill, kill,
}; };