diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index fc1f462c..1f228467 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -465,7 +465,11 @@ export const TerminalView = memo(function TerminalView({ gpu = null; } - fit.fit(); + // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`): + // fitting it there shrinks xterm to its 12×6 minimum, and that size would + // reach tmux with the attach. It stays unfitted until it is shown. + const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight; + if (hasBox()) fit.fit(); // Take the keyboard as soon as the pane is on screen: the terminal IS the // page here, and having to click it first meant the first thing typed went // nowhere. A click elsewhere still moves focus away, as in any editor. @@ -605,7 +609,7 @@ export const TerminalView = memo(function TerminalView({ */ const sendSizeNow = (drive = false) => { if (!ws || ws.readyState !== WebSocket.OPEN) return; - if (term.cols < 2 || term.rows < 2) return; + if (!hasBox() || term.cols < 2 || term.rows < 2) return; ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive })); }; // This browser is the one in use now: back to its own size, and the @@ -642,7 +646,10 @@ export const TerminalView = memo(function TerminalView({ // scrollback as torn fragments along the left margin, visible as soon as // you scroll back (issue #33). Happens on every attach: page load, // lane switch, and each reconnect. - const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; + // A hidden terminal sends none: the server attaches it at the window's + // own size, so a pane on a background tab is never resized by it. + const size = + hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); ws = sock; wsRef.current = sock; @@ -737,6 +744,9 @@ export const TerminalView = memo(function TerminalView({ if (!el || !el.clientWidth || !el.clientHeight) return; if (follow) return scaleToFit(); // the driver's grid, not ours fit.fit(); // a real change fires onResize, which reports it + // A tab shown for the first time may fit to the very size xterm already + // had (no onResize), yet tmux has never heard it: report it anyway. + sendSize(); }; const resizeObserver = new ResizeObserver(refit); if (containerRef.current) resizeObserver.observe(containerRef.current); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 72dd03de..6a1428df 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -11,7 +11,7 @@ * Alt+arrow word moves. * @author Nguyễn Ngọc Trí Vĩ */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest"; import { render, cleanup, @@ -154,6 +154,17 @@ class MockWebSocket { // @ts-expect-error test override global.WebSocket = MockWebSocket; +/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden + * tab); returns the restore. */ +function withBox(width = 800, height = 600) { + const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width); + const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height); + return () => { + w.mockRestore(); + h.mockRestore(); + }; +} + describe("TerminalView", () => { // xterm is a lazy chunk; once loaded every terminal sets up synchronously. beforeAll(() => loadXterm()); @@ -185,11 +196,51 @@ describe("TerminalView", () => { // it: tmux resizes the window to the newest client, so attaching at the // server's default 80x24 makes claude redraw its UI at 80 columns and // leaves that width in the scrollback (issue #33). - render(); - expect(MockWebSocket.instances).toHaveLength(1); - expect(MockWebSocket.instances[0]!.url).toBe( - "ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" - ); + const restore = withBox(); + try { + render(); + expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0]!.url).toBe( + "ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" + ); + } finally { + restore(); + } + }); + + it("a terminal on a hidden tab sends no size until it is shown", () => { + // Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and + // that size resized the real tmux pane under every other client. + let observed: (() => void) | null = null; + const RO = globalThis.ResizeObserver; + // @ts-expect-error test override + globalThis.ResizeObserver = class { + constructor(cb: () => void) { + observed = cb; + } + observe() {} + disconnect() {} + }; + vi.useFakeTimers(); + let restore = () => {}; + try { + render(); + const ws = MockWebSocket.instances[0]!; + expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1"); + ws.onopen?.(); + expect(ws.sent).toEqual([]); + // The tab is shown: the box gets a size and its size reaches tmux. + restore = withBox(); + observed!(); + vi.advanceTimersByTime(200); + expect(ws.sent).toEqual([ + JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }), + ]); + } finally { + restore(); + vi.useRealTimers(); + globalThis.ResizeObserver = RO; + } }); it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => { @@ -466,17 +517,24 @@ describe("TerminalView", () => { // next row's left margin, and tmux never repaints a row it believes // unchanged — the stray characters glued to the left edge after scrolling. it("reports its size when the socket opens", () => { - render(); - const ws = MockWebSocket.instances[0]!; - ws.onopen?.(); - expect(ws.sent).toContain( - JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }) - ); + const restore = withBox(); + try { + render(); + const ws = MockWebSocket.instances[0]!; + ws.onopen?.(); + expect(ws.sent).toContain( + JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }) + ); + } finally { + restore(); + } }); it("reports a size change xterm made on its own, with no container resize", async () => { // Webfont load and page zoom both move cols without resizing the element // ResizeObserver watches, which is why onResize is the hook. + const restore = withBox(); + onTestFinished(restore); render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index 531549e3..e96d73fc 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -252,7 +252,7 @@ export function SettingsHub({ section }: { section?: string } = {}) { }; }, [location.key, location.hash]); - const { isAdmin, local } = usePermissions(); + const { isAdmin, local, seesAll } = usePermissions(); const authState = useAuth().state; const signedIn = authState.mode === "server" && !!authState.user; const enabled = (id: string) => aiTools.byId(id)?.enabled !== false; @@ -307,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) { - {/* Every AI tool's login profiles, linked to the account pool. */} - + {/* Every AI tool's login profiles, linked to the account pool: the + host's logins, a staff-only read (a developer would get 403s). */} + {seesAll && } {/* The Hub's install switch: server mode, an admin's (it hides itself). */} {/* The host's omp login for members: server mode + S3, an admin's. */} diff --git a/docs/API.md b/docs/API.md index 4145e9ec..50e1dd93 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3453,7 +3453,7 @@ Sent when a notification is created. #### /ws-pty/:runId — PTY frames -A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. +A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. #### cc_config_changed diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js index 731b716e..1acf404c 100644 --- a/server/__tests__/pty-attach.test.js +++ b/server/__tests__/pty-attach.test.js @@ -235,6 +235,26 @@ describe("pty-attach", () => { assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" }); }); + it("attaches a terminal that names no size at the window's own size", () => { + // A terminal on a hidden tab is 0×0 and sends no size; spawning its attach + // at a default resized the pane under every other client. + const opts = []; + ptyAttach.__setSpawnImpl((_f, _a, o) => { + opts.push(o); + return makeFakePty(); + }); + tmux.__setExecImpl((args) => (args.includes("#{window_width} #{window_height}") ? "141 48\n" : "")); + ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: undefined, rows: undefined }); + assert.deepEqual([opts[0].cols, opts[0].rows], [141, 48]); + // tmux cannot answer: the old default, never a crash. + ptyAttach.__resetGroups(); + tmux.__setExecImpl(() => { + throw new Error("no server"); + }); + ptyAttach.attach(makeFakeWs(), "agenthub-lane-2", { cols: undefined, rows: undefined }); + assert.deepEqual([opts[1].cols, opts[1].rows], [80, 24]); + }); + it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => { // Windows node-pty re-throws a read-pipe error nobody listens for, and its // write pipe has no listener at all: Stop (kill-session) ended the attach diff --git a/server/__tests__/websocket-pty-size.test.js b/server/__tests__/websocket-pty-size.test.js index 453a564a..9eeed465 100644 --- a/server/__tests__/websocket-pty-size.test.js +++ b/server/__tests__/websocket-pty-size.test.js @@ -19,13 +19,17 @@ describe("sizeFromQuery — the attach carries the client's real size", () => { assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 }); }); - it("falls back to 80x24 when the client named no size", () => { - assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 }); + // No size (a terminal on a hidden tab) stays no size: the attach then takes + // the window's own, never a default that would resize the pane. + const none = { cols: undefined, rows: undefined }; + + it("names no size when the client named none", () => { + assert.deepEqual(sizeFromQuery(query("")), none); }); it("refuses anything out of bounds — this number reaches an ioctl", () => { - assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 }); - assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 }); - assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 }); + assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), none); + assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), none); + assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12")), none); }); }); diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js index 3c30ebf5..357f2e75 100644 --- a/server/lib/pty-attach.js +++ b/server/lib/pty-attach.js @@ -162,12 +162,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) { const [file, args] = require("./drop-caps").ptyArgv( ...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]]) ); + // An attach with no size of its own (a terminal on a hidden tab, 0×0) takes + // the window's current size, so attaching it never shrinks the pane. + const own = + cols && rows ? { cols, rows } : (!lead && tmux.windowSize(runId)) || { cols: 80, rows: 24 }; // A follower attaches at the driver's size: tmux sizes the window to the // newest client, so its own size would resize the driver's pane first. const pty = spawnImpl(file, args, { name: "xterm-256color", - cols: lead ? lead.cols : cols || 80, - rows: lead ? lead.rows : rows || 24, + cols: lead ? lead.cols : own.cols, + rows: lead ? lead.rows : own.rows, // Windows' inbox ConPTY re-renders psmux's output instead of passing it // through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻, // Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen. @@ -176,7 +180,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) { ...(platform === "win32" && { useConptyDll: true }), }); swallowPipeErrors(pty); - const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false }; + const me = { ws, pty, cols: own.cols, rows: own.rows, exited: false }; group.viewers.add(me); if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows }); else group.driver = me; diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 76b6300c..0a728e80 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -723,6 +723,25 @@ function leaveCopyMode(name) { } } +/** + * The session's window size as tmux has it now, `{cols, rows}`, or null when + * tmux can't answer. An attach that brings no size of its own (a terminal on + * a hidden tab) is spawned at this, so attaching it resizes nothing. + */ +function windowSize(name) { + try { + const [cols, rows] = String( + onServerOf(name, ["display-message", "-p", "-t", pane(name), "#{window_width} #{window_height}"]) + ) + .trim() + .split(" ") + .map(Number); + return cols >= 2 && rows >= 2 ? { cols, rows } : null; + } catch { + return null; + } +} + /** * The command currently running in the session's active pane (`bash`, `zsh`, * `claude`, …). Null when tmux can't answer — callers treat that as "unknown, @@ -1013,6 +1032,7 @@ module.exports = { enableMouse, leaveCopyMode, paneCommand, + windowSize, sendCommand, sendText, pasteText, diff --git a/server/websocket.js b/server/websocket.js index 50288493..44a4c312 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -115,13 +115,17 @@ const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/; * `resize` message lands, leaving 80-column wreckage in the scrollback that * shows up as torn left-margin fragments when the user scrolls back * (issue #33). Bounded, because this number reaches a `TIOCSWINSZ` ioctl. + * No size at all (a terminal on a hidden tab has none to give) stays + * `undefined`: the attach then takes the window's own size and resizes nothing. */ function sizeFromQuery(params) { - const read = (name, fallback) => { + const read = (name) => { const parsed = Number.parseInt(params.get(name) ?? "", 10); - return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : fallback; + return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : undefined; }; - return { cols: read("cols", 80), rows: read("rows", 24) }; + const cols = read("cols"); + const rows = read("rows"); + return cols && rows ? { cols, rows } : { cols: undefined, rows: undefined }; } /**