From e4a53b6f4763fde52d4a4a88464f4cce50296856 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 19:52:36 -0700 Subject: [PATCH] fix: stop terminal history duplicating on reconnect, agree one size across viewers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving a shared PTY with two real WebSocket viewers against a live instance surfaced two defects. Duplicated history: the server replayed the entire scrollback on every attach, and the client appended it into an xterm that still displayed that history. Every reconnect — backgrounded tab, laptop sleep, heartbeat timeout — therefore added a second copy, seen as the last prompt appearing twice. TerminalService now tracks cumulative output and serves a resume: a client reports the offset it has rendered and receives only the gap, or a full replay flagged reset so it clears first. Wrong size: resize was last-writer-wins. Measured — viewer A at 80x24 had its shell report 200x50 the moment viewer B attached on a bigger screen, while A still drew 80 columns, so wrapped lines and full-screen programs broke for A. TerminalViewportRegistry sizes the PTY to the per-dimension minimum across attached viewers, the rule terminal multiplexers settled on, and gives room back when a viewer leaves. Viewers that have not yet measured themselves do not constrain the size. Co-Authored-By: Claude Opus 5 --- .../terminal-shared-viewport-and-resume.md | 7 ++ .../app/components/TerminalModal.tsx | 12 ++- packages/dashboard/app/hooks/useTerminal.ts | 49 ++++++++--- .../src/__tests__/terminal-service.test.ts | 50 ++++++++++++ .../src/__tests__/terminal-viewport.test.ts | 62 ++++++++++++++ packages/dashboard/src/server.ts | 40 ++++++++- packages/dashboard/src/terminal-service.ts | 37 +++++++++ packages/dashboard/src/terminal-viewport.ts | 81 +++++++++++++++++++ 8 files changed, 323 insertions(+), 15 deletions(-) create mode 100644 .changeset/terminal-shared-viewport-and-resume.md create mode 100644 packages/dashboard/src/__tests__/terminal-viewport.test.ts create mode 100644 packages/dashboard/src/terminal-viewport.ts diff --git a/.changeset/terminal-shared-viewport-and-resume.md b/.changeset/terminal-shared-viewport-and-resume.md new file mode 100644 index 0000000000..a4a685d6e0 --- /dev/null +++ b/.changeset/terminal-shared-viewport-and-resume.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix duplicated terminal history on reconnect and a wrong terminal size when two browsers share a session. +category: fix +dev: Two defects found by driving a shared PTY with two real WebSocket viewers. (1) The server replayed the whole scrollback on every attach and the client appended it into an xterm that still displayed that history, so any reconnect — backgrounded tab, sleep, heartbeat timeout — added a second copy (visible as a duplicated prompt). `TerminalService` now tracks cumulative output (`scrollbackSeq`) and `getScrollbackSince(sessionId, sinceSeq)` returns only the delta when the offset is inside the retained window, or the full buffer with `reset: true`; the client reports `sinceSeq` on connect and resets the terminal before writing a full replay. (2) Resize was last-writer-wins across viewers: A at 80x24 had its shell report 200x50 as soon as B attached at that size, while A still rendered 80 columns. `TerminalViewportRegistry` sizes the PTY to the per-dimension minimum across attached viewers (the tmux rule) and restores room when a viewer disconnects; viewers that have not reported a size do not constrain it. diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 1b5cd78b81..cb0cef9569 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -1852,7 +1852,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG writeToExpectedSession(data); }); - const unsubScrollback = onScrollback((data) => { + /* + FNXC:TerminalSharing 2026-08-19-02:45: + A scrollback frame is either a RESUME (only the bytes this terminal missed — append them) or a + full replay (reset first). Appending a full replay to a terminal that still shows that history is + what duplicated the screen — visibly, the last prompt twice — every time a backgrounded tab + reconnected. + */ + const unsubScrollback = onScrollback((data, reset) => { + if (reset && xtermInitializedRef.current === expectedSessionId) { + xtermRef.current?.reset(); + } writeToExpectedSession(data); }); diff --git a/packages/dashboard/app/hooks/useTerminal.ts b/packages/dashboard/app/hooks/useTerminal.ts index 208f081856..c1682f9e91 100644 --- a/packages/dashboard/app/hooks/useTerminal.ts +++ b/packages/dashboard/app/hooks/useTerminal.ts @@ -17,7 +17,7 @@ export interface UseTerminalReturn { /** Register a callback for connection events */ onConnect: (callback: (info: { shell: string; cwd: string }) => void) => () => void; /** Register a callback for scrollback data */ - onScrollback: (callback: (data: string) => void) => () => void; + onScrollback: (callback: (data: string, reset: boolean) => void) => () => void; /** Manually reconnect */ reconnect: () => void; /** @@ -37,11 +37,15 @@ interface WebSocketMessage { cwd?: string; cols?: number; rows?: number; + /** Cumulative output offset this scrollback frame brings the client up to. */ + seq?: number; + /** True when the client must clear its terminal before writing this scrollback. */ + reset?: boolean; } /** Buffered initial message types that must survive late subscriber registration */ interface BufferedMessages { - scrollback: string | null; + scrollback: { data: string; reset: boolean } | null; connected: { shell: string; cwd: string } | null; /** Accumulated data messages received before any subscriber registered */ data: string[]; @@ -122,7 +126,16 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe const onDataCallbacksRef = useRef void>>(new Set()); const onExitCallbacksRef = useRef void>>(new Set()); const onConnectCallbacksRef = useRef void>>(new Set()); - const onScrollbackCallbacksRef = useRef void>>(new Set()); + const onScrollbackCallbacksRef = useRef void>>(new Set()); + /* + FNXC:TerminalSharing 2026-08-19-02:45: + How much of this session's output the terminal has already rendered. Sent as `sinceSeq` on + reconnect so the server replays only the gap: without it every reattach (backgrounded tab, sleep, + heartbeat timeout) appended a second copy of the scrollback to a terminal that still displayed it, + which is the duplicated-prompt-on-return symptom. Reset when the session changes, because the + offset is meaningless against a different PTY. + */ + const renderedSeqRef = useRef>(new Map()); const onSessionInvalidCallbacksRef = useRef void>>(new Set()); // Buffer for initial messages received before subscribers are registered. @@ -161,12 +174,12 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe return () => onConnectCallbacksRef.current.delete(callback); }, []); - const onScrollback = useCallback((callback: (data: string) => void) => { + const onScrollback = useCallback((callback: (data: string, reset: boolean) => void) => { onScrollbackCallbacksRef.current.add(callback); // Replay buffered scrollback const buffer = initialBufferRef.current; if (buffer.scrollback) { - callback(buffer.scrollback); + callback(buffer.scrollback.data, buffer.scrollback.reset); // Clear after replay to prevent stale re-delivery to subsequent subscribers buffer.scrollback = null; } @@ -301,6 +314,15 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe if (projectId) { wsUrl += `&projectId=${encodeURIComponent(projectId)}`; } + /* + FNXC:TerminalSharing 2026-08-19-02:45: + Tell the server what this terminal has already rendered so a reconnect replays only the gap. On a + first connect there is no offset and the server sends the retained buffer with reset:true. + */ + const renderedSeq = renderedSeqRef.current.get(sessionId); + if (typeof renderedSeq === "number") { + wsUrl += `&sinceSeq=${renderedSeq}`; + } // Carry the bearer token on the URL — WebSocket `new WebSocket` can't set // an Authorization header. `appendTokenQuery` adds `fn_token=` @@ -352,6 +374,8 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe switch (msg.type) { case "data": if (msg.data) { + // Live output counts toward the rendered offset, same as replayed scrollback. + renderedSeqRef.current.set(sessionId, (renderedSeqRef.current.get(sessionId) ?? 0) + msg.data.length); // Buffer data when no subscribers are registered yet if (onDataCallbacksRef.current.size === 0) { buffer.data.push(msg.data!); @@ -359,17 +383,22 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe onDataCallbacksRef.current.forEach((cb) => cb(msg.data!)); } break; - case "scrollback": - if (msg.data) { - // Buffer scrollback only when no subscribers are registered yet + case "scrollback": { + // FNXC:TerminalSharing 2026-08-19-02:45: record how far this session has been rendered so + // a later reconnect can ask for the gap instead of the whole buffer. + if (typeof msg.seq === "number") renderedSeqRef.current.set(sessionId, msg.seq); + const reset = msg.reset === true; + const data = msg.data ?? ""; + if (data || reset) { if (onScrollbackCallbacksRef.current.size === 0) { - buffer.scrollback = msg.data; + buffer.scrollback = { data, reset }; } else { - onScrollbackCallbacksRef.current.forEach((cb) => cb(msg.data!)); + onScrollbackCallbacksRef.current.forEach((cb) => cb(data, reset)); buffer.scrollback = null; } } break; + } case "connected": if (msg.shell && msg.cwd) { const connectedInfo = { shell: msg.shell!, cwd: msg.cwd! }; diff --git a/packages/dashboard/src/__tests__/terminal-service.test.ts b/packages/dashboard/src/__tests__/terminal-service.test.ts index bc29a889f6..d6930f8c37 100644 --- a/packages/dashboard/src/__tests__/terminal-service.test.ts +++ b/packages/dashboard/src/__tests__/terminal-service.test.ts @@ -581,6 +581,56 @@ describe("TerminalService", () => { is invisible with a single viewer and silently deletes a slice of everyone else's live stream once a second one connects. flushPendingOutput() is what an attach uses instead. */ + /* + FNXC:TerminalSharing 2026-08-19-02:45: + Every attach used to replay the whole scrollback. A reconnecting client (backgrounded tab, sleep, + heartbeat timeout) still displays that history, so the replay appended a second copy — the + duplicated-prompt-on-return symptom. A client reports the offset it rendered and gets only the gap. + */ + describe("scrollback resume", () => { + it("returns only the bytes a reattaching client missed", async () => { + const createResult = await service.createSession(); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + const id = createResult.session.id; + + mockPtyProcess._onDataCallback?.("first output\n"); + const initial = service.getScrollbackSince(id); + expect(initial).toEqual({ data: "first output\n", seq: 13, reset: true }); + + mockPtyProcess._onDataCallback?.("second output\n"); + const resumed = service.getScrollbackSince(id, initial!.seq); + // Only the delta, and no reset — the client keeps the screen it already has. + expect(resumed).toEqual({ data: "second output\n", seq: 27, reset: false }); + }); + + it("asks the client to reset when it is caught up (nothing to append)", async () => { + const createResult = await service.createSession(); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + const id = createResult.session.id; + + mockPtyProcess._onDataCallback?.("output\n"); + const at = service.getScrollbackSince(id)!.seq; + expect(service.getScrollbackSince(id, at)).toEqual({ data: "", seq: at, reset: false }); + }); + + it("falls back to a full replay with reset for a first attach or a bogus offset", async () => { + const createResult = await service.createSession(); + if (!createResult.success) throw new Error("Expected terminal session creation to succeed"); + const id = createResult.session.id; + + mockPtyProcess._onDataCallback?.("output\n"); + // First attach (no offset), an offset from the future, and a negative one all reset. + expect(service.getScrollbackSince(id)?.reset).toBe(true); + expect(service.getScrollbackSince(id, 9999)?.reset).toBe(true); + expect(service.getScrollbackSince(id, -5)?.reset).toBe(true); + expect(service.getScrollbackSince(id, Number.NaN)?.reset).toBe(true); + }); + + it("returns null for an unknown session", () => { + expect(service.getScrollbackSince("no-such-session", 0)).toBeNull(); + }); + }); + describe("shared-viewer output handoff", () => { it("delivers queued output to existing subscribers instead of discarding it", async () => { const existingViewer = vi.fn(); diff --git a/packages/dashboard/src/__tests__/terminal-viewport.test.ts b/packages/dashboard/src/__tests__/terminal-viewport.test.ts new file mode 100644 index 0000000000..b3c914b0fb --- /dev/null +++ b/packages/dashboard/src/__tests__/terminal-viewport.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { TerminalViewportRegistry } from "../terminal-viewport.js"; + +/* +FNXC:TerminalSharing 2026-08-19-02:45: +A PTY has one size but a shared session has many viewers. Applying every viewer's resize verbatim is +last-writer-wins: measured live, viewer A at 80x24 had its shell report 200x50 the moment viewer B +attached on a bigger screen, while A still drew 80 columns — wrapped lines and a broken cursor, with +full-screen programs worst hit. The agreed size is the per-dimension minimum, so content fits inside +every viewer. +*/ +describe("TerminalViewportRegistry", () => { + it("sizes to the smallest attached viewer per dimension", () => { + const registry = new TerminalViewportRegistry(); + registry.set("s1", "a", { cols: 80, rows: 50 }); + expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 50 }); + + registry.set("s1", "b", { cols: 200, rows: 24 }); + // Narrowest columns from A, shortest rows from B. + expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 }); + }); + + it("gives room back when a viewer leaves", () => { + const registry = new TerminalViewportRegistry(); + registry.set("s1", "a", { cols: 80, rows: 24 }); + registry.set("s1", "b", { cols: 200, rows: 50 }); + expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 }); + + registry.remove("s1", "a"); + expect(registry.effectiveSize("s1")).toEqual({ cols: 200, rows: 50 }); + }); + + it("reports no opinion until a viewer has actually measured itself", () => { + const registry = new TerminalViewportRegistry(); + expect(registry.effectiveSize("s1")).toBeNull(); + // A freshly attached socket must not pin the session to a placeholder size. + registry.set("s1", "a", { cols: 0, rows: 0 }); + registry.set("s1", "b", { cols: Number.NaN, rows: 24 }); + expect(registry.effectiveSize("s1")).toBeNull(); + expect(registry.viewerCount("s1")).toBe(0); + }); + + it("keeps sessions independent and forgets a dead one", () => { + const registry = new TerminalViewportRegistry(); + registry.set("s1", "a", { cols: 80, rows: 24 }); + registry.set("s2", "a", { cols: 200, rows: 50 }); + expect(registry.effectiveSize("s1")).toEqual({ cols: 80, rows: 24 }); + expect(registry.effectiveSize("s2")).toEqual({ cols: 200, rows: 50 }); + + registry.clear("s1"); + expect(registry.effectiveSize("s1")).toBeNull(); + expect(registry.effectiveSize("s2")).toEqual({ cols: 200, rows: 50 }); + }); + + it("re-measures rather than accumulating when one viewer resizes repeatedly", () => { + const registry = new TerminalViewportRegistry(); + registry.set("s1", "a", { cols: 80, rows: 24 }); + registry.set("s1", "a", { cols: 120, rows: 40 }); + expect(registry.viewerCount("s1")).toBe(1); + expect(registry.effectiveSize("s1")).toEqual({ cols: 120, rows: 40 }); + }); +}); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 0d6bbf95b6..e820c65de4 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -33,6 +33,7 @@ import { setOnProjectFirstCreated, } from "./project-store-resolver.js"; import { getOrCreateScopedChatStore } from "./chat-project-services.js"; +import { TerminalViewportRegistry } from "./terminal-viewport.js"; import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js"; import { WebSocketServer, type WebSocket } from "ws"; import { terminalSessionManager } from "./terminal.js"; @@ -2442,6 +2443,13 @@ export function setupTerminalWebSocket( store: TaskStore, options?: ServerOptions, ): void { + /* + FNXC:TerminalSharing 2026-08-19-02:45: + Per-session viewer sizes for the shared-PTY min-sizing rule. Server-scoped, matching the terminal + session registry's lifetime. + */ + const terminalViewports = new TerminalViewportRegistry(); + const wss = new WebSocketServer({ noServer: true }); // Default terminal service for stale eviction (uses default store's root dir) @@ -2553,11 +2561,19 @@ export function setupTerminalWebSocket( must not clear the pending-output queue. Flush it to whoever is already attached FIRST (this socket has not subscribed yet, so it cannot double-receive), then send the scrollback, which now contains those bytes for the newcomer. + + FNXC:TerminalSharing 2026-08-19-02:45: + `sinceSeq` lets a RE-attaching client (tab backgrounded, laptop asleep, heartbeat timeout) ask + for only what it missed. Replaying the whole buffer into a terminal that still shows it appended + a duplicate copy of history on every reconnect; `reset` tells the client when it must clear + first because the delta could not be served from the retained window. */ terminalService.flushPendingOutput(sessionId); - const scrollback = terminalService.getScrollback(sessionId); - if (scrollback) { - ws.send(JSON.stringify({ type: "scrollback", data: scrollback })); + const sinceSeqRaw = url.searchParams.get("sinceSeq"); + const sinceSeq = sinceSeqRaw === null ? undefined : Number(sinceSeqRaw); + const resume = terminalService.getScrollbackSince(sessionId, sinceSeq); + if (resume && (resume.data || resume.reset)) { + ws.send(JSON.stringify({ type: "scrollback", data: resume.data, seq: resume.seq, reset: resume.reset })); } // Send connection info @@ -2567,6 +2583,17 @@ export function setupTerminalWebSocket( cwd: session.cwd, })); + /* + FNXC:TerminalSharing 2026-08-19-02:45: + One PTY, one size, many viewers. Register this viewer so resizes agree on the SMALLEST attached + window instead of last-writer-wins, which left every other viewer rendering a stale column count. + */ + const viewerId = `${sessionId}:${Date.now()}:${Math.random().toString(36).slice(2)}`; + const applyEffectiveViewport = () => { + const effective = terminalViewports.effectiveSize(sessionId); + if (effective) terminalService.resize(sessionId, effective.cols, effective.rows); + }; + // Subscribe to data events dataUnsub = terminalService.onData((id, data) => { if (id === sessionId && isAlive) { @@ -2640,7 +2667,8 @@ export function setupTerminalWebSocket( break; case "resize": if (typeof msg.cols === "number" && typeof msg.rows === "number") { - terminalService.resize(sessionId, msg.cols, msg.rows); + terminalViewports.set(sessionId, viewerId, { cols: msg.cols, rows: msg.rows }); + applyEffectiveViewport(); } break; case "ping": @@ -2661,6 +2689,10 @@ export function setupTerminalWebSocket( clearInterval(pingInterval); if (dataUnsub) dataUnsub(); if (exitUnsub) exitUnsub(); + // FNXC:TerminalSharing 2026-08-19-02:45: a departing viewer no longer constrains the size, so + // the remaining viewers get their room back. + terminalViewports.remove(sessionId, viewerId); + applyEffectiveViewport(); // Do NOT kill the PTY session on WebSocket close — the session should // survive transient disconnects and modal close/reopen cycles. Sessions // are cleaned up through explicit kill paths (tab close, restart, shell diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index c1e916b95f..8bffa73086 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -102,6 +102,15 @@ export interface TerminalSession { lastActivityAt: Date; shell: string; scrollbackBuffer: string; + /* + FNXC:TerminalSharing 2026-08-19-02:45: + Total characters ever emitted by this PTY. scrollbackBuffer holds only the last MAX_SCROLLBACK_SIZE + of them, so the retained window is [scrollbackSeq - scrollbackBuffer.length, scrollbackSeq). A + reattaching client reports the offset it already rendered and receives only the delta, instead of + the whole buffer being replayed into a terminal that already shows it (which duplicated history — + visibly, the last prompt twice — on every reconnect after a tab was backgrounded). + */ + scrollbackSeq: number; /** * Pending output chunks awaiting flush to clients. Stored as an array * (not a single concatenated string) so heavy bursts — e.g. a `pnpm test` @@ -645,6 +654,7 @@ export class TerminalService extends EventEmitter { lastActivityAt: new Date(), shell, scrollbackBuffer: "", + scrollbackSeq: 0, outputChunks: [], outputBytes: 0, flushTimeout: null, @@ -739,6 +749,7 @@ export class TerminalService extends EventEmitter { // Always append to scrollback buffer so no output is lost session.scrollbackBuffer += data; + session.scrollbackSeq += data.length; if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); } @@ -1023,6 +1034,32 @@ export class TerminalService extends EventEmitter { return session.scrollbackBuffer || null; } + /** + * Resolve what a (re)attaching client still needs to render. + * + * FNXC:TerminalSharing 2026-08-19-02:45: + * `sinceSeq` is the cumulative offset the client last rendered for this session. When that offset + * still falls inside the retained scrollback window the client gets ONLY the bytes it missed and + * keeps its existing screen (`reset: false`). Otherwise — a first attach, a client that fell too + * far behind, or a bogus/rewound offset — it gets the whole retained buffer and is told to reset, + * because appending a full replay on top of an already-populated terminal is exactly what + * duplicated the visible history. + */ + getScrollbackSince(sessionId: string, sinceSeq?: number): { data: string; seq: number; reset: boolean } | null { + if (!this.isValidSessionId(sessionId)) return null; + const session = this.sessions.get(sessionId); + if (!session) return null; + + const seq = session.scrollbackSeq; + const buffer = session.scrollbackBuffer; + const windowStart = seq - buffer.length; + + if (typeof sinceSeq === "number" && Number.isFinite(sinceSeq) && sinceSeq >= windowStart && sinceSeq <= seq) { + return { data: buffer.slice(sinceSeq - windowStart), seq, reset: false }; + } + return { data: buffer, seq, reset: true }; + } + /** * Get all active sessions */ diff --git a/packages/dashboard/src/terminal-viewport.ts b/packages/dashboard/src/terminal-viewport.ts new file mode 100644 index 0000000000..777f08aeca --- /dev/null +++ b/packages/dashboard/src/terminal-viewport.ts @@ -0,0 +1,81 @@ +/* +FNXC:TerminalSharing 2026-08-19-03:05: +Several browsers can attach to one PTY, but a PTY has exactly ONE size. Each viewer reports the size +of its own window, so the naive "apply every resize" behaviour is last-writer-wins: when a second +browser attaches on a bigger screen the PTY grows to match it and the first viewer — still rendering +its old column count — sees wrapped lines and a broken cursor, with full-screen programs (vim, htop, +less) worst hit. Measured against a live instance: viewer A at 80x24, viewer B attaches at 200x50, +and A's shell reports 200x50 while A still draws 80 columns. + +The fix is the one terminal multiplexers settled on: size the PTY to the SMALLEST attached viewer, +per dimension. Content then fits inside every viewer's window; viewers larger than the agreed size +simply have unused space, which is recoverable, whereas content wider than a viewer is not. + +Only viewers that have actually reported a size participate — a freshly attached socket must not +pin the session to a placeholder size before its client has measured itself. +*/ + +export interface ViewportSize { + cols: number; + rows: number; +} + +function isUsable(size: ViewportSize): boolean { + return Number.isFinite(size.cols) && Number.isFinite(size.rows) && size.cols > 0 && size.rows > 0; +} + +/** + * Tracks each attached viewer's requested size per session and resolves the size the PTY should use. + * + * Deliberately pure and transport-free: the WebSocket layer owns viewer identity and lifetime, this + * owns only the arithmetic, so the min-sizing rule is testable without sockets or a real PTY. + */ +export class TerminalViewportRegistry { + private readonly sessions = new Map>(); + + /** Record (or update) one viewer's requested size. */ + set(sessionId: string, viewerId: string, size: ViewportSize): void { + if (!isUsable(size)) return; + let viewers = this.sessions.get(sessionId); + if (!viewers) { + viewers = new Map(); + this.sessions.set(sessionId, viewers); + } + viewers.set(viewerId, { cols: Math.floor(size.cols), rows: Math.floor(size.rows) }); + } + + /** Drop a viewer, e.g. on socket close. The remaining viewers may then get more room back. */ + remove(sessionId: string, viewerId: string): void { + const viewers = this.sessions.get(sessionId); + if (!viewers) return; + viewers.delete(viewerId); + if (viewers.size === 0) this.sessions.delete(sessionId); + } + + /** Forget a session entirely (PTY exited). */ + clear(sessionId: string): void { + this.sessions.delete(sessionId); + } + + /** How many viewers have reported a size for this session. */ + viewerCount(sessionId: string): number { + return this.sessions.get(sessionId)?.size ?? 0; + } + + /** + * The size the PTY should be: the per-dimension minimum across attached viewers, or null when no + * viewer has reported one yet (in which case the session keeps whatever size it was created with). + */ + effectiveSize(sessionId: string): ViewportSize | null { + const viewers = this.sessions.get(sessionId); + if (!viewers || viewers.size === 0) return null; + + let cols = Number.POSITIVE_INFINITY; + let rows = Number.POSITIVE_INFINITY; + for (const size of viewers.values()) { + cols = Math.min(cols, size.cols); + rows = Math.min(rows, size.rows); + } + return Number.isFinite(cols) && Number.isFinite(rows) ? { cols, rows } : null; + } +}