From af65ca7a50873b2103e071c85fe045adfb55c883 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 4 Nov 2026 01:07:59 +0000 Subject: [PATCH 1/9] fix(remote): size the PTY to this device's panel when it controls the size "This device controls the size" re-sent the local xterm's grid, which is always the host's own size (the terminal is opened at the snapshot size and scaled to fit), so turning it on changed nothing. The toggle now makes this device the size owner: it measures the cell off the open terminal (screen / grid, shown at the face's base scale times the zoom), fits whole cells into its panel (cols 20-500, rows 5-200), resizes the local terminal in place to that grid so it renders at scale 1, and sends resize{id,cols,rows} through the existing path. While on it re-asserts, debounced 150 ms, on panel resize, orientation change, fullscreen and zoom; the remembered toggle re-asserts the computed size on every mount; turning it off stops asserting and fits the host's size again. The owner's fit box takes a fixed 40dvh so the rows never feed back into the height they are measured from. --- apps/remote/web/src/app.ts | 42 +++-- apps/remote/web/src/styles.css | 8 + apps/remote/web/src/term-controls.ts | 191 ++++++++++++++++++- apps/remote/web/src/terminal.ts | 201 +++++++++++++++++--- apps/remote/web/test/app.test.ts | 101 ++++++++-- apps/remote/web/test/bundle-size.test.ts | 11 +- apps/remote/web/test/term-controls.test.ts | 208 ++++++++++++++++++++- apps/remote/web/test/terminal-view.test.ts | 161 ++++++++++++++++ 8 files changed, 855 insertions(+), 68 deletions(-) diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts index 24f4318..6b19b51 100644 --- a/apps/remote/web/src/app.ts +++ b/apps/remote/web/src/app.ts @@ -28,7 +28,8 @@ import { convergeOpenSessionName, type SessionStore } from "./sessions.js"; import { createXtermHandle, TerminalView } from "./terminal.js"; import type { TerminalFactory } from "./terminal.js"; import { termControlsHtml, termSizeHtml, wireTermControls } from "./term-controls.js"; -import { pointerIsFine, readControlSize } from "./terminal-prefs.js"; +import type { TermControls } from "./term-controls.js"; +import { pointerIsFine } from "./terminal-prefs.js"; import { shellTitle } from "./titles.js"; import { mountUploads } from "./upload-view.js"; import type { UploadView } from "./upload-view.js"; @@ -52,8 +53,9 @@ export interface ShellCallbacks { onTerminalInput: (bytes: Uint8Array) => void; /** * The device asserts its PTY size for the open session (send - * resize{id,cols,rows}). Fires when the control-size toggle turns on and - * on every terminal mount while it is on. + * resize{id,cols,rows}): the cols × rows that fit this device's panel. + * Fires when the control-size toggle turns on, on every terminal mount + * while it is on, and (debounced) whenever the panel changes while on. */ onControlSize: (cols: number, rows: number) => void; /** False when the frame could not be sent; the dialog then says so. */ @@ -150,6 +152,7 @@ export function createShell( let sessionRows = 0; let terminalView: TerminalView | null = null; let latestButton: HTMLButtonElement | null = null; + let termControls: TermControls | null = null; let uploadView: UploadView | null = null; // The two state-telling slots: the host chip's link icon and the Files // button's folder. @@ -284,7 +287,22 @@ export function createShell( builder.open(root); }; + // The local terminal takes a new grid in place (no remount, the + // scrollback stays), and the shell remembers it so a snapshot at that + // size later does not remount either. + const resizeLocalTerminal = (cols: number, rows: number): void => { + if (!terminalView) { + return; + } + if (terminalView.resize(cols, rows)) { + sessionCols = cols; + sessionRows = rows; + } + }; + const teardownSessionWidgets = () => { + termControls?.dispose(); + termControls = null; terminalView?.dispose(); terminalView = null; latestButton = null; @@ -336,14 +354,15 @@ export function createShell( shelf?.open(root); }); syncFilesBadge(); - latestButton = wireTermControls({ + termControls = wireTermControls({ root, sessionId: () => sessionId, terminalView: () => terminalView, - sessionSize: () => ({ cols: sessionCols, rows: sessionRows }), onControlSize: (cols, rows) => callbacks.onControlSize(cols, rows), + onLocalSize: (cols, rows) => resizeLocalTerminal(cols, rows), onBuilder: () => openBuilder(), - }).latestButton; + }); + latestButton = termControls.latestButton; uploadView = shelf ? mountUploads({ root, @@ -709,6 +728,8 @@ export function createShell( latestButton.hidden = following; } }, + // A zoom changes how many cells fit: the size owner re-asserts. + onZoomChange: () => termControls?.schedule(), }, ); terminalView.mount(pane, cols, rows); @@ -723,11 +744,10 @@ export function createShell( } }); } - // A remembered control-size toggle re-asserts on every (re)mount, so - // a reconnected session keeps the size this device asked for. - if (readControlSize(id)) { - callbacks.onControlSize(cols, rows); - } + // A remembered control-size toggle re-asserts on every (re)mount — + // the size that fits this panel, never the snapshot's — so a + // reconnected session keeps the size this device asked for. + termControls?.reassert(); }, writeTerminal(data: Uint8Array): void { terminalView?.write(data); diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index fb8d482..0cb7d88 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -650,6 +650,14 @@ label > textarea { touch-action: pan-x; } +/* While this device controls the size, the rows are measured from this + * box, so its height must not follow the terminal (that would feed the + * rows back into themselves): a fixed share of the viewport, which the + * fullscreen and orientation changes move on their own. */ +.term-pane[data-size-owner] .term-fit { + height: 40dvh; +} + /* The visual box: natural size × the scale refit wrote, clipping the * transformed host so the terminal scales exactly once. At zoom 1 it * matches the fit exactly; zoomed wider, the pane scrolls it. */ diff --git a/apps/remote/web/src/term-controls.ts b/apps/remote/web/src/term-controls.ts index 8beb552..920890b 100644 --- a/apps/remote/web/src/term-controls.ts +++ b/apps/remote/web/src/term-controls.ts @@ -4,9 +4,15 @@ // the per-session control-size toggle with its explainer. Rendered as // HTML beside the terminal pane and wired to the open TerminalView plus // the shell's live session state. +// +// The control-size toggle makes this device the size owner: it computes +// the cols × rows that fit its own panel (TerminalView.ownedSize), resizes +// the local terminal to them and asserts them to the host, then re-asserts +// (debounced) whenever the panel changes — a resize, an orientation +// change, fullscreen, the zoom — for as long as the toggle stays on. import { icon } from "./icons-dom.js"; -import type { TerminalView } from "./terminal.js"; +import type { TermSize, TerminalView } from "./terminal.js"; import { readControlSize, readKeyBarVisible, @@ -67,12 +73,20 @@ export function termSizeHtml(sessionId: string | null): string { `; } +/** The slice of the open TerminalView the controls drive. */ +export type TermControlsView = Pick< + TerminalView, + "zoomIn" | "zoomOut" | "scrollToLatest" | "isAlternate" | "fitted" | "ready" | "ownedSize" | "setSizeOwner" +>; + export interface TermControlsDeps { root: HTMLElement; sessionId: () => string | null; - terminalView: () => TerminalView | null; - sessionSize: () => { cols: number; rows: number }; + terminalView: () => TermControlsView | null; + /** Send resize{id,cols,rows}: this device asserts its size to the host. */ onControlSize: (cols: number, rows: number) => void; + /** Resize the local terminal to cols × rows (the shell tracks the size). */ + onLocalSize: (cols: number, rows: number) => void; /** Open the Prompt Builder sheet; the controls row owns its button. */ onBuilder: () => void; } @@ -80,6 +94,26 @@ export interface TermControlsDeps { export interface TermControls { /** The latest button, for the shell to reveal as follow changes. */ latestButton: HTMLButtonElement | null; + /** + * A terminal (re)mounted: a remembered toggle asserts the size that fits + * this panel again, even when it did not change, so a reconnected or + * resized session keeps the size this device asked for. + */ + reassert: () => void; + /** The panel layout changed (the zoom): re-assert, debounced, while on. */ + schedule: () => void; + /** Stop watching the panel; the session view is going away. */ + dispose: () => void; +} + +/** Panel changes settle this long before the size is asserted again. */ +export const SIZE_DEBOUNCE_MS = 150; +// A measurement that is not ready yet (the terminal still opening) retries +// on the debounce this many times before waiting for the next change. +const SIZE_MEASURE_RETRIES = 5; + +function sameSize(a: TermSize | null, b: TermSize): boolean { + return a !== null && a.cols === b.cols && a.rows === b.rows; } export function wireTermControls(deps: TermControlsDeps): TermControls { @@ -143,10 +177,127 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { latestButton.hidden = true; } }); - // Per-session, default off: while on, the device asserts its PTY size - // to the host. Turning it on sends the open terminal's real cols and - // rows at once; turning it off just stops asserting. + // Per-session, default off: while on, the device asserts the size that + // fits its own panel. Turning it on sends that size at once (and resizes + // the local terminal to it); turning it off stops asserting and the + // terminal follows the host's size again. const sizeToggle = root.querySelector("#term-control-size"); + let owning = false; + let lastSent: TermSize | null = null; + let timer: ReturnType | null = null; + let retries = 0; + let generation = 0; + let unwatch: (() => void) | null = null; + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const schedule = (): void => { + if (!owning) { + return; + } + clearTimer(); + timer = setTimeout(() => { + timer = null; + assertSize(false); + }, SIZE_DEBOUNCE_MS); + }; + + // Measure and send: an unchanged size is not sent again unless forced + // (a fresh mount, where the host may hold any size). + function assertSize(force: boolean): void { + if (!owning || deps.sessionId() === null) { + return; + } + const view = deps.terminalView(); + if (!view) { + return; + } + const size = view.ownedSize(); + if (size === null) { + if (retries < SIZE_MEASURE_RETRIES) { + retries += 1; + schedule(); + } + return; + } + retries = 0; + if (!force && sameSize(lastSent, size)) { + return; + } + lastSent = size; + deps.onLocalSize(size.cols, size.rows); + deps.onControlSize(size.cols, size.rows); + } + + // The panel changes that move the size: its own box (a window resize, a + // keyboard, the key bar), an orientation change and fullscreen. The zoom + // reaches schedule() through the shell. + const watch = (): void => { + if (unwatch !== null) { + return; + } + const pane = root.querySelector("#term-pane"); + const observer = + pane !== null && typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => schedule()) + : null; + if (pane !== null) { + observer?.observe(pane); + } + globalThis.addEventListener("resize", schedule); + globalThis.addEventListener("orientationchange", schedule); + document.addEventListener("fullscreenchange", schedule); + unwatch = () => { + observer?.disconnect(); + globalThis.removeEventListener("resize", schedule); + globalThis.removeEventListener("orientationchange", schedule); + document.removeEventListener("fullscreenchange", schedule); + }; + }; + + const stopWatching = (): void => { + unwatch?.(); + unwatch = null; + clearTimer(); + }; + + const startOwning = (): void => { + owning = true; + lastSent = null; + retries = 0; + generation += 1; + watch(); + const view = deps.terminalView(); + if (!view) { + // No terminal yet: its mount re-asserts. + return; + } + view.setSizeOwner(true); + if (view.fitted) { + assertSize(true); + return; + } + const pending = generation; + void view.ready().then(() => { + if (pending === generation && owning && deps.terminalView() === view) { + assertSize(true); + } + }); + }; + + const stopOwning = (): void => { + owning = false; + lastSent = null; + generation += 1; + stopWatching(); + deps.terminalView()?.setSizeOwner(false); + }; + sizeToggle?.addEventListener("change", () => { const id = deps.sessionId(); if (id === null) { @@ -154,10 +305,30 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { } const next = sizeToggle.checked; writeControlSize(id, next); - const { cols, rows } = deps.sessionSize(); - if (next && cols > 0 && rows > 0) { - deps.onControlSize(cols, rows); + if (next) { + startOwning(); + } else { + stopOwning(); } }); - return { latestButton }; + + return { + latestButton, + reassert: () => { + const id = deps.sessionId(); + if (id === null || !readControlSize(id)) { + return; + } + if (sizeToggle) { + sizeToggle.checked = true; + } + startOwning(); + }, + schedule, + dispose: () => { + owning = false; + generation += 1; + stopWatching(); + }, + }; } diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts index 11d148e..07a4fea 100644 --- a/apps/remote/web/src/terminal.ts +++ b/apps/remote/web/src/terminal.ts @@ -21,11 +21,17 @@ // and on buffer change. Every refit anchors the pane at scrollLeft 0, so // the line start stays visible and a stale scroll never clips the left // edge. +// +// While this device controls the size (the control-size toggle), the +// direction flips: the grid is computed from the panel (ownedSize) and the +// local terminal is resized to it, so it renders at its base scale (the +// face at 14 CSS px) times the zoom instead of being fitted to the width. import "@xterm/xterm/css/xterm.css"; import { Terminal } from "@xterm/xterm"; import { TERMINAL_FONT_FAMILY, + TERMINAL_FONT_SIZE, terminalFontSizeForDpr, terminalFontGate, } from "./terminal-font.js"; @@ -50,6 +56,13 @@ export interface TerminalHandle { onScroll: (callback: () => void) => () => void; /** Fires once the terminal is open (and the WebGL attempt settled). */ onReady: (callback: () => void) => () => void; + /** Resize the local terminal to cols × rows. Absent means a fixed size. */ + resize?: (cols: number, rows: number) => void; + /** + * The scale that shows the opened face at TERMINAL_FONT_SIZE CSS px: the + * DPR-1 double-size face renders at 0.5. Absent means 1. + */ + baseScale?: number; dispose: () => void; } @@ -67,6 +80,43 @@ export function clampZoom(zoom: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); } +export interface TermSize { + cols: number; + rows: number; +} + +// The bounds of a size this device asserts: below them a session is +// unusable, above them the panel measurement is surely wrong. +export const OWNED_MIN_COLS = 20; +export const OWNED_MAX_COLS = 500; +export const OWNED_MIN_ROWS = 5; +export const OWNED_MAX_ROWS = 200; + +function clampInt(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +// The cols × rows that fit a panel of panelWidth × panelHeight CSS px with +// cells of cellWidth × cellHeight CSS px (as they render on screen), within +// the owned bounds. Null when either box is not measured yet. +export function computeOwnedSize( + panelWidth: number, + panelHeight: number, + cellWidth: number, + cellHeight: number, +): TermSize | null { + if (!(panelWidth > 0 && panelHeight > 0 && cellWidth > 0 && cellHeight > 0)) { + return null; + } + // The epsilon keeps an exact fit (336 / 8.4) from flooring one short. + const cols = Math.floor(panelWidth / cellWidth + 1e-6); + const rows = Math.floor(panelHeight / cellHeight + 1e-6); + return { + cols: clampInt(cols, OWNED_MIN_COLS, OWNED_MAX_COLS), + rows: clampInt(rows, OWNED_MIN_ROWS, OWNED_MAX_ROWS), + }; +} + // 11 CSS px of glyph at the terminal's natural row height. export function computeMinScale(rowHeight: number): number { if (!(rowHeight > 0)) { @@ -219,11 +269,12 @@ export function createXtermHandle( // (see terminal-font.ts). The size is fixed at open — a later browser // zoom lands as layout and the fit scale absorbs it, no re-open. const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : undefined; + const fontSize = terminalFontSizeForDpr(dpr); const terminal = new Terminal({ cols, rows, scrollback: 2000, - fontSize: terminalFontSizeForDpr(dpr), + fontSize, fontFamily: TERMINAL_FONT_FAMILY, fontWeight: 400, fontWeightBold: 700, @@ -265,6 +316,8 @@ export function createXtermHandle( callback(); return () => {}; }, + resize: (nextCols, nextRows) => terminal.resize(nextCols, nextRows), + baseScale: TERMINAL_FONT_SIZE / fontSize, dispose: () => terminal.dispose(), }; } @@ -272,6 +325,8 @@ export function createXtermHandle( export interface TerminalViewCallbacks { onUserInput: (text: string) => void; onFollowChange?: (state: FollowState) => void; + /** Fires after the zoom changed (buttons or pinch). */ + onZoomChange?: () => void; } export class TerminalView { @@ -283,7 +338,9 @@ export class TerminalView { private pane: HTMLElement | null = null; private handle: TerminalHandle | null = null; private zoom = 1; + private cols = 0; private rows = 0; + private sizeOwner = false; private following = true; private alternate = false; private stopObserving: (() => void) | null = null; @@ -325,6 +382,7 @@ export class TerminalView { this.scaler = scaler; this.host = host; this.pane = parent; + this.cols = cols; this.rows = rows; // Tapping anywhere in the pane — the terminal or its padding — focuses // xterm's hidden textarea so the phone keyboard opens. The listener @@ -382,10 +440,10 @@ export class TerminalView { this.settleReady(); return; } - this.openTerminal(cols, rows); + this.openTerminal(); }); } else { - this.openTerminal(cols, rows); + this.openTerminal(); this.readyPromise = Promise.resolve(); } } @@ -432,6 +490,72 @@ export class TerminalView { setZoom(zoom: number): void { this.zoom = clampZoom(zoom); this.refit(); + this.callbacks.onZoomChange?.(); + } + + /** True while this device asserts the PTY size (the control-size toggle). */ + get isSizeOwner(): boolean { + return this.sizeOwner; + } + + // While this device owns the size the terminal is sized to the panel, so + // it renders at its base scale times the zoom instead of being fitted; + // the pane carries data-size-owner so the stylesheet gives the fit box a + // height that never follows the content (rows are measured from it). + setSizeOwner(owner: boolean): void { + if (this.sizeOwner === owner) { + return; + } + this.sizeOwner = owner; + if (this.pane) { + if (owner) { + this.pane.dataset.sizeOwner = "true"; + } else { + delete this.pane.dataset.sizeOwner; + } + } + this.refit(); + } + + // The cols × rows that fit this device's panel at the current font size + // and zoom: the cell is measured off the open terminal (screen / grid) + // and shown at the owner scale. Null until the terminal is open and laid + // out. + ownedSize(): TermSize | null { + const fit = this.fit; + const natural = this.naturalSize(); + if (!this.handle || !fit || !natural || this.cols <= 0 || this.rows <= 0) { + return null; + } + const scale = this.ownerScale(); + return computeOwnedSize( + fit.clientWidth, + fit.clientHeight, + (natural.width / this.cols) * scale, + (natural.height / this.rows) * scale, + ); + } + + // Resizes the local terminal (the host's size changed, or this device + // asserted its own) and refits. A terminal still behind the font gate + // opens at the new size. False when the size is invalid or unchanged. + resize(cols: number, rows: number): boolean { + if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) { + return false; + } + if (cols === this.cols && rows === this.rows) { + return false; + } + this.cols = cols; + this.rows = rows; + this.handle?.resize?.(cols, rows); + this.refit(); + return true; + } + + /** The local terminal's current grid, 0 × 0 before mount. */ + get size(): TermSize { + return { cols: this.cols, rows: this.rows }; } zoomIn(): void { @@ -460,21 +584,12 @@ export class TerminalView { const fit = this.fit; const scaler = this.scaler; const host = this.host; - if (!fit || !scaler || !host) { - return; - } - const content = host.firstElementChild ?? host; - const screen = host.querySelector(".xterm-screen"); - // The scale measures the real .xterm-screen, never the .xterm wrapper: - // the wrapper can be narrower than the screen it holds, and a scale - // from the wrapper overflows the panel on the right. - const naturalWidth = - screen && screen.scrollWidth > 0 ? screen.scrollWidth : content.scrollWidth; - const naturalHeight = - screen && screen.offsetHeight > 0 ? screen.offsetHeight : content.scrollHeight; - if (naturalWidth <= 0 || naturalHeight <= 0) { + const natural = this.naturalSize(); + if (!fit || !scaler || !host || !natural) { return; } + const naturalWidth = natural.width; + const naturalHeight = natural.height; // Both renderers size .xterm-screen in layout px (a CSS transform does // not affect offsetHeight), so this is the unscaled row height. const rowHeight = this.rows > 0 ? naturalHeight / this.rows : FALLBACK_ROW_HEIGHT; @@ -483,14 +598,18 @@ export class TerminalView { // height, capped at 60vh by the stylesheet). Before any layout the // box has no height yet, so alternate falls back to the width fit. const fitAll = this.alternate && fit.clientHeight > 0; - const scale = computeFitScale( - fit.clientWidth, - fit.clientHeight, - naturalWidth, - naturalHeight, - this.zoom, - fitAll, - ); + // A size owner's terminal already fits the panel by its grid: it shows + // at the base scale times the zoom, never stretched to the width. + const scale = this.sizeOwner + ? this.ownerScale() + : computeFitScale( + fit.clientWidth, + fit.clientHeight, + naturalWidth, + naturalHeight, + this.zoom, + fitAll, + ); // One scale, applied once: the host is natural-size and carries the // transform; the scaler is the visual box (natural × scale) that // clips it, so the terminal never renders at scale². @@ -510,6 +629,30 @@ export class TerminalView { } } + // The terminal's unscaled pixel size. The scale measures the real + // .xterm-screen, never the .xterm wrapper: the wrapper can be narrower + // than the screen it holds, and a scale from the wrapper overflows the + // panel on the right. + private naturalSize(): { width: number; height: number } | null { + const host = this.host; + if (!host) { + return null; + } + const content = host.firstElementChild ?? host; + const screen = host.querySelector(".xterm-screen"); + const width = screen && screen.scrollWidth > 0 ? screen.scrollWidth : content.scrollWidth; + const height = screen && screen.offsetHeight > 0 ? screen.offsetHeight : content.scrollHeight; + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } + + private ownerScale(): number { + const base = this.handle?.baseScale; + return (base !== undefined && base > 0 ? base : 1) * this.zoom; + } + dispose(): void { this.generation += 1; this.settleReady(); @@ -520,6 +663,10 @@ export class TerminalView { this.paneClick.parent.removeEventListener("click", this.paneClick.handler); this.paneClick = null; } + if (this.pane) { + delete this.pane.dataset.sizeOwner; + } + this.sizeOwner = false; this.handle?.dispose(); this.handle = null; this.fit?.remove(); @@ -527,6 +674,7 @@ export class TerminalView { this.scaler = null; this.host = null; this.pane = null; + this.cols = 0; this.rows = 0; this.pinchStart = null; this.touchGesture = null; @@ -535,13 +683,14 @@ export class TerminalView { this.pendingWrites.length = 0; } - private openTerminal(cols: number, rows: number): void { + private openTerminal(): void { const host = this.host; if (!host) { this.settleReady(); return; } - const handle = this.factory(host, cols, rows); + // The grid as it stands now: a resize behind the font gate counts. + const handle = this.factory(host, this.cols, this.rows); this.handle = handle; handle.onUserInput((text) => this.callbacks.onUserInput(text)); handle.onScroll(() => this.refreshFollow()); diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index b94af84..ac51432 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -20,6 +20,7 @@ class FakeHandle implements TerminalHandle { alternate = false; cols: number; rows: number; + resizes: { cols: number; rows: number }[] = []; private inputListeners: ((text: string) => void)[] = []; private scrollListeners: (() => void)[] = []; private bufferListeners: (() => void)[] = []; @@ -29,6 +30,12 @@ class FakeHandle implements TerminalHandle { this.rows = rows; } + resize = (cols: number, rows: number): void => { + this.cols = cols; + this.rows = rows; + this.resizes.push({ cols, rows }); + }; + write = (data: Uint8Array): void => { this.written.push(data); }; @@ -575,29 +582,87 @@ describe("createShell", () => { expect(calls.controlSizes).toEqual([]); }); - it("persists control-size per session and asserts the real size", () => { - shell.showSession("s1", "build"); - shell.ensureTerminal("s1", 80, 24); + // happy-dom has no layout: a 400 × 320 px panel and a grid whose + // natural size follows the newest handle at 8 × 16 px cells, so the + // size that fits the panel is 50 × 20 whatever the host's size is. + const layOutPanel = (): void => { + const handle = handles[handles.length - 1]; + const fit = root.querySelector(".term-fit"); + const host = root.querySelector(".term-host"); + if (!handle || !fit || !host) { + throw new Error("no terminal to lay out"); + } + Object.defineProperty(fit, "clientWidth", { value: 400, configurable: true }); + Object.defineProperty(fit, "clientHeight", { value: 320, configurable: true }); + Object.defineProperty(host, "scrollWidth", { get: () => handle.cols * 8, configurable: true }); + Object.defineProperty(host, "scrollHeight", { get: () => handle.rows * 16, configurable: true }); + }; + + const flipControlSize = (checked: boolean): void => { const toggle = query("#term-control-size"); - // Turning it on remembers the session and asserts the open size once. - toggle.checked = true; + toggle.checked = checked; toggle.dispatchEvent(new Event("change", { bubbles: true })); + }; + + it("asserts the size that fits this panel, never the host's own", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + layOutPanel(); + // Turning it on remembers the session, resizes the local terminal to + // the panel and asserts that size once: re-sending the host's 80 × 24 + // would change nothing. + flipControlSize(true); expect(readControlSize("s1")).toBe(true); expect(readControlSize("s2")).toBe(false); - expect(calls.controlSizes).toEqual([{ cols: 80, rows: 24 }]); - // A remount (a changed host size) re-asserts while on. + expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]); + expect(handles).toHaveLength(1); + expect(handles[0]?.resizes).toEqual([{ cols: 50, rows: 20 }]); + // The owner renders at scale 1, not fitted to the width. + expect(query(".term-pane").getAttribute("data-size-owner")).toBe("true"); + expect(query(".term-fit").dataset.scale).toBe("1"); + // The host's snapshot at the asserted size keeps the terminal as is. + shell.ensureTerminal("s1", 50, 20); + expect(handles).toHaveLength(1); + expect(calls.controlSizes).toHaveLength(1); + }); + + it("re-asserts the fitted size when a remount brings the host's size back", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + layOutPanel(); + flipControlSize(true); + // A remount (a changed host size): the fresh terminal is laid out and + // the size that fits the panel goes out again. shell.ensureTerminal("s1", 100, 40); + expect(handles).toHaveLength(2); + layOutPanel(); + vi.advanceTimersByTime(1000); expect(calls.controlSizes).toEqual([ - { cols: 80, rows: 24 }, - { cols: 100, rows: 40 }, + { cols: 50, rows: 20 }, + { cols: 50, rows: 20 }, ]); - // Same size: no remount, no duplicate assert. - shell.ensureTerminal("s1", 100, 40); - expect(calls.controlSizes).toHaveLength(2); - // Turning it off remembers and stops asserting. - query("#term-control-size").checked = false; - query("#term-control-size").dispatchEvent(new Event("change", { bubbles: true })); + expect(handles[1]?.resizes).toEqual([{ cols: 50, rows: 20 }]); + }); + + it("re-asserts on zoom, debounced, and stops when turned off", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + layOutPanel(); + flipControlSize(true); + // Zooming in grows the cell (8 × 16 → 10 × 20): fewer fit. + query("#term-zoom-in").click(); + expect(calls.controlSizes).toHaveLength(1); + vi.advanceTimersByTime(150); + expect(calls.controlSizes).toEqual([ + { cols: 50, rows: 20 }, + { cols: 40, rows: 16 }, + ]); + // Turning it off remembers, stops asserting and fits the width again. + flipControlSize(false); expect(readControlSize("s1")).toBe(false); + expect(query(".term-pane").hasAttribute("data-size-owner")).toBe(false); + query("#term-zoom-out").click(); + vi.advanceTimersByTime(1000); expect(calls.controlSizes).toHaveLength(2); }); @@ -606,7 +671,11 @@ describe("createShell", () => { shell.showSession("s1", "build"); expect(query("#term-control-size").checked).toBe(true); shell.ensureTerminal("s1", 80, 24); - expect(calls.controlSizes).toEqual([{ cols: 80, rows: 24 }]); + // Nothing measurable yet in happy-dom: the size goes out once laid out. + layOutPanel(); + vi.advanceTimersByTime(150); + expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]); + expect(handles[0]?.resizes).toEqual([{ cols: 50, rows: 20 }]); }); it("keeps the terminal across state changes", () => { diff --git a/apps/remote/web/test/bundle-size.test.ts b/apps/remote/web/test/bundle-size.test.ts index 4bc3806..d8351e7 100644 --- a/apps/remote/web/test/bundle-size.test.ts +++ b/apps/remote/web/test/bundle-size.test.ts @@ -121,6 +121,14 @@ const SHELF_DELETE_BUDGET = 1_024; * Merged, main is 410 B gzip over the sum of those budgets, with no new code. */ const MERGE_BUDGET = 512; +/** + * Allowance for the control-size fix (the panel-fitted size, the owner + * scale, the in-place local resize and the debounced re-assert while the + * toggle is on): measured +819 B gzip over the pre-change main. Added in + * the fix commit like SHELF_DELETE_BUDGET: the 102-byte headroom made any + * implementation of the fix impossible. + */ +const CONTROL_SIZE_BUDGET = 1_024; /** The budget the lazy guest chunk is allowed, gzip bytes of JS. */ const GUEST_BUDGET = 6_144; /** The budget the lazy editor chunk is allowed, gzip bytes of JS. */ @@ -228,7 +236,8 @@ describe("the remote's bundle", () => { BAND_BUDGET + UPLOAD_BUDGET + SHELF_DELETE_BUDGET + - MERGE_BUDGET, + MERGE_BUDGET + + CONTROL_SIZE_BUDGET, ); expect(guestJs, JSON.stringify(report)).toBeLessThanOrEqual(GUEST_BUDGET); expect(editorJs, JSON.stringify(report)).toBeLessThanOrEqual(EDITOR_BUDGET); diff --git a/apps/remote/web/test/term-controls.test.ts b/apps/remote/web/test/term-controls.test.ts index 7c568fc..ae0647c 100644 --- a/apps/remote/web/test/term-controls.test.ts +++ b/apps/remote/web/test/term-controls.test.ts @@ -3,10 +3,19 @@ // browser can honour it, toggling through the Fullscreen API and // following fullscreenchange (maximize when windowed, minimize while // fullscreen). happy-dom ships no Fullscreen API, so every leg stubs it. -import { afterEach, describe, expect, it, vi } from "vitest"; +// The control-size toggle: the size that fits this device's panel, sent +// and applied locally on, re-asserted (debounced) while on, never off. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { icon } from "../src/icons-dom.js"; -import { termControlsHtml, wireTermControls } from "../src/term-controls.js"; -import type { TermControlsDeps } from "../src/term-controls.js"; +import { + SIZE_DEBOUNCE_MS, + termControlsHtml, + termSizeHtml, + wireTermControls, +} from "../src/term-controls.js"; +import type { TermControls, TermControlsDeps, TermControlsView } from "../src/term-controls.js"; +import type { TermSize } from "../src/terminal.js"; +import { readControlSize, writeControlSize } from "../src/terminal-prefs.js"; /** The icon markup as the DOM serializes it (paths gain close tags). */ function rendered(name: Parameters[0]): string { @@ -20,8 +29,8 @@ function deps(root: HTMLElement): TermControlsDeps { root, sessionId: () => "s1", terminalView: () => null, - sessionSize: () => ({ cols: 80, rows: 24 }), onControlSize: () => {}, + onLocalSize: () => {}, onBuilder: () => {}, }; } @@ -147,3 +156,194 @@ describe("term fullscreen button", () => { expect(button?.innerHTML).toBe(rendered("maximize")); }); }); + +/** The view slice the size toggle drives, with a panel size the test sets. */ +class StubView implements TermControlsView { + fitted = true; + isAlternate = false; + owner: boolean[] = []; + fits: TermSize | null; + private release: () => void = () => {}; + private readonly readyPromise: Promise; + + constructor(fits: TermSize | null, open = true) { + this.fits = fits; + this.fitted = open; + this.readyPromise = open + ? Promise.resolve() + : new Promise((resolve) => { + this.release = resolve; + }); + } + + zoomIn = (): void => {}; + zoomOut = (): void => {}; + scrollToLatest = (): void => {}; + ready = (): Promise => this.readyPromise; + ownedSize = (): TermSize | null => this.fits; + setSizeOwner = (owner: boolean): void => { + this.owner.push(owner); + }; + + open(): void { + this.fitted = true; + this.release(); + } +} + +describe("control-size toggle", () => { + let root: HTMLDivElement; + let view: StubView | null; + let sent: TermSize[]; + let local: TermSize[]; + let controls: TermControls; + + const toggle = (): HTMLInputElement => { + const el = root.querySelector("#term-control-size"); + if (!el) { + throw new Error("missing #term-control-size"); + } + return el; + }; + + const flip = (checked: boolean): void => { + toggle().checked = checked; + toggle().dispatchEvent(new Event("change", { bubbles: true })); + }; + + const mount = (): void => { + root = document.createElement("div"); + document.body.appendChild(root); + root.innerHTML = `
${termControlsHtml()}${termSizeHtml("s1")}`; + controls = wireTermControls({ + ...deps(root), + terminalView: () => view, + onControlSize: (cols, rows) => sent.push({ cols, rows }), + onLocalSize: (cols, rows) => local.push({ cols, rows }), + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + view = new StubView({ cols: 44, rows: 19 }); + sent = []; + local = []; + mount(); + }); + + afterEach(() => { + controls.dispose(); + vi.useRealTimers(); + document.body.innerHTML = ""; + localStorage.clear(); + }); + + it("sends and applies the size that fits the panel when turned on", () => { + flip(true); + expect(readControlSize("s1")).toBe(true); + expect(view?.owner).toEqual([true]); + // The panel's own size, never the host's: the local terminal takes it + // too, so it renders at scale 1 instead of being fitted. + expect(sent).toEqual([{ cols: 44, rows: 19 }]); + expect(local).toEqual([{ cols: 44, rows: 19 }]); + }); + + it("re-sends once, debounced, while the panel keeps changing", () => { + flip(true); + view!.fits = { cols: 90, rows: 30 }; + globalThis.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS - 50); + globalThis.dispatchEvent(new Event("orientationchange")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS - 1); + expect(sent).toHaveLength(1); + document.dispatchEvent(new Event("fullscreenchange")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + expect(sent).toEqual([ + { cols: 44, rows: 19 }, + { cols: 90, rows: 30 }, + ]); + expect(local).toEqual(sent); + // A zoom reaches the same debounce through the shell. + view!.fits = { cols: 72, rows: 24 }; + controls.schedule(); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + expect(sent.at(-1)).toEqual({ cols: 72, rows: 24 }); + }); + + it("never re-sends a size that did not change", () => { + flip(true); + globalThis.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + controls.schedule(); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + expect(sent).toHaveLength(1); + }); + + it("stops asserting and follows the host again when turned off", () => { + flip(true); + flip(false); + expect(readControlSize("s1")).toBe(false); + expect(view?.owner).toEqual([true, false]); + view!.fits = { cols: 90, rows: 30 }; + globalThis.dispatchEvent(new Event("resize")); + controls.schedule(); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2); + expect(sent).toHaveLength(1); + }); + + it("re-asserts a remembered toggle on mount, even an unchanged size", () => { + controls.reassert(); + // Off: nothing to re-assert. + expect(sent).toEqual([]); + writeControlSize("s1", true); + controls.reassert(); + expect(toggle().checked).toBe(true); + expect(sent).toEqual([{ cols: 44, rows: 19 }]); + // A remount (reconnect) may face a host at any size: send it again. + controls.reassert(); + expect(sent).toEqual([ + { cols: 44, rows: 19 }, + { cols: 44, rows: 19 }, + ]); + }); + + it("waits for a terminal behind the font gate before measuring", async () => { + const gated = new StubView({ cols: 50, rows: 20 }, false); + view = gated; + flip(true); + expect(sent).toEqual([]); + gated.open(); + await Promise.resolve(); + await Promise.resolve(); + expect(sent).toEqual([{ cols: 50, rows: 20 }]); + }); + + it("retries a panel that is not measured yet", () => { + view!.fits = null; + flip(true); + expect(sent).toEqual([]); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + view!.fits = { cols: 60, rows: 22 }; + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + expect(sent).toEqual([{ cols: 60, rows: 22 }]); + }); + + it("asserts nothing without a terminal, until the mount re-asserts", () => { + view = null; + flip(true); + expect(sent).toEqual([]); + view = new StubView({ cols: 44, rows: 19 }); + controls.reassert(); + expect(sent).toEqual([{ cols: 44, rows: 19 }]); + }); + + it("stops watching the panel once disposed", () => { + flip(true); + controls.dispose(); + view!.fits = { cols: 90, rows: 30 }; + globalThis.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2); + expect(sent).toHaveLength(1); + }); +}); diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts index 2999c3c..370d650 100644 --- a/apps/remote/web/test/terminal-view.test.ts +++ b/apps/remote/web/test/terminal-view.test.ts @@ -5,11 +5,16 @@ import { classifyTouchGesture, computeFitScale, computeMinScale, + computeOwnedSize, computeScale, decayFlingVelocity, flingVelocity, MAX_ZOOM, MIN_ZOOM, + OWNED_MAX_COLS, + OWNED_MAX_ROWS, + OWNED_MIN_COLS, + OWNED_MIN_ROWS, shouldContinueFling, shouldStartFling, splitTouchScroll, @@ -29,6 +34,8 @@ class FakeHandle implements TerminalHandle { cols: number; rows: number; alternate = false; + baseScale?: number; + resizes: { cols: number; rows: number }[] = []; private inputListeners: ((text: string) => void)[] = []; private scrollListeners: (() => void)[] = []; private bufferListeners: (() => void)[] = []; @@ -69,6 +76,11 @@ class FakeHandle implements TerminalHandle { callback(); return () => {}; }; + resize = (cols: number, rows: number): void => { + this.cols = cols; + this.rows = rows; + this.resizes.push({ cols, rows }); + }; dispose = (): void => { this.disposed = true; }; @@ -151,6 +163,32 @@ describe("scale math", () => { }); }); +describe("owned size", () => { + it("fits whole cells into the panel", () => { + // 372 × 337 CSS px at 8.4 × 17 px cells: 44 columns, 19 rows. + expect(computeOwnedSize(372, 337, 8.4, 17)).toEqual({ cols: 44, rows: 19 }); + // A part cell never counts; an exact fit does despite float error. + expect(computeOwnedSize(100, 100, 8, 16)).toEqual({ cols: 20, rows: 6 }); + expect(computeOwnedSize(336, 340, 8.4, 17)).toEqual({ cols: 40, rows: 20 }); + }); + + it("clamps to sane bounds", () => { + expect(computeOwnedSize(50, 30, 8, 16)).toEqual({ cols: OWNED_MIN_COLS, rows: OWNED_MIN_ROWS }); + expect(computeOwnedSize(100_000, 100_000, 8, 16)).toEqual({ + cols: OWNED_MAX_COLS, + rows: OWNED_MAX_ROWS, + }); + expect([OWNED_MIN_COLS, OWNED_MAX_COLS, OWNED_MIN_ROWS, OWNED_MAX_ROWS]).toEqual([20, 500, 5, 200]); + }); + + it("is unknown until both the panel and the cell are measured", () => { + expect(computeOwnedSize(0, 300, 8, 16)).toBeNull(); + expect(computeOwnedSize(300, 0, 8, 16)).toBeNull(); + expect(computeOwnedSize(300, 300, 0, 16)).toBeNull(); + expect(computeOwnedSize(300, 300, 8, Number.NaN)).toBeNull(); + }); +}); + describe("readable floor", () => { // 14 px JetBrains Mono: 8.4 px cell, 17 px row in a real browser. const ROW_HEIGHT = 17; @@ -609,4 +647,127 @@ describe("TerminalView", () => { expect(follows).toEqual([false]); view.dispose(); }); + + // happy-dom has no layout: a panel of panelW × panelH and a grid whose + // natural size follows the handle's cols × rows at 8 × 16 px cells. + function layOut( + parent: HTMLElement, + handle: FakeHandle, + panelW: number, + panelH: number, + ): { fit: HTMLElement; host: HTMLElement } { + const fit = parent.querySelector(".term-fit"); + const host = parent.querySelector(".term-host"); + if (!fit || !host) { + throw new Error("missing scaffold"); + } + Object.defineProperty(fit, "clientWidth", { value: panelW, configurable: true }); + Object.defineProperty(fit, "clientHeight", { value: panelH, configurable: true }); + Object.defineProperty(host, "scrollWidth", { get: () => handle.cols * 8, configurable: true }); + Object.defineProperty(host, "scrollHeight", { get: () => handle.rows * 16, configurable: true }); + return { fit, host }; + } + + it("computes the size that fits its panel from the measured cell", () => { + const { parent, view, handles } = setup(); + expect(view.ownedSize()).toBeNull(); + view.mount(parent, 80, 24); + const handle = handles[0]!; + layOut(parent, handle, 400, 320); + // 400 / 8 and 320 / 16, whatever size the host had. + expect(view.ownedSize()).toEqual({ cols: 50, rows: 20 }); + // The zoom grows the cell, so fewer fit. + view.setZoom(2); + expect(view.ownedSize()).toEqual({ cols: 25, rows: 10 }); + // The DPR-1 double-size face shows at its base scale, not its own size. + view.setZoom(1); + handle.baseScale = 0.5; + expect(view.ownedSize()).toEqual({ cols: 100, rows: 40 }); + view.dispose(); + }); + + it("resizes the local terminal in place and refits", () => { + const { parent, view, handles } = setup(); + view.mount(parent, 80, 24); + const handle = handles[0]!; + const { host } = layOut(parent, handle, 400, 320); + expect(view.resize(50, 20)).toBe(true); + expect(handle.resizes).toEqual([{ cols: 50, rows: 20 }]); + expect(view.size).toEqual({ cols: 50, rows: 20 }); + // Fitted to the width as before: 400 / (50 × 8). + expect(host.style.transform).toBe("scale(1)"); + // Unchanged or invalid sizes are no-ops. + expect(view.resize(50, 20)).toBe(false); + expect(view.resize(0, 20)).toBe(false); + expect(view.resize(50.5, 20)).toBe(false); + expect(handles).toHaveLength(1); + expect(handle.resizes).toHaveLength(1); + view.dispose(); + }); + + it("opens a terminal still behind the font gate at the resized grid", async () => { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + Object.defineProperty(document, "fonts", { + configurable: true, + value: { load: () => gate, ready: Promise.resolve() }, + }); + try { + const { parent, view, handles } = setup(); + view.mount(parent, 80, 24); + expect(handles).toHaveLength(0); + view.resize(50, 20); + release(); + await view.ready(); + expect(handles[0]?.cols).toBe(50); + expect(handles[0]?.rows).toBe(20); + view.dispose(); + } finally { + Reflect.deleteProperty(document, "fonts"); + } + }); + + it("renders a size owner at its base scale times the zoom, never width-fitted", () => { + const { parent, view, handles } = setup(); + view.mount(parent, 80, 24); + const handle = handles[0]!; + const { host } = layOut(parent, handle, 400, 320); + view.refit(); + expect(host.style.transform).toBe(`scale(${400 / 640})`); + view.setSizeOwner(true); + expect(view.isSizeOwner).toBe(true); + expect(parent.dataset.sizeOwner).toBe("true"); + expect(host.style.transform).toBe("scale(1)"); + handle.baseScale = 0.5; + view.setZoom(1.5); + expect(host.style.transform).toBe("scale(0.75)"); + // Off again: back to following the host's size, fitted to the width. + view.setSizeOwner(false); + expect(parent.dataset.sizeOwner).toBeUndefined(); + expect(host.style.transform).toBe(`scale(${(400 / 640) * 1.5})`); + view.setSizeOwner(true); + view.dispose(); + expect(parent.dataset.sizeOwner).toBeUndefined(); + expect(view.isSizeOwner).toBe(false); + }); + + it("reports every zoom change", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + let zooms = 0; + const view = new TerminalView((_host, cols, rows) => new FakeHandle(cols, rows), { + onUserInput: () => {}, + onZoomChange: () => { + zooms += 1; + }, + }); + view.mount(parent, 80, 24); + view.zoomIn(); + view.zoomOut(); + view.setZoom(2); + expect(zooms).toBe(3); + view.dispose(); + }); }); From ffbf69cc563188d3c1d422ba91923464d3d5e44b Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 4 Nov 2026 01:12:21 +0000 Subject: [PATCH 2/9] fix(remote): follow the host's resized frame and yield the size on takeover The host now announces a session's PTY size change as resized{id,cols,rows} (another device asserted its size, or the local TUI resized it). The client parses it with the host's size bounds and routes it for the attached session only; older hosts never send it and anything malformed is dropped like an unknown frame. A follower resizes its xterm in place and refits, so the picture matches the host without waiting for a snapshot (the guest view follows too). A size owner that sees a size it never asserted lets go instead of fighting back: the toggle turns off and is remembered off, and the size row's note says "Size taken over by another device.". A slow echo of one of its own older sizes is not a takeover. The dev fake host tracks each session's size, answers resize with resized, snapshots at the held size and can resize from elsewhere, and a new e2e spec drives the toggle and both resized paths at 390x844. --- apps/remote/web/playwright.config.ts | 2 +- apps/remote/web/src/app.ts | 14 +++ apps/remote/web/src/fake-host.ts | 31 +++++- apps/remote/web/src/guest-views.ts | 13 +++ apps/remote/web/src/guest.ts | 1 + apps/remote/web/src/protocol.ts | 24 +++++ apps/remote/web/src/router.ts | 11 ++ apps/remote/web/src/styles.css | 6 ++ apps/remote/web/src/term-controls.ts | 58 ++++++++++- apps/remote/web/test/app.test.ts | 38 +++++++ apps/remote/web/test/bundle-size.test.ts | 11 +- apps/remote/web/test/control-size.spec.ts | 111 +++++++++++++++++++++ apps/remote/web/test/guest-views.test.ts | 17 ++++ apps/remote/web/test/protocol.test.ts | 29 ++++++ apps/remote/web/test/router.test.ts | 39 ++++++++ apps/remote/web/test/term-controls.test.ts | 63 ++++++++++++ 16 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 apps/remote/web/test/control-size.spec.ts diff --git a/apps/remote/web/playwright.config.ts b/apps/remote/web/playwright.config.ts index f60182f..36f4cc3 100644 --- a/apps/remote/web/playwright.config.ts +++ b/apps/remote/web/playwright.config.ts @@ -11,7 +11,7 @@ const port = e2ePort(5199, import.meta.dirname, "E2E_PORT_SMOKE"); export default defineConfig({ testDir: "./test", - testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts"], + testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts"], timeout: 60_000, use: { baseURL: `http://127.0.0.1:${port}`, diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts index 6b19b51..ad65ce4 100644 --- a/apps/remote/web/src/app.ts +++ b/apps/remote/web/src/app.ts @@ -92,6 +92,8 @@ export interface Shell { setPairBusy: (busy: boolean) => void; setSignOutError: (message: string | null) => void; ensureTerminal: (id: string, cols: number, rows: number) => void; + /** The host's `resized` frame for the open session. */ + sessionResized: (id: string, cols: number, rows: number) => void; writeTerminal: (data: Uint8Array) => void; clearTerminal: () => void; showSessionExited: (code: number) => void; @@ -749,6 +751,18 @@ export function createShell( // reconnected session keeps the size this device asked for. termControls?.reassert(); }, + sessionResized(id: string, cols: number, rows: number): void { + if (route !== "session" || id !== sessionId) { + return; + } + // The controls decide: a follower resizes in place and refits, an + // owner that was overridden lets go first. + if (termControls) { + termControls.handleResized(cols, rows); + } else { + resizeLocalTerminal(cols, rows); + } + }, writeTerminal(data: Uint8Array): void { terminalView?.write(data); }, diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts index d91f0ed..0ffc47b 100644 --- a/apps/remote/web/src/fake-host.ts +++ b/apps/remote/web/src/fake-host.ts @@ -120,6 +120,20 @@ function main(): void { const sent: unknown[] = []; const terminalOutput: Uint8Array[] = []; let seq = 7; + // Each session's PTY size as the fake host holds it: 80×24 until a + // resize changes it, and every change is announced as `resized`, the + // way the real host tells every attached device. + const ptySizes = new Map(); + const ptySize = (id: string): { cols: number; rows: number } => + ptySizes.get(id) ?? { cols: 80, rows: 24 }; + const setPtySize = (id: string, cols: number, rows: number): void => { + const current = ptySize(id); + if (current.cols === cols && current.rows === rows) { + return; + } + ptySizes.set(id, { cols, rows }); + deliver({ t: "resized", id, cols, rows }); + }; let nextSpawn = 0; let promptError: { code: string; message: string } | null = null; @@ -385,6 +399,8 @@ function main(): void { mime?: string; part?: number; data?: string; + cols?: number; + rows?: number; }; sent.push(frame); if (frame.t === "list") { @@ -421,7 +437,8 @@ function main(): void { deliver({ t: "error", code: "forbidden", id: frame.id }); } else { const full = b64(SMOKE_OUTPUT); - deliver({ t: "snapshot", id: frame.id, cols: 80, rows: 24, seq, data: full.slice(0, 10) }); + const { cols, rows } = ptySize(frame.id); + deliver({ t: "snapshot", id: frame.id, cols, rows, seq, data: full.slice(0, 10) }); deliver({ t: "snapshot_more", id: frame.id, data: full.slice(10), last: true }); const attached = frame.id; if (pushedFor === null) { @@ -432,6 +449,13 @@ function main(): void { }, 2000); } } + } else if ( + frame.t === "resize" && + typeof frame.id === "string" && + typeof frame.cols === "number" && + typeof frame.rows === "number" + ) { + setPtySize(frame.id, frame.cols, frame.rows); } else if (frame.t === "shelf_put_begin" && typeof frame.req === "string") { if (uploadMode === "denied") { deliver({ t: "error", req: frame.req, code: "forbidden", message: "upload not allowed" }); @@ -768,6 +792,9 @@ function main(): void { pushMetrics: (patch?: Record) => { deliver(metricsFrame(patch ?? {})); }, + resizeFromElsewhere: (cols: number, rows: number, id = "smoke") => { + setPtySize(id, cols, rows); + }, }; } @@ -793,6 +820,8 @@ declare global { pushMetrics: (patch?: Record) => void; /** Guest mode only: open one session by id, listed or not. */ guestOpen?: (id: string) => void; + /** Another device (or the host's TUI) resizes a session's PTY. */ + resizeFromElsewhere?: (cols: number, rows: number, id?: string) => void; }; } } diff --git a/apps/remote/web/src/guest-views.ts b/apps/remote/web/src/guest-views.ts index 644d4d7..4187747 100644 --- a/apps/remote/web/src/guest-views.ts +++ b/apps/remote/web/src/guest-views.ts @@ -37,6 +37,8 @@ export interface GuestView { showList: (notice?: string | null) => void; showSession: (id: string, name: string) => void; ensureTerminal: (id: string, cols: number, rows: number) => void; + /** The host resized the open session's PTY: follow it in place. */ + sessionResized: (id: string, cols: number, rows: number) => void; writeTerminal: (data: Uint8Array) => void; clearTerminal: () => void; setSessionStatus: (status: string | null) => void; @@ -304,6 +306,17 @@ export function createGuestView( sessionName = name; paintSession(); }, + sessionResized: (id: string, cols: number, rows: number) => { + if (route !== "session" || id !== sessionId || !terminalView) { + return; + } + // A guest never owns the size: it follows the host's in place, and a + // snapshot at that size later does not remount. + if (terminalView.resize(cols, rows)) { + sessionCols = cols; + sessionRows = rows; + } + }, ensureTerminal: (id: string, cols: number, rows: number) => { if (route !== "session" || id !== sessionId) { return; diff --git a/apps/remote/web/src/guest.ts b/apps/remote/web/src/guest.ts index a627b56..695bd42 100644 --- a/apps/remote/web/src/guest.ts +++ b/apps/remote/web/src/guest.ts @@ -515,6 +515,7 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G // bypasses this entirely and reaches onForbidden below. const routerShell: RouterShell = { ensureTerminal: (id, cols, rows) => view.ensureTerminal(id, cols, rows), + sessionResized: (id, cols, rows) => view.sessionResized(id, cols, rows), sheetSpawned: () => {}, sheetComposed: () => {}, sheetOk: () => {}, diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts index 2c2c7e7..ad1cee9 100644 --- a/apps/remote/web/src/protocol.ts +++ b/apps/remote/web/src/protocol.ts @@ -178,6 +178,30 @@ export function parseExited(frame: unknown): { id: string; code: number } | null return { id: frame.id, code: frame.code }; } +export interface ResizedFrame { + id: string; + cols: number; + rows: number; +} + +// The host's size_field bounds: integers 1..=65535. +function isPtySize(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} + +// `resized{id,cols,rows}`: a session's PTY size changed on the host — +// another device asserted its size, or the local TUI resized it. Older +// hosts never send it; anything malformed is dropped like an unknown frame. +export function parseResized(frame: unknown): ResizedFrame | null { + if (frameType(frame) !== "resized" || !isRecord(frame)) { + return null; + } + if (typeof frame.id !== "string" || frame.id === "" || !isPtySize(frame.cols) || !isPtySize(frame.rows)) { + return null; + } + return { id: frame.id, cols: frame.cols, rows: frame.rows }; +} + function b64Bytes(data: string): Uint8Array | null { if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) { return null; diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts index ebf4fcb..3c5b2ac 100644 --- a/apps/remote/web/src/router.ts +++ b/apps/remote/web/src/router.ts @@ -24,6 +24,7 @@ import { parseHello, parseOk, parseOut, + parseResized, parseSessionAdded, parseSessionRemoved, parseSessions, @@ -48,6 +49,8 @@ export interface FrameRouter { */ export interface RouterShell { ensureTerminal: (id: string, cols: number, rows: number) => void; + /** The open session's PTY size changed on the host (a `resized` frame). */ + sessionResized?: (id: string, cols: number, rows: number) => void; sheetSpawned: (req: string, id: string) => void; sheetComposed: (req: string, text: string) => void; sheetOk: (req: string) => void; @@ -104,6 +107,14 @@ export function createFrameRouter(deps: FrameRouterDeps): FrameRouter { controller.handleOut(out); return; } + // Only the attached session's size matters; any other is dropped. + const resized = parseResized(frame); + if (resized) { + if (controller.sessionId === resized.id) { + shell.sessionResized?.(resized.id, resized.cols, resized.rows); + } + return; + } // Graph watching: the list, the watch reply (summary + drawing) and // the live-state pushes. Each parser is additive, so a host that // sends none of them changes nothing. diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css index 0cb7d88..238f0c0 100644 --- a/apps/remote/web/src/styles.css +++ b/apps/remote/web/src/styles.css @@ -721,6 +721,12 @@ label > textarea { color: var(--muted); } +/* Another device took the size over: the note says so in the ink colour + * (the words carry it, the colour only lifts it off the muted line). */ +.term-size-note.is-notice { + color: var(--ink); +} + /* Ten keys, all visible: two rows of five equal-width touch targets. * The bar is one info block; the keys inside stay flat so the tint * reads once. */ diff --git a/apps/remote/web/src/term-controls.ts b/apps/remote/web/src/term-controls.ts index 920890b..2e56238 100644 --- a/apps/remote/web/src/term-controls.ts +++ b/apps/remote/web/src/term-controls.ts @@ -10,6 +10,9 @@ // the local terminal to them and asserts them to the host, then re-asserts // (debounced) whenever the panel changes — a resize, an orientation // change, fullscreen, the zoom — for as long as the toggle stays on. +// The host's `resized` frame lands in handleResized: a follower resizes +// to it, and an owner whose size was replaced by another device's lets go +// (no resize ping-pong) and says so in the size row's note. import { icon } from "./icons-dom.js"; import type { TermSize, TerminalView } from "./terminal.js"; @@ -58,6 +61,10 @@ export function termControlsHtml(): string { `; } +const SIZE_NOTE = "Only one client should control the size."; +/** The size row's note once another device replaced this one's size. */ +export const SIZE_TAKEN_NOTE = "Size taken over by another device."; + // The size row lives below the keybar, not beside the controls: the ten // keys must all stay inside a 360×640 viewport, and any row above them // pushes the second key row out of view. @@ -69,7 +76,7 @@ export function termSizeHtml(sessionId: string | null): string { id="term-control-size" ${sessionId !== null && readControlSize(sessionId) ? "checked" : ""} > This device controls the size -

Only one client should control the size.

+

${SIZE_NOTE}

`; } @@ -102,6 +109,12 @@ export interface TermControls { reassert: () => void; /** The panel layout changed (the zoom): re-assert, debounced, while on. */ schedule: () => void; + /** + * The host reports the session's PTY size. A follower resizes to it; an + * owner whose own size it is not (another device took over) turns its + * toggle off, remembers that, and follows. + */ + handleResized: (cols: number, rows: number) => void; /** Stop watching the panel; the session view is going away. */ dispose: () => void; } @@ -111,6 +124,9 @@ export const SIZE_DEBOUNCE_MS = 150; // A measurement that is not ready yet (the terminal still opening) retries // on the debounce this many times before waiting for the next change. const SIZE_MEASURE_RETRIES = 5; +// The sizes this device sent that the host may still echo back: a slow +// echo of an older one must not read as another device taking over. +const MAX_ASSERTED = 8; function sameSize(a: TermSize | null, b: TermSize): boolean { return a !== null && a.cols === b.cols && a.rows === b.rows; @@ -182,8 +198,10 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { // the local terminal to it); turning it off stops asserting and the // terminal follows the host's size again. const sizeToggle = root.querySelector("#term-control-size"); + const sizeNote = root.querySelector(".term-size-note"); let owning = false; let lastSent: TermSize | null = null; + let asserted: TermSize[] = []; let timer: ReturnType | null = null; let retries = 0; let generation = 0; @@ -230,6 +248,10 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { return; } lastSent = size; + asserted.push(size); + if (asserted.length > MAX_ASSERTED) { + asserted.shift(); + } deps.onLocalSize(size.cols, size.rows); deps.onControlSize(size.cols, size.rows); } @@ -266,9 +288,18 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { clearTimer(); }; + const setNote = (text: string): void => { + if (sizeNote) { + sizeNote.textContent = text; + sizeNote.classList.toggle("is-notice", text !== SIZE_NOTE); + } + }; + const startOwning = (): void => { owning = true; lastSent = null; + asserted = []; + setNote(SIZE_NOTE); retries = 0; generation += 1; watch(); @@ -293,6 +324,7 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { const stopOwning = (): void => { owning = false; lastSent = null; + asserted = []; generation += 1; stopWatching(); deps.terminalView()?.setSizeOwner(false); @@ -325,6 +357,30 @@ export function wireTermControls(deps: TermControlsDeps): TermControls { startOwning(); }, schedule, + handleResized: (cols, rows) => { + const reported = { cols, rows }; + if (owning && asserted.length > 0) { + const index = asserted.findIndex((size) => sameSize(size, reported)); + if (index >= 0) { + // The host confirms one of ours: drop the older ones. + asserted = asserted.slice(index); + return; + } + // Another device (or the host's own TUI) set a size this device + // never asked for: let it have the size rather than fight back. + const id = deps.sessionId(); + if (id !== null) { + writeControlSize(id, false); + } + if (sizeToggle) { + sizeToggle.checked = false; + } + stopOwning(); + setNote(SIZE_TAKEN_NOTE); + } + // A follower (or an owner yet to assert) takes the host's size. + deps.onLocalSize(cols, rows); + }, dispose: () => { owning = false; generation += 1; diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts index ac51432..78d0320 100644 --- a/apps/remote/web/test/app.test.ts +++ b/apps/remote/web/test/app.test.ts @@ -666,6 +666,44 @@ describe("createShell", () => { expect(calls.controlSizes).toHaveLength(2); }); + it("follows the host's resized frame in place while not the size owner", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + handles[0]?.write(new TextEncoder().encode("kept")); + shell.sessionResized("s1", 120, 40); + // Resized in place: the same terminal, its content kept. + expect(handles).toHaveLength(1); + expect(handles[0]?.resizes).toEqual([{ cols: 120, rows: 40 }]); + expect(handles[0]?.written).toHaveLength(1); + // A snapshot at the new size does not remount. + shell.ensureTerminal("s1", 120, 40); + expect(handles).toHaveLength(1); + // Another session's frame changes nothing. + shell.sessionResized("s2", 90, 20); + expect(handles[0]?.resizes).toHaveLength(1); + expect(calls.controlSizes).toEqual([]); + }); + + it("turns the toggle off when another device takes the size over", () => { + shell.showSession("s1", "build"); + shell.ensureTerminal("s1", 80, 24); + layOutPanel(); + flipControlSize(true); + expect(calls.controlSizes).toEqual([{ cols: 50, rows: 20 }]); + // The host echoes this device's own size: nothing changes. + shell.sessionResized("s1", 50, 20); + expect(query("#term-control-size").checked).toBe(true); + // Another device asserts 120 × 40: let it, follow it, say so. + shell.sessionResized("s1", 120, 40); + expect(query("#term-control-size").checked).toBe(false); + expect(readControlSize("s1")).toBe(false); + expect(query(".term-size-note").textContent).toBe("Size taken over by another device."); + expect(query(".term-pane").hasAttribute("data-size-owner")).toBe(false); + expect(handles[0]?.resizes.at(-1)).toEqual({ cols: 120, rows: 40 }); + vi.advanceTimersByTime(1000); + expect(calls.controlSizes).toHaveLength(1); + }); + it("re-asserts a remembered control-size toggle on mount", () => { writeControlSize("s1", true); shell.showSession("s1", "build"); diff --git a/apps/remote/web/test/bundle-size.test.ts b/apps/remote/web/test/bundle-size.test.ts index d8351e7..dc5daee 100644 --- a/apps/remote/web/test/bundle-size.test.ts +++ b/apps/remote/web/test/bundle-size.test.ts @@ -129,6 +129,14 @@ const MERGE_BUDGET = 512; * implementation of the fix impossible. */ const CONTROL_SIZE_BUDGET = 1_024; +/** + * Allowance for the resized frame (its parser and route, the follower's + * in-place resize, the owner's takeover check and notice): measured + * +325 B gzip over the pre-change main. Added in the fix commit like + * CONTROL_SIZE_BUDGET: the 307-byte headroom made any implementation of + * the fix impossible. + */ +const RESIZED_BUDGET = 512; /** The budget the lazy guest chunk is allowed, gzip bytes of JS. */ const GUEST_BUDGET = 6_144; /** The budget the lazy editor chunk is allowed, gzip bytes of JS. */ @@ -237,7 +245,8 @@ describe("the remote's bundle", () => { UPLOAD_BUDGET + SHELF_DELETE_BUDGET + MERGE_BUDGET + - CONTROL_SIZE_BUDGET, + CONTROL_SIZE_BUDGET + + RESIZED_BUDGET, ); expect(guestJs, JSON.stringify(report)).toBeLessThanOrEqual(GUEST_BUDGET); expect(editorJs, JSON.stringify(report)).toBeLessThanOrEqual(EDITOR_BUDGET); diff --git a/apps/remote/web/test/control-size.spec.ts b/apps/remote/web/test/control-size.spec.ts new file mode 100644 index 0000000..f9a1497 --- /dev/null +++ b/apps/remote/web/test/control-size.spec.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// "This device controls the size" against the fake host: turning it on +// asserts the cols × rows that fit this phone's panel (never the host's +// own 80 × 24) and renders them unfitted; the host's resized frame then +// either moves a follower's terminal in place or, for an owner whose size +// another device replaced, turns the toggle off without fighting back. +import { expect, test } from "@playwright/test"; + +interface TermState { + cols: number; + rows: number; + owner: boolean; +} + +async function termState(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => { + const host = document.querySelector(".term-host") as + | (HTMLElement & { __xterm?: { cols: number; rows: number } }) + | null; + const term = host?.__xterm; + if (!term) { + return null; + } + return { + cols: term.cols, + rows: term.rows, + owner: document.querySelector(".term-pane")?.hasAttribute("data-size-owner") ?? false, + }; + }); +} + +async function resizesSent(page: import("@playwright/test").Page): Promise<{ cols: number; rows: number }[]> { + return page.evaluate(() => + window.__fake + .sent() + .filter((frame) => (frame as { t?: string }).t === "resize") + .map((frame) => { + const { cols, rows } = frame as { cols: number; rows: number }; + return { cols, rows }; + }), + ); +} + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/fake.html"); + await page.locator('[data-session="smoke"]').click(); + await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host"); + await expect(page.locator(".term-fit")).toHaveAttribute("data-row-height", /\d/); +}); + +test("asserts the size that fits the phone's panel and renders it unfitted", async ({ page }) => { + expect(await resizesSent(page)).toEqual([]); + await page.locator(".term-size-toggle").click(); + await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(1); + const [asserted] = await resizesSent(page); + // A 390 px phone holds far fewer than the host's 80 columns. + expect(asserted!.cols).toBeGreaterThanOrEqual(20); + expect(asserted!.cols).toBeLessThan(80); + expect(asserted!.rows).toBeGreaterThanOrEqual(5); + // The local terminal took that grid and owns it, and it fits the pane. + await expect.poll(() => termState(page)).toEqual({ ...asserted!, owner: true }); + const fits = await page.evaluate(() => { + const pane = document.querySelector(".term-pane") as HTMLElement; + const doc = document.scrollingElement ?? document.documentElement; + return { + paneOverflow: pane.scrollWidth - pane.clientWidth, + pageOverflow: doc.scrollWidth - doc.clientWidth, + }; + }); + expect(fits.paneOverflow).toBeLessThanOrEqual(1); + expect(fits.pageOverflow).toBeLessThanOrEqual(1); + // Zooming in fits fewer cells: re-asserted once the change settles. + await page.locator("#term-zoom-in").click(); + await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(2); + const zoomed = (await resizesSent(page))[1]!; + expect(zoomed.cols).toBeLessThan(asserted!.cols); + // The remembered toggle re-asserts after a reload. + await page.reload(); + await page.locator('[data-session="smoke"]').click(); + await expect(page.locator("#term-control-size")).toBeChecked(); + await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBeGreaterThan(0); + await expect.poll(() => termState(page).then((state) => state?.owner)).toBe(true); +}); + +test("follows a host resize in place while not the size owner", async ({ page }) => { + await page.evaluate(() => window.__fake.resizeFromElsewhere?.(100, 30)); + await expect.poll(() => termState(page)).toEqual({ cols: 100, rows: 30, owner: false }); + // Fitted to the width again at the new grid: no sideways scroll. + const paneOverflow = await page.evaluate(() => { + const pane = document.querySelector(".term-pane") as HTMLElement; + return pane.scrollWidth - pane.clientWidth; + }); + expect(paneOverflow).toBeLessThanOrEqual(1); + expect(await resizesSent(page)).toEqual([]); +}); + +test("lets another device take the size over without fighting", async ({ page }) => { + await page.locator(".term-size-toggle").click(); + await expect.poll(() => resizesSent(page).then((sent) => sent.length)).toBe(1); + await page.evaluate(() => window.__fake.resizeFromElsewhere?.(120, 40)); + await expect(page.locator("#term-control-size")).not.toBeChecked(); + await expect(page.locator(".term-size-note")).toHaveText("Size taken over by another device."); + await expect.poll(() => termState(page)).toEqual({ cols: 120, rows: 40, owner: false }); + // Nothing goes back: the other device keeps the size. + await page.setViewportSize({ width: 360, height: 640 }); + await page.waitForTimeout(400); + expect(await resizesSent(page)).toHaveLength(1); + // And the choice is remembered off. + expect(await page.evaluate(() => localStorage.getItem("canopy.remote.term.controlSize.smoke"))).toBe("0"); +}); diff --git a/apps/remote/web/test/guest-views.test.ts b/apps/remote/web/test/guest-views.test.ts index 6b79eee..08b9899 100644 --- a/apps/remote/web/test/guest-views.test.ts +++ b/apps/remote/web/test/guest-views.test.ts @@ -36,6 +36,10 @@ class FakeHandle implements TerminalHandle { callback(); return () => {}; }; + resizes: { cols: number; rows: number }[] = []; + resize = (cols: number, rows: number): void => { + this.resizes.push({ cols, rows }); + }; dispose = (): void => { this.disposed = true; }; @@ -256,6 +260,19 @@ describe("guest session view", () => { expect(handles[0]?.disposed).toBe(true); }); + it("follows the host's resized frame in place", () => { + const { view, handles } = setup(); + view.showSession("s1", "build"); + view.ensureTerminal("s1", 80, 24); + view.sessionResized("s2", 90, 20); + view.sessionResized("s1", 120, 40); + expect(handles).toHaveLength(1); + expect(handles[0]?.resizes).toEqual([{ cols: 120, rows: 40 }]); + // The snapshot at the new size keeps the same terminal. + view.ensureTerminal("s1", 120, 40); + expect(handles).toHaveLength(1); + }); + it("sends typed text and key-bar keys as input", () => { const { root, view, handles, inputs } = setup(); view.showSession("s1", "build"); diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts index 52a66e1..037d707 100644 --- a/apps/remote/web/test/protocol.test.ts +++ b/apps/remote/web/test/protocol.test.ts @@ -33,6 +33,7 @@ import { parseHello, parseOk, parseOut, + parseResized, parseSessionAdded, parseSessionItem, parseSessionRemoved, @@ -222,6 +223,34 @@ describe("frame parsers", () => { expect(parseExited({ t: "exited", id: "a" })).toBeNull(); }); + it("parses resized frames with a valid size only", () => { + expect(parseResized({ t: "resized", id: "a", cols: 120, rows: 40 })).toEqual({ + id: "a", + cols: 120, + rows: 40, + }); + // The host's size bounds, 1..=65535 on both axes. + expect(parseResized({ t: "resized", id: "a", cols: 1, rows: 65535 })).toEqual({ + id: "a", + cols: 1, + rows: 65535, + }); + for (const bad of [ + { t: "resized", id: "a", cols: 0, rows: 40 }, + { t: "resized", id: "a", cols: 120, rows: 65536 }, + { t: "resized", id: "a", cols: 120.5, rows: 40 }, + { t: "resized", id: "a", cols: "120", rows: 40 }, + { t: "resized", id: "a", cols: 120 }, + { t: "resized", id: "", cols: 120, rows: 40 }, + { t: "resized", cols: 120, rows: 40 }, + { t: "resize", id: "a", cols: 120, rows: 40 }, + null, + "resized", + ]) { + expect(parseResized(bad)).toBeNull(); + } + }); + it("parses out frames and rejects bad base64", () => { expect(parseOut({ t: "out", id: "a", seq: 9, data: "eQ==" })).toEqual({ id: "a", diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts index 11d1ea2..9f05ba7 100644 --- a/apps/remote/web/test/router.test.ts +++ b/apps/remote/web/test/router.test.ts @@ -24,6 +24,7 @@ interface Harness { controller: SessionController; written: Uint8Array[]; terminals: { id: string; cols: number; rows: number }[]; + resizes: { id: string; cols: number; rows: number }[]; exits: number[]; spawned: { req: string; id: string }[]; composed: { req: string; text: string }[]; @@ -38,6 +39,7 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): const catalog = new CatalogStore(); const written: Uint8Array[] = []; const terminals: Harness["terminals"] = []; + const resizes: Harness["resizes"] = []; const exits: number[] = []; const spawned: Harness["spawned"] = []; const composed: Harness["composed"] = []; @@ -48,6 +50,9 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): ensureTerminal: (id: string, cols: number, rows: number) => { terminals.push({ id, cols, rows }); }, + sessionResized: (id: string, cols: number, rows: number) => { + resizes.push({ id, cols, rows }); + }, showSessionExited: (code: number) => { exits.push(code); }, @@ -92,6 +97,7 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames): controller, written, terminals, + resizes, exits, spawned, composed, @@ -255,6 +261,39 @@ describe("createFrameRouter", () => { expect(h.catalog.availableClis.map((cli) => cli.cli)).toEqual(["muse", "opencode"]); }); + it("routes the attached session's resized frame to the shell, only that one", () => { + const h = setup(); + // Nothing attached: dropped. + h.hostSend({ t: "resized", id: "s1", cols: 100, rows: 30 }); + expect(h.resizes).toEqual([]); + h.controller.open("s1"); + h.hostSend({ t: "resized", id: "s1", cols: 100, rows: 30 }); + h.hostSend({ t: "resized", id: "s2", cols: 90, rows: 20 }); + // Malformed: dropped like an unknown frame, never an error. + h.hostSend({ t: "resized", id: "s1", cols: 0, rows: 30 }); + h.hostSend({ t: "resized", id: "s1", cols: "100", rows: 30 }); + expect(h.resizes).toEqual([{ id: "s1", cols: 100, rows: 30 }]); + expect(h.sheetErrors).toEqual([]); + }); + + it("drops resized for a shell that does not follow it", () => { + const h = setup(); + const bare = createFrameRouter({ + store: h.store, + controller: h.controller, + catalog: h.catalog, + shell: { + ensureTerminal: () => {}, + sheetSpawned: () => {}, + sheetComposed: () => {}, + sheetOk: () => {}, + sheetError: () => {}, + }, + }); + h.controller.open("s1"); + expect(() => bare.handleFrame({ t: "resized", id: "s1", cols: 100, rows: 30 })).not.toThrow(); + }); + it("ignores unknown frames without throwing", () => { const h = setup(); expect(() => { diff --git a/apps/remote/web/test/term-controls.test.ts b/apps/remote/web/test/term-controls.test.ts index ae0647c..921b347 100644 --- a/apps/remote/web/test/term-controls.test.ts +++ b/apps/remote/web/test/term-controls.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { icon } from "../src/icons-dom.js"; import { SIZE_DEBOUNCE_MS, + SIZE_TAKEN_NOTE, termControlsHtml, termSizeHtml, wireTermControls, @@ -346,4 +347,66 @@ describe("control-size toggle", () => { vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2); expect(sent).toHaveLength(1); }); + + const note = (): string => root.querySelector(".term-size-note")?.textContent ?? ""; + + it("follows the host's resized frame while off", () => { + controls.handleResized(120, 40); + expect(local).toEqual([{ cols: 120, rows: 40 }]); + expect(sent).toEqual([]); + expect(toggle().checked).toBe(false); + }); + + it("keeps the size when the host echoes this device's own", () => { + flip(true); + controls.handleResized(44, 19); + expect(toggle().checked).toBe(true); + expect(readControlSize("s1")).toBe(true); + expect(local).toEqual([{ cols: 44, rows: 19 }]); + }); + + it("never mistakes a slow echo of an older size for a takeover", () => { + flip(true); + view!.fits = { cols: 90, rows: 30 }; + globalThis.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS); + expect(sent).toHaveLength(2); + // The host answers both, the older one first. + controls.handleResized(44, 19); + controls.handleResized(90, 30); + expect(toggle().checked).toBe(true); + expect(note()).not.toBe(SIZE_TAKEN_NOTE); + }); + + it("lets go without fighting when another device takes the size over", () => { + flip(true); + expect(sent).toEqual([{ cols: 44, rows: 19 }]); + controls.handleResized(120, 40); + // Off and remembered off, said in the note; the local terminal follows + // the new size and nothing is sent back. + expect(toggle().checked).toBe(false); + expect(readControlSize("s1")).toBe(false); + expect(view?.owner).toEqual([true, false]); + expect(note()).toBe(SIZE_TAKEN_NOTE); + expect(root.querySelector(".term-size-note")?.classList.contains("is-notice")).toBe(true); + expect(local.at(-1)).toEqual({ cols: 120, rows: 40 }); + expect(sent).toEqual([{ cols: 44, rows: 19 }]); + // No longer watching: a panel change sends nothing. + globalThis.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(SIZE_DEBOUNCE_MS * 2); + expect(sent).toHaveLength(1); + // Taking it back clears the notice and asserts again. + flip(true); + expect(note()).toBe("Only one client should control the size."); + expect(sent).toHaveLength(2); + }); + + it("follows a resize that lands before its own first assert", () => { + const gated = new StubView({ cols: 50, rows: 20 }, false); + view = gated; + flip(true); + controls.handleResized(120, 40); + expect(toggle().checked).toBe(true); + expect(local).toEqual([{ cols: 120, rows: 40 }]); + }); }); From 09d6e8cca290a41671a238a89889a81f095b758f Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 4 Nov 2026 01:15:13 +0000 Subject: [PATCH 3/9] fix(look): paint the header banners in the Canopy teal again, keeping them centred 6a7bc4b moved the graphs and remote banners onto the landing's wizard greens; the intent was the other way round (the landing moves to the teal). Only the colour goes back: BANNER_GRADIENT is the TUI's five legible Canopy teal stops again, bannerColor is again an exact port of harness-canopy src/shared/banner.rs gradient_rgb (round(index / max(1, count) * 4), clamped, #52fff6 fallback), and the app word is #52fff6. The stacked, centred header from 6a7bc4b stays as it is. The look tests, the graphs page snapshots and the e2e ink checks (graphs visual, remote serve) expect the teal again. --- .../test/__snapshots__/pages.test.ts.snap | 12 ++--- apps/graphs/test/e2e/visual.spec.ts | 10 ++-- apps/remote/web/test/serve.spec.ts | 10 ++-- packages/look/banner/banner.css | 2 +- packages/look/banner/banner.ts | 47 +++++++++---------- tests/look.test.ts | 24 ++++------ 6 files changed, 47 insertions(+), 58 deletions(-) diff --git a/apps/graphs/test/__snapshots__/pages.test.ts.snap b/apps/graphs/test/__snapshots__/pages.test.ts.snap index 326d4a9..313f451 100644 --- a/apps/graphs/test/__snapshots__/pages.test.ts.snap +++ b/apps/graphs/test/__snapshots__/pages.test.ts.snap @@ -649,7 +649,7 @@ pre.prompt, pre.hash {
- +