diff --git a/plugins/session-sharing/src/frontend/SharedSessionView.tsx b/plugins/session-sharing/src/frontend/SharedSessionView.tsx
index 3bf85eff9..b8164e64c 100644
--- a/plugins/session-sharing/src/frontend/SharedSessionView.tsx
+++ b/plugins/session-sharing/src/frontend/SharedSessionView.tsx
@@ -135,7 +135,11 @@ export function GuestTerminalView({
terminal.open(xtermRef.current);
fitAddon.fit();
- const resizeObserver = new ResizeObserver(() => fitAddon.fit());
+ let sharedSize: { cols: number; rows: number } | null = null;
+ const resizeObserver = new ResizeObserver(() => {
+ if (sharedSize) terminal.resize(sharedSize.cols, sharedSize.rows);
+ else fitAddon.fit();
+ });
resizeObserver.observe(xtermRef.current);
let cancelled = false;
@@ -163,6 +167,21 @@ export function GuestTerminalView({
}
switch (msg.type) {
+ case "resized": {
+ const { cols, rows } = msg;
+ if (
+ typeof cols === "number" &&
+ typeof rows === "number" &&
+ Number.isInteger(cols) &&
+ Number.isInteger(rows) &&
+ cols > 0 &&
+ rows > 0
+ ) {
+ sharedSize = { cols, rows };
+ terminal.resize(cols, rows);
+ }
+ break;
+ }
case "data":
if (typeof msg.data === "string") terminal.write(msg.data);
break;
diff --git a/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx b/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx
new file mode 100644
index 000000000..5adfede78
--- /dev/null
+++ b/plugins/session-sharing/tests/frontend/guest-terminal-size.test.tsx
@@ -0,0 +1,110 @@
+import { act, cleanup, render, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { GuestTerminalView } from "../../src/frontend/SharedSessionView";
+
+const f = vi.hoisted(() => ({
+ terminal: {
+ cols: 80,
+ rows: 24,
+ options: {},
+ loadAddon: vi.fn(),
+ open: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ },
+ fit: vi.fn(),
+ onResize: null as (() => void) | null,
+}));
+vi.mock("@termix/plugin-sdk/frontend", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock("react-xtermjs", () => ({
+ useXTerm: () => ({ instance: f.terminal, ref: { current: null } }),
+}));
+vi.mock("@xterm/addon-fit", () => ({
+ FitAddon: class {
+ fit() {
+ f.fit();
+ }
+ },
+}));
+vi.mock("../../src/frontend/shared", () => ({
+ wsUrlForPath: async () => "ws://termix.test/terminal",
+ RemoteDisplay: () => null,
+ Loader: () => null,
+}));
+class Socket {
+ static OPEN = 1;
+ static latest: Socket;
+ readyState = 1;
+ onopen?: () => void;
+ onmessage?: (event: { data: string }) => void;
+ onclose?: () => void;
+ onerror?: () => void;
+ constructor() {
+ Socket.latest = this;
+ }
+ send() {}
+ close() {}
+ message(value: object) {
+ this.onmessage?.({ data: JSON.stringify(value) });
+ }
+}
+beforeEach(() => {
+ vi.clearAllMocks();
+ f.terminal.cols = 80;
+ f.terminal.rows = 24;
+ f.terminal.resize.mockImplementation((cols: number, rows: number) => {
+ f.terminal.cols = cols;
+ f.terminal.rows = rows;
+ });
+ f.fit.mockImplementation(() => {
+ f.terminal.cols = 80;
+ f.terminal.rows = 24;
+ });
+ vi.stubGlobal("WebSocket", Socket);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(fn: () => void) {
+ f.onResize = fn;
+ }
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+it("keeps the presenter's grid while the guest window changes size", async () => {
+ render(
+ ,
+ );
+ await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
+ act(() => Socket.latest.message({ type: "resized", cols: 132, rows: 40 }));
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
+ act(() => f.onResize?.());
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
+ act(() => Socket.latest.message({ type: "resized", cols: 100, rows: 30 }));
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
+});
+
+describe("invalid shared terminal dimensions", () => {
+ it.each([0, -1, 1.5, "132"])("ignores cols=%s", async (cols) => {
+ render(
+ ,
+ );
+ await waitFor(() => expect(Socket.latest.onmessage).toBeTypeOf("function"));
+ act(() => Socket.latest.message({ type: "resized", cols, rows: 24 }));
+ expect(f.terminal.resize).not.toHaveBeenCalled();
+ });
+});
diff --git a/plugins/ssh-terminal/src/backend/session-manager.ts b/plugins/ssh-terminal/src/backend/session-manager.ts
index 06fcf5e20..3fcc7d21d 100644
--- a/plugins/ssh-terminal/src/backend/session-manager.ts
+++ b/plugins/ssh-terminal/src/backend/session-manager.ts
@@ -444,6 +444,15 @@ export class TerminalSessionManager {
tabInstanceId: opts.tabInstanceId,
joinedViaShareId: opts.shareId,
});
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "resized",
+ cols: session.cols,
+ rows: session.rows,
+ }),
+ );
+ }
this.broadcastParticipants(sessionId);
this.log.info("Participant joined shared session", {
@@ -812,6 +821,15 @@ export class TerminalSessionManager {
this.recordSessionEvent(session, "i", data);
}
+ resizeSession(sessionId: string, cols: number, rows: number): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+ session.cols = cols;
+ session.rows = rows;
+ this.broadcast(sessionId, { type: "resized", cols, rows });
+ this.bufferResize(sessionId, cols, rows);
+ }
+
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
diff --git a/plugins/ssh-terminal/src/backend/terminal-socket.ts b/plugins/ssh-terminal/src/backend/terminal-socket.ts
index 849e4e228..472479ac9 100644
--- a/plugins/ssh-terminal/src/backend/terminal-socket.ts
+++ b/plugins/ssh-terminal/src/backend/terminal-socket.ts
@@ -568,8 +568,11 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
attachRows,
attachCols,
);
- session.cols = attachCols;
- session.rows = attachRows;
+ sessionManager.resizeSession(
+ session.id,
+ attachCols,
+ attachRows,
+ );
}
ws.send(
@@ -2871,11 +2874,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
resizeStream.setWindow(rows, cols, rows, cols);
const session = sessionManager.getSession(currentSessionId);
if (session) {
- session.cols = cols;
- session.rows = rows;
- sessionManager.bufferResize(session.id, cols, rows);
+ sessionManager.resizeSession(session.id, cols, rows);
}
- ws.send(JSON.stringify({ type: "resized", cols, rows }));
}
}
diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx
index d8123dcf7..5ff36c487 100644
--- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx
+++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx
@@ -910,6 +910,33 @@ const TerminalInner = forwardRef(
}
}
+ const sharedSizeRef = useRef<{ cols: number; rows: number } | null>(null);
+
+ function applySharedSize(msg: { cols?: unknown; rows?: unknown }) {
+ if (!hostConfig.joinShareId || !terminal) return;
+ const { cols, rows } = msg;
+ if (
+ typeof cols !== "number" ||
+ typeof rows !== "number" ||
+ !Number.isInteger(cols) ||
+ !Number.isInteger(rows) ||
+ cols < 1 ||
+ rows < 1
+ )
+ return;
+ sharedSizeRef.current = { cols, rows };
+ terminal.resize(cols, rows);
+ }
+
+ function fitTerminal() {
+ const size = sharedSizeRef.current;
+ if (hostConfig.joinShareId && size) {
+ terminal?.resize(size.cols, size.rows);
+ } else {
+ fitAddonRef.current?.fit();
+ }
+ }
+
function performFit() {
if (
!fitAddonRef.current ||
@@ -923,7 +950,7 @@ const TerminalInner = forwardRef(
isFittingRef.current = true;
try {
- fitAddonRef.current.fit();
+ fitTerminal();
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
const lastSize = lastFittedSizeRef.current;
if (
@@ -1180,6 +1207,7 @@ const TerminalInner = forwardRef(
}
function scheduleNotify(cols: number, rows: number) {
+ if (hostConfig.joinShareId) return;
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows };
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
@@ -1390,7 +1418,7 @@ const TerminalInner = forwardRef(
if (!fitAddonRef.current || !terminal || isFittingRef.current) return;
isFittingRef.current = true;
try {
- fitAddonRef.current.fit();
+ fitTerminal();
if (terminal.cols > 0 && terminal.rows > 0) {
const lastSize = lastFittedSizeRef.current;
if (
@@ -1839,7 +1867,9 @@ const TerminalInner = forwardRef(
pongReceivedRef.current = true;
return;
}
- if (msg.type === "data") {
+ if (msg.type === "resized") {
+ applySharedSize(msg);
+ } else if (msg.type === "data") {
if (typeof msg.data === "string") {
outputListenersRef.current.forEach((listener) =>
listener(msg.data),
@@ -2674,7 +2704,7 @@ const TerminalInner = forwardRef(
});
document.fonts.ready.then(() => {
terminal.refresh(0, terminal.rows - 1);
- fitAddon.fit();
+ fitTerminal();
});
terminal.attachCustomWheelEventHandler((ev) => {
@@ -2699,12 +2729,12 @@ const TerminalInner = forwardRef(
return true;
});
- fitAddonRef.current?.fit();
+ fitTerminal();
// Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before
// committing the fitted size, preventing the "terminal too short" glitch.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- fitAddonRef.current?.fit();
+ fitTerminal();
setIsFitted(true);
});
});
@@ -3383,10 +3413,10 @@ const TerminalInner = forwardRef(
}
setIsConnecting(true);
- fitAddonRef.current?.fit();
+ fitTerminal();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- fitAddonRef.current?.fit();
+ fitTerminal();
if (terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
diff --git a/plugins/ssh-terminal/tests/backend/session-manager.test.ts b/plugins/ssh-terminal/tests/backend/session-manager.test.ts
index f15be1579..eb35d7216 100644
--- a/plugins/ssh-terminal/tests/backend/session-manager.test.ts
+++ b/plugins/ssh-terminal/tests/backend/session-manager.test.ts
@@ -182,6 +182,46 @@ describe("TerminalSessionManager - multiplayer participants", () => {
return id;
}
+ it("sends the presenter dimensions before a participant replays output", () => {
+ const id = createConnectedSession();
+ const session = sessionManager.getSession(id)!;
+ session.cols = 132;
+ session.rows = 40;
+ const ws = makeFakeWs();
+ sessionManager.joinAsParticipant(id, ws, {
+ userId: "viewer",
+ permissionLevel: "read-only",
+ });
+ expect(JSON.parse(vi.mocked(ws.send).mock.calls[0][0] as string)).toEqual({
+ type: "resized",
+ cols: 132,
+ rows: 40,
+ });
+ sessionManager.destroySession(id);
+ });
+
+ it("broadcasts later size changes and retains them for new participants", () => {
+ const id = createConnectedSession();
+ const owner = makeFakeWs();
+ const viewer = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", owner);
+ sessionManager.joinAsParticipant(id, viewer, {
+ userId: "viewer",
+ permissionLevel: "read-only",
+ });
+ sessionManager.resizeSession(id, 100, 35);
+ const message = JSON.stringify({ type: "resized", cols: 100, rows: 35 });
+ expect(owner.send).toHaveBeenCalledWith(message);
+ expect(viewer.send).toHaveBeenCalledWith(message);
+ const newcomer = makeFakeWs();
+ sessionManager.joinAsParticipant(id, newcomer, {
+ userId: null,
+ permissionLevel: "read-only",
+ });
+ expect(vi.mocked(newcomer.send).mock.calls[0][0]).toBe(message);
+ sessionManager.destroySession(id);
+ });
+
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
diff --git a/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts
new file mode 100644
index 000000000..ec95d0462
--- /dev/null
+++ b/plugins/ssh-terminal/tests/frontend/terminal/shared-terminal-size.test.ts
@@ -0,0 +1,109 @@
+import ts from "typescript";
+import { describe, expect, it, vi } from "vitest";
+import sourceText from "../../../src/frontend/terminal/Terminal.tsx?raw";
+
+const source = ts.createSourceFile(
+ "Terminal.tsx",
+ sourceText,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX,
+);
+const names = [
+ "setupWebSocketListeners",
+ "applySharedSize",
+ "fitTerminal",
+ "scheduleNotify",
+];
+const functions: string[] = [];
+function visit(node: ts.Node) {
+ if (
+ ts.isFunctionDeclaration(node) &&
+ node.name &&
+ names.includes(node.name.text)
+ )
+ functions.push(node.getText(source));
+ ts.forEachChild(node, visit);
+}
+visit(source);
+if (functions.length !== names.length)
+ throw new Error("Missing terminal size handlers");
+const create = new Function(
+ "state",
+ ts.transpileModule(
+ `
+ const { terminal, hostConfig, sharedSizeRef, fitAddonRef, webSocketRef, emitSessionMessage } = state;
+ const connectionAttemptIdRef = { current: 1 };
+ ${functions.join("\n")}
+ setupWebSocketListeners(webSocketRef.current, 80, 24, hostConfig);
+ return { fit: fitTerminal, notify: scheduleNotify };
+`,
+ { compilerOptions: { target: ts.ScriptTarget.ES2022 } },
+ ).outputText,
+) as (state: Record) => {
+ fit: () => void;
+ notify: (cols: number, rows: number) => void;
+};
+
+function fixture(shared = true) {
+ const events = new Map void>();
+ const terminal = {
+ cols: 80,
+ rows: 24,
+ resize: vi.fn((cols: number, rows: number) => {
+ terminal.cols = cols;
+ terminal.rows = rows;
+ }),
+ };
+ const fit = vi.fn(() => {
+ terminal.cols = 80;
+ terminal.rows = 24;
+ });
+ const socket = {
+ addEventListener: (name: string, fn: (event: { data: string }) => void) =>
+ events.set(name, fn),
+ send: vi.fn(),
+ };
+ const handlers = create({
+ terminal,
+ hostConfig: shared ? { joinShareId: "share" } : {},
+ sharedSizeRef: { current: null },
+ fitAddonRef: { current: { fit } },
+ webSocketRef: { current: socket },
+ emitSessionMessage: vi.fn(),
+ });
+ const resize = (cols: unknown, rows: unknown) =>
+ events.get("message")!({
+ data: JSON.stringify({ type: "resized", cols, rows }),
+ });
+ return { terminal, fit, handlers, socket, resize };
+}
+
+describe("in-app shared terminal sizing", () => {
+ it("uses presenter sizes and does not refit or resize the remote shell from the viewer", () => {
+ const f = fixture();
+ f.resize(132, 40);
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
+ f.handlers.fit();
+ expect(f.fit).not.toHaveBeenCalled();
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([132, 40]);
+ f.handlers.notify(80, 24);
+ expect(f.socket.send).not.toHaveBeenCalled();
+ f.resize(100, 30);
+ expect([f.terminal.cols, f.terminal.rows]).toEqual([100, 30]);
+ });
+
+ it("continues fitting the owner's terminal normally", () => {
+ const f = fixture(false);
+ f.resize(132, 40);
+ expect(f.terminal.resize).not.toHaveBeenCalled();
+ f.handlers.fit();
+ expect(f.fit).toHaveBeenCalledOnce();
+ });
+
+ it.each([0, -1, 1.5, "132"])("rejects an invalid column count %s", (cols) => {
+ const f = fixture();
+ f.resize(cols, 24);
+ expect(f.terminal.resize).not.toHaveBeenCalled();
+ });
+});