From 1fcc95529da0ab61eae28e0bb076855f51ae5000 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:58:57 -0700 Subject: [PATCH 01/48] test(web): retain sizing across attach recovery --- .../test/attached-terminal-sizing.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 cmux-tui/frontends/web/test/attached-terminal-sizing.test.tsx diff --git a/cmux-tui/frontends/web/test/attached-terminal-sizing.test.tsx b/cmux-tui/frontends/web/test/attached-terminal-sizing.test.tsx new file mode 100644 index 000000000000..8602fdb9fd70 --- /dev/null +++ b/cmux-tui/frontends/web/test/attached-terminal-sizing.test.tsx @@ -0,0 +1,99 @@ +import { render, waitFor } from "@testing-library/react"; +import { useCallback } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CmuxClient, DecodedAttachEvent } from "cmux/browser"; +import { useAttachedTerminal } from "../src/hooks/useAttachedTerminal"; + +const fitDimensions = { cols: 80, rows: 24 }; + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + proposeDimensions() { + return fitDimensions; + } + }, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + options: Record; + + constructor(options: Record) { + this.options = options; + } + + loadAddon() {} + open() {} + reset() {} + resize() {} + write() {} + focus() {} + dispose() {} + onData() { + return { dispose() {} }; + } + }, +})); + +vi.mock("../src/lib/webglRenderer", () => ({ + tryLoadWebglRenderer: () => null, +})); + +class TestStream { + private index = 0; + + constructor(private readonly events: DecodedAttachEvent[]) {} + + async next(): Promise { + const event = this.events[this.index++]; + if (event !== undefined) return event; + return await new Promise(() => {}); + } + + close() {} +} + +function Harness({ client }: { client: CmuxClient }) { + const onError = useCallback((error: Error) => { + throw error; + }, []); + const { terminalRef } = useAttachedTerminal({ client, surface: 7, onError }); + return
; +} + +describe("attached terminal sizing", () => { + const originalResizeObserver = globalThis.ResizeObserver; + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it("reports an unchanged local fit again after overflow reattachment", async () => { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + const streams = [ + new TestStream([ + { event: "vt-state", surface: 7, cols: 100, rows: 30, data: new Uint8Array(), colors: {} }, + { event: "overflow", scope: "surface", surface: 7, error: "subscriber fell behind" }, + ]), + new TestStream([ + { event: "vt-state", surface: 7, cols: 100, rows: 30, data: new Uint8Array(), colors: {} }, + ]), + ]; + const client = { + attachSurface: vi.fn(async () => streams.shift()!), + resizeSurface: vi.fn(async () => ({ accepted: true, reservation_id: null })), + send: vi.fn(async () => ({})), + } as unknown as CmuxClient; + + render(); + + await waitFor(() => expect(client.attachSurface).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(client.resizeSurface).toHaveBeenCalledTimes(2)); + expect(client.resizeSurface).toHaveBeenNthCalledWith(1, 7, 80, 24); + expect(client.resizeSurface).toHaveBeenNthCalledWith(2, 7, 80, 24); + }); +}); From 90c04db745241fc108c39fe752396e5777b35a76 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:00:32 -0700 Subject: [PATCH 02/48] fix(web): preserve smallest sizing across recovery --- .../typescript/src/protocol/events.ts | 3 +- .../web/src/hooks/useAttachedTerminal.ts | 3 + .../frontends/web/src/hooks/useCmuxClient.ts | 4 +- .../web/src/hooks/useRenderTerminal.ts | 129 ++++++++++++------ cmux-tui/frontends/web/src/lib/fit.ts | 2 +- cmux-tui/frontends/web/test/fit.test.ts | 4 +- 6 files changed, 97 insertions(+), 48 deletions(-) diff --git a/cmux-tui/bindings/typescript/src/protocol/events.ts b/cmux-tui/bindings/typescript/src/protocol/events.ts index ea760156f93f..db19e095336b 100644 --- a/cmux-tui/bindings/typescript/src/protocol/events.ts +++ b/cmux-tui/bindings/typescript/src/protocol/events.ts @@ -312,7 +312,8 @@ export type KnownRenderAttachEvent = | RenderStateEvent | RenderDeltaEvent | ScrollChangedEvent - | DetachedEvent; + | DetachedEvent + | OverflowEvent; /** Render attachment events, including unknown future event names. */ export type RenderAttachEvent = KnownRenderAttachEvent | UnknownEvent; diff --git a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts index 8108991c2858..db638d79b63c 100644 --- a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts @@ -117,6 +117,9 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin // Cleanup may have raced the attach round-trip; close the stream we // just opened or its buffered events leak for the surface's lifetime. if (cancelled) return; + // Closing the previous attachment removes this client's report on + // the server. Re-publish even when the viewport did not change. + reportedFit = null; let overflowed = false; for (;;) { let event; diff --git a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts index 35c4972a0654..8b88d4f86ed7 100644 --- a/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts +++ b/cmux-tui/frontends/web/src/hooks/useCmuxClient.ts @@ -219,8 +219,8 @@ export function useCmuxClient() { // browser tabs render the unsupported placeholder and never call // resizeSurface. A surface-resize-failed broadcast therefore // belongs to another client and must not be echoed into a - // multi-client retry loop. Browser rendering must add explicit - // per-client geometry ownership before handling that event. + // multi-client retry loop. Browser rendering must track which + // local size report produced the asynchronous failure first. if (["tree-changed", "layout-changed", "surface-resized", "surface-exited"].includes(event.event)) { discardPendingSurfaceTitles(); await refresh(); diff --git a/cmux-tui/frontends/web/src/hooks/useRenderTerminal.ts b/cmux-tui/frontends/web/src/hooks/useRenderTerminal.ts index afc3f761bf62..4db147af1783 100644 --- a/cmux-tui/frontends/web/src/hooks/useRenderTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useRenderTerminal.ts @@ -9,7 +9,9 @@ import { type RenderRow, type RenderStateEvent, } from "cmux/browser"; +import { ATTACH_RECOVERY_STABLE_MS, attachRecoveryDelay } from "../lib/attachRecovery"; import { debounce } from "../lib/debounce"; +import { t } from "../i18n"; import { syncCanvasBackground } from "../lib/canvasTheme"; import { nextFitSize, type TerminalSize } from "../lib/fit"; import { createFrameBatch } from "../lib/frameBatch"; @@ -129,6 +131,9 @@ export function useRenderTerminal({ client, surface, active, onError }: RenderTe let composing = false; let committedComposition: string | null = null; let touchStartY: number | null = null; + let retryTimer: ReturnType | undefined; + let stableTimer: ReturnType | undefined; + let wakeRetry: (() => void) | null = null; const frames = new Set(); const stage = host.closest(".terminal-stage"); const scroller = host.querySelector("[data-render-scroll]"); @@ -139,6 +144,15 @@ export function useRenderTerminal({ client, surface, active, onError }: RenderTe stage?.style.setProperty("--surface-background", background); syncCanvasBackground(host, background, activeRef.current); }; + const waitForRetry = (delayMs: number) => + new Promise((resolve) => { + wakeRetry = resolve; + retryTimer = setTimeout(() => { + retryTimer = undefined; + wakeRetry = null; + resolve(); + }, delayMs); + }); dispatch({ type: "bind", client, surface }); const frameBatch = createFrameBatch(() => { @@ -427,53 +441,81 @@ export function useRenderTerminal({ client, surface, active, onError }: RenderTe void (async () => { try { - stream = await client.attachSurface(surface, { mode: "render" }); - if (cancelled) return; + let recoveryAttempt = 0; for (;;) { - let event: RenderAttachEvent; - try { - event = await stream.next(); - } catch (error) { - if (cancelled) return; - if (error instanceof CmuxTimeoutError) continue; - throw error; - } + stream = await client.attachSurface(surface, { mode: "render" }); if (cancelled) return; - if (event.event === "detached") return; - if (event.event === "render-state") { - currentModel = applySnapshot(event as RenderStateEvent); - resetHistoryCache(currentModel.scrollbackRows, false); - applySurfaceBackground(currentModel.defaultBg); - applyFit(); - scheduleFrame(); - } else if (event.event === "render-delta" && currentModel !== null) { - const renderDelta = event as RenderDeltaEvent; - const previous: RenderModel = currentModel; - const nextModel: RenderModel = applyDelta(previous, renderDelta); - currentModel = nextModel; - if (nextModel === previous) continue; - const reconciliation = reconcileScrollbackWindow( - cache, - previous.scrollbackRows, - nextModel.scrollbackRows, - renderDelta.size !== undefined, - ); - if (reconciliation.invalidated) { - cacheGeneration += 1; - historyLoading = false; - cache = reconciliation.window; - if (historyActive) void loadHistoryPage("latest", false); - } else if (reconciliation.window !== cache) { - cache = reconciliation.window; + // Closing the previous attachment removes this client's report on + // the server. Re-publish even when the viewport did not change. + reportedFit = null; + let overflowed = false; + for (;;) { + let event: RenderAttachEvent; + try { + event = await stream.next(); + } catch (error) { + if (cancelled) return; + if (error instanceof CmuxTimeoutError) continue; + throw error; } - applySurfaceBackground(nextModel.defaultBg); - if (!historyActive) { - scheduleAfterRender(() => { - if (scroller !== null) scroller.scrollTop = scroller.scrollHeight; - }); + if (cancelled) return; + if (event.event === "detached") return; + if (event.event === "render-state") { + currentModel = applySnapshot(event as RenderStateEvent); + resetHistoryCache(currentModel.scrollbackRows, false); + applySurfaceBackground(currentModel.defaultBg); + applyFit(); + scheduleFrame(); + if (stableTimer !== undefined) clearTimeout(stableTimer); + stableTimer = setTimeout(() => { + stableTimer = undefined; + recoveryAttempt = 0; + }, ATTACH_RECOVERY_STABLE_MS); + } else if (event.event === "render-delta" && currentModel !== null) { + const renderDelta = event as RenderDeltaEvent; + const previous: RenderModel = currentModel; + const nextModel: RenderModel = applyDelta(previous, renderDelta); + currentModel = nextModel; + if (nextModel === previous) continue; + const reconciliation = reconcileScrollbackWindow( + cache, + previous.scrollbackRows, + nextModel.scrollbackRows, + renderDelta.size !== undefined, + ); + if (reconciliation.invalidated) { + cacheGeneration += 1; + historyLoading = false; + cache = reconciliation.window; + if (historyActive) void loadHistoryPage("latest", false); + } else if (reconciliation.window !== cache) { + cache = reconciliation.window; + } + applySurfaceBackground(nextModel.defaultBg); + if (!historyActive) { + scheduleAfterRender(() => { + if (scroller !== null) scroller.scrollTop = scroller.scrollHeight; + }); + } + scheduleFrame(); + } else if (event.event === "overflow") { + if (event.scope === "surface" && event.surface === surface) { + if (stableTimer !== undefined) { + clearTimeout(stableTimer); + stableTimer = undefined; + } + overflowed = true; + break; + } } - scheduleFrame(); } + stream.close(); + stream = null; + if (!overflowed) return; + const delayMs = attachRecoveryDelay(recoveryAttempt++); + if (delayMs === null) throw new Error(t("attachOverflowRecoveryFailed")); + await waitForRetry(delayMs); + if (cancelled) return; } } catch (error) { if (!cancelled) onError(error instanceof Error ? error : new Error(String(error))); @@ -489,6 +531,9 @@ export function useRenderTerminal({ client, surface, active, onError }: RenderTe window.visualViewport?.removeEventListener("resize", sendResize); window.visualViewport?.removeEventListener("scroll", sendResize); sendResize.cancel(); + if (retryTimer !== undefined) clearTimeout(retryTimer); + if (stableTimer !== undefined) clearTimeout(stableTimer); + wakeRetry?.(); textarea?.removeEventListener("focus", handleFocus); textarea?.removeEventListener("blur", handleBlur); textarea?.removeEventListener("keydown", handleKeyDown); diff --git a/cmux-tui/frontends/web/src/lib/fit.ts b/cmux-tui/frontends/web/src/lib/fit.ts index 7d73dfec6f35..2008940e6a84 100644 --- a/cmux-tui/frontends/web/src/lib/fit.ts +++ b/cmux-tui/frontends/web/src/lib/fit.ts @@ -6,7 +6,7 @@ export interface TerminalSize { // Decides whether a fit proposal should be applied to the terminal and pushed // to the server. Returns the size to apply, or null for no-op. Sends only // originate from local fits (attach replay, pane geometry changes), never from -// applying a server resize, so accepting a foreign size cannot echo back and +// applying a server resize, so receiving the shared size cannot echo back and // start a resize war between attached clients. export function nextFitSize(reported: TerminalSize | null, proposed: TerminalSize | undefined): TerminalSize | null { if (!proposed) return null; diff --git a/cmux-tui/frontends/web/test/fit.test.ts b/cmux-tui/frontends/web/test/fit.test.ts index 43b4eef8fc45..65fb099a623d 100644 --- a/cmux-tui/frontends/web/test/fit.test.ts +++ b/cmux-tui/frontends/web/test/fit.test.ts @@ -30,8 +30,8 @@ describe("nextFitSize", () => { }); it("applies a pane geometry change over the current server size", () => { - // Foreign client set 200x50; our pane later shrank: local fit wins on the - // next local interaction. + // The shared grid became 200x50; our pane later shrank, so its next local + // geometry measurement publishes 96x30. expect(nextFitSize({ cols: 200, rows: 50 }, { cols: 96, rows: 30 })).toEqual({ cols: 96, rows: 30 }); }); }); From 085731fd70caaab08f159d7be4322058f4c3f156 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:03:02 -0700 Subject: [PATCH 03/48] style(tui): satisfy detach resize lint --- cmux-tui/crates/cmux-tui-core/src/mux.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 95af7bf5dfae..fae0eaab4e08 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -635,10 +635,10 @@ impl Mux { } effective }; - if let Some((cols, rows)) = effective { - if self.resize_surface(id, cols, rows).is_ok() { - self.record_client_size(cols, rows); - } + if let Some((cols, rows)) = effective + && self.resize_surface(id, cols, rows).is_ok() + { + self.record_client_size(cols, rows); } } From 9b0f783c71961f27339cd41359a1d4c0c08495bf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:27:19 -0700 Subject: [PATCH 04/48] fix(web): align byte fallback terminal font --- cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts index db638d79b63c..a0897c09d019 100644 --- a/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts +++ b/cmux-tui/frontends/web/src/hooks/useAttachedTerminal.ts @@ -39,7 +39,7 @@ export function useAttachedTerminal({ client, surface, onError }: AttachedTermin allowProposedApi: true, convertEol: false, disableStdin: true, - fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace', + fontFamily: 'Menlo, "SFMono-Regular", Consolas, "Liberation Mono", monospace', fontSize: 13, lineHeight: 1.15, theme: baseTheme, From d3e90ac21c9032de42518ac4a8ec614702bac01c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:55:08 -0700 Subject: [PATCH 05/48] test(tui): keep input independent from sizing --- cmux-tui/crates/cmux-tui/src/app.rs | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index d0a9909e87da..b0db0f2ab27d 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -7509,6 +7509,54 @@ mod tests { assert!(!app.session.has_pending_mutations()); } + #[test] + fn input_never_reasserts_a_viewer_size() { + let mux = Mux::new( + "input-size-independence-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(None, Some((120, 40))).unwrap(); + mux.resize_surface_for_client(surface.id, 0, 120, 40).unwrap(); + mux.resize_surface_for_client(surface.id, 99, 80, 30).unwrap(); + + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 1, y: 1, width: 120, height: 40 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: content, + bar: None, + omnibar: None, + content, + track: None, + }); + + let inputs = [ + Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 2, + row: 2, + modifiers: KeyModifiers::NONE, + }), + Event::Paste("pasted".to_string()), + ]; + for input in inputs { + mux.record_client_size(99, 33); + app.handle(AppEvent::Input(input)).unwrap(); + assert_eq!(mux.new_workspace(None, None).unwrap().size(), (99, 33)); + } + } + #[test] fn canceled_mutation_does_not_block_on_a_full_app_channel() { let (events, receiver) = std::sync::mpsc::sync_channel(1); From b306078d932428b37f983513c62022fe906cf319 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:56:26 -0700 Subject: [PATCH 06/48] fix(tui): remove sizing from input path --- cmux-tui/crates/cmux-tui/src/app.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b0db0f2ab27d..4972374f0522 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3530,19 +3530,10 @@ impl App { } Ok(RenderAction::Draw) } - AppEvent::Input(Event::Key(key)) => { - if key.kind != KeyEventKind::Release { - self.reassert_visible_surface_sizes(); - } - self.handle_key(key) - } - AppEvent::Input(Event::Mouse(mouse)) => { - self.reassert_visible_surface_sizes(); - self.handle_mouse(mouse) - } + AppEvent::Input(Event::Key(key)) => self.handle_key(key), + AppEvent::Input(Event::Mouse(mouse)) => self.handle_mouse(mouse), AppEvent::Input(Event::Paste(text)) => { self.status_message = None; - self.reassert_visible_surface_sizes(); if self.pairing_dialog.is_some() { Ok(RenderAction::Draw) } else if let Some(prompt) = self.prompt.as_mut() { From 74f4fbfafebb97d73e6306cbea797acbd8f1b2a0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:06:18 -0700 Subject: [PATCH 07/48] test(web): reject sizing while render stream is detached --- .../web/test/render-terminal-sizing.test.tsx | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 cmux-tui/frontends/web/test/render-terminal-sizing.test.tsx diff --git a/cmux-tui/frontends/web/test/render-terminal-sizing.test.tsx b/cmux-tui/frontends/web/test/render-terminal-sizing.test.tsx new file mode 100644 index 000000000000..aa71985bd5fa --- /dev/null +++ b/cmux-tui/frontends/web/test/render-terminal-sizing.test.tsx @@ -0,0 +1,117 @@ +import { render, waitFor } from "@testing-library/react"; +import { useCallback } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CmuxClient, RenderAttachEvent, RenderCursor } from "cmux/browser"; +import { useRenderTerminal } from "../src/hooks/useRenderTerminal"; + +vi.mock("../src/lib/attachRecovery", () => ({ + ATTACH_RECOVERY_STABLE_MS: 60_000, + attachRecoveryDelay: () => 60_000, +})); + +let hostWidth = 800; + +class TestStream { + private index = 0; + readonly close = vi.fn(); + + constructor(private readonly events: RenderAttachEvent[]) {} + + async next(): Promise { + const event = this.events[this.index++]; + if (event !== undefined) return event; + return await new Promise(() => {}); + } +} + +function Harness({ client }: { client: CmuxClient }) { + const onError = useCallback((error: Error) => { + throw error; + }, []); + const { terminalRef } = useRenderTerminal({ client, surface: 7, active: true, onError }); + const hostRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + Object.defineProperty(node, "clientWidth", { configurable: true, get: () => hostWidth }); + Object.defineProperty(node, "clientHeight", { configurable: true, get: () => 480 }); + const probe = node.querySelector("[data-render-probe]")!; + probe.getBoundingClientRect = () => ({ + width: 10, + height: 20, + x: 0, + y: 0, + top: 0, + right: 10, + bottom: 20, + left: 0, + toJSON: () => ({}), + }); + } + terminalRef(node); + }, [terminalRef]); + + return ( +
+
+
+