From 80f40831dac6a6c0272dba30f8b07c1c804f8845 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:54:34 -0700 Subject: [PATCH] cmux-tui: render inline Kitty images through libghostty (#8811) * test: cover kitty placement frame reuse * fix: cache kitty placement frames * test: cover pixel-accurate kitty clipping * fix: clip kitty placements in pixel space * test: cover pixel-accurate kitty replay clipping * fix: clip kitty replay in pixel space * test: cover number-only kitty image attach * test: cover both numbered kitty image aliases * fix: preserve kitty number aliases across attach * test: cover inflight kitty replay across resize * fix: preserve inflight kitty replay * test: cover anonymous kitty replay collisions * fix: preserve anonymous kitty placements in replay * test: cover kitty object count limits * fix: bound kitty graphics object counts * test: cover Kitty graphics in web render mode * test: cover host kitty scene invalidation * fix: restore kitty graphics after host resize * feat: render Kitty graphics in web terminal * test: cover graphics writer shutdown quiescence * fix: layer Kitty graphics above cell backgrounds * fix: draw web graphics from callback ref * fix: quiesce graphics before terminal restore * test: cover kitty replay allocation order * fix: preserve kitty replay allocation order * test: cover incremental render graphics deltas * test(tui): cover linear graphics state maintenance * fix: send incremental render graphics deltas * test: cover bounded kitty replay semantics * test(tui): cover late Kitty image ordering * test: cover render transport size boundaries * fix(tui): maintain Kitty graphic IDs linearly * test: cover atomic cell geometry updates * test(tui): cover Kitty PNG compatibility * test: cover full render metadata budget * test: preserve measured cell pixels across resize * test: bound kitty pixel cache lookups * fix: make cell geometry updates atomic * test: bound kitty placement grouping * fix: align render transport size budgets * test: bound render taps and resize replay * fix: preserve kitty images in bounded vt replay * fix: bound render taps and skip unused replay * docs(tui): document inline Kitty image support * style(tui): format merged changes * test: cover UTF-8 before kitty replay * test: cover large kitty resize replay * fix: distinguish UTF-8 from C1 kitty APC * fix: preserve kitty upload across resize * test(tui-sdk): cover retained render metadata overflow * fix(tui-sdk): bound retained render events * test(tui-web): expose placement canvas memory blowup * fix(tui-web): bound graphic canvas backing * test: preserve hosted Kitty image aliases * fix: preserve hosted Kitty image aliases * test(tui): cover stale Kitty write after resize clear * fix(tui): discard stale Kitty writes after resize clear * test(browser): expose terminal host alias protocol gap * fix(browser): support terminal host Kitty aliases * test(cmux-tui): expect resize alias sidecars * test(tui): preserve sparse viewport across replay * fix(tui): preserve sparse rows in terminal replay * fix(tui): align replayed scrollback rows * test(tui): await terminal host process exit * test(tui): cover Kitty alias history and sparse replay * fix(tui): preserve Kitty alias and sparse row history * fix(tui): address Kitty graphics review findings * fix(tui): harden Kitty graphics integration * fix(tui): resolve final Kitty autoreview findings * fix(tui): close Kitty autoreview findings * test(tui): cover final Kitty review regressions * fix(tui): close final Kitty autoreview findings * test(tui): reject overflowing PTY pixel geometry * fix(tui): reject invalid PTY pixel geometry * test(tui): cover remaining Kitty review regressions * fix(tui): close remaining Kitty review findings * fix(cmux-tui): close graphics review gaps * fix(cmux-tui): preserve attach and startup progress * fix(tui): reconcile image render geometry * fix(tui): align attach wire progress * fix(tui): bound inline image rendering resources * fix(tui): close inline image review gaps * fix(tui): bound inline graphics hot paths * test(tui): cover graphics attachment memory regressions * fix(tui): bound graphics attachment allocations * test(tui): budget retained render capacity * test(tui): cover graphics review regressions * fix(tui): close graphics autoreview gaps * test(tui): cover second graphics review regressions * fix(tui): close remaining graphics review gaps * test(tui): cover remaining graphics review regressions * fix(tui): close graphics review findings * test(tui): cover final graphics review regressions * fix(tui): close final graphics review findings * test(tui): cover graphics admission regressions * fix(tui): make graphics admission lazy and refillable * test(tui): cover final host lifecycle findings * fix(tui): bound host lifecycle work * test(tui): cover final protocol review findings * fix(tui): close final protocol review gaps * test(tui): cover bounded graphics writer failure * fix(tui): bound graphics output failure lifecycle * test(tui): cover final remote graphics review gaps * fix(tui): validate and localize remote attach data * test(tui): cover final graphics ownership findings * fix(tui): scope graphics output ownership * test(tui): cover graphics resource safety gaps * fix(tui): bound graphics resource lifecycles * test(tui): cover graphics budget scan fanout * fix(tui): make graphics admission single-pass * test(tui): cover review resource safety gaps * fix(tui): bound graphics attachment resources * test(tui): cover enhanced input adapter compatibility * fix(tui): reconcile shortcut merge with graphics input * test(tui): reconcile merged attach fixtures * test(tui): bound inline surface state * fix(tui): keep libghostty state out of line * test(tui): cover cell pixel fanout retry gap * fix(tui): reconcile skipped cell pixel fanout * test(tui): cover aggregate graphics ownership gaps * fix(tui): bound aggregate graphics ownership * test(tui): cover graphics teardown ownership * fix(tui): rebalance graphics ownership on teardown * test(tui): cover aggregate graphics recovery * fix(tui): recover aggregate graphics capacity * test(tui): isolate graphics counters per thread * test(tui): cover graphics resource ownership gaps * fix(tui): close graphics resource ownership gaps * test(tui): cover Kitty replay state divergence * fix(tui): preserve Kitty replay state across mirrors * test(tui): cover terminal resource lifecycle stalls * fix(tui): decouple terminal resource lifecycle work * test(tui): cover review lifecycle regressions * fix(tui): close review lifecycle gaps * test(tui): cover graphics review regressions * fix(tui): reconcile graphics lifecycle under load * test(tui): cover graphics baseline and quota exhaustion * fix(tui): reconcile graphics baselines and quota overflow * test(tui): make graphics backpressure deterministic * test(tui): cover exited quota and reset replay ordering * fix(tui): preserve graphics state across resets and exits * test(tui): cover scrolled Kitty placement alignment * fix(tui): align Kitty graphics with scrolled viewports * test(tui): bound stalled renderer output * fix(tui): preserve renderer output backpressure * test(tui): cover relabel and retry bounds * fix(tui): bound graphics recovery work * fix(ci): isolate fork-agent singleton default * test(tui): bound persistent graphics recovery * fix(tui): bound persistent graphics recovery * test(tui): drain stalled quota worker * test(tui): cover panic and fanout lifecycles * fix(tui): bound graphics worker lifecycles * test(web): cover exhausted graphics decode queue * fix(web): retire exhausted graphics decode jobs * test(tui): cover final Kitty review findings * fix(tui): close final Kitty replay gaps * test(tui): cover encoded Kitty quota * fix(tui): budget encoded Kitty uploads * test(browser): sync Kitty replay ceilings * fix(browser): match Kitty replay ceilings * test(tui): cover unsupported Kitty grayscale * fix(tui): bound Kitty snapshot formats * test(tui): cover Kitty quota recovery * fix(tui): reconcile Kitty quota recovery * test(tui): cover reconnect completion retry * fix(tui): retry failed host reconnect completion * test(tui): cover attach priority and replay cursor state * fix(tui): preserve attach priority and replay state * test(tui): cover superseded attach resize failure * fix(tui): settle the latest promoted resize * chore(tui): satisfy strict attach lifecycle lint * test(tui): cover numeric Kitty final chunks * fix(tui): parse Kitty chunk flags numerically * test(tui): cover Kitty images in web scrollback * fix(tui): render Kitty images in web scrollback * test(tui): admit terminals after graphics quota failure * fix(tui): degrade graphics after quota failure * test(tui): finish graphics probe at DA1 marker * fix(tui): end graphics probe at DA1 marker * test(tui): reject stale scrollback image epochs * fix(tui): version scrollback image anchors * test(tui): refresh active scrollback epochs * fix(tui): refresh active scrollback epochs * test(tui): ignore screen-only history epochs * fix(tui): scope history epochs to retained rows * test(tui): keep image frames out of history epochs * fix(tui): keep image frames out of history epochs * test(tui): assert stable screen-only history epochs * test(tui): bound deferred work and reserve attaches * fix(tui): bound deferred graphics coordination * test(tui): exhaust saturated Kitty quota retries * fix(tui): exhaust saturated Kitty quota retries * test(tui): retain overlapping Kitty replay placements * fix(tui): clip Kitty placements at replay boundaries * fix(tui): preserve merged attach invariants * test(ci): cover Ghostty path metadata * fix(ci): inspect executable Ghostty consumers * test(ios): replace wall-clock synchronization * chore(xcode): normalize project ordering * fix(ssh): simplify retry script assembly * test(app): update detached transfer fixture * test: update remote PTY lifecycle fake * test: require explicit app-host test mode * fix: declare app-host test launch mode * fix: clear Xcode 26.3 warning gate * test: detect embedded app-host test bundle * fix: detect app-host tests from embedded bundle * chore: drop unreliable app-host scheme marker * test: avoid async ARC lifetime assertion * test: require app-host build identity * fix: stamp app-host test builds before launch * test: require test-runner app-host marker * fix: forward app-host test identity through xcodebuild * fix: keep completed iroh dial single-flight through install * test: re-report lifecycle after status clear * Fix cmux-tui merge integration * test(web): cover render attach WebSocket budget * fix(web): admit full render attach frames --------- Co-authored-by: austinpower1258 --- .../MobileCoreRPCTransportDrainTests.swift | 3 +- ...OnboardingMacDiscoveryKeepAliveTests.swift | 115 +- ...ommandRunnerDescriptorLifecycleTests.swift | 15 +- Sources/App/MacSentryStartupPolicy.swift | 5 + Sources/AppDelegate.swift | 7 +- Sources/SessionPersistence.swift | 3 + Sources/cmuxApp.swift | 1 + .../public/cpp/cmux_terminal_host_protocol.cc | 308 +- .../public/cpp/cmux_terminal_host_protocol.h | 74 +- .../cpp/cmux_terminal_host_protocol_test.cc | 582 ++- cmux-tui/Cargo.lock | 257 +- cmux-tui/Cargo.toml | 6 + cmux-tui/bindings/ERGONOMICS.md | 4 +- cmux-tui/bindings/RELEASING.md | 2 +- cmux-tui/bindings/cpp/.cmux-sdk-manifest.json | 18 +- .../include/cmux/raw/generated/commands.hpp | 1 + .../cpp/include/cmux/raw/generated/events.hpp | 2 +- .../cpp/include/cmux/raw/generated/models.hpp | 208 +- .../cpp/src/raw/generated/protocol.cpp | 1059 +++++- .../bindings/cpp/tests/test_generated.cpp | 4 +- .../bindings/go/raw/.cmux-sdk-manifest.json | 22 +- cmux-tui/bindings/go/raw/README.md | 2 +- cmux-tui/bindings/go/raw/client_test.go | 8 +- .../go/raw/ergonomics_external_test.go | 2 + .../bindings/go/raw/generated_commands.go | 28 +- cmux-tui/bindings/go/raw/generated_events.go | 306 +- .../bindings/go/raw/generated_metadata.go | 14 +- .../go/raw/generated_presence_test.go | 1168 +++++- cmux-tui/bindings/go/raw/generated_types.go | 542 ++- .../src/com/cmux/raw/.cmux-sdk-manifest.json | 102 +- .../src/com/cmux/raw/CellPixelSurface.java | 91 + .../java/src/com/cmux/raw/Commands.java | 2 + .../java/src/com/cmux/raw/Events.java | 2 + .../src/com/cmux/raw/GeneratedCmuxClient.java | 5 + .../com/cmux/raw/GetCellPixelsRequest.java | 50 + .../src/com/cmux/raw/GetCellPixelsResult.java | 91 + .../src/com/cmux/raw/GraphicsStatusEvent.java | 158 + .../com/cmux/raw/GraphicsStatusEventKind.java | 35 + .../src/com/cmux/raw/KittyGraphicsState.java | 175 + .../src/com/cmux/raw/KittyImageAlias.java | 77 + .../java/src/com/cmux/raw/Protocol.java | 3 +- .../com/cmux/raw/ReadScrollbackResult.java | 18 +- .../src/com/cmux/raw/RenderDeltaEvent.java | 30 +- .../src/com/cmux/raw/RenderGraphicFormat.java | 34 + .../src/com/cmux/raw/RenderGraphicImage.java | 133 + .../com/cmux/raw/RenderGraphicPlacement.java | 341 ++ .../java/src/com/cmux/raw/RenderGraphics.java | 103 + .../src/com/cmux/raw/RenderGraphicsDelta.java | 102 + .../src/com/cmux/raw/RenderStateEvent.java | 31 +- .../java/src/com/cmux/raw/ResizedEvent.java | 30 +- .../java/src/com/cmux/raw/VtStateEvent.java | 30 +- .../java/src/com/cmux/raw/VtStateResult.java | 30 +- .../tests/com/cmux/raw/ErgonomicsTest.java | 2 + .../com/cmux/raw/GeneratedCoverageTest.java | 4 +- .../raw/_generated/.cmux-sdk-manifest.json | 22 +- .../python/cmux/raw/_generated/_schema.py | 2 +- .../python/cmux/raw/_generated/client.py | 4 + .../python/cmux/raw/_generated/codec.py | 11 + .../python/cmux/raw/_generated/metadata.py | 13 +- .../python/cmux/raw/_generated/models.py | 141 +- .../bindings/python/tests/test_convenience.py | 6 +- .../bindings/python/tests/test_protocol.py | 2 +- .../src/generated/.cmux-sdk-manifest.json | 20 +- .../bindings/rust/src/generated/commands.rs | 11 +- .../bindings/rust/src/generated/events.rs | 59 +- .../bindings/rust/src/generated/metadata.rs | 26 +- cmux-tui/bindings/rust/src/generated/mod.rs | 2 +- cmux-tui/bindings/rust/src/generated/types.rs | 115 +- cmux-tui/bindings/typescript/README.md | 6 + cmux-tui/bindings/typescript/package.json | 5 + .../scripts/verify-packaged-consumer.mjs | 6 + .../bindings/typescript/src/raw/browser.ts | 11 + .../bindings/typescript/src/raw/client.ts | 192 +- .../src/raw/generated/.cmux-sdk-manifest.json | 20 +- .../typescript/src/raw/generated/commands.ts | 16 +- .../typescript/src/raw/generated/events.ts | 24 +- .../typescript/src/raw/generated/index.ts | 2 +- .../typescript/src/raw/generated/metadata.ts | 716 +++- .../typescript/src/raw/generated/types.ts | 83 +- .../typescript/src/raw/node-client.ts | 10 +- .../typescript/src/transport-limits.ts | 10 + .../bindings/typescript/test/client.test.ts | 499 ++- .../typescript/test/generated.test.ts | 6 +- cmux-tui/bindings/zig/examples/watch.zig | 4 +- cmux-tui/bindings/zig/src/raw.zig | 4 +- .../src/raw/generated/.cmux-sdk-manifest.json | 10 +- .../zig/src/raw/generated/presence_test.zig | 23 + .../zig/src/raw/generated/protocol.zig | 303 +- .../crates/cmux-remote-protocol/src/lib.rs | 4 + cmux-tui/crates/cmux-remote/src/bridge.rs | 2 +- cmux-tui/crates/cmux-remote/src/mux_codec.rs | 14 +- cmux-tui/crates/cmux-tui-core/Cargo.toml | 1 + cmux-tui/crates/cmux-tui-core/src/browser.rs | 27 +- .../crates/cmux-tui-core/src/event_bus.rs | 3 +- cmux-tui/crates/cmux-tui-core/src/lib.rs | 7 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 3010 ++++++++++++++- .../src/resource_router/content.rs | 2 +- cmux-tui/crates/cmux-tui-core/src/server.rs | 2263 ++++++++++-- .../cmux-tui-core/src/sidebar_resource.rs | 9 +- cmux-tui/crates/cmux-tui-core/src/surface.rs | 2502 +++++++++++-- .../src/terminal_host_protocol.rs | 33 +- .../src/terminal_host_runtime.rs | 2030 +++++++++- .../src/workspace_registry/resource_store.rs | 10 +- cmux-tui/crates/cmux-tui-core/tests/pty.rs | 243 +- .../tests/websocket_transport.rs | 94 +- cmux-tui/crates/cmux-tui/Cargo.toml | 2 + cmux-tui/crates/cmux-tui/src/app.rs | 1889 +++++++++- cmux-tui/crates/cmux-tui/src/config.rs | 11 +- cmux-tui/crates/cmux-tui/src/localization.rs | 136 + .../cmux-tui/src/machine_provider_client.rs | 26 +- .../cmux-tui/src/machine_provider_runtime.rs | 1 + .../crates/cmux-tui/src/machine_runtime.rs | 21 + cmux-tui/crates/cmux-tui/src/session/mod.rs | 44 +- .../crates/cmux-tui/src/session/remote.rs | 2002 +++++++++- cmux-tui/crates/cmux-tui/src/ui/graphics.rs | 2292 ++++++++++-- .../crates/cmux-tui/src/ui/graphics_writer.rs | 3282 +++++++++++------ cmux-tui/crates/cmux-tui/src/ui/mod.rs | 1 + cmux-tui/crates/cmux-tui/src/ui/overlay.rs | 42 +- cmux-tui/crates/cmux-tui/src/ui/pane.rs | 3 + .../crates/cmux-tui/src/ui/terminal_grid.rs | 4 + .../cmux-tui/tests/terminal_host_recovery.rs | 183 +- cmux-tui/crates/ghostty-vt/Cargo.toml | 2 + cmux-tui/crates/ghostty-vt/src/kitty.rs | 1357 +++++++ cmux-tui/crates/ghostty-vt/src/lib.rs | 16 +- cmux-tui/crates/ghostty-vt/src/render.rs | 147 + cmux-tui/crates/ghostty-vt/src/terminal.rs | 1876 +++++++++- .../crates/ghostty-vt/tests/kitty_graphics.rs | 1031 ++++++ .../crates/ghostty-vt/tests/png_compat.rs | 190 + cmux-tui/crates/ghostty-vt/tests/render_v7.rs | 21 + cmux-tui/crates/ghostty-vt/tests/terminal.rs | 17 +- cmux-tui/docs/concepts.md | 2 +- cmux-tui/docs/getting-started.md | 2 + cmux-tui/frontends/web/src/App.tsx | 9 + .../web/src/components/ByteTerminal.tsx | 2 +- .../web/src/components/ClientsIndicator.tsx | 2 +- .../web/src/components/ConnectScreen.tsx | 2 +- .../web/src/components/RenderGraphics.tsx | 617 ++++ .../web/src/components/RenderTerminal.tsx | 121 +- .../frontends/web/src/components/Sidebar.tsx | 2 +- .../web/src/components/StatusBar.tsx | 2 +- .../web/src/components/TerminalFrame.tsx | 2 +- .../web/src/components/TerminalPane.tsx | 16 +- .../frontends/web/src/components/Toasts.tsx | 3 +- .../web/src/hooks/useAttachedTerminal.ts | 12 +- .../frontends/web/src/hooks/useCmuxClient.ts | 8 +- .../hooks/useDecodedRenderGraphicImages.ts | 72 + .../web/src/hooks/useRenderTerminal.ts | 135 +- .../frontends/web/src/lib/clientSizing.ts | 2 +- cmux-tui/frontends/web/src/lib/layout.ts | 2 +- .../frontends/web/src/lib/localSelection.ts | 2 +- cmux-tui/frontends/web/src/lib/rename.ts | 4 +- .../frontends/web/src/lib/renderGraphics.ts | 253 ++ .../src/lib/renderGraphicsDecodeScheduler.ts | 327 ++ cmux-tui/frontends/web/src/lib/renderModel.ts | 399 +- .../frontends/web/src/lib/renderStyles.ts | 2 +- cmux-tui/frontends/web/src/lib/scrollback.ts | 67 +- cmux-tui/frontends/web/src/lib/splitDrag.ts | 2 +- .../frontends/web/src/lib/terminalColors.ts | 2 +- cmux-tui/frontends/web/src/lib/tree.ts | 6 +- cmux-tui/frontends/web/src/styles.css | 13 +- .../web/src/workers/renderGraphicsDecoder.ts | 39 + .../test/attached-terminal-sizing.test.tsx | 40 +- .../frontends/web/test/client-sizing.test.ts | 20 +- .../web/test/clients-indicator.test.tsx | 16 +- .../web/test/connect-screen.test.tsx | 2 +- cmux-tui/frontends/web/test/layout.test.ts | 92 +- .../web/test/local-selection.test.ts | 32 +- cmux-tui/frontends/web/test/rename.test.ts | 6 +- .../test/render-graphics-component.test.tsx | 1154 ++++++ .../web/test/render-graphics.test.ts | 238 ++ .../frontends/web/test/render-model.test.ts | 371 +- .../frontends/web/test/render-styles.test.ts | 2 +- .../web/test/render-terminal-history.test.tsx | 186 + .../web/test/render-terminal-sizing.test.tsx | 175 +- .../web/test/render-terminal.test.tsx | 245 +- .../frontends/web/test/scrollback.test.ts | 83 +- .../test/terminal-pane-split-drag.test.tsx | 186 +- cmux-tui/frontends/web/test/tree.test.ts | 80 +- .../frontends/web/test/tui-chrome.test.tsx | 10 +- .../web/test/use-cmux-client.test.tsx | 43 + cmux-tui/scripts/check-spec-inventory.py | 35 +- cmux-tui/scripts/test_check_spec_inventory.py | 8 +- cmux-tui/spec/commands.md | 12 +- cmux-tui/spec/events.md | 21 +- cmux-tui/spec/frontends.md | 4 +- cmux-tui/spec/inventory.json | 8 +- cmux-tui/spec/render.md | 12 +- cmux-tui/spec/sdk-schema.json | 544 +++ cmux-tui/vendor/crossterm/src/event.rs | 16 + .../vendor/terminput-crossterm/CMUX_PATCH.md | 8 + .../vendor/terminput-crossterm/Cargo.toml | 102 + .../vendor/terminput-crossterm/LICENSE-APACHE | 201 + .../vendor/terminput-crossterm/LICENSE-MIT | 21 + cmux-tui/vendor/terminput-crossterm/README.md | 18 + .../vendor/terminput-crossterm/src/lib.rs | 8 + .../vendor/terminput-crossterm/src/mapping.rs | 472 +++ cmuxTests/MacSentryStartupPolicyTests.swift | 9 + ghostty | 2 +- scripts/ci/run-app-host-xcodebuild.sh | 3 +- tests/test_ci_app_host_xcodebuild_retry.sh | 12 + 200 files changed, 36984 insertions(+), 3321 deletions(-) create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/CellPixelSurface.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsRequest.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsResult.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEvent.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEventKind.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/KittyGraphicsState.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/KittyImageAlias.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicFormat.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicImage.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicPlacement.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphics.java create mode 100644 cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicsDelta.java create mode 100644 cmux-tui/bindings/typescript/src/raw/browser.ts create mode 100644 cmux-tui/crates/ghostty-vt/src/kitty.rs create mode 100644 cmux-tui/crates/ghostty-vt/tests/kitty_graphics.rs create mode 100644 cmux-tui/crates/ghostty-vt/tests/png_compat.rs create mode 100644 cmux-tui/frontends/web/src/components/RenderGraphics.tsx create mode 100644 cmux-tui/frontends/web/src/hooks/useDecodedRenderGraphicImages.ts create mode 100644 cmux-tui/frontends/web/src/lib/renderGraphics.ts create mode 100644 cmux-tui/frontends/web/src/lib/renderGraphicsDecodeScheduler.ts create mode 100644 cmux-tui/frontends/web/src/workers/renderGraphicsDecoder.ts create mode 100644 cmux-tui/frontends/web/test/render-graphics-component.test.tsx create mode 100644 cmux-tui/frontends/web/test/render-graphics.test.ts create mode 100644 cmux-tui/frontends/web/test/render-terminal-history.test.tsx create mode 100644 cmux-tui/frontends/web/test/use-cmux-client.test.tsx create mode 100644 cmux-tui/vendor/terminput-crossterm/CMUX_PATCH.md create mode 100644 cmux-tui/vendor/terminput-crossterm/Cargo.toml create mode 100644 cmux-tui/vendor/terminput-crossterm/LICENSE-APACHE create mode 100644 cmux-tui/vendor/terminput-crossterm/LICENSE-MIT create mode 100644 cmux-tui/vendor/terminput-crossterm/README.md create mode 100644 cmux-tui/vendor/terminput-crossterm/src/lib.rs create mode 100644 cmux-tui/vendor/terminput-crossterm/src/mapping.rs diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift index a2cdbe36fa..7929b73f3c 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift @@ -100,11 +100,10 @@ import Testing await completion.finish() } #expect(await transport.waitUntilCloseCount(1)) - try await Task.sleep(nanoseconds: 10_000_000) + await drain.value #expect(await completion.isFinished) await transport.releaseConnects() - await drain.value #expect(await transport.waitUntilCloseCount(2)) request.cancel() _ = await request.result diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift index 762c8578bb..cfbed4ef88 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift @@ -1,5 +1,6 @@ #if os(iOS) @testable import CmuxMobileShellUI +import Foundation import Testing @MainActor @@ -68,7 +69,6 @@ struct OnboardingMacDiscoveryKeepAliveTests { } #expect(await eventually { !keepAlive.isRunning }) - try? await ContinuousClock().sleep(for: .milliseconds(20)) #expect(attemptCount == 1) } @@ -104,7 +104,6 @@ struct OnboardingMacDiscoveryKeepAliveTests { gate.resume(returning: false) #expect(await eventually { !keepAlive.isRunning }) - try? await ContinuousClock().sleep(for: .milliseconds(20)) #expect(!gate.cancellationObserved) #expect(attemptCount == 1) } @@ -183,7 +182,11 @@ struct OnboardingMacDiscoveryKeepAliveTests { func waitsUntilCoordinatorOwnershipIsReleased() async throws { let coordinator = MobileStartupConnectionCoordinator() let externalClaim = try #require(coordinator.claimStoredReconnect()) - let keepAlive = makeKeepAlive() + let clock = OnboardingDiscoveryManualClock() + let keepAlive = makeKeepAlive( + clock: clock, + claimRetryDelay: .seconds(1) + ) var attemptCount = 0 keepAlive.update( @@ -197,10 +200,11 @@ struct OnboardingMacDiscoveryKeepAliveTests { return true } - try? await ContinuousClock().sleep(for: .milliseconds(20)) + #expect(await eventually { clock.sleeperCount == 1 }) #expect(attemptCount == 0) coordinator.finishStoredReconnect(externalClaim) + clock.advance(by: .seconds(1)) #expect(await eventually { attemptCount == 1 && !keepAlive.isRunning }) } @@ -262,14 +266,17 @@ struct OnboardingMacDiscoveryKeepAliveTests { } #expect(await eventually { !keepAlive.isRunning }) - try? await ContinuousClock().sleep(for: .milliseconds(20)) #expect(attemptCount == 1) } - private func makeKeepAlive() -> OnboardingMacDiscoveryKeepAlive { + private func makeKeepAlive( + clock: any Clock = ContinuousClock(), + claimRetryDelay: Duration = .milliseconds(1) + ) -> OnboardingMacDiscoveryKeepAlive { OnboardingMacDiscoveryKeepAlive( + clock: clock, retryDelay: .milliseconds(1), - claimRetryDelay: .milliseconds(1) + claimRetryDelay: claimRetryDelay ) } @@ -289,6 +296,100 @@ struct OnboardingMacDiscoveryKeepAliveTests { } } +private final class OnboardingDiscoveryManualClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + func duration(to other: Instant) -> Duration { + other.offset - offset + } + + static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let id: UUID + let deadline: Instant + let continuation: UnsafeContinuation + } + + private let lock = NSLock() + private var current = Instant(offset: .zero) + private var sleepers: [Sleeper] = [] + private var preCancelledIDs: Set = [] + + var now: Instant { + lock.lock() + defer { lock.unlock() } + return current + } + + var minimumResolution: Duration { .zero } + + var sleeperCount: Int { + lock.lock() + defer { lock.unlock() } + return sleepers.count + } + + func sleep(until deadline: Instant, tolerance _: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { + (continuation: UnsafeContinuation) in + lock.lock() + if preCancelledIDs.remove(id) != nil { + lock.unlock() + continuation.resume(throwing: CancellationError()) + } else if deadline <= current { + lock.unlock() + continuation.resume() + } else { + sleepers.append(Sleeper( + id: id, + deadline: deadline, + continuation: continuation + )) + lock.unlock() + } + } + } onCancel: { + cancelSleeper(id: id) + } + } + + func advance(by duration: Duration) { + lock.lock() + current = current.advanced(by: duration) + let due = sleepers + .filter { $0.deadline <= current } + .sorted { $0.deadline < $1.deadline } + sleepers.removeAll { $0.deadline <= current } + lock.unlock() + for sleeper in due { + sleeper.continuation.resume() + } + } + + private func cancelSleeper(id: UUID) { + lock.lock() + guard let index = sleepers.firstIndex(where: { $0.id == id }) else { + preCancelledIDs.insert(id) + lock.unlock() + return + } + let sleeper = sleepers.remove(at: index) + lock.unlock() + sleeper.continuation.resume(throwing: CancellationError()) + } +} + @MainActor private final class AttemptParkingGate { private var continuation: CheckedContinuation? diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Process/CommandRunnerDescriptorLifecycleTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Process/CommandRunnerDescriptorLifecycleTests.swift index d4ca965e43..5181df5140 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Process/CommandRunnerDescriptorLifecycleTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Process/CommandRunnerDescriptorLifecycleTests.swift @@ -10,10 +10,8 @@ struct CommandRunnerDescriptorLifecycleTests { @Test("Capture pipes are close-on-exec and close before success returns") func capturePipesAreCloseOnExecAndCloseAfterSuccess() async throws { - weak var releasedProcess: Process? - var execution: CommandExecution? = try makeExecution(executable: "/usr/bin/true") - releasedProcess = execution?.process - let descriptors = try snapshotDescriptors(of: #require(execution)) + let execution = try makeExecution(executable: "/usr/bin/true") + let descriptors = try snapshotDescriptors(of: execution) #expect(descriptors.count == 8) for descriptor in descriptors { let flags = fcntl(descriptor.fileDescriptor, F_GETFD) @@ -24,15 +22,10 @@ struct CommandRunnerDescriptorLifecycleTests { ) } - let result: CommandResult - do { - let activeExecution = try #require(execution) - result = await activeExecution.run(timeout: 5) - } + let result = await execution.run(timeout: 5) #expect(result.exitStatus == 0) expectDescriptorsClosed(descriptors) - execution = nil - #expect(releasedProcess == nil) + #expect(execution.process.terminationHandler == nil) } @Test("Launch failure closes capture pipes") diff --git a/Sources/App/MacSentryStartupPolicy.swift b/Sources/App/MacSentryStartupPolicy.swift index e0f8f51e2e..2e6acfcfd5 100644 --- a/Sources/App/MacSentryStartupPolicy.swift +++ b/Sources/App/MacSentryStartupPolicy.swift @@ -1,3 +1,5 @@ +import Foundation + struct MacSentryStartupPolicy: Sendable { let telemetryEnabled: Bool let isRunningUnderXCTest: Bool @@ -29,6 +31,9 @@ struct MacSentryStartupPolicy: Sendable { } static func isRunningUnderXCTest(environment: [String: String]) -> Bool { + // xcodebuild strips TEST_RUNNER_ from variables forwarded to the test + // host, so the CI wrapper makes this available before XCTest connects. + if environment["CMUX_TEST_PROCESS"] == "1" { return true } if environment["XCTestConfigurationFilePath"] != nil { return true } if environment["XCTestBundlePath"] != nil { return true } if environment["XCTestSessionIdentifier"] != nil { return true } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 20705e74e3..4d748c9529 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -568,9 +568,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ConnectivityInvalidationSubscriberCoordinator() private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { - // On some macOS/Xcode setups, the app-under-test process doesn't get - // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests - // can reliably skip heavyweight startup work and bring up a window. + // The CI wrapper uses xcodebuild's TEST_RUNNER_ forwarding so its marker + // exists before XCTest connects. Standard XCTest keys cover other paths. MacSentryStartupPolicy.isRunningUnderXCTest(environment: env) } @@ -16709,7 +16708,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func handleMainTerminalWindowShouldClose() -> Bool { // XCTest has no UI for the warn-before-quit dialog and would either block // on runModal or have NSApp.terminate kill the test process. - if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { return true } + if isRunningUnderXCTest(ProcessInfo.processInfo.environment) { return true } guard !isTerminatingApp, mainWindowContexts.count <= 1 else { return true } _ = handleQuitShortcutWarning() return false diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 43077ac234..13a77d33fa 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -125,6 +125,9 @@ enum SessionRestorePolicy { static func isRunningUnderAutomatedTests( environment: [String: String] = ProcessInfo.processInfo.environment ) -> Bool { + if environment["CMUX_TEST_PROCESS"] == "1" { + return true + } if environment["CMUX_UI_TEST_MODE"] == "1" { return true } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 949d64fe18..4c13ad36b8 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5230,6 +5230,7 @@ enum AppIconSettings { private static var liveEnvironmentProvider: () -> Environment = { .live() } private static func isRunningUnderXCTest(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + if env["CMUX_TEST_PROCESS"] == "1" { return true } if env["XCTestConfigurationFilePath"] != nil { return true } if env["XCTestBundlePath"] != nil { return true } if env["XCTestSessionIdentifier"] != nil { return true } diff --git a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.cc b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.cc index f702cda32c..d0e9019717 100644 --- a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.cc +++ b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace cmux { @@ -13,6 +14,10 @@ namespace { constexpr size_t kClientHelloLength = 60; constexpr size_t kHostHelloLength = 40; +constexpr uint64_t kMaxKittyImageBytes = 10'000'000; +constexpr uint64_t kMaxKittyInflightBytes = 13'595'480; +constexpr uint64_t kMaxKittyImages = 4'096; +constexpr uint64_t kMaxKittyPlacements = 16'384; void AppendU16(std::vector* output, uint16_t value) { output->push_back(static_cast(value)); @@ -88,6 +93,12 @@ std::optional ParseMessageKind(uint16_t value) { return TerminalHostMessageKind::kCapability; case 16: return TerminalHostMessageKind::kResizeAck; + case 17: + return TerminalHostMessageKind::kClearHistoryAck; + case 18: + return TerminalHostMessageKind::kCellPixelSizeAck; + case 19: + return TerminalHostMessageKind::kKittyGraphicsLimitsAck; case 100: return TerminalHostMessageKind::kInput; case 101: @@ -100,6 +111,14 @@ std::optional ParseMessageKind(uint16_t value) { return TerminalHostMessageKind::kTerminate; case 105: return TerminalHostMessageKind::kMintCapability; + case 106: + return TerminalHostMessageKind::kSetDefaults; + case 107: + return TerminalHostMessageKind::kClearHistory; + case 108: + return TerminalHostMessageKind::kSetCellPixelSize; + case 109: + return TerminalHostMessageKind::kSetKittyGraphicsLimits; default: return std::nullopt; } @@ -225,6 +244,15 @@ class PayloadReader { return true; } + bool U64(uint64_t* value) { + std::string_view bytes; + if (!value || !Take(8, &bytes)) { + return false; + } + *value = ReadU64(bytes, 0); + return true; + } + bool Bytes(size_t limit, std::string_view* bytes) { uint32_t length = 0; return U32(&length) && length <= limit && Take(length, bytes); @@ -267,6 +295,109 @@ class PayloadReader { size_t offset_ = 0; }; +TerminalHostProtocolError ValidateKittyImageAliases( + const std::vector& aliases) { + if (aliases.size() > kTerminalHostMaxKittyImageAliases) { + return TerminalHostProtocolError::kPayloadTooLarge; + } + // Repeated image numbers preserve Kitty's assignment history. Image IDs + // remain unique identities within a snapshot. + std::unordered_set image_ids; + image_ids.reserve(aliases.size()); + for (const TerminalHostKittyImageAlias& alias : aliases) { + if (alias.image_id == 0 || alias.image_number == 0 || + !image_ids.insert(alias.image_id).second) { + return TerminalHostProtocolError::kMalformedPayload; + } + } + return TerminalHostProtocolError::kNone; +} + +void AppendKittyImageAliases( + std::vector* output, + const std::vector& aliases) { + AppendU16(output, static_cast(aliases.size())); + for (const TerminalHostKittyImageAlias& alias : aliases) { + AppendU32(output, alias.image_id); + AppendU32(output, alias.image_number); + } +} + +TerminalHostProtocolError ReadKittyImageAliases( + PayloadReader* reader, + std::vector* aliases) { + uint16_t count = 0; + if (!reader || !aliases || !reader->U16(&count) || + count > kTerminalHostMaxKittyImageAliases) { + return TerminalHostProtocolError::kMalformedPayload; + } + std::vector decoded; + decoded.reserve(count); + for (size_t index = 0; index < count; ++index) { + TerminalHostKittyImageAlias alias; + if (!reader->U32(&alias.image_id) || + !reader->U32(&alias.image_number)) { + return TerminalHostProtocolError::kMalformedPayload; + } + decoded.push_back(alias); + } + if (ValidateKittyImageAliases(decoded) != + TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + *aliases = std::move(decoded); + return TerminalHostProtocolError::kNone; +} + +TerminalHostProtocolError ValidateKittyReplayState( + const TerminalHostKittyReplayState& state) { + if (state.limits.image_bytes > kMaxKittyImageBytes || + state.limits.inflight_bytes > kMaxKittyInflightBytes || + state.limits.images > kMaxKittyImages || + state.limits.placements > kMaxKittyPlacements || + state.replay_next_image_ids.primary == 0 || + state.next_image_ids.primary == 0 || + state.replay_next_image_ids.alternate == 0 || + state.next_image_ids.alternate == 0) { + return TerminalHostProtocolError::kMalformedPayload; + } + return TerminalHostProtocolError::kNone; +} + +void AppendKittyReplayState(std::vector* output, + const TerminalHostKittyReplayState& state) { + AppendU64(output, state.limits.image_bytes); + AppendU64(output, state.limits.inflight_bytes); + AppendU64(output, state.limits.images); + AppendU64(output, state.limits.placements); + AppendU32(output, state.replay_cursor_offset); + AppendU32(output, state.replay_next_image_ids.primary); + AppendU32(output, state.next_image_ids.primary); + AppendU32(output, state.replay_next_image_ids.alternate); + AppendU32(output, state.next_image_ids.alternate); +} + +TerminalHostProtocolError ReadKittyReplayState( + PayloadReader* reader, + TerminalHostKittyReplayState* state) { + TerminalHostKittyReplayState decoded; + if (!reader || !state || + !reader->U64(&decoded.limits.image_bytes) || + !reader->U64(&decoded.limits.inflight_bytes) || + !reader->U64(&decoded.limits.images) || + !reader->U64(&decoded.limits.placements) || + !reader->U32(&decoded.replay_cursor_offset) || + !reader->U32(&decoded.replay_next_image_ids.primary) || + !reader->U32(&decoded.next_image_ids.primary) || + !reader->U32(&decoded.replay_next_image_ids.alternate) || + !reader->U32(&decoded.next_image_ids.alternate) || + ValidateKittyReplayState(decoded) != TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + *state = decoded; + return TerminalHostProtocolError::kNone; +} + bool AppendBytes(std::vector* output, std::string_view bytes, size_t limit) { @@ -786,6 +917,7 @@ TerminalHostRendererGrantError ValidateTerminalHostRendererGrant( std::string_view incarnation, std::string_view token, uint64_t rights, + uint64_t protocol_version, uint64_t ttl_ms, uint64_t expected_ttl_ms, TerminalHostRendererGrant* grant) { @@ -811,6 +943,10 @@ TerminalHostRendererGrantError ValidateTerminalHostRendererGrant( if (rights != kRendererRights) { return TerminalHostRendererGrantError::kInvalidRights; } + if (protocol_version < kTerminalHostProtocolVersionV1 || + protocol_version > kTerminalHostProtocolVersion) { + return TerminalHostRendererGrantError::kInvalidProtocolVersion; + } if (ttl_ms == 0 || ttl_ms > kTerminalHostMaxRendererCapabilityTtlMs || expected_ttl_ms == 0 || expected_ttl_ms > kTerminalHostMaxRendererCapabilityTtlMs || @@ -824,11 +960,24 @@ TerminalHostRendererGrantError ValidateTerminalHostRendererGrant( } decoded.endpoint = std::string(endpoint); decoded.rights = TerminalHostCapabilityRights::kRenderer; + decoded.protocol_version = static_cast(protocol_version); decoded.ttl_ms = static_cast(ttl_ms); *grant = std::move(decoded); return TerminalHostRendererGrantError::kNone; } +TerminalHostClientHello TerminalHostClientHelloForRendererGrant( + const TerminalHostRendererGrant& grant) { + TerminalHostClientHello hello; + hello.min_version = grant.protocol_version; + hello.max_version = grant.protocol_version; + hello.role = TerminalHostClientRole::kRenderer; + hello.requested_rights = grant.rights; + hello.terminal_id = grant.terminal_id; + hello.token = grant.token; + return hello; +} + const char* TerminalHostRendererGrantErrorMessage( TerminalHostRendererGrantError error) { switch (error) { @@ -844,6 +993,8 @@ const char* TerminalHostRendererGrantErrorMessage( return "invalid terminal-host one-use token"; case TerminalHostRendererGrantError::kInvalidRights: return "invalid terminal-host renderer rights"; + case TerminalHostRendererGrantError::kInvalidProtocolVersion: + return "invalid terminal-host protocol version"; case TerminalHostRendererGrantError::kInvalidTtl: return "invalid terminal-host renderer TTL"; } @@ -854,6 +1005,26 @@ std::string EncodeTerminalHostId(const TerminalHostId& id) { return EncodeLowerHex(id); } +bool TerminalHostKittyGraphicsLimits::operator==( + const TerminalHostKittyGraphicsLimits& other) const { + return image_bytes == other.image_bytes && + inflight_bytes == other.inflight_bytes && images == other.images && + placements == other.placements; +} + +bool TerminalHostKittyImageIdCursors::operator==( + const TerminalHostKittyImageIdCursors& other) const { + return primary == other.primary && alternate == other.alternate; +} + +bool TerminalHostKittyReplayState::operator==( + const TerminalHostKittyReplayState& other) const { + return limits == other.limits && + replay_cursor_offset == other.replay_cursor_offset && + replay_next_image_ids == other.replay_next_image_ids && + next_image_ids == other.next_image_ids; +} + TerminalHostSnapshot::TerminalHostSnapshot() = default; TerminalHostSnapshot::TerminalHostSnapshot(const TerminalHostSnapshot&) = default; @@ -865,8 +1036,12 @@ TerminalHostSnapshot& TerminalHostSnapshot::operator=(TerminalHostSnapshot&&) = TerminalHostSnapshot::~TerminalHostSnapshot() = default; bool TerminalHostSnapshot::operator==(const TerminalHostSnapshot& other) const { - return cols == other.cols && rows == other.rows && pid == other.pid && - replay == other.replay && cwd == other.cwd && command == other.command; + return cols == other.cols && rows == other.rows && + cell_width == other.cell_width && cell_height == other.cell_height && + pid == other.pid && replay == other.replay && cwd == other.cwd && + command == other.command && + kitty_image_aliases == other.kitty_image_aliases && + kitty_state == other.kitty_state; } TerminalHostProtocolError EncodeTerminalHostSnapshot( @@ -879,6 +1054,19 @@ TerminalHostProtocolError EncodeTerminalHostSnapshot( snapshot.command.size() > kTerminalHostMaxCommandArguments) { return TerminalHostProtocolError::kPayloadTooLarge; } + const TerminalHostProtocolError aliases_error = + ValidateKittyImageAliases(snapshot.kitty_image_aliases); + if (aliases_error != TerminalHostProtocolError::kNone) { + return aliases_error; + } + const TerminalHostProtocolError state_error = + ValidateKittyReplayState(snapshot.kitty_state); + if (state_error != TerminalHostProtocolError::kNone) { + return state_error; + } + if (snapshot.kitty_state.replay_cursor_offset > snapshot.replay.size()) { + return TerminalHostProtocolError::kMalformedPayload; + } std::vector encoded; AppendU16(&encoded, snapshot.cols); AppendU16(&encoded, snapshot.rows); @@ -897,6 +1085,13 @@ TerminalHostProtocolError EncodeTerminalHostSnapshot( return TerminalHostProtocolError::kMalformedPayload; } } + AppendKittyImageAliases(&encoded, snapshot.kitty_image_aliases); + AppendU16(&encoded, std::max(snapshot.cell_width, 1)); + AppendU16(&encoded, std::max(snapshot.cell_height, 1)); + AppendKittyReplayState(&encoded, snapshot.kitty_state); + if (encoded.size() > kTerminalHostMaxFramePayload) { + return TerminalHostProtocolError::kPayloadTooLarge; + } *payload = std::move(encoded); return TerminalHostProtocolError::kNone; } @@ -904,9 +1099,24 @@ TerminalHostProtocolError EncodeTerminalHostSnapshot( TerminalHostProtocolError DecodeTerminalHostSnapshot( std::string_view payload, TerminalHostSnapshot* snapshot) { + return DecodeTerminalHostSnapshotForVersion( + payload, kTerminalHostProtocolVersion, snapshot); +} + +TerminalHostProtocolError DecodeTerminalHostSnapshotForVersion( + std::string_view payload, + uint16_t protocol_version, + TerminalHostSnapshot* snapshot) { if (!snapshot) { return TerminalHostProtocolError::kInvalidArgument; } + if (protocol_version < kTerminalHostProtocolVersionV1 || + protocol_version > kTerminalHostProtocolVersion) { + return TerminalHostProtocolError::kInvalidVersion; + } + if (payload.size() > kTerminalHostMaxFramePayload) { + return TerminalHostProtocolError::kPayloadTooLarge; + } PayloadReader reader(payload); TerminalHostSnapshot decoded; uint32_t pid = 0; @@ -938,6 +1148,27 @@ TerminalHostProtocolError DecodeTerminalHostSnapshot( } decoded.command.push_back(std::move(argument)); } + if (protocol_version >= kTerminalHostProtocolVersionV2 && + ReadKittyImageAliases(&reader, &decoded.kitty_image_aliases) != + TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersionV2 && + (!reader.U16(&decoded.cell_width) || + !reader.U16(&decoded.cell_height))) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersion && + ReadKittyReplayState(&reader, &decoded.kitty_state) != + TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersion && + decoded.kitty_state.replay_cursor_offset > decoded.replay.size()) { + return TerminalHostProtocolError::kMalformedPayload; + } + decoded.cell_width = std::max(decoded.cell_width, 1); + decoded.cell_height = std::max(decoded.cell_height, 1); if (!reader.finished()) { return TerminalHostProtocolError::kMalformedPayload; } @@ -955,7 +1186,11 @@ TerminalHostResize& TerminalHostResize::operator=(TerminalHostResize&&) = TerminalHostResize::~TerminalHostResize() = default; bool TerminalHostResize::operator==(const TerminalHostResize& other) const { - return cols == other.cols && rows == other.rows && replay == other.replay; + return cols == other.cols && rows == other.rows && + cell_width == other.cell_width && cell_height == other.cell_height && + replay == other.replay && + kitty_image_aliases == other.kitty_image_aliases && + kitty_state == other.kitty_state; } TerminalHostProtocolError EncodeTerminalHostResize( @@ -967,32 +1202,91 @@ TerminalHostProtocolError EncodeTerminalHostResize( if (resize.replay.size() > kTerminalHostMaxSnapshotReplay) { return TerminalHostProtocolError::kPayloadTooLarge; } + const TerminalHostProtocolError aliases_error = + ValidateKittyImageAliases(resize.kitty_image_aliases); + if (aliases_error != TerminalHostProtocolError::kNone) { + return aliases_error; + } + const TerminalHostProtocolError state_error = + ValidateKittyReplayState(resize.kitty_state); + if (state_error != TerminalHostProtocolError::kNone) { + return state_error; + } + if (resize.kitty_state.replay_cursor_offset > resize.replay.size()) { + return TerminalHostProtocolError::kMalformedPayload; + } std::vector encoded; - encoded.reserve(8 + resize.replay.size()); + encoded.reserve(14 + resize.replay.size() + + resize.kitty_image_aliases.size() * 8); AppendU16(&encoded, resize.cols); AppendU16(&encoded, resize.rows); AppendU32(&encoded, static_cast(resize.replay.size())); encoded.insert(encoded.end(), resize.replay.begin(), resize.replay.end()); + AppendKittyImageAliases(&encoded, resize.kitty_image_aliases); + AppendU16(&encoded, std::max(resize.cell_width, 1)); + AppendU16(&encoded, std::max(resize.cell_height, 1)); + AppendKittyReplayState(&encoded, resize.kitty_state); + if (encoded.size() > kTerminalHostMaxFramePayload) { + return TerminalHostProtocolError::kPayloadTooLarge; + } *payload = std::move(encoded); return TerminalHostProtocolError::kNone; } TerminalHostProtocolError DecodeTerminalHostResize(std::string_view payload, TerminalHostResize* resize) { + return DecodeTerminalHostResizeForVersion( + payload, kTerminalHostProtocolVersion, resize); +} + +TerminalHostProtocolError DecodeTerminalHostResizeForVersion( + std::string_view payload, + uint16_t protocol_version, + TerminalHostResize* resize) { if (!resize) { return TerminalHostProtocolError::kInvalidArgument; } + if (protocol_version < kTerminalHostProtocolVersionV1 || + protocol_version > kTerminalHostProtocolVersion) { + return TerminalHostProtocolError::kInvalidVersion; + } + if (payload.size() > kTerminalHostMaxFramePayload) { + return TerminalHostProtocolError::kPayloadTooLarge; + } PayloadReader reader(payload); TerminalHostResize decoded; std::string_view replay; if (!reader.U16(&decoded.cols) || !reader.U16(&decoded.rows) || - !reader.Bytes(kTerminalHostMaxSnapshotReplay, &replay) || - !reader.finished()) { + !reader.Bytes(kTerminalHostMaxSnapshotReplay, &replay)) { + return TerminalHostProtocolError::kMalformedPayload; + } + decoded.replay.assign(replay.begin(), replay.end()); + if (protocol_version >= kTerminalHostProtocolVersionV2 && + ReadKittyImageAliases(&reader, &decoded.kitty_image_aliases) != + TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersionV2 && + (!reader.U16(&decoded.cell_width) || + !reader.U16(&decoded.cell_height))) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersion && + ReadKittyReplayState(&reader, &decoded.kitty_state) != + TerminalHostProtocolError::kNone) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (protocol_version >= kTerminalHostProtocolVersion && + decoded.kitty_state.replay_cursor_offset > decoded.replay.size()) { + return TerminalHostProtocolError::kMalformedPayload; + } + if (!reader.finished()) { return TerminalHostProtocolError::kMalformedPayload; } decoded.cols = std::max(decoded.cols, 1); decoded.rows = std::max(decoded.rows, 1); - decoded.replay.assign(replay.begin(), replay.end()); + decoded.cell_width = std::max(decoded.cell_width, 1); + decoded.cell_height = std::max(decoded.cell_height, 1); *resize = std::move(decoded); return TerminalHostProtocolError::kNone; } diff --git a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.h b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.h index 445525a315..9b52826f96 100644 --- a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.h +++ b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol.h @@ -17,12 +17,15 @@ namespace cmux { inline constexpr std::array kTerminalHostMagic = {'C', 'M', 'T', 'H'}; inline constexpr size_t kTerminalHostHeaderLength = 32; -inline constexpr uint16_t kTerminalHostProtocolVersion = 1; +inline constexpr uint16_t kTerminalHostProtocolVersionV1 = 1; +inline constexpr uint16_t kTerminalHostProtocolVersionV2 = 2; +inline constexpr uint16_t kTerminalHostProtocolVersion = 3; inline constexpr uint32_t kTerminalHostFlagColorsFollow = 1u << 0; inline constexpr uint32_t kTerminalHostFlagViewerSizeAcks = 1u << 1; inline constexpr uint32_t kTerminalHostResizeAckCanonicalChanged = 1u << 0; inline constexpr size_t kTerminalHostMaxFramePayload = 16 * 1024 * 1024; -inline constexpr size_t kTerminalHostMaxSnapshotReplay = 8 * 1024 * 1024; +inline constexpr size_t kTerminalHostMaxSnapshotReplay = 15'692'632; +inline constexpr size_t kTerminalHostMaxKittyImageAliases = 4'096; inline constexpr size_t kTerminalHostMaxString = 256 * 1024; inline constexpr size_t kTerminalHostMaxCommandArguments = 256; inline constexpr uint16_t kTerminalHostColorsVersionV1 = 1; @@ -48,12 +51,19 @@ enum class TerminalHostMessageKind : uint16_t { kLaunch = 14, kCapability = 15, kResizeAck = 16, + kClearHistoryAck = 17, + kCellPixelSizeAck = 18, + kKittyGraphicsLimitsAck = 19, kInput = 100, kPaste = 101, kViewerSize = 102, kReleaseViewer = 103, kTerminate = 104, kMintCapability = 105, + kSetDefaults = 106, + kClearHistory = 107, + kSetCellPixelSize = 108, + kSetKittyGraphicsLimits = 109, }; enum class TerminalHostProtocolError { @@ -240,6 +250,7 @@ enum class TerminalHostRendererGrantError { kInvalidIncarnation, kInvalidToken, kInvalidRights, + kInvalidProtocolVersion, kInvalidTtl, }; @@ -249,26 +260,65 @@ struct TerminalHostRendererGrant { TerminalHostIncarnation incarnation{}; TerminalHostCapabilityToken token{}; TerminalHostCapabilityRights rights = TerminalHostCapabilityRights::kNone; + uint16_t protocol_version = kTerminalHostProtocolVersion; uint32_t ttl_ms = 0; }; // Parses the canonical JSON grant fields without depending on a JSON library. // IDs are lowercase, unhyphenated UUIDv4 hex; the capability is 32 nonzero -// bytes of lowercase hex; rights must be exactly the renderer set; and the -// response TTL must equal the bounded TTL sent in the request. +// bytes of lowercase hex; rights must be exactly the renderer set; the host +// protocol version must be supported; and the response TTL must equal the +// bounded TTL sent in the request. TerminalHostRendererGrantError ValidateTerminalHostRendererGrant( std::string_view endpoint, std::string_view terminal_id, std::string_view incarnation, std::string_view token, uint64_t rights, + uint64_t protocol_version, uint64_t ttl_ms, uint64_t expected_ttl_ms, TerminalHostRendererGrant* grant); +TerminalHostClientHello TerminalHostClientHelloForRendererGrant( + const TerminalHostRendererGrant& grant); const char* TerminalHostRendererGrantErrorMessage( TerminalHostRendererGrantError error); std::string EncodeTerminalHostId(const TerminalHostId& id); +struct TerminalHostKittyImageAlias { + uint32_t image_id = 0; + uint32_t image_number = 0; + + bool operator==(const TerminalHostKittyImageAlias& other) const { + return image_id == other.image_id && image_number == other.image_number; + } +}; + +struct TerminalHostKittyGraphicsLimits { + uint64_t image_bytes = 0; + uint64_t inflight_bytes = 0; + uint64_t images = 0; + uint64_t placements = 0; + + bool operator==(const TerminalHostKittyGraphicsLimits& other) const; +}; + +struct TerminalHostKittyImageIdCursors { + uint32_t primary = 2'147'483'647; + uint32_t alternate = 2'147'483'647; + + bool operator==(const TerminalHostKittyImageIdCursors& other) const; +}; + +struct TerminalHostKittyReplayState { + TerminalHostKittyGraphicsLimits limits; + uint32_t replay_cursor_offset = 0; + TerminalHostKittyImageIdCursors replay_next_image_ids; + TerminalHostKittyImageIdCursors next_image_ids; + + bool operator==(const TerminalHostKittyReplayState& other) const; +}; + struct TerminalHostSnapshot { TerminalHostSnapshot(); TerminalHostSnapshot(const TerminalHostSnapshot&); @@ -279,10 +329,14 @@ struct TerminalHostSnapshot { uint16_t cols = 80; uint16_t rows = 24; + uint16_t cell_width = 8; + uint16_t cell_height = 16; std::optional pid; std::vector replay; std::optional cwd; std::vector command; + std::vector kitty_image_aliases; + TerminalHostKittyReplayState kitty_state; bool operator==(const TerminalHostSnapshot& other) const; }; @@ -293,6 +347,10 @@ TerminalHostProtocolError EncodeTerminalHostSnapshot( TerminalHostProtocolError DecodeTerminalHostSnapshot( std::string_view payload, TerminalHostSnapshot* snapshot); +TerminalHostProtocolError DecodeTerminalHostSnapshotForVersion( + std::string_view payload, + uint16_t protocol_version, + TerminalHostSnapshot* snapshot); struct TerminalHostResize { TerminalHostResize(); @@ -304,7 +362,11 @@ struct TerminalHostResize { uint16_t cols = 80; uint16_t rows = 24; + uint16_t cell_width = 8; + uint16_t cell_height = 16; std::vector replay; + std::vector kitty_image_aliases; + TerminalHostKittyReplayState kitty_state; bool operator==(const TerminalHostResize& other) const; }; @@ -314,6 +376,10 @@ TerminalHostProtocolError EncodeTerminalHostResize( std::vector* payload); TerminalHostProtocolError DecodeTerminalHostResize(std::string_view payload, TerminalHostResize* resize); +TerminalHostProtocolError DecodeTerminalHostResizeForVersion( + std::string_view payload, + uint16_t protocol_version, + TerminalHostResize* resize); struct TerminalHostResizeAck { uint16_t cols = 80; diff --git a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol_test.cc b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol_test.cc index 45ef259456..9e40744187 100644 --- a/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol_test.cc +++ b/cmux-browser/overlay/chrome/services/cmux_terminal_renderer/public/cpp/cmux_terminal_host_protocol_test.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -18,6 +19,11 @@ namespace { int checks = 0; int failures = 0; +constexpr uint16_t kRustTerminalHostProtocolVersion = 3; +constexpr size_t kRustTerminalHostMaxReplay = 15'692'632; +constexpr size_t kRustTerminalHostMaxKittyImageAliases = 4'096; +constexpr size_t kRustTerminalHostKittyReplayStateLength = 52; + void Check(bool condition, const char* message) { ++checks; if (!condition) { @@ -38,6 +44,246 @@ std::array Filled(uint8_t value) { return result; } +template +struct HasKittyImageAliases : std::false_type {}; + +template +struct HasKittyImageAliases< + Payload, + std::void_t< + decltype(std::declval().kitty_image_aliases)>> + : std::true_type {}; + +template +struct HasCellPixels : std::false_type {}; + +template +struct HasCellPixels< + Payload, + std::void_t().cell_width), + decltype(std::declval().cell_height)>> + : std::true_type {}; + +template +struct HasKittyReplayState : std::false_type {}; + +template +struct HasKittyReplayState< + Payload, + std::void_t().kitty_state)>> + : std::true_type {}; + +template +struct HasProtocolVersion : std::false_type {}; + +template +struct HasProtocolVersion< + Grant, + std::void_t().protocol_version)>> + : std::true_type {}; + +template +bool GrantProtocolVersionIs(const Grant& grant, uint16_t expected) { + if constexpr (!HasProtocolVersion::value) { + return false; + } else { + return grant.protocol_version == expected; + } +} + +template +constexpr size_t ReplayMetadataSuffixLength() { + return (HasCellPixels::value ? 4 : 0) + + (HasKittyReplayState::value + ? kRustTerminalHostKittyReplayStateLength + : 0); +} + +template +bool CellPixelsAre(const Payload& payload, + uint16_t expected_width, + uint16_t expected_height) { + if constexpr (!HasCellPixels::value) { + return false; + } else { + return payload.cell_width == expected_width && + payload.cell_height == expected_height; + } +} + +template +using PayloadEncoder = cmux::TerminalHostProtocolError (*)( + const Payload&, + std::vector*); + +template +using PayloadDecoder = cmux::TerminalHostProtocolError (*)( + std::string_view, + Payload*); + +template +bool KittyAliasesRoundTrip(PayloadEncoder encode, + PayloadDecoder decode) { + if constexpr (!HasKittyImageAliases::value) { + return false; + } else { + using AliasVector = + std::decay_t().kitty_image_aliases)>; + using Alias = typename AliasVector::value_type; + Payload value; + value.kitty_image_aliases = {Alias{41, 77}, Alias{42, 77}}; + std::vector payload; + Payload decoded; + if (encode(value, &payload) != cmux::TerminalHostProtocolError::kNone || + decode(Bytes(payload), &decoded) != + cmux::TerminalHostProtocolError::kNone || + !(decoded == value)) { + return false; + } + constexpr std::array kAliasSuffix = { + 2, 0, 41, 0, 0, 0, 77, 0, 0, 0, 42, 0, 0, 0, 77, 0, 0, 0, + }; + const size_t trailing = ReplayMetadataSuffixLength(); + return payload.size() >= kAliasSuffix.size() + trailing && + std::equal(kAliasSuffix.begin(), kAliasSuffix.end(), + payload.end() - trailing - kAliasSuffix.size()); + } +} + +enum class InvalidKittyAlias { + kZeroId, + kZeroNumber, + kDuplicateId, +}; + +template +bool KittyAliasEncoderRejects(PayloadEncoder encode, + InvalidKittyAlias invalid) { + if constexpr (!HasKittyImageAliases::value) { + return false; + } else { + using AliasVector = + std::decay_t().kitty_image_aliases)>; + using Alias = typename AliasVector::value_type; + Payload value; + switch (invalid) { + case InvalidKittyAlias::kZeroId: + value.kitty_image_aliases = {Alias{0, 77}}; + break; + case InvalidKittyAlias::kZeroNumber: + value.kitty_image_aliases = {Alias{41, 0}}; + break; + case InvalidKittyAlias::kDuplicateId: + value.kitty_image_aliases = {Alias{41, 77}, Alias{41, 78}}; + break; + } + std::vector untouched = {0xa5}; + return encode(value, &untouched) == + cmux::TerminalHostProtocolError::kMalformedPayload && + untouched == std::vector({0xa5}); + } +} + +template +bool KittyAliasDecoderRejects(PayloadEncoder encode, + PayloadDecoder decode, + InvalidKittyAlias invalid) { + if constexpr (!HasKittyImageAliases::value) { + return false; + } else { + using AliasVector = + std::decay_t().kitty_image_aliases)>; + using Alias = typename AliasVector::value_type; + Payload value; + value.kitty_image_aliases = {Alias{41, 77}, Alias{42, 78}}; + std::vector payload; + if (encode(value, &payload) != cmux::TerminalHostProtocolError::kNone || + payload.size() < 18) { + return false; + } + const size_t aliases = + payload.size() - 18 - ReplayMetadataSuffixLength(); + switch (invalid) { + case InvalidKittyAlias::kZeroId: + std::fill(payload.begin() + aliases + 2, + payload.begin() + aliases + 6, 0); + break; + case InvalidKittyAlias::kZeroNumber: + std::fill(payload.begin() + aliases + 6, + payload.begin() + aliases + 10, 0); + break; + case InvalidKittyAlias::kDuplicateId: + std::copy(payload.begin() + aliases + 2, + payload.begin() + aliases + 6, + payload.begin() + aliases + 10); + break; + } + Payload decoded; + return decode(Bytes(payload), &decoded) == + cmux::TerminalHostProtocolError::kMalformedPayload; + } +} + +template +bool KittyAliasDecoderRejectsBadFraming(PayloadEncoder encode, + PayloadDecoder decode) { + if constexpr (!HasKittyImageAliases::value) { + return false; + } else { + using AliasVector = + std::decay_t().kitty_image_aliases)>; + using Alias = typename AliasVector::value_type; + Payload value; + value.kitty_image_aliases = {Alias{41, 77}}; + std::vector payload; + if (encode(value, &payload) != cmux::TerminalHostProtocolError::kNone) { + return false; + } + Payload decoded; + std::vector truncated = payload; + truncated.pop_back(); + std::vector trailing = payload; + trailing.push_back(0); + return decode(Bytes(truncated), &decoded) == + cmux::TerminalHostProtocolError::kMalformedPayload && + decode(Bytes(trailing), &decoded) == + cmux::TerminalHostProtocolError::kMalformedPayload; + } +} + +template +bool KittyAliasCountIsBounded(PayloadEncoder encode) { + if constexpr (!HasKittyImageAliases::value) { + return false; + } else { + using AliasVector = + std::decay_t().kitty_image_aliases)>; + using Alias = typename AliasVector::value_type; + Payload value; + value.kitty_image_aliases.reserve( + kRustTerminalHostMaxKittyImageAliases + 1); + for (size_t index = 0; + index < kRustTerminalHostMaxKittyImageAliases; ++index) { + value.kitty_image_aliases.push_back( + Alias{static_cast(index + 1), + static_cast(index + 10'001)}); + } + std::vector payload; + if (encode(value, &payload) != cmux::TerminalHostProtocolError::kNone) { + return false; + } + value.kitty_image_aliases.push_back( + Alias{static_cast( + kRustTerminalHostMaxKittyImageAliases + 1), + static_cast( + kRustTerminalHostMaxKittyImageAliases + 10'001)}); + payload = {0xa5}; + return encode(value, &payload) == + cmux::TerminalHostProtocolError::kPayloadTooLarge && + payload == std::vector({0xa5}); + } +} + cmux::TerminalHostFrame SampleFrame() { cmux::TerminalHostFrame frame; frame.kind = cmux::TerminalHostMessageKind::kOutput; @@ -50,6 +296,9 @@ cmux::TerminalHostFrame SampleFrame() { void TestFrameGoldenAndKinds() { using Error = cmux::TerminalHostProtocolError; + Check(cmux::kTerminalHostProtocolVersion == + kRustTerminalHostProtocolVersion, + "terminal-host default protocol version matches Rust v3"); Check(cmux::kTerminalHostFlagColorsFollow == 1u, "COLORS_FOLLOW is exactly header flag bit zero"); Check(cmux::kTerminalHostFlagViewerSizeAcks == 2u, @@ -58,7 +307,7 @@ void TestFrameGoldenAndKinds() { Check(cmux::EncodeTerminalHostFrame(SampleFrame(), &encoded) == Error::kNone, "sample frame encodes"); Check(encoded == std::vector({ - 'C', 'M', 'T', 'H', 0x01, 0x00, 0x06, 0x00, 0x44, + 'C', 'M', 'T', 'H', 0x03, 0x00, 0x06, 0x00, 0x44, 0x33, 0x22, 0x11, 0x03, 0x00, 0x00, 0x00, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x18, 0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, 0xaa, 0xbb, 0xcc, @@ -77,7 +326,7 @@ void TestFrameGoldenAndKinds() { Check(cmux::DecodeTerminalHostFrameHeader( Bytes(encoded).substr(0, cmux::kTerminalHostHeaderLength), 3, &header) == Error::kNone && - header.version == 1 && + header.version == 3 && header.kind == cmux::TerminalHostMessageKind::kOutput && header.flags == 0x11223344 && header.payload_length == 3 && header.request_id == 0x0102030405060708ULL && @@ -106,12 +355,19 @@ void TestFrameGoldenAndKinds() { {cmux::TerminalHostMessageKind::kLaunch, 14}, {cmux::TerminalHostMessageKind::kCapability, 15}, {cmux::TerminalHostMessageKind::kResizeAck, 16}, + {cmux::TerminalHostMessageKind::kClearHistoryAck, 17}, + {cmux::TerminalHostMessageKind::kCellPixelSizeAck, 18}, + {cmux::TerminalHostMessageKind::kKittyGraphicsLimitsAck, 19}, {cmux::TerminalHostMessageKind::kInput, 100}, {cmux::TerminalHostMessageKind::kPaste, 101}, {cmux::TerminalHostMessageKind::kViewerSize, 102}, {cmux::TerminalHostMessageKind::kReleaseViewer, 103}, {cmux::TerminalHostMessageKind::kTerminate, 104}, {cmux::TerminalHostMessageKind::kMintCapability, 105}, + {cmux::TerminalHostMessageKind::kSetDefaults, 106}, + {cmux::TerminalHostMessageKind::kClearHistory, 107}, + {cmux::TerminalHostMessageKind::kSetCellPixelSize, 108}, + {cmux::TerminalHostMessageKind::kSetKittyGraphicsLimits, 109}, }; std::vector stream; for (const auto& [kind, wire_number] : kinds) { @@ -178,14 +434,39 @@ void TestRendererGrantValidation() { cmux::TerminalHostRendererGrant grant; Check(cmux::ValidateTerminalHostRendererGrant( endpoint, terminal_hex, incarnation_hex, token_hex, - static_cast(Rights::kRenderer), 30000, 30000, + static_cast(Rights::kRenderer), + cmux::kTerminalHostProtocolVersionV1, 30000, 30000, &grant) == Error::kNone, "canonical renderer grant validates"); Check(grant.endpoint == endpoint && grant.terminal_id == terminal && grant.incarnation == incarnation && grant.token == Filled<32>(0xa5) && - grant.rights == Rights::kRenderer && grant.ttl_ms == 30000, + grant.rights == Rights::kRenderer && + grant.protocol_version == cmux::kTerminalHostProtocolVersionV1 && + grant.ttl_ms == 30000, "validated renderer grant contains typed binary identities and token"); + Check(HasProtocolVersion::value && + GrantProtocolVersionIs(grant, + cmux::kTerminalHostProtocolVersionV1), + "validated renderer grant carries the selected host protocol version"); + using Validator = decltype(&cmux::ValidateTerminalHostRendererGrant); + Check( + (std::is_invocable_r_v< + Error, Validator, std::string_view, std::string_view, + std::string_view, std::string_view, uint64_t, uint64_t, uint64_t, + uint64_t, cmux::TerminalHostRendererGrant*>), + "renderer grant validation requires the selected protocol version"); + const cmux::TerminalHostClientHello renderer_hello = + cmux::TerminalHostClientHelloForRendererGrant(grant); + Check(renderer_hello.min_version == + cmux::kTerminalHostProtocolVersionV1 && + renderer_hello.max_version == + cmux::kTerminalHostProtocolVersionV1 && + renderer_hello.role == cmux::TerminalHostClientRole::kRenderer && + renderer_hello.requested_rights == Rights::kRenderer && + renderer_hello.terminal_id == terminal && + renderer_hello.token == Filled<32>(0xa5), + "validated grant pins renderer Hello to the selected host version"); Check(std::string(cmux::TerminalHostRendererGrantErrorMessage(Error::kNone)) .empty(), "successful grant validation has no error text"); @@ -200,7 +481,8 @@ void TestRendererGrantValidation() { output.endpoint = "untouched"; const Error error = cmux::ValidateTerminalHostRendererGrant( candidate_endpoint, candidate_terminal, candidate_incarnation, - candidate_token, candidate_rights, candidate_ttl, expected_ttl, + candidate_token, candidate_rights, + cmux::kTerminalHostProtocolVersion, candidate_ttl, expected_ttl, &output); Check(error == Error::kNone || output.endpoint == "untouched", "invalid grant leaves typed destination untouched"); @@ -292,8 +574,19 @@ void TestRendererGrantValidation() { Check(Validate(endpoint, terminal_hex, incarnation_hex, token_hex, 7, 30000, 29999) == Error::kInvalidTtl, "renderer grant TTL must echo the exact request"); + for (const uint64_t invalid_version : {0ULL, 4ULL, 65537ULL}) { + cmux::TerminalHostRendererGrant output; + output.endpoint = "untouched"; + Check(cmux::ValidateTerminalHostRendererGrant( + endpoint, terminal_hex, incarnation_hex, token_hex, 7, + invalid_version, 30000, 30000, + &output) == Error::kInvalidProtocolVersion && + output.endpoint == "untouched", + "renderer grant rejects an unsupported host protocol version"); + } Check(cmux::ValidateTerminalHostRendererGrant( - endpoint, terminal_hex, incarnation_hex, token_hex, 7, 30000, 30000, + endpoint, terminal_hex, incarnation_hex, token_hex, 7, + cmux::kTerminalHostProtocolVersion, 30000, 30000, nullptr) == Error::kInvalidEndpoint, "renderer grant requires an output destination"); } @@ -354,11 +647,11 @@ void TestMalformedFrames() { Check(DecodeError(bad) == Error::kInvalidVersion, "zero header version is rejected"); bad = encoded; - bad[4] = 2; + bad[4] = 3; cmux::TerminalHostFrameDecoder future_version; std::vector frames; Check(future_version.Push(Bytes(bad), &frames) == Error::kNone && - frames.size() == 1 && frames[0].version == 2, + frames.size() == 1 && frames[0].version == 3, "nonzero frame versions remain parseable before hello negotiation"); bad = encoded; bad[6] = 0xe7; @@ -507,6 +800,8 @@ void TestSnapshotPayload() { cmux::TerminalHostSnapshot snapshot; snapshot.cols = 120; snapshot.rows = 41; + snapshot.cell_width = 9; + snapshot.cell_height = 18; snapshot.pid = 0x01020304; snapshot.replay = {0, 0xff, 'A'}; snapshot.cwd = "/tmp/\xe2\x98\x83"; @@ -526,18 +821,47 @@ void TestSnapshotPayload() { Check(cmux::DecodeTerminalHostSnapshot(Bytes(payload), &decoded) == Error::kNone && decoded == snapshot, - "snapshot round trips replay, metadata, and argv"); + "snapshot round trips replay, metadata, cell pixels, and Kitty state"); + Check(payload.size() >= 4 + kRustTerminalHostKittyReplayStateLength && + std::equal( + payload.end() - kRustTerminalHostKittyReplayStateLength - 4, + payload.end() - kRustTerminalHostKittyReplayStateLength, + std::array{9, 0, 18, 0}.begin()), + "snapshot cell pixels match the Rust v2 suffix"); + + cmux::TerminalHostSnapshot golden; + golden.cols = 1; + golden.rows = 2; + golden.cell_width = 9; + golden.cell_height = 18; + golden.kitty_state.limits = {1, 2, 3, 4}; + golden.kitty_state.replay_next_image_ids = {5, 7}; + golden.kitty_state.next_image_ids = {6, 8}; + Check(cmux::EncodeTerminalHostSnapshot(golden, &payload) == Error::kNone && + payload == std::vector({ + 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 9, 0, 18, 0, 1, 0, + 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, + 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 8, 0, + 0, 0, + }), + "snapshot exact bytes match the Rust v3 golden payload"); cmux::TerminalHostSnapshot empty; empty.cols = 0; empty.rows = 0; + empty.cell_width = 0; + empty.cell_height = 0; Check(cmux::EncodeTerminalHostSnapshot(empty, &payload) == Error::kNone, "empty snapshot encodes"); Check(cmux::DecodeTerminalHostSnapshot(Bytes(payload), &decoded) == Error::kNone && decoded.cols == 1 && decoded.rows == 1 && !decoded.pid && + decoded.cell_width == 1 && decoded.cell_height == 1 && !decoded.cwd && decoded.command.empty(), - "snapshot decode matches Rust zero-size clamp and zero-pid sentinel"); + "snapshot decode matches Rust geometry clamps and zero-pid sentinel"); std::vector malformed = payload; malformed.push_back(0); @@ -557,12 +881,19 @@ void TestSnapshotPayload() { Error::kMalformedPayload, "snapshot rejects truncated replay"); - std::vector oversized_blob = { - 80, 0, 24, 0, 0, 0, 0, 0, 1, 0x00, 0x80, 0x00, - }; + std::vector oversized_blob(12); + oversized_blob[0] = 80; + oversized_blob[2] = 24; + const uint32_t oversized_replay = + static_cast(cmux::kTerminalHostMaxSnapshotReplay + 1); + for (size_t index = 0; index < sizeof(oversized_replay); ++index) { + oversized_blob[8 + index] = + static_cast(oversized_replay >> (index * 8)); + } Check(cmux::DecodeTerminalHostSnapshot(Bytes(oversized_blob), &decoded) == Error::kMalformedPayload, - "snapshot rejects replay length above 8 MiB before allocation"); + "snapshot rejects replay length above the exact Rust bound before " + "allocation"); cmux::TerminalHostSnapshot invalid_utf8; invalid_utf8.command = {std::string("\xc0\x80", 2)}; @@ -580,6 +911,211 @@ void TestSnapshotPayload() { "snapshot encoder bounds argv count"); } +void TestProtocolV1SnapshotAndResizeCompatibility() { + using Error = cmux::TerminalHostProtocolError; + cmux::TerminalHostSnapshot snapshot; + snapshot.cols = 91; + snapshot.rows = 27; + snapshot.replay = {'v', '1'}; + snapshot.command = {"/bin/sh"}; + std::vector snapshot_payload; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &snapshot_payload) == + Error::kNone && + snapshot_payload.size() >= 2, + "v3 snapshot fixture encodes"); + cmux::TerminalHostSnapshot decoded_snapshot; + std::vector snapshot_v2 = snapshot_payload; + snapshot_v2.resize(snapshot_v2.size() - + kRustTerminalHostKittyReplayStateLength); + Check(cmux::DecodeTerminalHostSnapshotForVersion( + Bytes(snapshot_v2), cmux::kTerminalHostProtocolVersionV2, + &decoded_snapshot) == Error::kNone && + decoded_snapshot == snapshot, + "protocol-v2 snapshot decodes aliases and cell pixels without Kitty " + "state"); + snapshot_payload.resize( + snapshot_payload.size() - kRustTerminalHostKittyReplayStateLength - 2 - + 4); + + Check(cmux::DecodeTerminalHostSnapshotForVersion( + Bytes(snapshot_payload), cmux::kTerminalHostProtocolVersionV1, + &decoded_snapshot) == Error::kNone && + decoded_snapshot == snapshot, + "protocol-v1 snapshot decodes without Kitty aliases or cell pixels"); + Check(cmux::DecodeTerminalHostSnapshotForVersion( + Bytes(snapshot_payload), cmux::kTerminalHostProtocolVersion, + &decoded_snapshot) == Error::kMalformedPayload, + "protocol-v3 snapshot requires Kitty aliases, cell pixels, and state"); + + cmux::TerminalHostResize resize; + resize.cols = 101; + resize.rows = 33; + resize.replay = {'o', 'l', 'd'}; + std::vector resize_payload; + Check(cmux::EncodeTerminalHostResize(resize, &resize_payload) == + Error::kNone && + resize_payload.size() >= 2, + "v3 resize fixture encodes"); + cmux::TerminalHostResize decoded_resize; + std::vector resize_v2 = resize_payload; + resize_v2.resize(resize_v2.size() - + kRustTerminalHostKittyReplayStateLength); + Check(cmux::DecodeTerminalHostResizeForVersion( + Bytes(resize_v2), cmux::kTerminalHostProtocolVersionV2, + &decoded_resize) == Error::kNone && + decoded_resize == resize, + "protocol-v2 resize decodes aliases and cell pixels without Kitty " + "state"); + resize_payload.resize( + resize_payload.size() - kRustTerminalHostKittyReplayStateLength - 2 - + 4); + + Check(cmux::DecodeTerminalHostResizeForVersion( + Bytes(resize_payload), cmux::kTerminalHostProtocolVersionV1, + &decoded_resize) == Error::kNone && + decoded_resize == resize, + "protocol-v1 resize decodes without a Kitty alias suffix"); + Check(cmux::DecodeTerminalHostResizeForVersion( + Bytes(resize_payload), cmux::kTerminalHostProtocolVersion, + &decoded_resize) == Error::kMalformedPayload, + "protocol-v3 resize requires aliases, cell pixels, and Kitty state"); +} + +void TestProtocolV2KittyAliasesAndBounds() { + using Error = cmux::TerminalHostProtocolError; + Check(cmux::kTerminalHostMaxSnapshotReplay == + kRustTerminalHostMaxReplay, + "snapshot and resized replay limit matches Rust exactly"); + Check(cmux::kTerminalHostMaxFramePayload == 16 * 1024 * 1024, + "terminal-host frame payload limit remains exactly 16 MiB"); + + Check(KittyAliasesRoundTrip( + cmux::EncodeTerminalHostSnapshot, + cmux::DecodeTerminalHostSnapshot), + "snapshot Kitty aliases round trip after existing metadata"); + Check(KittyAliasesRoundTrip( + cmux::EncodeTerminalHostResize, cmux::DecodeTerminalHostResize), + "resized Kitty aliases round trip after replay"); + Check(HasCellPixels::value, + "resized payload exposes the v2 cell-pixel suffix"); + const std::vector rust_v2_resize = { + 101, 0, 33, 0, 3, 0, 0, 0, 'r', 's', 'z', + 0, 0, 13, 0, 29, 0, + }; + cmux::TerminalHostResize decoded_rust_v2_resize; + Check(cmux::DecodeTerminalHostResizeForVersion( + Bytes(rust_v2_resize), cmux::kTerminalHostProtocolVersionV2, + &decoded_rust_v2_resize) == Error::kNone && + decoded_rust_v2_resize.cols == 101 && + decoded_rust_v2_resize.rows == 33 && + decoded_rust_v2_resize.replay == + std::vector({'r', 's', 'z'}) && + CellPixelsAre(decoded_rust_v2_resize, 13, 29), + "C++ decodes the exact Rust v2 resized payload including cell pixels"); + + for (InvalidKittyAlias invalid : + {InvalidKittyAlias::kZeroId, InvalidKittyAlias::kZeroNumber, + InvalidKittyAlias::kDuplicateId}) { + Check(KittyAliasEncoderRejects( + cmux::EncodeTerminalHostSnapshot, invalid), + "snapshot encoder rejects invalid Kitty alias identity"); + Check(KittyAliasDecoderRejects( + cmux::EncodeTerminalHostSnapshot, + cmux::DecodeTerminalHostSnapshot, invalid), + "snapshot decoder rejects invalid Kitty alias identity"); + Check(KittyAliasEncoderRejects( + cmux::EncodeTerminalHostResize, invalid), + "resized encoder rejects invalid Kitty alias identity"); + Check(KittyAliasDecoderRejects( + cmux::EncodeTerminalHostResize, + cmux::DecodeTerminalHostResize, invalid), + "resized decoder rejects invalid Kitty alias identity"); + } + Check(KittyAliasDecoderRejectsBadFraming( + cmux::EncodeTerminalHostSnapshot, + cmux::DecodeTerminalHostSnapshot), + "snapshot rejects truncated and trailing alias bytes"); + Check(KittyAliasDecoderRejectsBadFraming( + cmux::EncodeTerminalHostResize, cmux::DecodeTerminalHostResize), + "resized rejects truncated and trailing alias bytes"); + Check(KittyAliasCountIsBounded( + cmux::EncodeTerminalHostSnapshot), + "snapshot admits 4096 aliases and rejects 4097"); + Check(KittyAliasCountIsBounded( + cmux::EncodeTerminalHostResize), + "resized admits 4096 aliases and rejects 4097"); + + cmux::TerminalHostResize resize; + resize.replay.resize(kRustTerminalHostMaxReplay); + std::vector payload; + Check(cmux::EncodeTerminalHostResize(resize, &payload) == Error::kNone && + payload.size() == 8 + kRustTerminalHostMaxReplay + 2 + 4 + + kRustTerminalHostKittyReplayStateLength, + "resized admits the exact Rust replay ceiling plus v3 suffix"); + resize.replay.push_back(0); + payload = {0xa5}; + Check(cmux::EncodeTerminalHostResize(resize, &payload) == + Error::kPayloadTooLarge && + payload == std::vector({0xa5}), + "resized rejects one byte beyond the exact Rust replay ceiling"); + + resize.replay.pop_back(); + cmux::TerminalHostSnapshot snapshot; + snapshot.replay = std::move(resize.replay); + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == Error::kNone, + "snapshot admits the exact Rust replay ceiling"); + snapshot.replay.push_back(0); + payload = {0xa5}; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == + Error::kPayloadTooLarge && + payload == std::vector({0xa5}), + "snapshot rejects one byte beyond the exact Rust replay ceiling"); + + cmux::TerminalHostSnapshot oversized_frame; + oversized_frame.replay.resize(8 * 1024 * 1024); + oversized_frame.command.assign( + 32, std::string(cmux::kTerminalHostMaxString, 'x')); + payload = {0xa5}; + Check(cmux::EncodeTerminalHostSnapshot(oversized_frame, &payload) == + Error::kPayloadTooLarge && + payload == std::vector({0xa5}), + "snapshot total payload cannot exceed the exact frame cap"); +} + +void TestProtocolV3KittyReplayState() { + using Error = cmux::TerminalHostProtocolError; + cmux::TerminalHostSnapshot snapshot; + snapshot.replay = {'x'}; + snapshot.kitty_state.limits = {10'000'000, 13'595'480, 4'096, 16'384}; + snapshot.kitty_state.replay_cursor_offset = 1; + snapshot.kitty_state.replay_next_image_ids = {41, 43}; + snapshot.kitty_state.next_image_ids = {42, 44}; + std::vector payload; + cmux::TerminalHostSnapshot decoded; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == Error::kNone && + cmux::DecodeTerminalHostSnapshot(Bytes(payload), &decoded) == + Error::kNone && + decoded == snapshot, + "protocol-v3 snapshot preserves bounded per-screen Kitty cursors"); + + snapshot.kitty_state.limits.image_bytes += 1; + payload = {0xa5}; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == + Error::kMalformedPayload && + payload == std::vector({0xa5}), + "Kitty replay state rejects a resource limit above Rust's bound"); + snapshot.kitty_state.limits.image_bytes = 10'000'000; + snapshot.kitty_state.replay_next_image_ids.alternate = 0; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == + Error::kMalformedPayload, + "Kitty replay state rejects a zero automatic image-ID cursor"); + snapshot.kitty_state.replay_next_image_ids.alternate = 43; + snapshot.kitty_state.replay_cursor_offset = 2; + Check(cmux::EncodeTerminalHostSnapshot(snapshot, &payload) == + Error::kMalformedPayload, + "Kitty replay state rejects a cursor offset beyond replay bytes"); +} + void TestCapabilityPayloads() { using Error = cmux::TerminalHostProtocolError; using Rights = cmux::TerminalHostCapabilityRights; @@ -648,12 +1184,21 @@ void TestResizeAndViewerSizePayloads() { resize.cols = 0x0123; resize.rows = 0x4567; resize.replay = {0xaa, 0xbb, 0xcc}; + resize.kitty_state.limits = {1, 2, 3, 4}; + resize.kitty_state.replay_next_image_ids = {5, 7}; + resize.kitty_state.next_image_ids = {6, 8}; std::vector payload; Check(cmux::EncodeTerminalHostResize(resize, &payload) == Error::kNone, "resized payload encodes"); - Check(payload == std::vector( - {0x23, 0x01, 0x67, 0x45, 3, 0, 0, 0, 0xaa, 0xbb, 0xcc}), - "resized payload matches Rust u16/u16/u32/blob layout"); + Check(payload == std::vector({ + 0x23, 0x01, 0x67, 0x45, 3, 0, 0, 0, 0xaa, 0xbb, + 0xcc, 0, 0, 8, 0, 16, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 2, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, + 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 8, 0, + 0, 0, + }), + "resized payload matches Rust v3 replay and Kitty-state layout"); cmux::TerminalHostResize decoded; Check(cmux::DecodeTerminalHostResize(Bytes(payload), &decoded) == Error::kNone && @@ -846,6 +1391,9 @@ int main() { TestHelloPayloads(); TestCapabilityPayloads(); TestSnapshotPayload(); + TestProtocolV1SnapshotAndResizeCompatibility(); + TestProtocolV2KittyAliasesAndBounds(); + TestProtocolV3KittyReplayState(); TestResizeAndViewerSizePayloads(); TestColorMetadataSetThenReset(); TestAuthoritativeCursorMetadataFollowsReplay(); diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index 16e100355a..e6ac1bb8de 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -63,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -109,7 +115,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -395,9 +401,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.1" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" @@ -416,9 +422,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -517,9 +523,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -655,6 +661,8 @@ dependencies = [ "serde_json", "smallvec", "tempfile", + "terminput", + "terminput-crossterm", "tokio", "toml", "unicode-segmentation", @@ -681,6 +689,7 @@ dependencies = [ "anyhow", "base64", "cmux-pty", + "cmux-remote-protocol", "cmux-tui-cdp", "fs4", "getrandom 0.3.4", @@ -725,7 +734,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -827,6 +836,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1208,9 +1226,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "embedded-io" @@ -1290,9 +1308,18 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] [[package]] name = "fiat-crypto" @@ -1335,6 +1362,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1368,9 +1405,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1396,9 +1433,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1406,15 +1443,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1442,9 +1479,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -1459,15 +1496,15 @@ checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1558,7 +1595,9 @@ dependencies = [ name = "ghostty-vt" version = "0.1.0" dependencies = [ + "base64", "ghostty-vt-sys", + "png", ] [[package]] @@ -1570,9 +1609,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -1660,7 +1699,7 @@ dependencies = [ "jni 0.22.4", "rand 0.10.2", "rustls", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tokio", "tokio-rustls", @@ -1682,7 +1721,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "url", @@ -1710,7 +1749,7 @@ dependencies = [ "rustls", "smallvec", "system-configuration", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-rustls", "tracing", @@ -1727,9 +1766,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2279,7 +2318,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -2344,7 +2383,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -2361,9 +2400,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2526,6 +2565,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -2674,7 +2723,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -2777,7 +2826,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tracing", @@ -2805,7 +2854,7 @@ dependencies = [ "rustls-pki-types", "slab", "sorted-index-buffer", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3135,9 +3184,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -3145,9 +3194,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -3155,9 +3204,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", @@ -3168,9 +3217,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", ] @@ -3292,6 +3341,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3317,9 +3379,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" dependencies = [ "serde", ] @@ -3454,9 +3516,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3472,9 +3534,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3593,7 +3655,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3985,9 +4047,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4005,29 +4067,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -4080,9 +4142,9 @@ dependencies = [ [[package]] name = "serial2" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" dependencies = [ "cfg-if", "libc", @@ -4205,6 +4267,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -4481,6 +4549,23 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "terminput" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a8a2c741a146c297baff16b315401ab7ec4b4817eff783606e89734242408a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "terminput-crossterm" +version = "0.4.7" +dependencies = [ + "crossterm", + "terminput", +] + [[package]] name = "termios" version = "0.3.3" @@ -4543,11 +4628,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -4563,13 +4648,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -4583,9 +4668,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -4606,9 +4691,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4942,7 +5027,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -5645,7 +5730,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "windows", "windows-core", ] @@ -5669,7 +5754,7 @@ dependencies = [ "pharos", "rustc_version", "send_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.19", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5701,7 +5786,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -5755,18 +5840,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 877979559b..f6cbfee45c 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -55,6 +55,7 @@ ratatui = "0.30" unicode-width = "0.2" unicode-segmentation = "1" base64 = "0.22" +png = "0.17" libc = "0.2" tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } uds_windows = "1.2" @@ -65,6 +66,8 @@ getrandom = "0.3" bytes = "1" diffy = "0.4" fs4 = "1.1.0" +terminput = "=0.5.11" +terminput-crossterm = { version = "=0.4.7", default-features = false, features = ["crossterm_0_29"] } futures-util = "0.3" hmac = "0.12" http = "1" @@ -87,6 +90,9 @@ zeroize = { version = "1", features = ["derive"] } # text while parsing CSI-u input. cmux needs those fields to preserve keyboard # layouts across its shortcut layer and nested terminals. crossterm = { path = "vendor/crossterm" } +# The cmux Crossterm patch adds `Event::EnhancedKey`. Keep the adapter's +# exhaustive event mapping in the same source-controlled compatibility layer. +terminput-crossterm = { path = "vendor/terminput-crossterm" } [profile.release] lto = "thin" diff --git a/cmux-tui/bindings/ERGONOMICS.md b/cmux-tui/bindings/ERGONOMICS.md index df8bc3ce4a..a78ebef35f 100644 --- a/cmux-tui/bindings/ERGONOMICS.md +++ b/cmux-tui/bindings/ERGONOMICS.md @@ -2,7 +2,7 @@ The seven public SDKs expose handwritten resource handles over the reviewed 112-operation `cmux.protocol/1` catalog. The raw protocol inventory is a -separate compatibility surface with 91 commands and 44 events. Deterministic +separate compatibility surface with 92 commands and 45 events. Deterministic generation is limited to those private protocol-10 models under each package's explicit `raw` namespace. Consumers do not run a generator or install a generator runtime. @@ -71,7 +71,7 @@ and secret redaction. The exact-binary live matrix adds one isolated create, run, exit, restart, and cleanup flow per language. TypeScript repeats it over authenticated WebSocket, for eight live transport runs. The separate raw protocol-10 suite runs 266 -compatibility checks over its 91 commands and 44 events. +compatibility checks over its 92 commands and 45 events. Each package suite also opens a stream with a short request deadline, leaves it idle past that deadline, then delivers and cancels normally. This separates diff --git a/cmux-tui/bindings/RELEASING.md b/cmux-tui/bindings/RELEASING.md index 90a84af437..07034ba0a9 100644 --- a/cmux-tui/bindings/RELEASING.md +++ b/cmux-tui/bindings/RELEASING.md @@ -23,7 +23,7 @@ from app release tags such as `vX.Y.Z`. Current SDK package versions are | C++ | C++20 and CMake 3.20 | standard library and platform socket APIs | installable CMake package | | Zig | Zig 0.15.2 | standard library only | source package | -All packages target mux protocol 10 and expose the same 91 commands and 44 +All packages target mux protocol 10 and expose the same 92 commands and 45 events. The shared conformance suite verifies their common wire behavior. ## One-time registry setup diff --git a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json index 01b298383b..ccd1792492 100644 --- a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json @@ -2,27 +2,27 @@ "files": [ { "path": "include/cmux/raw/generated/commands.hpp", - "sha256": "2f2639ee4f2176926c80d791531dd3278f6c3d2684d3299d5cbaa2fe5ab73cfb", - "size": 13229 + "sha256": "bb02d6e2390d48b3d7008f9be8183d04090ab8b4efa30a0332f715760c27bfc4", + "size": 13363 }, { "path": "include/cmux/raw/generated/events.hpp", - "sha256": "e3a6276f574e14171250cd6df38e8f56eabd438e9ed07e6556ecff14f622b689", - "size": 1965 + "sha256": "11f825023a6b0faa094c53a852007e660210329642b51480240c492523a866aa", + "size": 1986 }, { "path": "include/cmux/raw/generated/models.hpp", - "sha256": "cec984d4dd226e37e607722bf063be0c89f58d29f4e889326af19023e5483c8a", - "size": 101285 + "sha256": "35687a732640759594c62150c3826e04d791271cd8e3b0f64b90ad28d760b827", + "size": 108123 }, { "path": "src/raw/generated/protocol.cpp", - "sha256": "828a55826e6a0538dd97e607236be78e75720df580648cd9623f09165c3b470a", - "size": 667725 + "sha256": "18efa59646ad007a8f2fc5fd7ec4e3c8cb06559793254b4f78fbd98e56733483", + "size": 716210 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "cpp", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp index a853784d4a..7b0eeb4938 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/commands.hpp @@ -71,6 +71,7 @@ public: [[nodiscard]] Result export_layout(const ExportLayoutRequest& request = {}, RequestOptions options = {}); [[nodiscard]] Result focus_direction(const FocusDirectionRequest& request, RequestOptions options = {}); [[nodiscard]] Result focus_pane(const FocusPaneRequest& request, RequestOptions options = {}); + [[nodiscard]] Result get_cell_pixels(const GetCellPixelsRequest& request = {}, RequestOptions options = {}); [[nodiscard]] Result get_frontend_projection(const GetFrontendProjectionRequest& request, RequestOptions options = {}); [[nodiscard]] Result identify(const IdentifyRequest& request = {}, RequestOptions options = {}); [[nodiscard]] Result ids(const IdsRequest& request = {}, RequestOptions options = {}); diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/events.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/events.hpp index 2c5308267a..0f5a14643c 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/events.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/events.hpp @@ -17,7 +17,7 @@ struct UnknownEvent { }; struct Event { - using Variant = std::variant; + using Variant = std::variant; Variant value; Json raw; [[nodiscard]] std::string_view name() const noexcept; diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp index 0a73fa035c..464d9e4ca0 100644 --- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp +++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp @@ -14,7 +14,7 @@ namespace cmux::raw { inline constexpr std::uint32_t kMuxProtocolVersion = 10U; -inline constexpr std::string_view kProtocolIrSha256 = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e"; +inline constexpr std::string_view kProtocolIrSha256 = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350"; struct AgentRecord; enum class AgentReportSource; @@ -26,6 +26,7 @@ struct Base64; struct BrowserFrame; struct CellPixelFailure; struct CellPixelResize; +struct CellPixelSurface; struct ClientInfo; struct ClientSize; enum class ClientTransport; @@ -40,11 +41,14 @@ struct ExportLayoutResult; struct ExportedPane; struct FocusDirectionResult; struct FrontendProjection; +struct GetCellPixelsResult; struct Id; struct IdMapping; struct IdentifyResult; struct IdsResult; struct JsonValue; +struct KittyGraphicsState; +struct KittyImageAlias; struct Layout; struct LayoutUndoConfirmationRequired; struct LayoutUndoResult; @@ -66,6 +70,11 @@ struct ProviderWorkspaceMutationResult; struct ReadScreenResult; struct ReadScrollbackResult; struct RenderCursor; +enum class RenderGraphicFormat; +struct RenderGraphicImage; +struct RenderGraphicPlacement; +struct RenderGraphics; +struct RenderGraphicsDelta; struct RenderRow; struct RenderRun; enum class RenderUnderline; @@ -127,6 +136,7 @@ struct DetachClientRequest; struct ExportLayoutRequest; struct FocusDirectionRequest; struct FocusPaneRequest; +struct GetCellPixelsRequest; struct GetFrontendProjectionRequest; struct IdentifyRequest; struct IdsRequest; @@ -202,6 +212,7 @@ struct DetachedEvent; struct EmptyEvent; struct FrameEvent; struct FrontendProjectionChangedEvent; +struct GraphicsStatusEvent; struct LayoutChangedEvent; struct NotificationEvent; struct OutputEvent; @@ -255,6 +266,7 @@ enum class SubscribeRequestTreeEvents; enum class ZoomPaneRequestMode; enum class BrowserStateEventStatus; enum class ClientAttachedEventTransport; +enum class GraphicsStatusEventKind; enum class AgentSource { detected, @@ -521,6 +533,13 @@ struct CellPixelResize { friend bool operator==(const CellPixelResize&, const CellPixelResize&) = default; }; +struct CellPixelSurface { + std::uint16_t height_px{}; + Id surface{}; + std::uint16_t width_px{}; + friend bool operator==(const CellPixelSurface&, const CellPixelSurface&) = default; +}; + enum class TerminalKey { unidentified, backquote, @@ -988,6 +1007,17 @@ struct FrontendProjectionChangedEvent { friend bool operator==(const FrontendProjectionChangedEvent&, const FrontendProjectionChangedEvent&) = default; }; +struct GetCellPixelsRequest { + friend bool operator==(const GetCellPixelsRequest&, const GetCellPixelsRequest&) = default; +}; + +struct GetCellPixelsResult { + std::uint16_t height_px{}; + std::vector surfaces{}; + std::uint16_t width_px{}; + friend bool operator==(const GetCellPixelsResult&, const GetCellPixelsResult&) = default; +}; + struct GetFrontendProjectionRequest { std::string frontend{}; std::string scope{}; @@ -995,6 +1025,24 @@ struct GetFrontendProjectionRequest { friend bool operator==(const GetFrontendProjectionRequest&, const GetFrontendProjectionRequest&) = default; }; +enum class GraphicsStatusEventKind { + kitty_image_budget_worker_start_failed, + kitty_image_budget_update_failed, + cell_pixel_update_retries_exhausted, +}; + +struct GraphicsStatusEvent { + std::optional attempts{}; + std::optional cell_height{}; + std::optional cell_width{}; + std::optional error{}; + GraphicsStatusEventKind kind{}; + std::optional remaining{}; + std::optional retry_exhausted{}; + std::optional summary{}; + friend bool operator==(const GraphicsStatusEvent&, const GraphicsStatusEvent&) = default; +}; + enum class IdMappingKind { workspace, screen, @@ -1045,6 +1093,25 @@ struct IdsResult { friend bool operator==(const IdsResult&, const IdsResult&) = default; }; +struct KittyGraphicsState { + std::uint32_t alternate_next_image_id{}; + std::uint32_t alternate_replay_next_image_id{}; + std::uint64_t image_bytes{}; + std::uint64_t images{}; + std::uint64_t inflight_bytes{}; + std::uint64_t placements{}; + std::uint32_t primary_next_image_id{}; + std::uint32_t primary_replay_next_image_id{}; + std::uint32_t replay_cursor_offset{}; + friend bool operator==(const KittyGraphicsState&, const KittyGraphicsState&) = default; +}; + +struct KittyImageAlias { + std::uint32_t image_id{}; + std::uint32_t image_number{}; + friend bool operator==(const KittyImageAlias&, const KittyImageAlias&) = default; +}; + struct LayoutChangedEvent { Id screen{}; friend bool operator==(const LayoutChangedEvent&, const LayoutChangedEvent&) = default; @@ -1490,6 +1557,7 @@ struct RenderRow { }; struct ReadScrollbackResult { + std::uint64_t epoch{}; std::vector rows{}; std::uint32_t start{}; std::uint32_t total{}; @@ -1557,11 +1625,61 @@ struct RenderCursor { friend bool operator==(const RenderCursor&, const RenderCursor&) = default; }; +enum class RenderGraphicFormat { + rgb, + rgba, +}; + +struct RenderGraphicImage { + Base64 data{}; + RenderGraphicFormat format{}; + std::uint64_t generation{}; + std::uint32_t height{}; + std::uint32_t id{}; + std::uint32_t width{}; + friend bool operator==(const RenderGraphicImage&, const RenderGraphicImage&) = default; +}; + +struct RenderGraphicPlacement { + std::optional anchor_col{}; + std::optional anchor_row{}; + std::uint32_t columns{}; + std::uint32_t grid_cols{}; + std::uint32_t grid_rows{}; + std::uint32_t image_id{}; + std::uint32_t ordinal{}; + std::uint32_t pixel_height{}; + std::uint32_t pixel_width{}; + std::uint32_t placement_id{}; + std::uint32_t rows{}; + std::uint32_t source_height{}; + std::uint32_t source_width{}; + std::uint32_t source_x{}; + std::uint32_t source_y{}; + std::int32_t viewport_col{}; + std::int32_t viewport_row{}; + bool viewport_visible{}; + std::uint32_t x_offset{}; + std::uint32_t y_offset{}; + std::int32_t z{}; + friend bool operator==(const RenderGraphicPlacement&, const RenderGraphicPlacement&) = default; +}; + +struct RenderGraphicsDelta { + std::uint64_t generation{}; + std::optional> images{}; + std::optional> placements{}; + std::optional> removed_image_ids{}; + friend bool operator==(const RenderGraphicsDelta&, const RenderGraphicsDelta&) = default; +}; + struct RenderDeltaEvent { RenderCursor cursor{}; std::optional default_bg{}; std::optional default_fg{}; bool full{}; + std::optional graphics{}; + std::optional history_epoch{}; std::vector rows{}; std::optional scrollback_rows{}; std::optional size{}; @@ -1569,10 +1687,20 @@ struct RenderDeltaEvent { friend bool operator==(const RenderDeltaEvent&, const RenderDeltaEvent&) = default; }; +struct RenderGraphics { + std::uint64_t generation{}; + std::optional> images{}; + std::vector placements{}; + std::optional> removed_image_ids{}; + friend bool operator==(const RenderGraphics&, const RenderGraphics&) = default; +}; + struct RenderStateEvent { RenderCursor cursor{}; ColorHex default_bg{}; ColorHex default_fg{}; + std::optional graphics{}; + std::uint64_t history_epoch{}; std::vector rows{}; std::uint32_t scrollback_rows{}; Size size{}; @@ -1613,6 +1741,8 @@ struct ResizedEvent { std::optional colors{}; std::uint16_t cols{}; std::optional data{}; + std::optional kitty_graphics_state{}; + std::optional> kitty_image_aliases{}; std::optional replay{}; std::uint16_t rows{}; Id surface{}; @@ -2018,6 +2148,8 @@ struct VtStateEvent { std::optional colors{}; std::uint16_t cols{}; Base64 data{}; + std::optional kitty_graphics_state{}; + std::optional> kitty_image_aliases{}; std::uint16_t rows{}; Id surface{}; friend bool operator==(const VtStateEvent&, const VtStateEvent&) = default; @@ -2031,6 +2163,8 @@ struct VtStateRequest { struct VtStateResult { std::uint16_t cols{}; Base64 data{}; + std::optional kitty_graphics_state{}; + std::optional> kitty_image_aliases{}; std::uint16_t rows{}; friend bool operator==(const VtStateResult&, const VtStateResult&) = default; }; @@ -2191,6 +2325,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const CellPixelSurface& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const ClientInfo& value); @@ -2275,6 +2415,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const GetCellPixelsResult& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const Id& value); @@ -2305,6 +2451,18 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const KittyGraphicsState& value); + static Result decode(const Json& value); +}; + +template <> +struct Codec { + static Result encode(const KittyImageAlias& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const Layout& value); @@ -2431,6 +2589,36 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const RenderGraphicFormat& value); + static Result decode(const Json& value); +}; + +template <> +struct Codec { + static Result encode(const RenderGraphicImage& value); + static Result decode(const Json& value); +}; + +template <> +struct Codec { + static Result encode(const RenderGraphicPlacement& value); + static Result decode(const Json& value); +}; + +template <> +struct Codec { + static Result encode(const RenderGraphics& value); + static Result decode(const Json& value); +}; + +template <> +struct Codec { + static Result encode(const RenderGraphicsDelta& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const RenderRow& value); @@ -2797,6 +2985,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const GetCellPixelsRequest& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const GetFrontendProjectionRequest& value); @@ -3247,6 +3441,12 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const GraphicsStatusEvent& value); + static Result decode(const Json& value); +}; + template <> struct Codec { static Result encode(const LayoutChangedEvent& value); @@ -3565,4 +3765,10 @@ struct Codec { static Result decode(const Json& value); }; +template <> +struct Codec { + static Result encode(const GraphicsStatusEventKind& value); + static Result decode(const Json& value); +}; + } // namespace cmux::raw diff --git a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp index 398f994296..c16e66d150 100644 --- a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp +++ b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp @@ -380,6 +380,55 @@ Result Codec::decode(const Json& value) { return result; } +Result Codec::encode(const CellPixelSurface& value) { + (void)value; + Json::Object object; + auto encoded_height_px = encode_value(value.height_px); + if (!encoded_height_px) return std::move(encoded_height_px).error(); + object.emplace("height_px", std::move(encoded_height_px).value()); + auto encoded_surface = encode_value(value.surface); + if (!encoded_surface) return std::move(encoded_surface).error(); + object.emplace("surface", std::move(encoded_surface).value()); + auto encoded_width_px = encode_value(value.width_px); + if (!encoded_width_px) return std::move(encoded_width_px).error(); + object.emplace("width_px", std::move(encoded_width_px).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + CellPixelSurface result{}; + const Json* field_height_px = value.find("height_px"); + if (!field_height_px) { + return make_error(ErrorCode::decode, "missing required field 'height_px'"); + } + if (field_height_px) { + auto decoded = decode_value(*field_height_px); + if (!decoded) return std::move(decoded).error(); + result.height_px = std::move(decoded).value(); + } + const Json* field_surface = value.find("surface"); + if (!field_surface) { + return make_error(ErrorCode::decode, "missing required field 'surface'"); + } + if (field_surface) { + auto decoded = decode_value(*field_surface); + if (!decoded) return std::move(decoded).error(); + result.surface = std::move(decoded).value(); + } + const Json* field_width_px = value.find("width_px"); + if (!field_width_px) { + return make_error(ErrorCode::decode, "missing required field 'width_px'"); + } + if (field_width_px) { + auto decoded = decode_value(*field_width_px); + if (!decoded) return std::move(decoded).error(); + result.width_px = std::move(decoded).value(); + } + return result; +} + Result Codec::encode(const ClientInfo& value) { (void)value; Json::Object object; @@ -1060,6 +1109,55 @@ Result Codec::decode(const Json& value) return result; } +Result Codec::encode(const GetCellPixelsResult& value) { + (void)value; + Json::Object object; + auto encoded_height_px = encode_value(value.height_px); + if (!encoded_height_px) return std::move(encoded_height_px).error(); + object.emplace("height_px", std::move(encoded_height_px).value()); + auto encoded_surfaces = encode_value(value.surfaces); + if (!encoded_surfaces) return std::move(encoded_surfaces).error(); + object.emplace("surfaces", std::move(encoded_surfaces).value()); + auto encoded_width_px = encode_value(value.width_px); + if (!encoded_width_px) return std::move(encoded_width_px).error(); + object.emplace("width_px", std::move(encoded_width_px).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + GetCellPixelsResult result{}; + const Json* field_height_px = value.find("height_px"); + if (!field_height_px) { + return make_error(ErrorCode::decode, "missing required field 'height_px'"); + } + if (field_height_px) { + auto decoded = decode_value(*field_height_px); + if (!decoded) return std::move(decoded).error(); + result.height_px = std::move(decoded).value(); + } + const Json* field_surfaces = value.find("surfaces"); + if (!field_surfaces) { + return make_error(ErrorCode::decode, "missing required field 'surfaces'"); + } + if (field_surfaces) { + auto decoded = decode_value>(*field_surfaces); + if (!decoded) return std::move(decoded).error(); + result.surfaces = std::move(decoded).value(); + } + const Json* field_width_px = value.find("width_px"); + if (!field_width_px) { + return make_error(ErrorCode::decode, "missing required field 'width_px'"); + } + if (field_width_px) { + auto decoded = decode_value(*field_width_px); + if (!decoded) return std::move(decoded).error(); + result.width_px = std::move(decoded).value(); + } + return result; +} + Result Codec::encode(const Id& value) { return encode_value(value.value); } @@ -1324,6 +1422,164 @@ Result Codec::decode(const Json& value) { return JsonValue{std::move(decoded).value()}; } +Result Codec::encode(const KittyGraphicsState& value) { + (void)value; + Json::Object object; + auto encoded_alternate_next_image_id = encode_value(value.alternate_next_image_id); + if (!encoded_alternate_next_image_id) return std::move(encoded_alternate_next_image_id).error(); + object.emplace("alternate_next_image_id", std::move(encoded_alternate_next_image_id).value()); + auto encoded_alternate_replay_next_image_id = encode_value(value.alternate_replay_next_image_id); + if (!encoded_alternate_replay_next_image_id) return std::move(encoded_alternate_replay_next_image_id).error(); + object.emplace("alternate_replay_next_image_id", std::move(encoded_alternate_replay_next_image_id).value()); + auto encoded_image_bytes = encode_value(value.image_bytes); + if (!encoded_image_bytes) return std::move(encoded_image_bytes).error(); + object.emplace("image_bytes", std::move(encoded_image_bytes).value()); + auto encoded_images = encode_value(value.images); + if (!encoded_images) return std::move(encoded_images).error(); + object.emplace("images", std::move(encoded_images).value()); + auto encoded_inflight_bytes = encode_value(value.inflight_bytes); + if (!encoded_inflight_bytes) return std::move(encoded_inflight_bytes).error(); + object.emplace("inflight_bytes", std::move(encoded_inflight_bytes).value()); + auto encoded_placements = encode_value(value.placements); + if (!encoded_placements) return std::move(encoded_placements).error(); + object.emplace("placements", std::move(encoded_placements).value()); + auto encoded_primary_next_image_id = encode_value(value.primary_next_image_id); + if (!encoded_primary_next_image_id) return std::move(encoded_primary_next_image_id).error(); + object.emplace("primary_next_image_id", std::move(encoded_primary_next_image_id).value()); + auto encoded_primary_replay_next_image_id = encode_value(value.primary_replay_next_image_id); + if (!encoded_primary_replay_next_image_id) return std::move(encoded_primary_replay_next_image_id).error(); + object.emplace("primary_replay_next_image_id", std::move(encoded_primary_replay_next_image_id).value()); + auto encoded_replay_cursor_offset = encode_value(value.replay_cursor_offset); + if (!encoded_replay_cursor_offset) return std::move(encoded_replay_cursor_offset).error(); + object.emplace("replay_cursor_offset", std::move(encoded_replay_cursor_offset).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + KittyGraphicsState result{}; + const Json* field_alternate_next_image_id = value.find("alternate_next_image_id"); + if (!field_alternate_next_image_id) { + return make_error(ErrorCode::decode, "missing required field 'alternate_next_image_id'"); + } + if (field_alternate_next_image_id) { + auto decoded = decode_value(*field_alternate_next_image_id); + if (!decoded) return std::move(decoded).error(); + result.alternate_next_image_id = std::move(decoded).value(); + } + const Json* field_alternate_replay_next_image_id = value.find("alternate_replay_next_image_id"); + if (!field_alternate_replay_next_image_id) { + return make_error(ErrorCode::decode, "missing required field 'alternate_replay_next_image_id'"); + } + if (field_alternate_replay_next_image_id) { + auto decoded = decode_value(*field_alternate_replay_next_image_id); + if (!decoded) return std::move(decoded).error(); + result.alternate_replay_next_image_id = std::move(decoded).value(); + } + const Json* field_image_bytes = value.find("image_bytes"); + if (!field_image_bytes) { + return make_error(ErrorCode::decode, "missing required field 'image_bytes'"); + } + if (field_image_bytes) { + auto decoded = decode_value(*field_image_bytes); + if (!decoded) return std::move(decoded).error(); + result.image_bytes = std::move(decoded).value(); + } + const Json* field_images = value.find("images"); + if (!field_images) { + return make_error(ErrorCode::decode, "missing required field 'images'"); + } + if (field_images) { + auto decoded = decode_value(*field_images); + if (!decoded) return std::move(decoded).error(); + result.images = std::move(decoded).value(); + } + const Json* field_inflight_bytes = value.find("inflight_bytes"); + if (!field_inflight_bytes) { + return make_error(ErrorCode::decode, "missing required field 'inflight_bytes'"); + } + if (field_inflight_bytes) { + auto decoded = decode_value(*field_inflight_bytes); + if (!decoded) return std::move(decoded).error(); + result.inflight_bytes = std::move(decoded).value(); + } + const Json* field_placements = value.find("placements"); + if (!field_placements) { + return make_error(ErrorCode::decode, "missing required field 'placements'"); + } + if (field_placements) { + auto decoded = decode_value(*field_placements); + if (!decoded) return std::move(decoded).error(); + result.placements = std::move(decoded).value(); + } + const Json* field_primary_next_image_id = value.find("primary_next_image_id"); + if (!field_primary_next_image_id) { + return make_error(ErrorCode::decode, "missing required field 'primary_next_image_id'"); + } + if (field_primary_next_image_id) { + auto decoded = decode_value(*field_primary_next_image_id); + if (!decoded) return std::move(decoded).error(); + result.primary_next_image_id = std::move(decoded).value(); + } + const Json* field_primary_replay_next_image_id = value.find("primary_replay_next_image_id"); + if (!field_primary_replay_next_image_id) { + return make_error(ErrorCode::decode, "missing required field 'primary_replay_next_image_id'"); + } + if (field_primary_replay_next_image_id) { + auto decoded = decode_value(*field_primary_replay_next_image_id); + if (!decoded) return std::move(decoded).error(); + result.primary_replay_next_image_id = std::move(decoded).value(); + } + const Json* field_replay_cursor_offset = value.find("replay_cursor_offset"); + if (!field_replay_cursor_offset) { + return make_error(ErrorCode::decode, "missing required field 'replay_cursor_offset'"); + } + if (field_replay_cursor_offset) { + auto decoded = decode_value(*field_replay_cursor_offset); + if (!decoded) return std::move(decoded).error(); + result.replay_cursor_offset = std::move(decoded).value(); + } + return result; +} + +Result Codec::encode(const KittyImageAlias& value) { + (void)value; + Json::Object object; + auto encoded_image_id = encode_value(value.image_id); + if (!encoded_image_id) return std::move(encoded_image_id).error(); + object.emplace("image_id", std::move(encoded_image_id).value()); + auto encoded_image_number = encode_value(value.image_number); + if (!encoded_image_number) return std::move(encoded_image_number).error(); + object.emplace("image_number", std::move(encoded_image_number).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + KittyImageAlias result{}; + const Json* field_image_id = value.find("image_id"); + if (!field_image_id) { + return make_error(ErrorCode::decode, "missing required field 'image_id'"); + } + if (field_image_id) { + auto decoded = decode_value(*field_image_id); + if (!decoded) return std::move(decoded).error(); + result.image_id = std::move(decoded).value(); + } + const Json* field_image_number = value.find("image_number"); + if (!field_image_number) { + return make_error(ErrorCode::decode, "missing required field 'image_number'"); + } + if (field_image_number) { + auto decoded = decode_value(*field_image_number); + if (!decoded) return std::move(decoded).error(); + result.image_number = std::move(decoded).value(); + } + return result; +} + Result Codec::encode(const Layout& value) { return encode_value(value.value); } @@ -2347,6 +2603,9 @@ Result Codec::decode(const Json& value) { Result Codec::encode(const ReadScrollbackResult& value) { (void)value; Json::Object object; + auto encoded_epoch = encode_value(value.epoch); + if (!encoded_epoch) return std::move(encoded_epoch).error(); + object.emplace("epoch", std::move(encoded_epoch).value()); auto encoded_rows = encode_value(value.rows); if (!encoded_rows) return std::move(encoded_rows).error(); object.emplace("rows", std::move(encoded_rows).value()); @@ -2363,6 +2622,15 @@ Result Codec::decode(const Json& val auto source = value.as_object(); if (!source) return std::move(source).error(); ReadScrollbackResult result{}; + const Json* field_epoch = value.find("epoch"); + if (!field_epoch) { + return make_error(ErrorCode::decode, "missing required field 'epoch'"); + } + if (field_epoch) { + auto decoded = decode_value(*field_epoch); + if (!decoded) return std::move(decoded).error(); + result.epoch = std::move(decoded).value(); + } const Json* field_rows = value.find("rows"); if (!field_rows) { return make_error(ErrorCode::decode, "missing required field 'rows'"); @@ -2486,6 +2754,485 @@ Result Codec::decode(const Json& value) { return result; } +Result Codec::encode(const RenderGraphicFormat& value) { + switch (value) { + case RenderGraphicFormat::rgb: return Json(std::string("rgb")); + case RenderGraphicFormat::rgba: return Json(std::string("rgba")); + } + return make_error(ErrorCode::invalid_argument, "invalid enum value"); +} + +Result Codec::decode(const Json& value) { + if (value == Json(std::string("rgb"))) return RenderGraphicFormat::rgb; + if (value == Json(std::string("rgba"))) return RenderGraphicFormat::rgba; + return make_error(ErrorCode::decode, "unknown RenderGraphicFormat value"); +} + +Result Codec::encode(const RenderGraphicImage& value) { + (void)value; + Json::Object object; + auto encoded_data = encode_value(value.data); + if (!encoded_data) return std::move(encoded_data).error(); + object.emplace("data", std::move(encoded_data).value()); + auto encoded_format = encode_value(value.format); + if (!encoded_format) return std::move(encoded_format).error(); + object.emplace("format", std::move(encoded_format).value()); + auto encoded_generation = encode_value(value.generation); + if (!encoded_generation) return std::move(encoded_generation).error(); + object.emplace("generation", std::move(encoded_generation).value()); + auto encoded_height = encode_value(value.height); + if (!encoded_height) return std::move(encoded_height).error(); + object.emplace("height", std::move(encoded_height).value()); + auto encoded_id = encode_value(value.id); + if (!encoded_id) return std::move(encoded_id).error(); + object.emplace("id", std::move(encoded_id).value()); + auto encoded_width = encode_value(value.width); + if (!encoded_width) return std::move(encoded_width).error(); + object.emplace("width", std::move(encoded_width).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + RenderGraphicImage result{}; + const Json* field_data = value.find("data"); + if (!field_data) { + return make_error(ErrorCode::decode, "missing required field 'data'"); + } + if (field_data) { + auto decoded = decode_value(*field_data); + if (!decoded) return std::move(decoded).error(); + result.data = std::move(decoded).value(); + } + const Json* field_format = value.find("format"); + if (!field_format) { + return make_error(ErrorCode::decode, "missing required field 'format'"); + } + if (field_format) { + auto decoded = decode_value(*field_format); + if (!decoded) return std::move(decoded).error(); + result.format = std::move(decoded).value(); + } + const Json* field_generation = value.find("generation"); + if (!field_generation) { + return make_error(ErrorCode::decode, "missing required field 'generation'"); + } + if (field_generation) { + auto decoded = decode_value(*field_generation); + if (!decoded) return std::move(decoded).error(); + result.generation = std::move(decoded).value(); + } + const Json* field_height = value.find("height"); + if (!field_height) { + return make_error(ErrorCode::decode, "missing required field 'height'"); + } + if (field_height) { + auto decoded = decode_value(*field_height); + if (!decoded) return std::move(decoded).error(); + result.height = std::move(decoded).value(); + } + const Json* field_id = value.find("id"); + if (!field_id) { + return make_error(ErrorCode::decode, "missing required field 'id'"); + } + if (field_id) { + auto decoded = decode_value(*field_id); + if (!decoded) return std::move(decoded).error(); + result.id = std::move(decoded).value(); + } + const Json* field_width = value.find("width"); + if (!field_width) { + return make_error(ErrorCode::decode, "missing required field 'width'"); + } + if (field_width) { + auto decoded = decode_value(*field_width); + if (!decoded) return std::move(decoded).error(); + result.width = std::move(decoded).value(); + } + return result; +} + +Result Codec::encode(const RenderGraphicPlacement& value) { + (void)value; + Json::Object object; + if (value.anchor_col) { + auto encoded = encode_value(*value.anchor_col); + if (!encoded) return std::move(encoded).error(); + object.emplace("anchor_col", std::move(encoded).value()); + } + if (value.anchor_row) { + auto encoded = encode_value(*value.anchor_row); + if (!encoded) return std::move(encoded).error(); + object.emplace("anchor_row", std::move(encoded).value()); + } + auto encoded_columns = encode_value(value.columns); + if (!encoded_columns) return std::move(encoded_columns).error(); + object.emplace("columns", std::move(encoded_columns).value()); + auto encoded_grid_cols = encode_value(value.grid_cols); + if (!encoded_grid_cols) return std::move(encoded_grid_cols).error(); + object.emplace("grid_cols", std::move(encoded_grid_cols).value()); + auto encoded_grid_rows = encode_value(value.grid_rows); + if (!encoded_grid_rows) return std::move(encoded_grid_rows).error(); + object.emplace("grid_rows", std::move(encoded_grid_rows).value()); + auto encoded_image_id = encode_value(value.image_id); + if (!encoded_image_id) return std::move(encoded_image_id).error(); + object.emplace("image_id", std::move(encoded_image_id).value()); + auto encoded_ordinal = encode_value(value.ordinal); + if (!encoded_ordinal) return std::move(encoded_ordinal).error(); + object.emplace("ordinal", std::move(encoded_ordinal).value()); + auto encoded_pixel_height = encode_value(value.pixel_height); + if (!encoded_pixel_height) return std::move(encoded_pixel_height).error(); + object.emplace("pixel_height", std::move(encoded_pixel_height).value()); + auto encoded_pixel_width = encode_value(value.pixel_width); + if (!encoded_pixel_width) return std::move(encoded_pixel_width).error(); + object.emplace("pixel_width", std::move(encoded_pixel_width).value()); + auto encoded_placement_id = encode_value(value.placement_id); + if (!encoded_placement_id) return std::move(encoded_placement_id).error(); + object.emplace("placement_id", std::move(encoded_placement_id).value()); + auto encoded_rows = encode_value(value.rows); + if (!encoded_rows) return std::move(encoded_rows).error(); + object.emplace("rows", std::move(encoded_rows).value()); + auto encoded_source_height = encode_value(value.source_height); + if (!encoded_source_height) return std::move(encoded_source_height).error(); + object.emplace("source_height", std::move(encoded_source_height).value()); + auto encoded_source_width = encode_value(value.source_width); + if (!encoded_source_width) return std::move(encoded_source_width).error(); + object.emplace("source_width", std::move(encoded_source_width).value()); + auto encoded_source_x = encode_value(value.source_x); + if (!encoded_source_x) return std::move(encoded_source_x).error(); + object.emplace("source_x", std::move(encoded_source_x).value()); + auto encoded_source_y = encode_value(value.source_y); + if (!encoded_source_y) return std::move(encoded_source_y).error(); + object.emplace("source_y", std::move(encoded_source_y).value()); + auto encoded_viewport_col = encode_value(value.viewport_col); + if (!encoded_viewport_col) return std::move(encoded_viewport_col).error(); + object.emplace("viewport_col", std::move(encoded_viewport_col).value()); + auto encoded_viewport_row = encode_value(value.viewport_row); + if (!encoded_viewport_row) return std::move(encoded_viewport_row).error(); + object.emplace("viewport_row", std::move(encoded_viewport_row).value()); + auto encoded_viewport_visible = encode_value(value.viewport_visible); + if (!encoded_viewport_visible) return std::move(encoded_viewport_visible).error(); + object.emplace("viewport_visible", std::move(encoded_viewport_visible).value()); + auto encoded_x_offset = encode_value(value.x_offset); + if (!encoded_x_offset) return std::move(encoded_x_offset).error(); + object.emplace("x_offset", std::move(encoded_x_offset).value()); + auto encoded_y_offset = encode_value(value.y_offset); + if (!encoded_y_offset) return std::move(encoded_y_offset).error(); + object.emplace("y_offset", std::move(encoded_y_offset).value()); + auto encoded_z = encode_value(value.z); + if (!encoded_z) return std::move(encoded_z).error(); + object.emplace("z", std::move(encoded_z).value()); + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + RenderGraphicPlacement result{}; + const Json* field_anchor_col = value.find("anchor_col"); + if (field_anchor_col) { + auto decoded = decode_value(*field_anchor_col); + if (!decoded) return std::move(decoded).error(); + result.anchor_col = std::move(decoded).value(); + } + const Json* field_anchor_row = value.find("anchor_row"); + if (field_anchor_row) { + auto decoded = decode_value(*field_anchor_row); + if (!decoded) return std::move(decoded).error(); + result.anchor_row = std::move(decoded).value(); + } + const Json* field_columns = value.find("columns"); + if (!field_columns) { + return make_error(ErrorCode::decode, "missing required field 'columns'"); + } + if (field_columns) { + auto decoded = decode_value(*field_columns); + if (!decoded) return std::move(decoded).error(); + result.columns = std::move(decoded).value(); + } + const Json* field_grid_cols = value.find("grid_cols"); + if (!field_grid_cols) { + return make_error(ErrorCode::decode, "missing required field 'grid_cols'"); + } + if (field_grid_cols) { + auto decoded = decode_value(*field_grid_cols); + if (!decoded) return std::move(decoded).error(); + result.grid_cols = std::move(decoded).value(); + } + const Json* field_grid_rows = value.find("grid_rows"); + if (!field_grid_rows) { + return make_error(ErrorCode::decode, "missing required field 'grid_rows'"); + } + if (field_grid_rows) { + auto decoded = decode_value(*field_grid_rows); + if (!decoded) return std::move(decoded).error(); + result.grid_rows = std::move(decoded).value(); + } + const Json* field_image_id = value.find("image_id"); + if (!field_image_id) { + return make_error(ErrorCode::decode, "missing required field 'image_id'"); + } + if (field_image_id) { + auto decoded = decode_value(*field_image_id); + if (!decoded) return std::move(decoded).error(); + result.image_id = std::move(decoded).value(); + } + const Json* field_ordinal = value.find("ordinal"); + if (!field_ordinal) { + return make_error(ErrorCode::decode, "missing required field 'ordinal'"); + } + if (field_ordinal) { + auto decoded = decode_value(*field_ordinal); + if (!decoded) return std::move(decoded).error(); + result.ordinal = std::move(decoded).value(); + } + const Json* field_pixel_height = value.find("pixel_height"); + if (!field_pixel_height) { + return make_error(ErrorCode::decode, "missing required field 'pixel_height'"); + } + if (field_pixel_height) { + auto decoded = decode_value(*field_pixel_height); + if (!decoded) return std::move(decoded).error(); + result.pixel_height = std::move(decoded).value(); + } + const Json* field_pixel_width = value.find("pixel_width"); + if (!field_pixel_width) { + return make_error(ErrorCode::decode, "missing required field 'pixel_width'"); + } + if (field_pixel_width) { + auto decoded = decode_value(*field_pixel_width); + if (!decoded) return std::move(decoded).error(); + result.pixel_width = std::move(decoded).value(); + } + const Json* field_placement_id = value.find("placement_id"); + if (!field_placement_id) { + return make_error(ErrorCode::decode, "missing required field 'placement_id'"); + } + if (field_placement_id) { + auto decoded = decode_value(*field_placement_id); + if (!decoded) return std::move(decoded).error(); + result.placement_id = std::move(decoded).value(); + } + const Json* field_rows = value.find("rows"); + if (!field_rows) { + return make_error(ErrorCode::decode, "missing required field 'rows'"); + } + if (field_rows) { + auto decoded = decode_value(*field_rows); + if (!decoded) return std::move(decoded).error(); + result.rows = std::move(decoded).value(); + } + const Json* field_source_height = value.find("source_height"); + if (!field_source_height) { + return make_error(ErrorCode::decode, "missing required field 'source_height'"); + } + if (field_source_height) { + auto decoded = decode_value(*field_source_height); + if (!decoded) return std::move(decoded).error(); + result.source_height = std::move(decoded).value(); + } + const Json* field_source_width = value.find("source_width"); + if (!field_source_width) { + return make_error(ErrorCode::decode, "missing required field 'source_width'"); + } + if (field_source_width) { + auto decoded = decode_value(*field_source_width); + if (!decoded) return std::move(decoded).error(); + result.source_width = std::move(decoded).value(); + } + const Json* field_source_x = value.find("source_x"); + if (!field_source_x) { + return make_error(ErrorCode::decode, "missing required field 'source_x'"); + } + if (field_source_x) { + auto decoded = decode_value(*field_source_x); + if (!decoded) return std::move(decoded).error(); + result.source_x = std::move(decoded).value(); + } + const Json* field_source_y = value.find("source_y"); + if (!field_source_y) { + return make_error(ErrorCode::decode, "missing required field 'source_y'"); + } + if (field_source_y) { + auto decoded = decode_value(*field_source_y); + if (!decoded) return std::move(decoded).error(); + result.source_y = std::move(decoded).value(); + } + const Json* field_viewport_col = value.find("viewport_col"); + if (!field_viewport_col) { + return make_error(ErrorCode::decode, "missing required field 'viewport_col'"); + } + if (field_viewport_col) { + auto decoded = decode_value(*field_viewport_col); + if (!decoded) return std::move(decoded).error(); + result.viewport_col = std::move(decoded).value(); + } + const Json* field_viewport_row = value.find("viewport_row"); + if (!field_viewport_row) { + return make_error(ErrorCode::decode, "missing required field 'viewport_row'"); + } + if (field_viewport_row) { + auto decoded = decode_value(*field_viewport_row); + if (!decoded) return std::move(decoded).error(); + result.viewport_row = std::move(decoded).value(); + } + const Json* field_viewport_visible = value.find("viewport_visible"); + if (!field_viewport_visible) { + return make_error(ErrorCode::decode, "missing required field 'viewport_visible'"); + } + if (field_viewport_visible) { + auto decoded = decode_value(*field_viewport_visible); + if (!decoded) return std::move(decoded).error(); + result.viewport_visible = std::move(decoded).value(); + } + const Json* field_x_offset = value.find("x_offset"); + if (!field_x_offset) { + return make_error(ErrorCode::decode, "missing required field 'x_offset'"); + } + if (field_x_offset) { + auto decoded = decode_value(*field_x_offset); + if (!decoded) return std::move(decoded).error(); + result.x_offset = std::move(decoded).value(); + } + const Json* field_y_offset = value.find("y_offset"); + if (!field_y_offset) { + return make_error(ErrorCode::decode, "missing required field 'y_offset'"); + } + if (field_y_offset) { + auto decoded = decode_value(*field_y_offset); + if (!decoded) return std::move(decoded).error(); + result.y_offset = std::move(decoded).value(); + } + const Json* field_z = value.find("z"); + if (!field_z) { + return make_error(ErrorCode::decode, "missing required field 'z'"); + } + if (field_z) { + auto decoded = decode_value(*field_z); + if (!decoded) return std::move(decoded).error(); + result.z = std::move(decoded).value(); + } + return result; +} + +Result Codec::encode(const RenderGraphics& value) { + (void)value; + Json::Object object; + auto encoded_generation = encode_value(value.generation); + if (!encoded_generation) return std::move(encoded_generation).error(); + object.emplace("generation", std::move(encoded_generation).value()); + if (value.images) { + auto encoded = encode_value(*value.images); + if (!encoded) return std::move(encoded).error(); + object.emplace("images", std::move(encoded).value()); + } + auto encoded_placements = encode_value(value.placements); + if (!encoded_placements) return std::move(encoded_placements).error(); + object.emplace("placements", std::move(encoded_placements).value()); + if (value.removed_image_ids) { + auto encoded = encode_value(*value.removed_image_ids); + if (!encoded) return std::move(encoded).error(); + object.emplace("removed_image_ids", std::move(encoded).value()); + } + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + RenderGraphics result{}; + const Json* field_generation = value.find("generation"); + if (!field_generation) { + return make_error(ErrorCode::decode, "missing required field 'generation'"); + } + if (field_generation) { + auto decoded = decode_value(*field_generation); + if (!decoded) return std::move(decoded).error(); + result.generation = std::move(decoded).value(); + } + const Json* field_images = value.find("images"); + if (field_images) { + auto decoded = decode_value>(*field_images); + if (!decoded) return std::move(decoded).error(); + result.images = std::move(decoded).value(); + } + const Json* field_placements = value.find("placements"); + if (!field_placements) { + return make_error(ErrorCode::decode, "missing required field 'placements'"); + } + if (field_placements) { + auto decoded = decode_value>(*field_placements); + if (!decoded) return std::move(decoded).error(); + result.placements = std::move(decoded).value(); + } + const Json* field_removed_image_ids = value.find("removed_image_ids"); + if (field_removed_image_ids) { + auto decoded = decode_value>(*field_removed_image_ids); + if (!decoded) return std::move(decoded).error(); + result.removed_image_ids = std::move(decoded).value(); + } + return result; +} + +Result Codec::encode(const RenderGraphicsDelta& value) { + (void)value; + Json::Object object; + auto encoded_generation = encode_value(value.generation); + if (!encoded_generation) return std::move(encoded_generation).error(); + object.emplace("generation", std::move(encoded_generation).value()); + if (value.images) { + auto encoded = encode_value(*value.images); + if (!encoded) return std::move(encoded).error(); + object.emplace("images", std::move(encoded).value()); + } + if (value.placements) { + auto encoded = encode_value(*value.placements); + if (!encoded) return std::move(encoded).error(); + object.emplace("placements", std::move(encoded).value()); + } + if (value.removed_image_ids) { + auto encoded = encode_value(*value.removed_image_ids); + if (!encoded) return std::move(encoded).error(); + object.emplace("removed_image_ids", std::move(encoded).value()); + } + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + RenderGraphicsDelta result{}; + const Json* field_generation = value.find("generation"); + if (!field_generation) { + return make_error(ErrorCode::decode, "missing required field 'generation'"); + } + if (field_generation) { + auto decoded = decode_value(*field_generation); + if (!decoded) return std::move(decoded).error(); + result.generation = std::move(decoded).value(); + } + const Json* field_images = value.find("images"); + if (field_images) { + auto decoded = decode_value>(*field_images); + if (!decoded) return std::move(decoded).error(); + result.images = std::move(decoded).value(); + } + const Json* field_placements = value.find("placements"); + if (field_placements) { + auto decoded = decode_value>(*field_placements); + if (!decoded) return std::move(decoded).error(); + result.placements = std::move(decoded).value(); + } + const Json* field_removed_image_ids = value.find("removed_image_ids"); + if (field_removed_image_ids) { + auto decoded = decode_value>(*field_removed_image_ids); + if (!decoded) return std::move(decoded).error(); + result.removed_image_ids = std::move(decoded).value(); + } + return result; +} + Result Codec::encode(const RenderRow& value) { (void)value; Json::Object object; @@ -4832,6 +5579,16 @@ Result Codec::encode(const VtStateResult& value) { auto encoded_data = encode_value(value.data); if (!encoded_data) return std::move(encoded_data).error(); object.emplace("data", std::move(encoded_data).value()); + if (value.kitty_graphics_state) { + auto encoded = encode_value(*value.kitty_graphics_state); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_graphics_state", std::move(encoded).value()); + } + if (value.kitty_image_aliases) { + auto encoded = encode_value(*value.kitty_image_aliases); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_image_aliases", std::move(encoded).value()); + } auto encoded_rows = encode_value(value.rows); if (!encoded_rows) return std::move(encoded_rows).error(); object.emplace("rows", std::move(encoded_rows).value()); @@ -4860,6 +5617,18 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.data = std::move(decoded).value(); } + const Json* field_kitty_graphics_state = value.find("kitty_graphics_state"); + if (field_kitty_graphics_state) { + auto decoded = decode_value(*field_kitty_graphics_state); + if (!decoded) return std::move(decoded).error(); + result.kitty_graphics_state = std::move(decoded).value(); + } + const Json* field_kitty_image_aliases = value.find("kitty_image_aliases"); + if (field_kitty_image_aliases) { + auto decoded = decode_value>(*field_kitty_image_aliases); + if (!decoded) return std::move(decoded).error(); + result.kitty_image_aliases = std::move(decoded).value(); + } const Json* field_rows = value.find("rows"); if (!field_rows) { return make_error(ErrorCode::decode, "missing required field 'rows'"); @@ -6937,6 +7706,19 @@ Result Codec::decode(const Json& value) { return result; } +Result Codec::encode(const GetCellPixelsRequest& value) { + (void)value; + Json::Object object; + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + GetCellPixelsRequest result{}; + return result; +} + Result Codec::encode(const GetFrontendProjectionRequest& value) { (void)value; Json::Object object; @@ -10954,6 +11736,118 @@ Result Codec::de return result; } +Result Codec::encode(const GraphicsStatusEvent& value) { + (void)value; + Json::Object object; + object.emplace("event", Json(std::string("graphics-status"))); + if (value.attempts) { + auto encoded = encode_value(*value.attempts); + if (!encoded) return std::move(encoded).error(); + object.emplace("attempts", std::move(encoded).value()); + } + if (value.cell_height) { + auto encoded = encode_value(*value.cell_height); + if (!encoded) return std::move(encoded).error(); + object.emplace("cell_height", std::move(encoded).value()); + } + if (value.cell_width) { + auto encoded = encode_value(*value.cell_width); + if (!encoded) return std::move(encoded).error(); + object.emplace("cell_width", std::move(encoded).value()); + } + if (value.error) { + auto encoded = encode_value(*value.error); + if (!encoded) return std::move(encoded).error(); + object.emplace("error", std::move(encoded).value()); + } + auto encoded_kind = encode_value(value.kind); + if (!encoded_kind) return std::move(encoded_kind).error(); + object.emplace("kind", std::move(encoded_kind).value()); + if (value.remaining) { + auto encoded = encode_value(*value.remaining); + if (!encoded) return std::move(encoded).error(); + object.emplace("remaining", std::move(encoded).value()); + } + if (value.retry_exhausted) { + auto encoded = encode_value(*value.retry_exhausted); + if (!encoded) return std::move(encoded).error(); + object.emplace("retry_exhausted", std::move(encoded).value()); + } + if (value.summary) { + auto encoded = encode_value(*value.summary); + if (!encoded) return std::move(encoded).error(); + object.emplace("summary", std::move(encoded).value()); + } + return Json(std::move(object)); +} + +Result Codec::decode(const Json& value) { + auto source = value.as_object(); + if (!source) return std::move(source).error(); + GraphicsStatusEvent result{}; + const Json* field_attempts = value.find("attempts"); + if (field_attempts) { + auto decoded = decode_value(*field_attempts); + if (!decoded) return std::move(decoded).error(); + result.attempts = std::move(decoded).value(); + } + const Json* field_cell_height = value.find("cell_height"); + if (field_cell_height) { + auto decoded = decode_value(*field_cell_height); + if (!decoded) return std::move(decoded).error(); + result.cell_height = std::move(decoded).value(); + } + const Json* field_cell_width = value.find("cell_width"); + if (field_cell_width) { + auto decoded = decode_value(*field_cell_width); + if (!decoded) return std::move(decoded).error(); + result.cell_width = std::move(decoded).value(); + } + const Json* field_error = value.find("error"); + if (field_error) { + auto decoded = decode_value(*field_error); + if (!decoded) return std::move(decoded).error(); + result.error = std::move(decoded).value(); + } + const Json* field_kind = value.find("kind"); + if (!field_kind) { + return make_error(ErrorCode::decode, "missing required field 'kind'"); + } + if (field_kind) { + auto decoded = decode_value(*field_kind); + if (!decoded) return std::move(decoded).error(); + result.kind = std::move(decoded).value(); + } + const Json* field_remaining = value.find("remaining"); + if (field_remaining) { + auto decoded = decode_value(*field_remaining); + if (!decoded) return std::move(decoded).error(); + result.remaining = std::move(decoded).value(); + } + const Json* field_retry_exhausted = value.find("retry_exhausted"); + if (field_retry_exhausted) { + auto decoded = decode_value(*field_retry_exhausted); + if (!decoded) return std::move(decoded).error(); + result.retry_exhausted = std::move(decoded).value(); + } + const Json* field_summary = value.find("summary"); + if (field_summary) { + auto decoded = decode_value(*field_summary); + if (!decoded) return std::move(decoded).error(); + result.summary = std::move(decoded).value(); + } + const Json* field_event = value.find("event"); + if (!field_event) { + return make_error(ErrorCode::decode, "missing required field 'event'"); + } + if (field_event) { + if (*field_event != Json(std::string("graphics-status"))) { + return make_error(ErrorCode::decode, "field 'event' has the wrong literal value"); + } + } + return result; +} + Result Codec::encode(const LayoutChangedEvent& value) { (void)value; Json::Object object; @@ -11491,6 +12385,16 @@ Result Codec::encode(const RenderDeltaEvent& value) { auto encoded_full = encode_value(value.full); if (!encoded_full) return std::move(encoded_full).error(); object.emplace("full", std::move(encoded_full).value()); + if (value.graphics) { + auto encoded = encode_value(*value.graphics); + if (!encoded) return std::move(encoded).error(); + object.emplace("graphics", std::move(encoded).value()); + } + if (value.history_epoch) { + auto encoded = encode_value(*value.history_epoch); + if (!encoded) return std::move(encoded).error(); + object.emplace("history_epoch", std::move(encoded).value()); + } auto encoded_rows = encode_value(value.rows); if (!encoded_rows) return std::move(encoded_rows).error(); object.emplace("rows", std::move(encoded_rows).value()); @@ -11544,6 +12448,18 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.full = std::move(decoded).value(); } + const Json* field_graphics = value.find("graphics"); + if (field_graphics) { + auto decoded = decode_value(*field_graphics); + if (!decoded) return std::move(decoded).error(); + result.graphics = std::move(decoded).value(); + } + const Json* field_history_epoch = value.find("history_epoch"); + if (field_history_epoch) { + auto decoded = decode_value(*field_history_epoch); + if (!decoded) return std::move(decoded).error(); + result.history_epoch = std::move(decoded).value(); + } const Json* field_rows = value.find("rows"); if (!field_rows) { return make_error(ErrorCode::decode, "missing required field 'rows'"); @@ -11599,6 +12515,14 @@ Result Codec::encode(const RenderStateEvent& value) { auto encoded_default_fg = encode_value(value.default_fg); if (!encoded_default_fg) return std::move(encoded_default_fg).error(); object.emplace("default_fg", std::move(encoded_default_fg).value()); + if (value.graphics) { + auto encoded = encode_value(*value.graphics); + if (!encoded) return std::move(encoded).error(); + object.emplace("graphics", std::move(encoded).value()); + } + auto encoded_history_epoch = encode_value(value.history_epoch); + if (!encoded_history_epoch) return std::move(encoded_history_epoch).error(); + object.emplace("history_epoch", std::move(encoded_history_epoch).value()); auto encoded_rows = encode_value(value.rows); if (!encoded_rows) return std::move(encoded_rows).error(); object.emplace("rows", std::move(encoded_rows).value()); @@ -11645,6 +12569,21 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.default_fg = std::move(decoded).value(); } + const Json* field_graphics = value.find("graphics"); + if (field_graphics) { + auto decoded = decode_value(*field_graphics); + if (!decoded) return std::move(decoded).error(); + result.graphics = std::move(decoded).value(); + } + const Json* field_history_epoch = value.find("history_epoch"); + if (!field_history_epoch) { + return make_error(ErrorCode::decode, "missing required field 'history_epoch'"); + } + if (field_history_epoch) { + auto decoded = decode_value(*field_history_epoch); + if (!decoded) return std::move(decoded).error(); + result.history_epoch = std::move(decoded).value(); + } const Json* field_rows = value.find("rows"); if (!field_rows) { return make_error(ErrorCode::decode, "missing required field 'rows'"); @@ -11710,6 +12649,16 @@ Result Codec::encode(const ResizedEvent& value) { if (!encoded) return std::move(encoded).error(); object.emplace("data", std::move(encoded).value()); } + if (value.kitty_graphics_state) { + auto encoded = encode_value(*value.kitty_graphics_state); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_graphics_state", std::move(encoded).value()); + } + if (value.kitty_image_aliases) { + auto encoded = encode_value(*value.kitty_image_aliases); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_image_aliases", std::move(encoded).value()); + } if (value.replay) { auto encoded = encode_value(*value.replay); if (!encoded) return std::move(encoded).error(); @@ -11749,6 +12698,18 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.data = std::move(decoded).value(); } + const Json* field_kitty_graphics_state = value.find("kitty_graphics_state"); + if (field_kitty_graphics_state) { + auto decoded = decode_value(*field_kitty_graphics_state); + if (!decoded) return std::move(decoded).error(); + result.kitty_graphics_state = std::move(decoded).value(); + } + const Json* field_kitty_image_aliases = value.find("kitty_image_aliases"); + if (field_kitty_image_aliases) { + auto decoded = decode_value>(*field_kitty_image_aliases); + if (!decoded) return std::move(decoded).error(); + result.kitty_image_aliases = std::move(decoded).value(); + } const Json* field_replay = value.find("replay"); if (field_replay) { auto decoded = decode_value(*field_replay); @@ -12766,6 +13727,16 @@ Result Codec::encode(const VtStateEvent& value) { auto encoded_data = encode_value(value.data); if (!encoded_data) return std::move(encoded_data).error(); object.emplace("data", std::move(encoded_data).value()); + if (value.kitty_graphics_state) { + auto encoded = encode_value(*value.kitty_graphics_state); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_graphics_state", std::move(encoded).value()); + } + if (value.kitty_image_aliases) { + auto encoded = encode_value(*value.kitty_image_aliases); + if (!encoded) return std::move(encoded).error(); + object.emplace("kitty_image_aliases", std::move(encoded).value()); + } auto encoded_rows = encode_value(value.rows); if (!encoded_rows) return std::move(encoded_rows).error(); object.emplace("rows", std::move(encoded_rows).value()); @@ -12803,6 +13774,18 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); result.data = std::move(decoded).value(); } + const Json* field_kitty_graphics_state = value.find("kitty_graphics_state"); + if (field_kitty_graphics_state) { + auto decoded = decode_value(*field_kitty_graphics_state); + if (!decoded) return std::move(decoded).error(); + result.kitty_graphics_state = std::move(decoded).value(); + } + const Json* field_kitty_image_aliases = value.find("kitty_image_aliases"); + if (field_kitty_image_aliases) { + auto decoded = decode_value>(*field_kitty_image_aliases); + if (!decoded) return std::move(decoded).error(); + result.kitty_image_aliases = std::move(decoded).value(); + } const Json* field_rows = value.find("rows"); if (!field_rows) { return make_error(ErrorCode::decode, "missing required field 'rows'"); @@ -13891,6 +14874,22 @@ Result Codec::decode return make_error(ErrorCode::decode, "unknown ClientAttachedEventTransport value"); } +Result Codec::encode(const GraphicsStatusEventKind& value) { + switch (value) { + case GraphicsStatusEventKind::kitty_image_budget_worker_start_failed: return Json(std::string("kitty-image-budget-worker-start-failed")); + case GraphicsStatusEventKind::kitty_image_budget_update_failed: return Json(std::string("kitty-image-budget-update-failed")); + case GraphicsStatusEventKind::cell_pixel_update_retries_exhausted: return Json(std::string("cell-pixel-update-retries-exhausted")); + } + return make_error(ErrorCode::invalid_argument, "invalid enum value"); +} + +Result Codec::decode(const Json& value) { + if (value == Json(std::string("kitty-image-budget-worker-start-failed"))) return GraphicsStatusEventKind::kitty_image_budget_worker_start_failed; + if (value == Json(std::string("kitty-image-budget-update-failed"))) return GraphicsStatusEventKind::kitty_image_budget_update_failed; + if (value == Json(std::string("cell-pixel-update-retries-exhausted"))) return GraphicsStatusEventKind::cell_pixel_update_retries_exhausted; + return make_error(ErrorCode::decode, "unknown GraphicsStatusEventKind value"); +} + std::string_view Event::name() const noexcept { if (const auto* unknown = std::get_if(&value)) { return unknown->name; @@ -13977,6 +14976,11 @@ Result Codec::decode(const Json& value) { if (!decoded) return std::move(decoded).error(); return Event{Event::Variant(std::move(decoded).value()), value}; } + if (name.value() == "graphics-status") { + auto decoded = decode_value(value); + if (!decoded) return std::move(decoded).error(); + return Event{Event::Variant(std::move(decoded).value()), value}; + } if (name.value() == "layout-changed") { auto decoded = decode_value(value); if (!decoded) return std::move(decoded).error(); @@ -14160,27 +15164,27 @@ constexpr std::array kCommand22FieldRequirements{{ constexpr std::array kCommand24FieldRequirements{{ {"terminal_id", 9U, ""}, }}; -constexpr std::array kCommand41FieldRequirements{{ +constexpr std::array kCommand42FieldRequirements{{ {"expected_generation", 7U, ""}, {"expected_revision", 7U, ""}, {"key", 7U, "workspace-registry-v1"}, {"mutation_id", 7U, ""}, {"origin", 7U, ""}, }}; -constexpr std::array kCommand62FieldRequirements{{ +constexpr std::array kCommand63FieldRequirements{{ {"expected_generation", 7U, ""}, {"expected_revision", 7U, ""}, {"key", 7U, "workspace-registry-v1"}, {"mutation_id", 7U, ""}, {"origin", 7U, ""}, }}; -constexpr std::array kCommand66FieldRequirements{{ +constexpr std::array kCommand67FieldRequirements{{ {"key", 9U, ""}, }}; -constexpr std::array kCommand71FieldRequirements{{ +constexpr std::array kCommand72FieldRequirements{{ {"paste", 7U, ""}, }}; -constexpr std::array kCommand76FieldRequirements{{ +constexpr std::array kCommand77FieldRequirements{{ {"complete", 9U, ""}, {"cursor", 9U, ""}, {"cursor_blink", 9U, ""}, @@ -14189,20 +15193,20 @@ constexpr std::array kCommand76FieldRequirements{{ {"selection_bg", 9U, ""}, {"selection_fg", 9U, ""}, }}; -constexpr std::array kCommand78FieldRequirements{{ - {"transaction", 9U, "layout-undo-v1"}, -}}; constexpr std::array kCommand79FieldRequirements{{ {"transaction", 9U, "layout-undo-v1"}, }}; -constexpr std::array kCommand81FieldRequirements{{ +constexpr std::array kCommand80FieldRequirements{{ + {"transaction", 9U, "layout-undo-v1"}, +}}; +constexpr std::array kCommand82FieldRequirements{{ {"force", 10U, "daemon-handoff-force-v1"}, }}; -constexpr std::array kCommand84FieldRequirements{{ +constexpr std::array kCommand85FieldRequirements{{ {"surface", 9U, "surface-subscribe-filter"}, {"tree_events", 7U, ""}, }}; -constexpr std::array kCommands{{ +constexpr std::array kCommands{{ {"apply-layout", "control", 6U, "", false, "", "", std::span{}}, {"attach-surface", "frontend", 5U, "", true, "attach", "detached", std::span(kCommand1FieldRequirements)}, {"browser-activate", "frontend", 6U, "", false, "", "", std::span{}}, @@ -14233,6 +15237,7 @@ constexpr std::array kCommands{{ {"export-layout", "control", 6U, "", false, "", "", std::span{}}, {"focus-direction", "control", 6U, "", false, "", "", std::span{}}, {"focus-pane", "control", 5U, "", false, "", "", std::span{}}, + {"get-cell-pixels", "frontend", 6U, "", false, "", "", std::span{}}, {"get-frontend-projection", "control", 7U, "", false, "", "", std::span{}}, {"identify", "control", 5U, "", false, "", "", std::span{}}, {"ids", "control", 6U, "", false, "", "", std::span{}}, @@ -14244,7 +15249,7 @@ constexpr std::array kCommands{{ {"mint-terminal-renderer", "frontend", 9U, "", false, "", "", std::span{}}, {"move-tab", "control", 5U, "", false, "", "", std::span{}}, {"move-terminal", "control", 9U, "", false, "", "", std::span{}}, - {"move-workspace", "control", 5U, "", false, "", "", std::span(kCommand41FieldRequirements)}, + {"move-workspace", "control", 5U, "", false, "", "", std::span(kCommand42FieldRequirements)}, {"new-browser-tab", "control", 5U, "", false, "", "", std::span{}}, {"new-pane", "control", 9U, "", false, "", "", std::span{}}, {"new-pane-right", "control", 9U, "viewport-splits-v1", false, "", "", std::span{}}, @@ -14265,29 +15270,29 @@ constexpr std::array kCommands{{ {"rename-provider-managed-workspace", "provider-authority", 9U, "provider-managed-workspace-authority-v2", false, "", "", std::span{}}, {"rename-screen", "control", 5U, "", false, "", "", std::span{}}, {"rename-surface", "control", 5U, "", false, "", "", std::span{}}, - {"rename-workspace", "control", 5U, "", false, "", "", std::span(kCommand62FieldRequirements)}, + {"rename-workspace", "control", 5U, "", false, "", "", std::span(kCommand63FieldRequirements)}, {"report-agent", "control", 6U, "", false, "", "", std::span{}}, {"resize-surface", "control", 5U, "", false, "", "", std::span{}}, {"resolve-terminal", "control", 9U, "", false, "", "", std::span{}}, - {"run", "control", 6U, "", false, "", "", std::span(kCommand66FieldRequirements)}, + {"run", "control", 6U, "", false, "", "", std::span(kCommand67FieldRequirements)}, {"scroll-surface", "control", 5U, "", false, "", "", std::span{}}, {"select-screen", "control", 5U, "", false, "", "", std::span{}}, {"select-tab", "control", 5U, "", false, "", "", std::span{}}, {"select-workspace", "control", 5U, "", false, "", "", std::span{}}, - {"send", "control", 5U, "", false, "", "", std::span(kCommand71FieldRequirements)}, + {"send", "control", 5U, "", false, "", "", std::span(kCommand72FieldRequirements)}, {"send-key", "control", 6U, "", false, "", "", std::span{}}, {"set-cell-pixels", "frontend", 6U, "", false, "", "", std::span{}}, {"set-client-info", "control", 6U, "", false, "", "", std::span{}}, {"set-client-sizing", "control", 10U, "", false, "", "", std::span{}}, - {"set-default-colors", "control", 5U, "", false, "", "", std::span(kCommand76FieldRequirements)}, + {"set-default-colors", "control", 5U, "", false, "", "", std::span(kCommand77FieldRequirements)}, {"set-ratio", "control", 5U, "", false, "", "", std::span{}}, - {"set-split-ratio", "control", 8U, "", false, "", "", std::span(kCommand78FieldRequirements)}, - {"set-viewport-pane-width", "control", 9U, "viewport-column-resize-v1", false, "", "", std::span(kCommand79FieldRequirements)}, + {"set-split-ratio", "control", 8U, "", false, "", "", std::span(kCommand79FieldRequirements)}, + {"set-viewport-pane-width", "control", 9U, "viewport-column-resize-v1", false, "", "", std::span(kCommand80FieldRequirements)}, {"set-window-title", "control", 6U, "", false, "", "", std::span{}}, - {"shutdown-daemon", "local-admin", 9U, "", false, "", "", std::span(kCommand81FieldRequirements)}, + {"shutdown-daemon", "local-admin", 9U, "", false, "", "", std::span(kCommand82FieldRequirements)}, {"sidebar-plugin", "frontend", 6U, "", false, "", "", std::span{}}, {"split", "control", 5U, "", false, "", "", std::span{}}, - {"subscribe", "frontend", 5U, "", true, "subscribe", "", std::span(kCommand84FieldRequirements)}, + {"subscribe", "frontend", 5U, "", true, "subscribe", "", std::span(kCommand85FieldRequirements)}, {"swap-pane", "control", 6U, "", false, "", "", std::span{}}, {"terminal-events", "control", 9U, "", false, "", "", std::span{}}, {"undo-layout", "control", 9U, "layout-undo-v1", false, "", "", std::span{}}, @@ -14295,7 +15300,7 @@ constexpr std::array kCommands{{ {"wait-for", "control", 6U, "", false, "", "", std::span{}}, {"zoom-pane", "control", 6U, "", false, "", "", std::span{}}, }}; -constexpr std::array kEvents{{ +constexpr std::array kEvents{{ {"bell", 5U, "", "subscribe", "emitted"}, {"browser-state", 6U, "", "attach-browser", "emitted"}, {"client-attached", 6U, "", "subscribe", "emitted"}, @@ -14308,6 +15313,7 @@ constexpr std::array kEvents{{ {"empty", 5U, "", "subscribe", "emitted"}, {"frame", 6U, "", "attach-browser", "emitted"}, {"frontend-projection-changed", 7U, "", "subscribe", "emitted"}, + {"graphics-status", 10U, "", "subscribe", "emitted"}, {"layout-changed", 6U, "", "subscribe", "emitted"}, {"notification", 6U, "", "subscribe,attach-byte,attach-browser", "emitted"}, {"output", 5U, "", "attach-byte", "emitted"}, @@ -14690,6 +15696,17 @@ Result Client::focus_pane( return decode_value(response.value()); } +Result Client::get_cell_pixels( + const GetCellPixelsRequest& request, RequestOptions options) { + auto encoded = encode_value(request); + if (!encoded) return std::move(encoded).error(); + auto parameters = encoded.value().as_object(); + if (!parameters) return std::move(parameters).error(); + auto response = core_.request("get-cell-pixels", *parameters.value(), options.timeout); + if (!response) return std::move(response).error(); + return decode_value(response.value()); +} + Result Client::get_frontend_projection( const GetFrontendProjectionRequest& request, RequestOptions options) { auto encoded = encode_value(request); diff --git a/cmux-tui/bindings/cpp/tests/test_generated.cpp b/cmux-tui/bindings/cpp/tests/test_generated.cpp index d0a1c22496..10e540918e 100644 --- a/cmux-tui/bindings/cpp/tests/test_generated.cpp +++ b/cmux-tui/bindings/cpp/tests/test_generated.cpp @@ -84,7 +84,7 @@ static_assert(std::is_same_v< static_assert(!std::is_copy_constructible_v); static_assert(std::is_move_constructible_v); -constexpr std::size_t kExpectedRawCommandCount = 91U; +constexpr std::size_t kExpectedRawCommandCount = 92U; constexpr std::array kViewportHistoryCommandNames{ "clear-history", "new-pane-right", @@ -102,7 +102,7 @@ TEST("generated command and event metadata is exhaustive and unique") { const auto commands = cmux::raw::command_metadata(); const auto events = cmux::raw::event_metadata(); CHECK_EQ(commands.size(), kExpectedRawCommandCount); - CHECK_EQ(events.size(), 44U); + CHECK_EQ(events.size(), 45U); std::set command_names; bool checked_attach_fields = false; diff --git a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json index bcea403f4b..69b8b10226 100644 --- a/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "generated_commands.go", - "sha256": "87dc6478f63716719e55c00ddc4891f4b44afdae1c583fd8ba6f32005e01fff4", - "size": 319070 + "sha256": "a1efaacea3cbf1af5ce1e69b293d0fdc67b4e91512a7e4b5303219d2374c9542", + "size": 319901 }, { "path": "generated_events.go", - "sha256": "109a07a79fee909753d6fc0664051ef4b4f07176ea1a6276821f398be10a5c28", - "size": 96693 + "sha256": "2a013f474ca4d2d51bf7c93cae58a11fc94fc01614152e7fba00f3f2c776de5e", + "size": 105515 }, { "path": "generated_metadata.go", - "sha256": "231d209bbf07d0a2b147b9027e1a85aec6b19fc08874402e7db98a020a11fdcf", - "size": 43354 + "sha256": "2c0f23f4da7d2e3dff327b15223dc307018b848836166dcb35eba249ef7f9400", + "size": 43920 }, { "path": "generated_presence_test.go", - "sha256": "deb7759851ed8eee77a3ef08a726bb077cd20b1e2632d87dedfff5679030e726", - "size": 802758 + "sha256": "aceab1f1c81b4cdf17ffe3eed9bfc87f8e66c7a3e2718a658ff5a9dd30761093", + "size": 871216 }, { "path": "generated_types.go", - "sha256": "85e098909cf5311e446862571a77d85ceb3831e864a5b75bb13cfa6210406b83", - "size": 196327 + "sha256": "f104bda0cfb9d766f287d15bb9eb0ee510f2a9136b4a54d046628cc6a84fbbd5", + "size": 216980 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "go", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/go/raw/README.md b/cmux-tui/bindings/go/raw/README.md index dad0a3a3ec..04d6c4295d 100644 --- a/cmux-tui/bindings/go/raw/README.md +++ b/cmux-tui/bindings/go/raw/README.md @@ -1,6 +1,6 @@ # cmux Go SDK -The Go SDK covers all 91 protocol-10 commands and 44 event shapes using only +The Go SDK covers all 92 protocol-10 commands and 45 event shapes using only the Go standard library. ```go diff --git a/cmux-tui/bindings/go/raw/client_test.go b/cmux-tui/bindings/go/raw/client_test.go index 241c20f79d..6ce472b68f 100644 --- a/cmux-tui/bindings/go/raw/client_test.go +++ b/cmux-tui/bindings/go/raw/client_test.go @@ -20,8 +20,8 @@ import ( func TestGeneratedInventoryHasTypedMethodForEveryCommand(t *testing.T) { commands := AllCommandMetadata() - if len(commands) != 91 { - t.Fatalf("generated commands = %d, want 91", len(commands)) + if len(commands) != 92 { + t.Fatalf("generated commands = %d, want 92", len(commands)) } clientType := reflect.TypeOf((*Client)(nil)) commandNames := make(map[string]struct{}, len(commands)) @@ -51,8 +51,8 @@ func TestGeneratedInventoryHasTypedMethodForEveryCommand(t *testing.T) { t.Errorf("generated command inventory is missing %s", name) } } - if events := AllEventMetadata(); len(events) != 44 { - t.Fatalf("generated events = %d, want 44", len(events)) + if events := AllEventMetadata(); len(events) != 45 { + t.Fatalf("generated events = %d, want 45", len(events)) } } diff --git a/cmux-tui/bindings/go/raw/ergonomics_external_test.go b/cmux-tui/bindings/go/raw/ergonomics_external_test.go index 6f73d7ed73..6345fe21ed 100644 --- a/cmux-tui/bindings/go/raw/ergonomics_external_test.go +++ b/cmux-tui/bindings/go/raw/ergonomics_external_test.go @@ -88,6 +88,7 @@ func TestPublicReadScrollbackTailUsesTwoBoundedSnapshots(t *testing.T) { return } if err := externalWriteSuccess(conn, probe["id"], map[string]any{ + "epoch": 1, "rows": []any{}, "start": 0, "total": 10, @@ -111,6 +112,7 @@ func TestPublicReadScrollbackTailUsesTwoBoundedSnapshots(t *testing.T) { return } serverDone <- externalWriteSuccess(conn, page["id"], map[string]any{ + "epoch": 1, "rows": []any{ externalRenderRow(7, "eight"), externalRenderRow(8, "nine"), diff --git a/cmux-tui/bindings/go/raw/generated_commands.go b/cmux-tui/bindings/go/raw/generated_commands.go index b47b3abc74..1b6f6969d1 100644 --- a/cmux-tui/bindings/go/raw/generated_commands.go +++ b/cmux-tui/bindings/go/raw/generated_commands.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 10; IR SHA-256 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// Mux protocol 10; IR SHA-256 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. package raw @@ -3168,6 +3168,25 @@ func (value *FocusPaneRequest) UnmarshalJSON(data []byte) error { type FocusPaneResult = EmptyResult +// GetCellPixelsRequest is the exact get-cell-pixels wire payload. +type GetCellPixelsRequest struct { +} + +func (value *GetCellPixelsRequest) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode GetCellPixelsRequest: expected object") + } + var fields struct { + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode GetCellPixelsRequest: %w", err) + } + type wire GetCellPixelsRequest + var decoded wire + *value = GetCellPixelsRequest(decoded) + return nil +} + // GetFrontendProjectionRequest is the exact get-frontend-projection wire payload. type GetFrontendProjectionRequest struct { Frontend string `json:"frontend"` @@ -9374,6 +9393,13 @@ func (c *Client) FocusPane(ctx context.Context, pane ID) error { return err } +// GetCellPixels sends get-cell-pixels. Protocol v6; authority frontend. +func (c *Client) GetCellPixels(ctx context.Context) (GetCellPixelsResult, error) { + var result GetCellPixelsResult + err := c.requestGenerated(ctx, commandMetadata["get-cell-pixels"], "get-cell-pixels", nil, &result) + return result, err +} + // GetFrontendProjection sends get-frontend-projection. Protocol v7; authority control. func (c *Client) GetFrontendProjection(ctx context.Context, frontend string, scope string, subjectKey string) (GetFrontendProjectionResult, error) { var result GetFrontendProjectionResult diff --git a/cmux-tui/bindings/go/raw/generated_events.go b/cmux-tui/bindings/go/raw/generated_events.go index 73c76a846f..74e4575b45 100644 --- a/cmux-tui/bindings/go/raw/generated_events.go +++ b/cmux-tui/bindings/go/raw/generated_events.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 10; IR SHA-256 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// Mux protocol 10; IR SHA-256 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. package raw @@ -853,6 +853,143 @@ func (FrontendProjectionChangedEvent) EventName() string { return "frontend-proj func (FrontendProjectionChangedEvent) isDeltaEvent() {} func (FrontendProjectionChangedEvent) isSubscribeEvent() {} +// GraphicsStatusEvent is emitted by protocol v10. +type GraphicsStatusEventKind string + +const ( + GraphicsStatusEventKindKittyImageBudgetWorkerStartFailed GraphicsStatusEventKind = "kitty-image-budget-worker-start-failed" + GraphicsStatusEventKindKittyImageBudgetUpdateFailed GraphicsStatusEventKind = "kitty-image-budget-update-failed" + GraphicsStatusEventKindCellPixelUpdateRetriesExhausted GraphicsStatusEventKind = "cell-pixel-update-retries-exhausted" +) + +func (value GraphicsStatusEventKind) valid() bool { + switch value { + case GraphicsStatusEventKindKittyImageBudgetWorkerStartFailed, GraphicsStatusEventKindKittyImageBudgetUpdateFailed, GraphicsStatusEventKindCellPixelUpdateRetriesExhausted: + return true + default: + return false + } +} + +func (value GraphicsStatusEventKind) MarshalJSON() ([]byte, error) { + if !value.valid() { + return nil, fmt.Errorf("%s has invalid value %v", "GraphicsStatusEventKind", value) + } + return json.Marshal(string(value)) +} + +func (value *GraphicsStatusEventKind) UnmarshalJSON(data []byte) error { + var decoded string + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + candidate := GraphicsStatusEventKind(decoded) + if !candidate.valid() { + return fmt.Errorf("%s has invalid value %v", "GraphicsStatusEventKind", decoded) + } + *value = candidate + return nil +} + +type GraphicsStatusEvent struct { + Attempts *uint16 `json:"attempts,omitempty"` + CellHeight *uint16 `json:"cell_height,omitempty"` + CellWidth *uint16 `json:"cell_width,omitempty"` + Error *string `json:"error,omitempty"` + Kind GraphicsStatusEventKind `json:"kind"` + Remaining *uint64 `json:"remaining,omitempty"` + RetryExhausted *bool `json:"retry_exhausted,omitempty"` + Summary *string `json:"summary,omitempty"` +} + +func (value GraphicsStatusEvent) MarshalJSON() ([]byte, error) { + switch value.Kind { + case "kitty-image-budget-worker-start-failed", "kitty-image-budget-update-failed", "cell-pixel-update-retries-exhausted": + default: + return nil, fmt.Errorf("encode GraphicsStatusEvent.Kind: invalid value %v", value.Kind) + } + type wire GraphicsStatusEvent + return json.Marshal(wire(value)) +} + +func (value *GraphicsStatusEvent) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode GraphicsStatusEvent: expected object") + } + var fields struct { + Attempts optionalNonNullJSON[uint16] `json:"attempts"` + CellHeight optionalNonNullJSON[uint16] `json:"cell_height"` + CellWidth optionalNonNullJSON[uint16] `json:"cell_width"` + Error optionalNonNullJSON[string] `json:"error"` + Kind *GraphicsStatusEventKind `json:"kind"` + Remaining optionalNonNullJSON[uint64] `json:"remaining"` + RetryExhausted optionalNonNullJSON[bool] `json:"retry_exhausted"` + Summary optionalNonNullJSON[string] `json:"summary"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode GraphicsStatusEvent: %w", err) + } + type wire GraphicsStatusEvent + var decoded wire + if fields.Attempts.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field attempts is null") + } + if fields.Attempts.set { + decoded.Attempts = &fields.Attempts.value + } + if fields.CellHeight.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field cell_height is null") + } + if fields.CellHeight.set { + decoded.CellHeight = &fields.CellHeight.value + } + if fields.CellWidth.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field cell_width is null") + } + if fields.CellWidth.set { + decoded.CellWidth = &fields.CellWidth.value + } + if fields.Error.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field error is null") + } + if fields.Error.set { + decoded.Error = &fields.Error.value + } + if fields.Kind == nil { + return fmt.Errorf("decode GraphicsStatusEvent: required field kind is missing or null") + } + decoded.Kind = *fields.Kind + switch decoded.Kind { + case "kitty-image-budget-worker-start-failed", "kitty-image-budget-update-failed", "cell-pixel-update-retries-exhausted": + default: + return fmt.Errorf("decode GraphicsStatusEvent.Kind: invalid value %v", decoded.Kind) + } + if fields.Remaining.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field remaining is null") + } + if fields.Remaining.set { + decoded.Remaining = &fields.Remaining.value + } + if fields.RetryExhausted.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field retry_exhausted is null") + } + if fields.RetryExhausted.set { + decoded.RetryExhausted = &fields.RetryExhausted.value + } + if fields.Summary.null { + return fmt.Errorf("decode GraphicsStatusEvent: non-nullable field summary is null") + } + if fields.Summary.set { + decoded.Summary = &fields.Summary.value + } + *value = GraphicsStatusEvent(decoded) + return nil +} + +func (GraphicsStatusEvent) EventName() string { return "graphics-status" } +func (GraphicsStatusEvent) isDeltaEvent() {} +func (GraphicsStatusEvent) isSubscribeEvent() {} + // LayoutChangedEvent is emitted by protocol v6. type LayoutChangedEvent struct { Screen ID `json:"screen"` @@ -1292,14 +1429,16 @@ func (PaneClosedEvent) isSubscribeEvent() {} // RenderDeltaEvent is emitted by protocol v7. type RenderDeltaEvent struct { - Cursor RenderCursor `json:"cursor"` - DefaultBg *ColorHex `json:"default_bg,omitempty"` - DefaultFg *ColorHex `json:"default_fg,omitempty"` - Full bool `json:"full"` - Rows []RenderRow `json:"rows"` - ScrollbackRows *uint32 `json:"scrollback_rows,omitempty"` - Size *Size `json:"size,omitempty"` - Surface ID `json:"surface"` + Cursor RenderCursor `json:"cursor"` + DefaultBg *ColorHex `json:"default_bg,omitempty"` + DefaultFg *ColorHex `json:"default_fg,omitempty"` + Full bool `json:"full"` + Graphics *RenderGraphicsDelta `json:"graphics,omitempty"` + HistoryEpoch *uint64 `json:"history_epoch,omitempty"` + Rows []RenderRow `json:"rows"` + ScrollbackRows *uint32 `json:"scrollback_rows,omitempty"` + Size *Size `json:"size,omitempty"` + Surface ID `json:"surface"` } func (value *RenderDeltaEvent) UnmarshalJSON(data []byte) error { @@ -1307,14 +1446,16 @@ func (value *RenderDeltaEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode RenderDeltaEvent: expected object") } var fields struct { - Cursor *RenderCursor `json:"cursor"` - DefaultBg optionalNonNullJSON[ColorHex] `json:"default_bg"` - DefaultFg optionalNonNullJSON[ColorHex] `json:"default_fg"` - Full *bool `json:"full"` - Rows *[]RenderRow `json:"rows"` - ScrollbackRows optionalNonNullJSON[uint32] `json:"scrollback_rows"` - Size optionalNonNullJSON[Size] `json:"size"` - Surface *ID `json:"surface"` + Cursor *RenderCursor `json:"cursor"` + DefaultBg optionalNonNullJSON[ColorHex] `json:"default_bg"` + DefaultFg optionalNonNullJSON[ColorHex] `json:"default_fg"` + Full *bool `json:"full"` + Graphics optionalNonNullJSON[RenderGraphicsDelta] `json:"graphics"` + HistoryEpoch optionalNonNullJSON[uint64] `json:"history_epoch"` + Rows *[]RenderRow `json:"rows"` + ScrollbackRows optionalNonNullJSON[uint32] `json:"scrollback_rows"` + Size optionalNonNullJSON[Size] `json:"size"` + Surface *ID `json:"surface"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode RenderDeltaEvent: %w", err) @@ -1341,6 +1482,18 @@ func (value *RenderDeltaEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode RenderDeltaEvent: required field full is missing or null") } decoded.Full = *fields.Full + if fields.Graphics.null { + return fmt.Errorf("decode RenderDeltaEvent: non-nullable field graphics is null") + } + if fields.Graphics.set { + decoded.Graphics = &fields.Graphics.value + } + if fields.HistoryEpoch.null { + return fmt.Errorf("decode RenderDeltaEvent: non-nullable field history_epoch is null") + } + if fields.HistoryEpoch.set { + decoded.HistoryEpoch = &fields.HistoryEpoch.value + } if fields.Rows == nil { return fmt.Errorf("decode RenderDeltaEvent: required field rows is missing or null") } @@ -1371,13 +1524,15 @@ func (RenderDeltaEvent) isRenderAttachEvent() {} // RenderStateEvent is emitted by protocol v7. type RenderStateEvent struct { - Cursor RenderCursor `json:"cursor"` - DefaultBg ColorHex `json:"default_bg"` - DefaultFg ColorHex `json:"default_fg"` - Rows []RenderRow `json:"rows"` - ScrollbackRows uint32 `json:"scrollback_rows"` - Size Size `json:"size"` - Surface ID `json:"surface"` + Cursor RenderCursor `json:"cursor"` + DefaultBg ColorHex `json:"default_bg"` + DefaultFg ColorHex `json:"default_fg"` + Graphics *RenderGraphics `json:"graphics,omitempty"` + HistoryEpoch uint64 `json:"history_epoch"` + Rows []RenderRow `json:"rows"` + ScrollbackRows uint32 `json:"scrollback_rows"` + Size Size `json:"size"` + Surface ID `json:"surface"` } func (value *RenderStateEvent) UnmarshalJSON(data []byte) error { @@ -1385,13 +1540,15 @@ func (value *RenderStateEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode RenderStateEvent: expected object") } var fields struct { - Cursor *RenderCursor `json:"cursor"` - DefaultBg *ColorHex `json:"default_bg"` - DefaultFg *ColorHex `json:"default_fg"` - Rows *[]RenderRow `json:"rows"` - ScrollbackRows *uint32 `json:"scrollback_rows"` - Size *Size `json:"size"` - Surface *ID `json:"surface"` + Cursor *RenderCursor `json:"cursor"` + DefaultBg *ColorHex `json:"default_bg"` + DefaultFg *ColorHex `json:"default_fg"` + Graphics optionalNonNullJSON[RenderGraphics] `json:"graphics"` + HistoryEpoch *uint64 `json:"history_epoch"` + Rows *[]RenderRow `json:"rows"` + ScrollbackRows *uint32 `json:"scrollback_rows"` + Size *Size `json:"size"` + Surface *ID `json:"surface"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode RenderStateEvent: %w", err) @@ -1410,6 +1567,16 @@ func (value *RenderStateEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode RenderStateEvent: required field default_fg is missing or null") } decoded.DefaultFg = *fields.DefaultFg + if fields.Graphics.null { + return fmt.Errorf("decode RenderStateEvent: non-nullable field graphics is null") + } + if fields.Graphics.set { + decoded.Graphics = &fields.Graphics.value + } + if fields.HistoryEpoch == nil { + return fmt.Errorf("decode RenderStateEvent: required field history_epoch is missing or null") + } + decoded.HistoryEpoch = *fields.HistoryEpoch if fields.Rows == nil { return fmt.Errorf("decode RenderStateEvent: required field rows is missing or null") } @@ -1439,10 +1606,12 @@ type ResizedEvent struct { Colors *TerminalColors `json:"colors,omitempty"` Cols uint16 `json:"cols"` // Protocol 6 compatibility field. - Data *Base64 `json:"data,omitempty"` - Replay *Base64 `json:"replay,omitempty"` - Rows uint16 `json:"rows"` - Surface ID `json:"surface"` + Data *Base64 `json:"data,omitempty"` + KittyGraphicsState *KittyGraphicsState `json:"kitty_graphics_state,omitempty"` + KittyImageAliases *[]KittyImageAlias `json:"kitty_image_aliases,omitempty"` + Replay *Base64 `json:"replay,omitempty"` + Rows uint16 `json:"rows"` + Surface ID `json:"surface"` } func (value *ResizedEvent) UnmarshalJSON(data []byte) error { @@ -1450,12 +1619,14 @@ func (value *ResizedEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode ResizedEvent: expected object") } var fields struct { - Colors optionalNonNullJSON[TerminalColors] `json:"colors"` - Cols *uint16 `json:"cols"` - Data optionalNonNullJSON[Base64] `json:"data"` - Replay optionalNonNullJSON[Base64] `json:"replay"` - Rows *uint16 `json:"rows"` - Surface *ID `json:"surface"` + Colors optionalNonNullJSON[TerminalColors] `json:"colors"` + Cols *uint16 `json:"cols"` + Data optionalNonNullJSON[Base64] `json:"data"` + KittyGraphicsState optionalNonNullJSON[KittyGraphicsState] `json:"kitty_graphics_state"` + KittyImageAliases optionalNonNullJSON[[]KittyImageAlias] `json:"kitty_image_aliases"` + Replay optionalNonNullJSON[Base64] `json:"replay"` + Rows *uint16 `json:"rows"` + Surface *ID `json:"surface"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode ResizedEvent: %w", err) @@ -1478,6 +1649,18 @@ func (value *ResizedEvent) UnmarshalJSON(data []byte) error { if fields.Data.set { decoded.Data = &fields.Data.value } + if fields.KittyGraphicsState.null { + return fmt.Errorf("decode ResizedEvent: non-nullable field kitty_graphics_state is null") + } + if fields.KittyGraphicsState.set { + decoded.KittyGraphicsState = &fields.KittyGraphicsState.value + } + if fields.KittyImageAliases.null { + return fmt.Errorf("decode ResizedEvent: non-nullable field kitty_image_aliases is null") + } + if fields.KittyImageAliases.set { + decoded.KittyImageAliases = &fields.KittyImageAliases.value + } if fields.Replay.null { return fmt.Errorf("decode ResizedEvent: non-nullable field replay is null") } @@ -2268,11 +2451,13 @@ func (TreeChangedEvent) isSubscribeEvent() {} // VTStateEvent is emitted by protocol v5. type VTStateEvent struct { - Colors *TerminalColors `json:"colors,omitempty"` - Cols uint16 `json:"cols"` - Data Base64 `json:"data"` - Rows uint16 `json:"rows"` - Surface ID `json:"surface"` + Colors *TerminalColors `json:"colors,omitempty"` + Cols uint16 `json:"cols"` + Data Base64 `json:"data"` + KittyGraphicsState *KittyGraphicsState `json:"kitty_graphics_state,omitempty"` + KittyImageAliases *[]KittyImageAlias `json:"kitty_image_aliases,omitempty"` + Rows uint16 `json:"rows"` + Surface ID `json:"surface"` } func (value *VTStateEvent) UnmarshalJSON(data []byte) error { @@ -2280,11 +2465,13 @@ func (value *VTStateEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode VTStateEvent: expected object") } var fields struct { - Colors optionalNonNullJSON[TerminalColors] `json:"colors"` - Cols *uint16 `json:"cols"` - Data *Base64 `json:"data"` - Rows *uint16 `json:"rows"` - Surface *ID `json:"surface"` + Colors optionalNonNullJSON[TerminalColors] `json:"colors"` + Cols *uint16 `json:"cols"` + Data *Base64 `json:"data"` + KittyGraphicsState optionalNonNullJSON[KittyGraphicsState] `json:"kitty_graphics_state"` + KittyImageAliases optionalNonNullJSON[[]KittyImageAlias] `json:"kitty_image_aliases"` + Rows *uint16 `json:"rows"` + Surface *ID `json:"surface"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode VTStateEvent: %w", err) @@ -2305,6 +2492,18 @@ func (value *VTStateEvent) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode VTStateEvent: required field data is missing or null") } decoded.Data = *fields.Data + if fields.KittyGraphicsState.null { + return fmt.Errorf("decode VTStateEvent: non-nullable field kitty_graphics_state is null") + } + if fields.KittyGraphicsState.set { + decoded.KittyGraphicsState = &fields.KittyGraphicsState.value + } + if fields.KittyImageAliases.null { + return fmt.Errorf("decode VTStateEvent: non-nullable field kitty_image_aliases is null") + } + if fields.KittyImageAliases.set { + decoded.KittyImageAliases = &fields.KittyImageAliases.value + } if fields.Rows == nil { return fmt.Errorf("decode VTStateEvent: required field rows is missing or null") } @@ -2720,6 +2919,11 @@ func parseEvent(raw map[string]any) Event { if decodeEvent(raw, &event) { return event } + case "graphics-status": + var event GraphicsStatusEvent + if decodeEvent(raw, &event) { + return event + } case "layout-changed": var event LayoutChangedEvent if decodeEvent(raw, &event) { diff --git a/cmux-tui/bindings/go/raw/generated_metadata.go b/cmux-tui/bindings/go/raw/generated_metadata.go index 8991dfb74a..e5e95c3c24 100644 --- a/cmux-tui/bindings/go/raw/generated_metadata.go +++ b/cmux-tui/bindings/go/raw/generated_metadata.go @@ -1,12 +1,12 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 10; IR SHA-256 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// Mux protocol 10; IR SHA-256 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. package raw const ( SDKSchemaVersion = 2 MuxProtocolVersion = 10 - SDKIRSHA256 = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e" + SDKIRSHA256 = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350" ) type Authority string @@ -92,6 +92,7 @@ var commandMetadata = map[string]CommandMetadata{ "export-layout": {Name: "export-layout", GoMethod: "ExportLayout", Authority: AuthorityControl, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "focus-direction": {Name: "focus-direction", GoMethod: "FocusDirection", Authority: AuthorityControl, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "focus-pane": {Name: "focus-pane", GoMethod: "FocusPane", Authority: AuthorityControl, Since: 5, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, + "get-cell-pixels": {Name: "get-cell-pixels", GoMethod: "GetCellPixels", Authority: AuthorityFrontend, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "get-frontend-projection": {Name: "get-frontend-projection", GoMethod: "GetFrontendProjection", Authority: AuthorityControl, Since: 7, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "identify": {Name: "identify", GoMethod: "Identify", Authority: AuthorityControl, Since: 5, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, "ids": {Name: "ids", GoMethod: "IDs", Authority: AuthorityControl, Since: 6, Capability: "", Stream: "", FieldSince: nil, FieldCapabilities: nil}, @@ -168,6 +169,7 @@ var eventMetadata = map[string]EventMetadata{ "empty": {Name: "empty", Since: 5, Capability: "", Streams: []string{"subscribe"}, Emission: "emitted"}, "frame": {Name: "frame", Since: 6, Capability: "", Streams: []string{"attach-browser"}, Emission: "emitted"}, "frontend-projection-changed": {Name: "frontend-projection-changed", Since: 7, Capability: "", Streams: []string{"subscribe"}, Emission: "emitted"}, + "graphics-status": {Name: "graphics-status", Since: 10, Capability: "", Streams: []string{"subscribe"}, Emission: "emitted"}, "layout-changed": {Name: "layout-changed", Since: 6, Capability: "", Streams: []string{"subscribe"}, Emission: "emitted"}, "notification": {Name: "notification", Since: 6, Capability: "", Streams: []string{"subscribe", "attach-byte", "attach-browser"}, Emission: "emitted"}, "output": {Name: "output", Since: 5, Capability: "", Streams: []string{"attach-byte"}, Emission: "emitted"}, @@ -245,7 +247,7 @@ func ProfileInfo(name Profile) (ProfileMetadata, bool) { } func AllCommandMetadata() []CommandMetadata { - result := make([]CommandMetadata, 0, 91) + result := make([]CommandMetadata, 0, 92) result = append(result, cloneCommandMetadata(commandMetadata["apply-layout"])) result = append(result, cloneCommandMetadata(commandMetadata["attach-surface"])) result = append(result, cloneCommandMetadata(commandMetadata["browser-activate"])) @@ -276,6 +278,7 @@ func AllCommandMetadata() []CommandMetadata { result = append(result, cloneCommandMetadata(commandMetadata["export-layout"])) result = append(result, cloneCommandMetadata(commandMetadata["focus-direction"])) result = append(result, cloneCommandMetadata(commandMetadata["focus-pane"])) + result = append(result, cloneCommandMetadata(commandMetadata["get-cell-pixels"])) result = append(result, cloneCommandMetadata(commandMetadata["get-frontend-projection"])) result = append(result, cloneCommandMetadata(commandMetadata["identify"])) result = append(result, cloneCommandMetadata(commandMetadata["ids"])) @@ -341,7 +344,7 @@ func AllCommandMetadata() []CommandMetadata { } func AllEventMetadata() []EventMetadata { - result := make([]EventMetadata, 0, 44) + result := make([]EventMetadata, 0, 45) var metadata EventMetadata metadata = eventMetadata["bell"] metadata.Streams = append([]string(nil), metadata.Streams...) @@ -379,6 +382,9 @@ func AllEventMetadata() []EventMetadata { metadata = eventMetadata["frontend-projection-changed"] metadata.Streams = append([]string(nil), metadata.Streams...) result = append(result, metadata) + metadata = eventMetadata["graphics-status"] + metadata.Streams = append([]string(nil), metadata.Streams...) + result = append(result, metadata) metadata = eventMetadata["layout-changed"] metadata.Streams = append([]string(nil), metadata.Streams...) result = append(result, metadata) diff --git a/cmux-tui/bindings/go/raw/generated_presence_test.go b/cmux-tui/bindings/go/raw/generated_presence_test.go index e11a2a1920..4a250fcd2d 100644 --- a/cmux-tui/bindings/go/raw/generated_presence_test.go +++ b/cmux-tui/bindings/go/raw/generated_presence_test.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 10; IR SHA-256 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// Mux protocol 10; IR SHA-256 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. package raw @@ -751,6 +751,160 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "color", true, "\"value\"") }) + t.Run("RenderGraphicPlacement.AnchorCol", func(t *testing.T) { + var missing RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.AnchorCol != nil { + t.Fatal("AnchorCol did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "anchor_col", false, "") + var nullValue RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1,\"anchor_col\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field anchor_col accepted null") + } + var presentValue RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1,\"anchor_col\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.AnchorCol == nil { + t.Fatal("AnchorCol lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "anchor_col", true, "1") + }) + t.Run("RenderGraphicPlacement.AnchorRow", func(t *testing.T) { + var missing RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.AnchorRow != nil { + t.Fatal("AnchorRow did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "anchor_row", false, "") + var nullValue RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1,\"anchor_row\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field anchor_row accepted null") + } + var presentValue RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1,\"anchor_row\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.AnchorRow == nil { + t.Fatal("AnchorRow lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "anchor_row", true, "1") + }) + t.Run("RenderGraphics.Images", func(t *testing.T) { + var missing RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[]}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Images != nil { + t.Fatal("Images did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "images", false, "") + var nullValue RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[],\"images\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field images accepted null") + } + var presentValue RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[],\"images\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Images == nil { + t.Fatal("Images lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "images", true, "[]") + }) + t.Run("RenderGraphics.RemovedImageIDs", func(t *testing.T) { + var missing RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[]}"), &missing); err != nil { + t.Fatal(err) + } + if missing.RemovedImageIDs != nil { + t.Fatal("RemovedImageIDs did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "removed_image_ids", false, "") + var nullValue RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[],\"removed_image_ids\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field removed_image_ids accepted null") + } + var presentValue RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[],\"removed_image_ids\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.RemovedImageIDs == nil { + t.Fatal("RemovedImageIDs lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "removed_image_ids", true, "[]") + }) + t.Run("RenderGraphicsDelta.Images", func(t *testing.T) { + var missing RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Images != nil { + t.Fatal("Images did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "images", false, "") + var nullValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"images\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field images accepted null") + } + var presentValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"images\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Images == nil { + t.Fatal("Images lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "images", true, "[]") + }) + t.Run("RenderGraphicsDelta.Placements", func(t *testing.T) { + var missing RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Placements != nil { + t.Fatal("Placements did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "placements", false, "") + var nullValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field placements accepted null") + } + var presentValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Placements == nil { + t.Fatal("Placements lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "placements", true, "[]") + }) + t.Run("RenderGraphicsDelta.RemovedImageIDs", func(t *testing.T) { + var missing RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.RemovedImageIDs != nil { + t.Fatal("RemovedImageIDs did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "removed_image_ids", false, "") + var nullValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"removed_image_ids\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field removed_image_ids accepted null") + } + var presentValue RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":1,\"removed_image_ids\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.RemovedImageIDs == nil { + t.Fatal("RemovedImageIDs lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "removed_image_ids", true, "[]") + }) t.Run("RenderRun.Bg", func(t *testing.T) { var missing RenderRun if err := json.Unmarshal([]byte("{\"attrs\":1,\"fg\":null,\"text\":\"value\"}"), &missing); err == nil { @@ -2003,6 +2157,50 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "workspace_revision", true, "1") }) + t.Run("VTStateResult.KittyGraphicsState", func(t *testing.T) { + var missing VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyGraphicsState != nil { + t.Fatal("KittyGraphicsState did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_graphics_state", false, "") + var nullValue VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"kitty_graphics_state\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_graphics_state accepted null") + } + var presentValue VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"kitty_graphics_state\":{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyGraphicsState == nil { + t.Fatal("KittyGraphicsState lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_graphics_state", true, "{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}") + }) + t.Run("VTStateResult.KittyImageAliases", func(t *testing.T) { + var missing VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyImageAliases != nil { + t.Fatal("KittyImageAliases did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_image_aliases", false, "") + var nullValue VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"kitty_image_aliases\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_image_aliases accepted null") + } + var presentValue VTStateResult + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"kitty_image_aliases\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyImageAliases == nil { + t.Fatal("KittyImageAliases lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_image_aliases", true, "[]") + }) t.Run("Workspace.Key", func(t *testing.T) { var missing Workspace if err := json.Unmarshal([]byte("{\"active\":true,\"id\":1,\"name\":\"value\",\"screens\":[]}"), &missing); err != nil { @@ -9641,6 +9839,160 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "surface", true, "1") }) + t.Run("GraphicsStatusEvent.Attempts", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Attempts != nil { + t.Fatal("Attempts did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "attempts", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"attempts\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field attempts accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"attempts\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Attempts == nil { + t.Fatal("Attempts lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "attempts", true, "1") + }) + t.Run("GraphicsStatusEvent.CellHeight", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.CellHeight != nil { + t.Fatal("CellHeight did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "cell_height", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"cell_height\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field cell_height accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"cell_height\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.CellHeight == nil { + t.Fatal("CellHeight lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "cell_height", true, "1") + }) + t.Run("GraphicsStatusEvent.CellWidth", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.CellWidth != nil { + t.Fatal("CellWidth did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "cell_width", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"cell_width\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field cell_width accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"cell_width\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.CellWidth == nil { + t.Fatal("CellWidth lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "cell_width", true, "1") + }) + t.Run("GraphicsStatusEvent.Error", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Error != nil { + t.Fatal("Error did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "error", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"error\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field error accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"error\":\"value\"}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Error == nil { + t.Fatal("Error lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "error", true, "\"value\"") + }) + t.Run("GraphicsStatusEvent.Remaining", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Remaining != nil { + t.Fatal("Remaining did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "remaining", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"remaining\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field remaining accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"remaining\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Remaining == nil { + t.Fatal("Remaining lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "remaining", true, "1") + }) + t.Run("GraphicsStatusEvent.RetryExhausted", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.RetryExhausted != nil { + t.Fatal("RetryExhausted did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "retry_exhausted", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"retry_exhausted\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field retry_exhausted accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"retry_exhausted\":true}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.RetryExhausted == nil { + t.Fatal("RetryExhausted lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "retry_exhausted", true, "true") + }) + t.Run("GraphicsStatusEvent.Summary", func(t *testing.T) { + var missing GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Summary != nil { + t.Fatal("Summary did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "summary", false, "") + var nullValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"summary\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field summary accepted null") + } + var presentValue GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\",\"summary\":\"value\"}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Summary == nil { + t.Fatal("Summary lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "summary", true, "\"value\"") + }) t.Run("NotificationEvent.Surface", func(t *testing.T) { var missing NotificationEvent if err := json.Unmarshal([]byte("{\"body\":\"value\",\"level\":\"info\",\"notification\":1,\"title\":\"value\"}"), &missing); err == nil { @@ -9773,6 +10125,50 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "default_fg", true, "\"value\"") }) + t.Run("RenderDeltaEvent.Graphics", func(t *testing.T) { + var missing RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Graphics != nil { + t.Fatal("Graphics did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "graphics", false, "") + var nullValue RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1,\"graphics\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field graphics accepted null") + } + var presentValue RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1,\"graphics\":{\"generation\":1}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Graphics == nil { + t.Fatal("Graphics lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "graphics", true, "{\"generation\":1}") + }) + t.Run("RenderDeltaEvent.HistoryEpoch", func(t *testing.T) { + var missing RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.HistoryEpoch != nil { + t.Fatal("HistoryEpoch did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "history_epoch", false, "") + var nullValue RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1,\"history_epoch\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field history_epoch accepted null") + } + var presentValue RenderDeltaEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1,\"history_epoch\":1}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.HistoryEpoch == nil { + t.Fatal("HistoryEpoch lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "history_epoch", true, "1") + }) t.Run("RenderDeltaEvent.ScrollbackRows", func(t *testing.T) { var missing RenderDeltaEvent if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"full\":true,\"rows\":[],\"surface\":1}"), &missing); err != nil { @@ -9817,6 +10213,28 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "size", true, "{\"cols\":1,\"rows\":1}") }) + t.Run("RenderStateEvent.Graphics", func(t *testing.T) { + var missing RenderStateEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.Graphics != nil { + t.Fatal("Graphics did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "graphics", false, "") + var nullValue RenderStateEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1,\"graphics\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field graphics accepted null") + } + var presentValue RenderStateEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1,\"graphics\":{\"generation\":1,\"placements\":[]}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.Graphics == nil { + t.Fatal("Graphics lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "graphics", true, "{\"generation\":1,\"placements\":[]}") + }) t.Run("ResizedEvent.Colors", func(t *testing.T) { var missing ResizedEvent if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1}"), &missing); err != nil { @@ -9861,6 +10279,50 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "data", true, "\"value\"") }) + t.Run("ResizedEvent.KittyGraphicsState", func(t *testing.T) { + var missing ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyGraphicsState != nil { + t.Fatal("KittyGraphicsState did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_graphics_state", false, "") + var nullValue ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1,\"kitty_graphics_state\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_graphics_state accepted null") + } + var presentValue ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1,\"kitty_graphics_state\":{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyGraphicsState == nil { + t.Fatal("KittyGraphicsState lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_graphics_state", true, "{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}") + }) + t.Run("ResizedEvent.KittyImageAliases", func(t *testing.T) { + var missing ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyImageAliases != nil { + t.Fatal("KittyImageAliases did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_image_aliases", false, "") + var nullValue ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1,\"kitty_image_aliases\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_image_aliases accepted null") + } + var presentValue ResizedEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1,\"kitty_image_aliases\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyImageAliases == nil { + t.Fatal("KittyImageAliases lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_image_aliases", true, "[]") + }) t.Run("ResizedEvent.Replay", func(t *testing.T) { var missing ResizedEvent if err := json.Unmarshal([]byte("{\"cols\":1,\"rows\":1,\"surface\":1}"), &missing); err != nil { @@ -9993,6 +10455,50 @@ func TestGeneratedSchemaPresenceRoundTrips(t *testing.T) { } assertGeneratedFieldJSON(t, presentValue, "colors", true, "{\"bg\":null,\"fg\":null,\"selection_bg\":null,\"selection_fg\":null}") }) + t.Run("VTStateEvent.KittyGraphicsState", func(t *testing.T) { + var missing VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyGraphicsState != nil { + t.Fatal("KittyGraphicsState did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_graphics_state", false, "") + var nullValue VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1,\"kitty_graphics_state\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_graphics_state accepted null") + } + var presentValue VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1,\"kitty_graphics_state\":{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyGraphicsState == nil { + t.Fatal("KittyGraphicsState lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_graphics_state", true, "{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}") + }) + t.Run("VTStateEvent.KittyImageAliases", func(t *testing.T) { + var missing VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1}"), &missing); err != nil { + t.Fatal(err) + } + if missing.KittyImageAliases != nil { + t.Fatal("KittyImageAliases did not preserve absence") + } + assertGeneratedFieldJSON(t, missing, "kitty_image_aliases", false, "") + var nullValue VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1,\"kitty_image_aliases\":null}"), &nullValue); err == nil { + t.Fatal("non-nullable field kitty_image_aliases accepted null") + } + var presentValue VTStateEvent + if err := json.Unmarshal([]byte("{\"cols\":1,\"data\":\"value\",\"rows\":1,\"surface\":1,\"kitty_image_aliases\":[]}"), &presentValue); err != nil { + t.Fatal(err) + } + if presentValue.KittyImageAliases == nil { + t.Fatal("KittyImageAliases lost its value") + } + assertGeneratedFieldJSON(t, presentValue, "kitty_image_aliases", true, "[]") + }) t.Run("WorkspaceAddedEvent.MutationID", func(t *testing.T) { var missing WorkspaceAddedEvent if err := json.Unmarshal([]byte("{\"entity\":{\"active\":true,\"id\":1,\"name\":\"value\",\"screens\":[]},\"generation\":\"value\",\"index\":1,\"registry_id\":\"value\",\"workspace\":1,\"workspace_revision\":1}"), &missing); err != nil { @@ -10286,6 +10792,24 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field surface decoded successfully") } }) + t.Run("CellPixelSurface.HeightPx", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"surface\":1,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("missing required field height_px decoded successfully") + } + }) + t.Run("CellPixelSurface.Surface", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"height_px\":1,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("missing required field surface decoded successfully") + } + }) + t.Run("CellPixelSurface.WidthPx", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surface\":1}"), &decoded); err == nil { + t.Fatal("missing required field width_px decoded successfully") + } + }) t.Run("ClientInfo.Attached", func(t *testing.T) { var decoded ClientInfo if err := json.Unmarshal([]byte("{\"client\":1,\"connected_seconds\":1,\"kind\":null,\"name\":null,\"self\":true,\"sizes\":[],\"transport\":\"local\"}"), &decoded); err == nil { @@ -10550,6 +11074,24 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field subject_key decoded successfully") } }) + t.Run("GetCellPixelsResult.HeightPx", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"surfaces\":[],\"width_px\":1}"), &decoded); err == nil { + t.Fatal("missing required field height_px decoded successfully") + } + }) + t.Run("GetCellPixelsResult.Surfaces", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"height_px\":1,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("missing required field surfaces decoded successfully") + } + }) + t.Run("GetCellPixelsResult.WidthPx", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surfaces\":[]}"), &decoded); err == nil { + t.Fatal("missing required field width_px decoded successfully") + } + }) t.Run("IDMapping.ID", func(t *testing.T) { var decoded IDMapping if err := json.Unmarshal([]byte("{\"kind\":\"workspace\",\"short_id\":\"value\"}"), &decoded); err == nil { @@ -10634,6 +11176,72 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field ids decoded successfully") } }) + t.Run("KittyGraphicsState.AlternateNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field alternate_next_image_id decoded successfully") + } + }) + t.Run("KittyGraphicsState.AlternateReplayNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field alternate_replay_next_image_id decoded successfully") + } + }) + t.Run("KittyGraphicsState.ImageBytes", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field image_bytes decoded successfully") + } + }) + t.Run("KittyGraphicsState.Images", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field images decoded successfully") + } + }) + t.Run("KittyGraphicsState.InflightBytes", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field inflight_bytes decoded successfully") + } + }) + t.Run("KittyGraphicsState.Placements", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field placements decoded successfully") + } + }) + t.Run("KittyGraphicsState.PrimaryNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field primary_next_image_id decoded successfully") + } + }) + t.Run("KittyGraphicsState.PrimaryReplayNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field primary_replay_next_image_id decoded successfully") + } + }) + t.Run("KittyGraphicsState.ReplayCursorOffset", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1}"), &decoded); err == nil { + t.Fatal("missing required field replay_cursor_offset decoded successfully") + } + }) + t.Run("KittyImageAlias.ImageID", func(t *testing.T) { + var decoded KittyImageAlias + if err := json.Unmarshal([]byte("{\"image_number\":1}"), &decoded); err == nil { + t.Fatal("missing required field image_id decoded successfully") + } + }) + t.Run("KittyImageAlias.ImageNumber", func(t *testing.T) { + var decoded KittyImageAlias + if err := json.Unmarshal([]byte("{\"image_id\":1}"), &decoded); err == nil { + t.Fatal("missing required field image_number decoded successfully") + } + }) t.Run("LayoutLeaf.Pane", func(t *testing.T) { var decoded LayoutLeaf if err := json.Unmarshal([]byte("{\"type\":\"leaf\"}"), &decoded); err == nil { @@ -11000,21 +11608,27 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field text decoded successfully") } }) + t.Run("ReadScrollbackResult.Epoch", func(t *testing.T) { + var decoded ReadScrollbackResult + if err := json.Unmarshal([]byte("{\"rows\":[],\"start\":1,\"total\":1}"), &decoded); err == nil { + t.Fatal("missing required field epoch decoded successfully") + } + }) t.Run("ReadScrollbackResult.Rows", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"start\":1,\"total\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"start\":1,\"total\":1}"), &decoded); err == nil { t.Fatal("missing required field rows decoded successfully") } }) t.Run("ReadScrollbackResult.Start", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"rows\":[],\"total\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"rows\":[],\"total\":1}"), &decoded); err == nil { t.Fatal("missing required field start decoded successfully") } }) t.Run("ReadScrollbackResult.Total", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"rows\":[],\"start\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"rows\":[],\"start\":1}"), &decoded); err == nil { t.Fatal("missing required field total decoded successfully") } }) @@ -11054,6 +11668,174 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field y decoded successfully") } }) + t.Run("RenderGraphicImage.Data", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("missing required field data decoded successfully") + } + }) + t.Run("RenderGraphicImage.Format", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("missing required field format decoded successfully") + } + }) + t.Run("RenderGraphicImage.Generation", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("missing required field generation decoded successfully") + } + }) + t.Run("RenderGraphicImage.Height", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("missing required field height decoded successfully") + } + }) + t.Run("RenderGraphicImage.ID", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("missing required field id decoded successfully") + } + }) + t.Run("RenderGraphicImage.Width", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":1}"), &decoded); err == nil { + t.Fatal("missing required field width decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.Columns", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field columns decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.GridCols", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field grid_cols decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.GridRows", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field grid_rows decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.ImageID", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field image_id decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.Ordinal", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field ordinal decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.PixelHeight", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field pixel_height decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.PixelWidth", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field pixel_width decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.PlacementID", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field placement_id decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.Rows", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field rows decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.SourceHeight", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field source_height decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.SourceWidth", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field source_width decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.SourceX", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field source_x decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.SourceY", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field source_y decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.ViewportCol", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field viewport_col decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.ViewportRow", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field viewport_row decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.ViewportVisible", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field viewport_visible decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.XOffset", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field x_offset decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.YOffset", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("missing required field y_offset decoded successfully") + } + }) + t.Run("RenderGraphicPlacement.Z", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1}"), &decoded); err == nil { + t.Fatal("missing required field z decoded successfully") + } + }) + t.Run("RenderGraphics.Generation", func(t *testing.T) { + var decoded RenderGraphics + if err := json.Unmarshal([]byte("{\"placements\":[]}"), &decoded); err == nil { + t.Fatal("missing required field generation decoded successfully") + } + }) + t.Run("RenderGraphics.Placements", func(t *testing.T) { + var decoded RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1}"), &decoded); err == nil { + t.Fatal("missing required field placements decoded successfully") + } + }) + t.Run("RenderGraphicsDelta.Generation", func(t *testing.T) { + var decoded RenderGraphicsDelta + if err := json.Unmarshal([]byte("{}"), &decoded); err == nil { + t.Fatal("missing required field generation decoded successfully") + } + }) t.Run("RenderRow.Row", func(t *testing.T) { var decoded RenderRow if err := json.Unmarshal([]byte("{\"runs\":[]}"), &decoded); err == nil { @@ -12764,6 +13546,12 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { t.Fatal("missing required field subject_key decoded successfully") } }) + t.Run("GraphicsStatusEvent.Kind", func(t *testing.T) { + var decoded GraphicsStatusEvent + if err := json.Unmarshal([]byte("{}"), &decoded); err == nil { + t.Fatal("missing required field kind decoded successfully") + } + }) t.Run("LayoutChangedEvent.Screen", func(t *testing.T) { var decoded LayoutChangedEvent if err := json.Unmarshal([]byte("{}"), &decoded); err == nil { @@ -12934,43 +13722,49 @@ func TestGeneratedRequiredFieldsRejectOmission(t *testing.T) { }) t.Run("RenderStateEvent.Cursor", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field cursor decoded successfully") } }) t.Run("RenderStateEvent.DefaultBg", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field default_bg decoded successfully") } }) t.Run("RenderStateEvent.DefaultFg", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field default_fg decoded successfully") } }) + t.Run("RenderStateEvent.HistoryEpoch", func(t *testing.T) { + var decoded RenderStateEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + t.Fatal("missing required field history_epoch decoded successfully") + } + }) t.Run("RenderStateEvent.Rows", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field rows decoded successfully") } }) t.Run("RenderStateEvent.ScrollbackRows", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field scrollback_rows decoded successfully") } }) t.Run("RenderStateEvent.Size", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"surface\":1}"), &decoded); err == nil { t.Fatal("missing required field size decoded successfully") } }) t.Run("RenderStateEvent.Surface", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1}}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1}}"), &decoded); err == nil { t.Fatal("missing required field surface decoded successfully") } }) @@ -13565,6 +14359,24 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field surface accepted null") } }) + t.Run("CellPixelSurface.HeightPx", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"height_px\":null,\"surface\":1,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field height_px accepted null") + } + }) + t.Run("CellPixelSurface.Surface", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surface\":null,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field surface accepted null") + } + }) + t.Run("CellPixelSurface.WidthPx", func(t *testing.T) { + var decoded CellPixelSurface + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surface\":1,\"width_px\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field width_px accepted null") + } + }) t.Run("ClientInfo.Attached", func(t *testing.T) { var decoded ClientInfo if err := json.Unmarshal([]byte("{\"attached\":null,\"client\":1,\"connected_seconds\":1,\"kind\":null,\"name\":null,\"self\":true,\"sizes\":[],\"transport\":\"local\"}"), &decoded); err == nil { @@ -13787,6 +14599,24 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field subject_key accepted null") } }) + t.Run("GetCellPixelsResult.HeightPx", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"height_px\":null,\"surfaces\":[],\"width_px\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field height_px accepted null") + } + }) + t.Run("GetCellPixelsResult.Surfaces", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surfaces\":null,\"width_px\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field surfaces accepted null") + } + }) + t.Run("GetCellPixelsResult.WidthPx", func(t *testing.T) { + var decoded GetCellPixelsResult + if err := json.Unmarshal([]byte("{\"height_px\":1,\"surfaces\":[],\"width_px\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field width_px accepted null") + } + }) t.Run("IDMapping.ID", func(t *testing.T) { var decoded IDMapping if err := json.Unmarshal([]byte("{\"id\":null,\"kind\":\"workspace\",\"short_id\":\"value\"}"), &decoded); err == nil { @@ -13871,6 +14701,72 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field ids accepted null") } }) + t.Run("KittyGraphicsState.AlternateNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":null,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field alternate_next_image_id accepted null") + } + }) + t.Run("KittyGraphicsState.AlternateReplayNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":null,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field alternate_replay_next_image_id accepted null") + } + }) + t.Run("KittyGraphicsState.ImageBytes", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":null,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field image_bytes accepted null") + } + }) + t.Run("KittyGraphicsState.Images", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":null,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field images accepted null") + } + }) + t.Run("KittyGraphicsState.InflightBytes", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":null,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field inflight_bytes accepted null") + } + }) + t.Run("KittyGraphicsState.Placements", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":null,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field placements accepted null") + } + }) + t.Run("KittyGraphicsState.PrimaryNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":null,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field primary_next_image_id accepted null") + } + }) + t.Run("KittyGraphicsState.PrimaryReplayNextImageID", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":null,\"replay_cursor_offset\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field primary_replay_next_image_id accepted null") + } + }) + t.Run("KittyGraphicsState.ReplayCursorOffset", func(t *testing.T) { + var decoded KittyGraphicsState + if err := json.Unmarshal([]byte("{\"alternate_next_image_id\":1,\"alternate_replay_next_image_id\":1,\"image_bytes\":1,\"images\":1,\"inflight_bytes\":1,\"placements\":1,\"primary_next_image_id\":1,\"primary_replay_next_image_id\":1,\"replay_cursor_offset\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field replay_cursor_offset accepted null") + } + }) + t.Run("KittyImageAlias.ImageID", func(t *testing.T) { + var decoded KittyImageAlias + if err := json.Unmarshal([]byte("{\"image_id\":null,\"image_number\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field image_id accepted null") + } + }) + t.Run("KittyImageAlias.ImageNumber", func(t *testing.T) { + var decoded KittyImageAlias + if err := json.Unmarshal([]byte("{\"image_id\":1,\"image_number\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field image_number accepted null") + } + }) t.Run("LayoutLeaf.Pane", func(t *testing.T) { var decoded LayoutLeaf if err := json.Unmarshal([]byte("{\"pane\":null,\"type\":\"leaf\"}"), &decoded); err == nil { @@ -14177,21 +15073,27 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field text accepted null") } }) + t.Run("ReadScrollbackResult.Epoch", func(t *testing.T) { + var decoded ReadScrollbackResult + if err := json.Unmarshal([]byte("{\"epoch\":null,\"rows\":[],\"start\":1,\"total\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field epoch accepted null") + } + }) t.Run("ReadScrollbackResult.Rows", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"rows\":null,\"start\":1,\"total\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"rows\":null,\"start\":1,\"total\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field rows accepted null") } }) t.Run("ReadScrollbackResult.Start", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"rows\":[],\"start\":null,\"total\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"rows\":[],\"start\":null,\"total\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field start accepted null") } }) t.Run("ReadScrollbackResult.Total", func(t *testing.T) { var decoded ReadScrollbackResult - if err := json.Unmarshal([]byte("{\"rows\":[],\"start\":1,\"total\":null}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"epoch\":1,\"rows\":[],\"start\":1,\"total\":null}"), &decoded); err == nil { t.Fatal("required non-nullable field total accepted null") } }) @@ -14225,6 +15127,174 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field y accepted null") } }) + t.Run("RenderGraphicImage.Data", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":null,\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field data accepted null") + } + }) + t.Run("RenderGraphicImage.Format", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":null,\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field format accepted null") + } + }) + t.Run("RenderGraphicImage.Generation", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":null,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field generation accepted null") + } + }) + t.Run("RenderGraphicImage.Height", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":null,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field height accepted null") + } + }) + t.Run("RenderGraphicImage.ID", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":null,\"width\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field id accepted null") + } + }) + t.Run("RenderGraphicImage.Width", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":1,\"width\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field width accepted null") + } + }) + t.Run("RenderGraphicPlacement.Columns", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":null,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field columns accepted null") + } + }) + t.Run("RenderGraphicPlacement.GridCols", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":null,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field grid_cols accepted null") + } + }) + t.Run("RenderGraphicPlacement.GridRows", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":null,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field grid_rows accepted null") + } + }) + t.Run("RenderGraphicPlacement.ImageID", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":null,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field image_id accepted null") + } + }) + t.Run("RenderGraphicPlacement.Ordinal", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":null,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field ordinal accepted null") + } + }) + t.Run("RenderGraphicPlacement.PixelHeight", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":null,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field pixel_height accepted null") + } + }) + t.Run("RenderGraphicPlacement.PixelWidth", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":null,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field pixel_width accepted null") + } + }) + t.Run("RenderGraphicPlacement.PlacementID", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":null,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field placement_id accepted null") + } + }) + t.Run("RenderGraphicPlacement.Rows", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":null,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field rows accepted null") + } + }) + t.Run("RenderGraphicPlacement.SourceHeight", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":null,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field source_height accepted null") + } + }) + t.Run("RenderGraphicPlacement.SourceWidth", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":null,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field source_width accepted null") + } + }) + t.Run("RenderGraphicPlacement.SourceX", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":null,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field source_x accepted null") + } + }) + t.Run("RenderGraphicPlacement.SourceY", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":null,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field source_y accepted null") + } + }) + t.Run("RenderGraphicPlacement.ViewportCol", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":null,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field viewport_col accepted null") + } + }) + t.Run("RenderGraphicPlacement.ViewportRow", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":null,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field viewport_row accepted null") + } + }) + t.Run("RenderGraphicPlacement.ViewportVisible", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":null,\"x_offset\":1,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field viewport_visible accepted null") + } + }) + t.Run("RenderGraphicPlacement.XOffset", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":null,\"y_offset\":1,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field x_offset accepted null") + } + }) + t.Run("RenderGraphicPlacement.YOffset", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":null,\"z\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field y_offset accepted null") + } + }) + t.Run("RenderGraphicPlacement.Z", func(t *testing.T) { + var decoded RenderGraphicPlacement + if err := json.Unmarshal([]byte("{\"columns\":1,\"grid_cols\":1,\"grid_rows\":1,\"image_id\":1,\"ordinal\":1,\"pixel_height\":1,\"pixel_width\":1,\"placement_id\":1,\"rows\":1,\"source_height\":1,\"source_width\":1,\"source_x\":1,\"source_y\":1,\"viewport_col\":1,\"viewport_row\":1,\"viewport_visible\":true,\"x_offset\":1,\"y_offset\":1,\"z\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field z accepted null") + } + }) + t.Run("RenderGraphics.Generation", func(t *testing.T) { + var decoded RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":null,\"placements\":[]}"), &decoded); err == nil { + t.Fatal("required non-nullable field generation accepted null") + } + }) + t.Run("RenderGraphics.Placements", func(t *testing.T) { + var decoded RenderGraphics + if err := json.Unmarshal([]byte("{\"generation\":1,\"placements\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field placements accepted null") + } + }) + t.Run("RenderGraphicsDelta.Generation", func(t *testing.T) { + var decoded RenderGraphicsDelta + if err := json.Unmarshal([]byte("{\"generation\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field generation accepted null") + } + }) t.Run("RenderRow.Row", func(t *testing.T) { var decoded RenderRow if err := json.Unmarshal([]byte("{\"row\":null,\"runs\":[]}"), &decoded); err == nil { @@ -15707,6 +16777,12 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { t.Fatal("required non-nullable field subject_key accepted null") } }) + t.Run("GraphicsStatusEvent.Kind", func(t *testing.T) { + var decoded GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":null}"), &decoded); err == nil { + t.Fatal("required non-nullable field kind accepted null") + } + }) t.Run("LayoutChangedEvent.Screen", func(t *testing.T) { var decoded LayoutChangedEvent if err := json.Unmarshal([]byte("{\"screen\":null}"), &decoded); err == nil { @@ -15871,43 +16947,49 @@ func TestGeneratedRequiredNonnullableFieldsRejectNull(t *testing.T) { }) t.Run("RenderStateEvent.Cursor", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":null,\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":null,\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field cursor accepted null") } }) t.Run("RenderStateEvent.DefaultBg", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":null,\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":null,\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field default_bg accepted null") } }) t.Run("RenderStateEvent.DefaultFg", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":null,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":null,\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field default_fg accepted null") } }) + t.Run("RenderStateEvent.HistoryEpoch", func(t *testing.T) { + var decoded RenderStateEvent + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":null,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + t.Fatal("required non-nullable field history_epoch accepted null") + } + }) t.Run("RenderStateEvent.Rows", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":null,\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":null,\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field rows accepted null") } }) t.Run("RenderStateEvent.ScrollbackRows", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":null,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":null,\"size\":{\"cols\":1,\"rows\":1},\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field scrollback_rows accepted null") } }) t.Run("RenderStateEvent.Size", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":null,\"surface\":1}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":null,\"surface\":1}"), &decoded); err == nil { t.Fatal("required non-nullable field size accepted null") } }) t.Run("RenderStateEvent.Surface", func(t *testing.T) { var decoded RenderStateEvent - if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":null}"), &decoded); err == nil { + if err := json.Unmarshal([]byte("{\"cursor\":{\"blink\":true,\"color\":null,\"style\":\"block\",\"visible\":true,\"x\":1,\"y\":1},\"default_bg\":\"value\",\"default_fg\":\"value\",\"history_epoch\":1,\"rows\":[],\"scrollback_rows\":1,\"size\":{\"cols\":1,\"rows\":1},\"surface\":null}"), &decoded); err == nil { t.Fatal("required non-nullable field surface accepted null") } }) @@ -16526,6 +17608,12 @@ func TestGeneratedConstrainedFieldsRejectUnknownValues(t *testing.T) { t.Fatal("invalid constrained field style decoded successfully") } }) + t.Run("RenderGraphicImage.Format", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"__cmux_invalid__\",\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err == nil { + t.Fatal("invalid constrained field format decoded successfully") + } + }) t.Run("RenderRun.Underline", func(t *testing.T) { var decoded RenderRun if err := json.Unmarshal([]byte("{\"attrs\":1,\"bg\":null,\"fg\":null,\"text\":\"value\",\"underline\":\"__cmux_invalid__\"}"), &decoded); err == nil { @@ -16778,6 +17866,12 @@ func TestGeneratedConstrainedFieldsRejectUnknownValues(t *testing.T) { t.Fatal("invalid constrained field cursor_style decoded successfully") } }) + t.Run("GraphicsStatusEvent.Kind", func(t *testing.T) { + var decoded GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"__cmux_invalid__\"}"), &decoded); err == nil { + t.Fatal("invalid constrained field kind decoded successfully") + } + }) t.Run("NotificationEvent.Level", func(t *testing.T) { var decoded NotificationEvent if err := json.Unmarshal([]byte("{\"body\":\"value\",\"level\":\"__cmux_invalid__\",\"notification\":1,\"surface\":null,\"title\":\"value\"}"), &decoded); err == nil { @@ -17050,6 +18144,16 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { t.Fatal("invalid constrained field style encoded successfully") } }) + t.Run("RenderGraphicImage.Format", func(t *testing.T) { + var decoded RenderGraphicImage + if err := json.Unmarshal([]byte("{\"data\":\"value\",\"format\":\"rgb\",\"generation\":1,\"height\":1,\"id\":1,\"width\":1}"), &decoded); err != nil { + t.Fatal(err) + } + decoded.Format = RenderGraphicFormat("__cmux_invalid__") + if _, err := json.Marshal(decoded); err == nil { + t.Fatal("invalid constrained field format encoded successfully") + } + }) t.Run("RenderRun.Underline", func(t *testing.T) { var decoded RenderRun if err := json.Unmarshal([]byte("{\"attrs\":1,\"bg\":null,\"fg\":null,\"text\":\"value\"}"), &decoded); err != nil { @@ -17471,6 +18575,16 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { t.Fatal("invalid constrained field cursor_style encoded successfully") } }) + t.Run("GraphicsStatusEvent.Kind", func(t *testing.T) { + var decoded GraphicsStatusEvent + if err := json.Unmarshal([]byte("{\"kind\":\"kitty-image-budget-worker-start-failed\"}"), &decoded); err != nil { + t.Fatal(err) + } + decoded.Kind = GraphicsStatusEventKind("__cmux_invalid__") + if _, err := json.Marshal(decoded); err == nil { + t.Fatal("invalid constrained field kind encoded successfully") + } + }) t.Run("NotificationEvent.Level", func(t *testing.T) { var decoded NotificationEvent if err := json.Unmarshal([]byte("{\"body\":\"value\",\"level\":\"info\",\"notification\":1,\"surface\":null,\"title\":\"value\"}"), &decoded); err != nil { @@ -17505,12 +18619,12 @@ func TestGeneratedConstrainedFieldsRejectUnknownValuesOnMarshal(t *testing.T) { } const ( - generatedFieldShapeCount = 408 + generatedFieldShapeCount = 431 generatedOptionalNullableFieldCount = 287 generatedRequiredNullableFieldCount = 61 - generatedOptionalNonnullableFieldCount = 60 - generatedRequiredFieldCount = 547 - generatedRequiredNonnullableFieldCount = 486 - generatedConstrainedFieldCount = 70 - generatedEncodedConstrainedFieldCount = 70 + generatedOptionalNonnullableFieldCount = 83 + generatedRequiredFieldCount = 595 + generatedRequiredNonnullableFieldCount = 534 + generatedConstrainedFieldCount = 72 + generatedEncodedConstrainedFieldCount = 72 ) diff --git a/cmux-tui/bindings/go/raw/generated_types.go b/cmux-tui/bindings/go/raw/generated_types.go index 0fb79c164a..7ece75f6be 100644 --- a/cmux-tui/bindings/go/raw/generated_types.go +++ b/cmux-tui/bindings/go/raw/generated_types.go @@ -1,5 +1,5 @@ // Code generated by cmux-tui SDK codegen. DO NOT EDIT. -// Mux protocol 10; IR SHA-256 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// Mux protocol 10; IR SHA-256 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. package raw @@ -497,6 +497,42 @@ func (value *CellPixelResize) UnmarshalJSON(data []byte) error { return nil } +type CellPixelSurface struct { + HeightPx uint16 `json:"height_px"` + Surface ID `json:"surface"` + WidthPx uint16 `json:"width_px"` +} + +func (value *CellPixelSurface) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode CellPixelSurface: expected object") + } + var fields struct { + HeightPx *uint16 `json:"height_px"` + Surface *ID `json:"surface"` + WidthPx *uint16 `json:"width_px"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode CellPixelSurface: %w", err) + } + type wire CellPixelSurface + var decoded wire + if fields.HeightPx == nil { + return fmt.Errorf("decode CellPixelSurface: required field height_px is missing or null") + } + decoded.HeightPx = *fields.HeightPx + if fields.Surface == nil { + return fmt.Errorf("decode CellPixelSurface: required field surface is missing or null") + } + decoded.Surface = *fields.Surface + if fields.WidthPx == nil { + return fmt.Errorf("decode CellPixelSurface: required field width_px is missing or null") + } + decoded.WidthPx = *fields.WidthPx + *value = CellPixelSurface(decoded) + return nil +} + type ClientInfo struct { Attached []ID `json:"attached"` Client uint64 `json:"client"` @@ -1589,6 +1625,42 @@ func (value *FrontendProjection) UnmarshalJSON(data []byte) error { return nil } +type GetCellPixelsResult struct { + HeightPx uint16 `json:"height_px"` + Surfaces []CellPixelSurface `json:"surfaces"` + WidthPx uint16 `json:"width_px"` +} + +func (value *GetCellPixelsResult) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode GetCellPixelsResult: expected object") + } + var fields struct { + HeightPx *uint16 `json:"height_px"` + Surfaces *[]CellPixelSurface `json:"surfaces"` + WidthPx *uint16 `json:"width_px"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode GetCellPixelsResult: %w", err) + } + type wire GetCellPixelsResult + var decoded wire + if fields.HeightPx == nil { + return fmt.Errorf("decode GetCellPixelsResult: required field height_px is missing or null") + } + decoded.HeightPx = *fields.HeightPx + if fields.Surfaces == nil { + return fmt.Errorf("decode GetCellPixelsResult: required field surfaces is missing or null") + } + decoded.Surfaces = *fields.Surfaces + if fields.WidthPx == nil { + return fmt.Errorf("decode GetCellPixelsResult: required field width_px is missing or null") + } + decoded.WidthPx = *fields.WidthPx + *value = GetCellPixelsResult(decoded) + return nil +} + type ID = uint64 type IDMappingKind string @@ -1925,6 +1997,108 @@ func (value *IDsResult) UnmarshalJSON(data []byte) error { type JSONValue = json.RawMessage +type KittyGraphicsState struct { + AlternateNextImageID uint32 `json:"alternate_next_image_id"` + AlternateReplayNextImageID uint32 `json:"alternate_replay_next_image_id"` + ImageBytes uint64 `json:"image_bytes"` + Images uint64 `json:"images"` + InflightBytes uint64 `json:"inflight_bytes"` + Placements uint64 `json:"placements"` + PrimaryNextImageID uint32 `json:"primary_next_image_id"` + PrimaryReplayNextImageID uint32 `json:"primary_replay_next_image_id"` + ReplayCursorOffset uint32 `json:"replay_cursor_offset"` +} + +func (value *KittyGraphicsState) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode KittyGraphicsState: expected object") + } + var fields struct { + AlternateNextImageID *uint32 `json:"alternate_next_image_id"` + AlternateReplayNextImageID *uint32 `json:"alternate_replay_next_image_id"` + ImageBytes *uint64 `json:"image_bytes"` + Images *uint64 `json:"images"` + InflightBytes *uint64 `json:"inflight_bytes"` + Placements *uint64 `json:"placements"` + PrimaryNextImageID *uint32 `json:"primary_next_image_id"` + PrimaryReplayNextImageID *uint32 `json:"primary_replay_next_image_id"` + ReplayCursorOffset *uint32 `json:"replay_cursor_offset"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode KittyGraphicsState: %w", err) + } + type wire KittyGraphicsState + var decoded wire + if fields.AlternateNextImageID == nil { + return fmt.Errorf("decode KittyGraphicsState: required field alternate_next_image_id is missing or null") + } + decoded.AlternateNextImageID = *fields.AlternateNextImageID + if fields.AlternateReplayNextImageID == nil { + return fmt.Errorf("decode KittyGraphicsState: required field alternate_replay_next_image_id is missing or null") + } + decoded.AlternateReplayNextImageID = *fields.AlternateReplayNextImageID + if fields.ImageBytes == nil { + return fmt.Errorf("decode KittyGraphicsState: required field image_bytes is missing or null") + } + decoded.ImageBytes = *fields.ImageBytes + if fields.Images == nil { + return fmt.Errorf("decode KittyGraphicsState: required field images is missing or null") + } + decoded.Images = *fields.Images + if fields.InflightBytes == nil { + return fmt.Errorf("decode KittyGraphicsState: required field inflight_bytes is missing or null") + } + decoded.InflightBytes = *fields.InflightBytes + if fields.Placements == nil { + return fmt.Errorf("decode KittyGraphicsState: required field placements is missing or null") + } + decoded.Placements = *fields.Placements + if fields.PrimaryNextImageID == nil { + return fmt.Errorf("decode KittyGraphicsState: required field primary_next_image_id is missing or null") + } + decoded.PrimaryNextImageID = *fields.PrimaryNextImageID + if fields.PrimaryReplayNextImageID == nil { + return fmt.Errorf("decode KittyGraphicsState: required field primary_replay_next_image_id is missing or null") + } + decoded.PrimaryReplayNextImageID = *fields.PrimaryReplayNextImageID + if fields.ReplayCursorOffset == nil { + return fmt.Errorf("decode KittyGraphicsState: required field replay_cursor_offset is missing or null") + } + decoded.ReplayCursorOffset = *fields.ReplayCursorOffset + *value = KittyGraphicsState(decoded) + return nil +} + +type KittyImageAlias struct { + ImageID uint32 `json:"image_id"` + ImageNumber uint32 `json:"image_number"` +} + +func (value *KittyImageAlias) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode KittyImageAlias: expected object") + } + var fields struct { + ImageID *uint32 `json:"image_id"` + ImageNumber *uint32 `json:"image_number"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode KittyImageAlias: %w", err) + } + type wire KittyImageAlias + var decoded wire + if fields.ImageID == nil { + return fmt.Errorf("decode KittyImageAlias: required field image_id is missing or null") + } + decoded.ImageID = *fields.ImageID + if fields.ImageNumber == nil { + return fmt.Errorf("decode KittyImageAlias: required field image_number is missing or null") + } + decoded.ImageNumber = *fields.ImageNumber + *value = KittyImageAlias(decoded) + return nil +} + type LayoutLeafType string const ( @@ -3343,6 +3517,7 @@ func (value *ReadScreenResult) UnmarshalJSON(data []byte) error { } type ReadScrollbackResult struct { + Epoch uint64 `json:"epoch"` Rows []RenderRow `json:"rows"` Start uint32 `json:"start"` Total uint32 `json:"total"` @@ -3353,6 +3528,7 @@ func (value *ReadScrollbackResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode ReadScrollbackResult: expected object") } var fields struct { + Epoch *uint64 `json:"epoch"` Rows *[]RenderRow `json:"rows"` Start *uint32 `json:"start"` Total *uint32 `json:"total"` @@ -3362,6 +3538,10 @@ func (value *ReadScrollbackResult) UnmarshalJSON(data []byte) error { } type wire ReadScrollbackResult var decoded wire + if fields.Epoch == nil { + return fmt.Errorf("decode ReadScrollbackResult: required field epoch is missing or null") + } + decoded.Epoch = *fields.Epoch if fields.Rows == nil { return fmt.Errorf("decode ReadScrollbackResult: required field rows is missing or null") } @@ -3458,6 +3638,338 @@ func (value *RenderCursor) UnmarshalJSON(data []byte) error { return nil } +type RenderGraphicFormat string + +const ( + RenderGraphicFormatRGB RenderGraphicFormat = "rgb" + RenderGraphicFormatRgba RenderGraphicFormat = "rgba" +) + +func (value RenderGraphicFormat) valid() bool { + switch value { + case RenderGraphicFormatRGB, RenderGraphicFormatRgba: + return true + default: + return false + } +} + +func (value RenderGraphicFormat) MarshalJSON() ([]byte, error) { + if !value.valid() { + return nil, fmt.Errorf("%s has invalid value %v", "RenderGraphicFormat", value) + } + return json.Marshal(string(value)) +} + +func (value *RenderGraphicFormat) UnmarshalJSON(data []byte) error { + var decoded string + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + candidate := RenderGraphicFormat(decoded) + if !candidate.valid() { + return fmt.Errorf("%s has invalid value %v", "RenderGraphicFormat", decoded) + } + *value = candidate + return nil +} + +type RenderGraphicImage struct { + Data Base64 `json:"data"` + Format RenderGraphicFormat `json:"format"` + Generation uint64 `json:"generation"` + Height uint32 `json:"height"` + ID uint32 `json:"id"` + Width uint32 `json:"width"` +} + +func (value *RenderGraphicImage) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode RenderGraphicImage: expected object") + } + var fields struct { + Data *Base64 `json:"data"` + Format *RenderGraphicFormat `json:"format"` + Generation *uint64 `json:"generation"` + Height *uint32 `json:"height"` + ID *uint32 `json:"id"` + Width *uint32 `json:"width"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode RenderGraphicImage: %w", err) + } + type wire RenderGraphicImage + var decoded wire + if fields.Data == nil { + return fmt.Errorf("decode RenderGraphicImage: required field data is missing or null") + } + decoded.Data = *fields.Data + if fields.Format == nil { + return fmt.Errorf("decode RenderGraphicImage: required field format is missing or null") + } + decoded.Format = *fields.Format + if fields.Generation == nil { + return fmt.Errorf("decode RenderGraphicImage: required field generation is missing or null") + } + decoded.Generation = *fields.Generation + if fields.Height == nil { + return fmt.Errorf("decode RenderGraphicImage: required field height is missing or null") + } + decoded.Height = *fields.Height + if fields.ID == nil { + return fmt.Errorf("decode RenderGraphicImage: required field id is missing or null") + } + decoded.ID = *fields.ID + if fields.Width == nil { + return fmt.Errorf("decode RenderGraphicImage: required field width is missing or null") + } + decoded.Width = *fields.Width + *value = RenderGraphicImage(decoded) + return nil +} + +type RenderGraphicPlacement struct { + AnchorCol *uint16 `json:"anchor_col,omitempty"` + AnchorRow *uint32 `json:"anchor_row,omitempty"` + Columns uint32 `json:"columns"` + GridCols uint32 `json:"grid_cols"` + GridRows uint32 `json:"grid_rows"` + ImageID uint32 `json:"image_id"` + Ordinal uint32 `json:"ordinal"` + PixelHeight uint32 `json:"pixel_height"` + PixelWidth uint32 `json:"pixel_width"` + PlacementID uint32 `json:"placement_id"` + Rows uint32 `json:"rows"` + SourceHeight uint32 `json:"source_height"` + SourceWidth uint32 `json:"source_width"` + SourceX uint32 `json:"source_x"` + SourceY uint32 `json:"source_y"` + ViewportCol int32 `json:"viewport_col"` + ViewportRow int32 `json:"viewport_row"` + ViewportVisible bool `json:"viewport_visible"` + XOffset uint32 `json:"x_offset"` + YOffset uint32 `json:"y_offset"` + Z int32 `json:"z"` +} + +func (value *RenderGraphicPlacement) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode RenderGraphicPlacement: expected object") + } + var fields struct { + AnchorCol optionalNonNullJSON[uint16] `json:"anchor_col"` + AnchorRow optionalNonNullJSON[uint32] `json:"anchor_row"` + Columns *uint32 `json:"columns"` + GridCols *uint32 `json:"grid_cols"` + GridRows *uint32 `json:"grid_rows"` + ImageID *uint32 `json:"image_id"` + Ordinal *uint32 `json:"ordinal"` + PixelHeight *uint32 `json:"pixel_height"` + PixelWidth *uint32 `json:"pixel_width"` + PlacementID *uint32 `json:"placement_id"` + Rows *uint32 `json:"rows"` + SourceHeight *uint32 `json:"source_height"` + SourceWidth *uint32 `json:"source_width"` + SourceX *uint32 `json:"source_x"` + SourceY *uint32 `json:"source_y"` + ViewportCol *int32 `json:"viewport_col"` + ViewportRow *int32 `json:"viewport_row"` + ViewportVisible *bool `json:"viewport_visible"` + XOffset *uint32 `json:"x_offset"` + YOffset *uint32 `json:"y_offset"` + Z *int32 `json:"z"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode RenderGraphicPlacement: %w", err) + } + type wire RenderGraphicPlacement + var decoded wire + if fields.AnchorCol.null { + return fmt.Errorf("decode RenderGraphicPlacement: non-nullable field anchor_col is null") + } + if fields.AnchorCol.set { + decoded.AnchorCol = &fields.AnchorCol.value + } + if fields.AnchorRow.null { + return fmt.Errorf("decode RenderGraphicPlacement: non-nullable field anchor_row is null") + } + if fields.AnchorRow.set { + decoded.AnchorRow = &fields.AnchorRow.value + } + if fields.Columns == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field columns is missing or null") + } + decoded.Columns = *fields.Columns + if fields.GridCols == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field grid_cols is missing or null") + } + decoded.GridCols = *fields.GridCols + if fields.GridRows == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field grid_rows is missing or null") + } + decoded.GridRows = *fields.GridRows + if fields.ImageID == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field image_id is missing or null") + } + decoded.ImageID = *fields.ImageID + if fields.Ordinal == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field ordinal is missing or null") + } + decoded.Ordinal = *fields.Ordinal + if fields.PixelHeight == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field pixel_height is missing or null") + } + decoded.PixelHeight = *fields.PixelHeight + if fields.PixelWidth == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field pixel_width is missing or null") + } + decoded.PixelWidth = *fields.PixelWidth + if fields.PlacementID == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field placement_id is missing or null") + } + decoded.PlacementID = *fields.PlacementID + if fields.Rows == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field rows is missing or null") + } + decoded.Rows = *fields.Rows + if fields.SourceHeight == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field source_height is missing or null") + } + decoded.SourceHeight = *fields.SourceHeight + if fields.SourceWidth == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field source_width is missing or null") + } + decoded.SourceWidth = *fields.SourceWidth + if fields.SourceX == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field source_x is missing or null") + } + decoded.SourceX = *fields.SourceX + if fields.SourceY == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field source_y is missing or null") + } + decoded.SourceY = *fields.SourceY + if fields.ViewportCol == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field viewport_col is missing or null") + } + decoded.ViewportCol = *fields.ViewportCol + if fields.ViewportRow == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field viewport_row is missing or null") + } + decoded.ViewportRow = *fields.ViewportRow + if fields.ViewportVisible == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field viewport_visible is missing or null") + } + decoded.ViewportVisible = *fields.ViewportVisible + if fields.XOffset == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field x_offset is missing or null") + } + decoded.XOffset = *fields.XOffset + if fields.YOffset == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field y_offset is missing or null") + } + decoded.YOffset = *fields.YOffset + if fields.Z == nil { + return fmt.Errorf("decode RenderGraphicPlacement: required field z is missing or null") + } + decoded.Z = *fields.Z + *value = RenderGraphicPlacement(decoded) + return nil +} + +type RenderGraphics struct { + Generation uint64 `json:"generation"` + Images *[]RenderGraphicImage `json:"images,omitempty"` + Placements []RenderGraphicPlacement `json:"placements"` + RemovedImageIDs *[]uint32 `json:"removed_image_ids,omitempty"` +} + +func (value *RenderGraphics) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode RenderGraphics: expected object") + } + var fields struct { + Generation *uint64 `json:"generation"` + Images optionalNonNullJSON[[]RenderGraphicImage] `json:"images"` + Placements *[]RenderGraphicPlacement `json:"placements"` + RemovedImageIDs optionalNonNullJSON[[]uint32] `json:"removed_image_ids"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode RenderGraphics: %w", err) + } + type wire RenderGraphics + var decoded wire + if fields.Generation == nil { + return fmt.Errorf("decode RenderGraphics: required field generation is missing or null") + } + decoded.Generation = *fields.Generation + if fields.Images.null { + return fmt.Errorf("decode RenderGraphics: non-nullable field images is null") + } + if fields.Images.set { + decoded.Images = &fields.Images.value + } + if fields.Placements == nil { + return fmt.Errorf("decode RenderGraphics: required field placements is missing or null") + } + decoded.Placements = *fields.Placements + if fields.RemovedImageIDs.null { + return fmt.Errorf("decode RenderGraphics: non-nullable field removed_image_ids is null") + } + if fields.RemovedImageIDs.set { + decoded.RemovedImageIDs = &fields.RemovedImageIDs.value + } + *value = RenderGraphics(decoded) + return nil +} + +type RenderGraphicsDelta struct { + Generation uint64 `json:"generation"` + Images *[]RenderGraphicImage `json:"images,omitempty"` + Placements *[]RenderGraphicPlacement `json:"placements,omitempty"` + RemovedImageIDs *[]uint32 `json:"removed_image_ids,omitempty"` +} + +func (value *RenderGraphicsDelta) UnmarshalJSON(data []byte) error { + if !isJSONObject(data) { + return fmt.Errorf("decode RenderGraphicsDelta: expected object") + } + var fields struct { + Generation *uint64 `json:"generation"` + Images optionalNonNullJSON[[]RenderGraphicImage] `json:"images"` + Placements optionalNonNullJSON[[]RenderGraphicPlacement] `json:"placements"` + RemovedImageIDs optionalNonNullJSON[[]uint32] `json:"removed_image_ids"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return fmt.Errorf("decode RenderGraphicsDelta: %w", err) + } + type wire RenderGraphicsDelta + var decoded wire + if fields.Generation == nil { + return fmt.Errorf("decode RenderGraphicsDelta: required field generation is missing or null") + } + decoded.Generation = *fields.Generation + if fields.Images.null { + return fmt.Errorf("decode RenderGraphicsDelta: non-nullable field images is null") + } + if fields.Images.set { + decoded.Images = &fields.Images.value + } + if fields.Placements.null { + return fmt.Errorf("decode RenderGraphicsDelta: non-nullable field placements is null") + } + if fields.Placements.set { + decoded.Placements = &fields.Placements.value + } + if fields.RemovedImageIDs.null { + return fmt.Errorf("decode RenderGraphicsDelta: non-nullable field removed_image_ids is null") + } + if fields.RemovedImageIDs.set { + decoded.RemovedImageIDs = &fields.RemovedImageIDs.value + } + *value = RenderGraphicsDelta(decoded) + return nil +} + type RenderRow struct { Row uint32 `json:"row"` Runs []RenderRun `json:"runs"` @@ -5756,9 +6268,11 @@ func (value *Tree) UnmarshalJSON(data []byte) error { } type VTStateResult struct { - Cols uint16 `json:"cols"` - Data Base64 `json:"data"` - Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` + Data Base64 `json:"data"` + KittyGraphicsState *KittyGraphicsState `json:"kitty_graphics_state,omitempty"` + KittyImageAliases *[]KittyImageAlias `json:"kitty_image_aliases,omitempty"` + Rows uint16 `json:"rows"` } func (value *VTStateResult) UnmarshalJSON(data []byte) error { @@ -5766,9 +6280,11 @@ func (value *VTStateResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode VTStateResult: expected object") } var fields struct { - Cols *uint16 `json:"cols"` - Data *Base64 `json:"data"` - Rows *uint16 `json:"rows"` + Cols *uint16 `json:"cols"` + Data *Base64 `json:"data"` + KittyGraphicsState optionalNonNullJSON[KittyGraphicsState] `json:"kitty_graphics_state"` + KittyImageAliases optionalNonNullJSON[[]KittyImageAlias] `json:"kitty_image_aliases"` + Rows *uint16 `json:"rows"` } if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("decode VTStateResult: %w", err) @@ -5783,6 +6299,18 @@ func (value *VTStateResult) UnmarshalJSON(data []byte) error { return fmt.Errorf("decode VTStateResult: required field data is missing or null") } decoded.Data = *fields.Data + if fields.KittyGraphicsState.null { + return fmt.Errorf("decode VTStateResult: non-nullable field kitty_graphics_state is null") + } + if fields.KittyGraphicsState.set { + decoded.KittyGraphicsState = &fields.KittyGraphicsState.value + } + if fields.KittyImageAliases.null { + return fmt.Errorf("decode VTStateResult: non-nullable field kitty_image_aliases is null") + } + if fields.KittyImageAliases.set { + decoded.KittyImageAliases = &fields.KittyImageAliases.value + } if fields.Rows == nil { return fmt.Errorf("decode VTStateResult: required field rows is missing or null") } diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json index c315b4de71..0b2bacfd84 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json @@ -170,6 +170,11 @@ "sha256": "154b12d0dfa1f306a9bcaf0e40d98ca2a9986ad4091e1ef5b86779919f43df21", "size": 3964 }, + { + "path": "CellPixelSurface.java", + "sha256": "1c8c5bfa39d949770a5a6ceafd4e95b3bc383ea59b6d05319019e742c044b828", + "size": 3300 + }, { "path": "ClearHistoryRequest.java", "sha256": "ffc80cfcdf28e7c70a8caf1416776000f2dcb237635c9d163101d65868dd9c8f", @@ -267,8 +272,8 @@ }, { "path": "Commands.java", - "sha256": "668e659ba7dc3e14d484019a6b09853ecfafc10834e622f554b49f47064c2899", - "size": 21774 + "sha256": "de0a2a553db5edba17b4b28c472059e9403dd407b4ea0d93ca3e2d59114f5ce2", + "size": 21994 }, { "path": "ConfigReloadRequestedEvent.java", @@ -367,8 +372,8 @@ }, { "path": "Events.java", - "sha256": "2882c85325083df5043f7421143c35ef6f749d6b2ba108cbdbe104dcea8ea649", - "size": 8671 + "sha256": "ea4d3a5388d173e2a5a132e6e44deda398f091f867c4aec4481c8719cdde80af", + "size": 8859 }, { "path": "ExportLayoutRequest.java", @@ -417,14 +422,34 @@ }, { "path": "GeneratedCmuxClient.java", - "sha256": "ccdfa230076d713f61870a015464ac4612019cd70b71ca679d05f3bf74814393", - "size": 21121 + "sha256": "7fca834f19533a0fb525d2bbac09930f5aad1a018035d60035b8ce170a1d9664", + "size": 21326 + }, + { + "path": "GetCellPixelsRequest.java", + "sha256": "bdd5e6d52e1c900f523cc688ff381e91f0566b5fc6a822ce824b84787257dc0c", + "size": 1369 + }, + { + "path": "GetCellPixelsResult.java", + "sha256": "d29e71cb2e5f6ada5e49e14496576397f67f5a202cb042fadd48555f19333fa1", + "size": 3473 }, { "path": "GetFrontendProjectionRequest.java", "sha256": "f5ed502bdb2612a465c16ff64056d8f9b4f17bc4b093c02718fdc605d7a1b8b9", "size": 3602 }, + { + "path": "GraphicsStatusEvent.java", + "sha256": "a53bd8d72dd6c0b4b618d1c9bc43aa15840ee6b18daa65de41221b8e7422a506", + "size": 6890 + }, + { + "path": "GraphicsStatusEventKind.java", + "sha256": "47503857ebc1b24813505e9f47dad13f23477bac6a38c90887be3bf3dd70f52c", + "size": 1135 + }, { "path": "IdMapping.java", "sha256": "e35ddb332efca399d079c362172f47ba1053f917077d86c3ef62652c254b2403", @@ -460,6 +485,16 @@ "sha256": "e7d564deb5069396421c91febce6d1ac41475c2440efc3f21e58926241f6f625", "size": 1873 }, + { + "path": "KittyGraphicsState.java", + "sha256": "0e848438699ec59e5e350eb366300d0013ebc245d8d68adfb57f5b36fe092e80", + "size": 9264 + }, + { + "path": "KittyImageAlias.java", + "sha256": "24f3178aefaad9b9bc8bb7c0116c5bda9b8e52fc9a873060f9e8a1b1f0167d4f", + "size": 2661 + }, { "path": "Layout.java", "sha256": "648d2528387849a64a3827238cabdaa776471db4bd346d8d1ef54fd80b56be26", @@ -702,8 +737,8 @@ }, { "path": "Protocol.java", - "sha256": "f9b6177545a0721b7c47ca110ba9a42d79c787472c26bffa6486e8c3f24ad9ad", - "size": 3892 + "sha256": "fa738484567eb60411b02de02f126c89473a8a9ab7d1bce1a10b9b8d4a1b7423", + "size": 3967 }, { "path": "ProtocolEvent.java", @@ -742,8 +777,8 @@ }, { "path": "ReadScrollbackResult.java", - "sha256": "0d14e6942c5086a0c42367870cde40fe6a1dbd9941f79b361907729c63f127be", - "size": 3239 + "sha256": "a0cb7c51adc7de08e1e8ad0fd4f42be2ecc8e6afbe738205af9253a190a6c5b5", + "size": 3892 }, { "path": "ReleaseSurfaceSizeRequest.java", @@ -797,8 +832,33 @@ }, { "path": "RenderDeltaEvent.java", - "sha256": "da32ee758213d771b1dff5499ba5d2705d23745b46050271628daaa95983a607", - "size": 6832 + "sha256": "891813611f010ed7701f8121921010732d71be3b6ddc41e17f7468cc01e34960", + "size": 8213 + }, + { + "path": "RenderGraphicFormat.java", + "sha256": "2a9287077f3ac37fb0eeea11551193c560976d79e2511f43871fef4cbe4018db", + "size": 909 + }, + { + "path": "RenderGraphicImage.java", + "sha256": "d2d3211bfdd32b677e9f73aa9c6b9efdcbf72389a85b6da6e35a714395e356a5", + "size": 5186 + }, + { + "path": "RenderGraphicPlacement.java", + "sha256": "1d5f8f7f12e9929a8c4918aa5b45ead129aa74e7caca707b51038ae417d64b81", + "size": 16010 + }, + { + "path": "RenderGraphics.java", + "sha256": "1abf863d205ff1a359a7f65b83c51e9cb711067d98f445765d4de774d01e3be2", + "size": 4528 + }, + { + "path": "RenderGraphicsDelta.java", + "sha256": "f4b51dce1b732e072e901a0ed0559f2be276eb17b0ebdf6b747871b6aeec7f93", + "size": 4518 }, { "path": "RenderRow.java", @@ -812,8 +872,8 @@ }, { "path": "RenderStateEvent.java", - "sha256": "b53dea205aea0d62cfae1853c87c8ab7cfd98f8b94d7401789cdefb51e5570f5", - "size": 6563 + "sha256": "01c4a4e5fc08242bef40fd80de2ae2fcd77d14d8c9bd994c7584bb936294f4d6", + "size": 8024 }, { "path": "RenderUnderline.java", @@ -842,8 +902,8 @@ }, { "path": "ResizedEvent.java", - "sha256": "718ad6b4b0c97ae4a2f9e32bd36d1716c185e7844a7b269b31ab0412a0942c1a", - "size": 5172 + "sha256": "c47b105665396450dcc1372287890933d9d8d087cbca29028a79214b5b8c34ba", + "size": 6958 }, { "path": "ResolveTerminalRequest.java", @@ -1177,8 +1237,8 @@ }, { "path": "VtStateEvent.java", - "sha256": "2870f38755bb8bc0222f7960e260513130b926161379acb2f1a43c5c49b5d380", - "size": 4620 + "sha256": "32984fa6573b5d89371f1c5de476773ad2258c265156b69ecaabf8946bd78d54", + "size": 6406 }, { "path": "VtStateRequest.java", @@ -1187,8 +1247,8 @@ }, { "path": "VtStateResult.java", - "sha256": "cc15f7f7c05155a2b424994690b33feca62024e3613e5006a7b0b179cd4fcf05", - "size": 3031 + "sha256": "27507e2d0f1e9273bcdc7264a7f6ab529204ee04ab7df1aa0e3255d62c4a5951", + "size": 4818 }, { "path": "WaitForRequest.java", @@ -1252,7 +1312,7 @@ } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "java", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/CellPixelSurface.java b/cmux-tui/bindings/java/src/com/cmux/raw/CellPixelSurface.java new file mode 100644 index 0000000000..61358b7b01 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/CellPixelSurface.java @@ -0,0 +1,91 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class CellPixelSurface implements WireValue { + private final int heightPx; + private final UInt64 surface; + private final int widthPx; + + private CellPixelSurface(Builder builder) { + if (!builder.heightPxSet) throw new IllegalArgumentException("height_px is required"); + this.heightPx = builder.heightPx; + if (!builder.surfaceSet) throw new IllegalArgumentException("surface is required"); + this.surface = Wire.nonNull(builder.surface, "surface"); + if (!builder.widthPxSet) throw new IllegalArgumentException("width_px is required"); + this.widthPx = builder.widthPx; + } + + public static Builder builder() { return new Builder(); } + + public int heightPx() { return heightPx; } + public UInt64 surface() { return surface; } + public int widthPx() { return widthPx; } + + public static CellPixelSurface fromWire(Object value) { + Map object = Wire.object(value, "CellPixelSurface"); + Builder builder = builder(); + Object rawHeightPx = Wire.required(object, "height_px"); + builder.heightPx(Wire.uint16(rawHeightPx, "CellPixelSurface.height_px")); + Object rawSurface = Wire.required(object, "surface"); + builder.surface(Wire.uint64(rawSurface, "CellPixelSurface.surface")); + Object rawWidthPx = Wire.required(object, "width_px"); + builder.widthPx(Wire.uint16(rawWidthPx, "CellPixelSurface.width_px")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "height_px", heightPx); + Wire.put(object, "surface", surface); + Wire.put(object, "width_px", widthPx); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof CellPixelSurface that)) return false; + return Objects.equals(heightPx, that.heightPx) && Objects.equals(surface, that.surface) && Objects.equals(widthPx, that.widthPx); + } + + @Override + public int hashCode() { return Objects.hash(heightPx, surface, widthPx); } + + @Override + public String toString() { return "CellPixelSurface" + toWire(); } + + public static final class Builder { + private Integer heightPx; + private boolean heightPxSet; + private UInt64 surface; + private boolean surfaceSet; + private Integer widthPx; + private boolean widthPxSet; + + public Builder heightPx(int value) { + this.heightPx = value; + this.heightPxSet = true; + return this; + } + public Builder surface(UInt64 value) { + this.surface = value; + this.surfaceSet = true; + return this; + } + public Builder widthPx(int value) { + this.widthPx = value; + this.widthPxSet = true; + return this; + } + public CellPixelSurface build() { return new CellPixelSurface(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java b/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java index 8f11f31456..4a6ce85ca2 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Commands.java @@ -40,6 +40,7 @@ public final class Commands { public static final CommandMetadata EXPORT_LAYOUT = new CommandMetadata("export-layout", Authority.CONTROL, 6, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata FOCUS_DIRECTION = new CommandMetadata("focus-direction", Authority.CONTROL, 6, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata FOCUS_PANE = new CommandMetadata("focus-pane", Authority.CONTROL, 5, null, StreamKind.NONE, Map.of(), Map.of()); + public static final CommandMetadata GET_CELL_PIXELS = new CommandMetadata("get-cell-pixels", Authority.FRONTEND, 6, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata GET_FRONTEND_PROJECTION = new CommandMetadata("get-frontend-projection", Authority.CONTROL, 7, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata IDENTIFY = new CommandMetadata("identify", Authority.CONTROL, 5, null, StreamKind.NONE, Map.of(), Map.of()); public static final CommandMetadata IDS = new CommandMetadata("ids", Authority.CONTROL, 6, null, StreamKind.NONE, Map.of(), Map.of()); @@ -135,6 +136,7 @@ public final class Commands { values.put("export-layout", EXPORT_LAYOUT); values.put("focus-direction", FOCUS_DIRECTION); values.put("focus-pane", FOCUS_PANE); + values.put("get-cell-pixels", GET_CELL_PIXELS); values.put("get-frontend-projection", GET_FRONTEND_PROJECTION); values.put("identify", IDENTIFY); values.put("ids", IDS); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Events.java b/cmux-tui/bindings/java/src/com/cmux/raw/Events.java index aebc325720..b6572df627 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Events.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Events.java @@ -23,6 +23,7 @@ public final class Events { public static final EventMetadata EMPTY = new EventMetadata("empty", 5, null, List.of("subscribe"), true); public static final EventMetadata FRAME = new EventMetadata("frame", 6, null, List.of("attach-browser"), true); public static final EventMetadata FRONTEND_PROJECTION_CHANGED = new EventMetadata("frontend-projection-changed", 7, null, List.of("subscribe"), true); + public static final EventMetadata GRAPHICS_STATUS = new EventMetadata("graphics-status", 10, null, List.of("subscribe"), true); public static final EventMetadata LAYOUT_CHANGED = new EventMetadata("layout-changed", 6, null, List.of("subscribe"), true); public static final EventMetadata NOTIFICATION = new EventMetadata("notification", 6, null, List.of("subscribe", "attach-byte", "attach-browser"), true); public static final EventMetadata OUTPUT = new EventMetadata("output", 5, null, List.of("attach-byte"), true); @@ -71,6 +72,7 @@ public final class Events { values.put("empty", EMPTY); values.put("frame", FRAME); values.put("frontend-projection-changed", FRONTEND_PROJECTION_CHANGED); + values.put("graphics-status", GRAPHICS_STATUS); values.put("layout-changed", LAYOUT_CHANGED); values.put("notification", NOTIFICATION); values.put("output", OUTPUT); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java index 793effb4f1..4c19c11346 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GeneratedCmuxClient.java @@ -163,6 +163,11 @@ public abstract class GeneratedCmuxClient { return EmptyResult.fromWire(result); } + public final GetCellPixelsResult getCellPixels() throws CmuxException { + Object result = execute(Commands.GET_CELL_PIXELS, Map.of()); + return GetCellPixelsResult.fromWire(result); + } + public final FrontendProjection getFrontendProjection(GetFrontendProjectionRequest request) throws CmuxException { Object result = execute(Commands.GET_FRONTEND_PROJECTION, request.toWire()); return FrontendProjection.fromWire(result); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsRequest.java b/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsRequest.java new file mode 100644 index 0000000000..7043fb7e60 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsRequest.java @@ -0,0 +1,50 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +/** Immutable get-cell-pixels request. Protocol v6; authority: frontend. */ +public final class GetCellPixelsRequest implements WireValue { + + private GetCellPixelsRequest(Builder builder) { + } + + public static Builder builder() { return new Builder(); } + + + public static GetCellPixelsRequest fromWire(Object value) { + Map object = Wire.object(value, "GetCellPixelsRequest"); + Builder builder = builder(); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GetCellPixelsRequest that)) return false; + return true; + } + + @Override + public int hashCode() { return Objects.hash(); } + + @Override + public String toString() { return "GetCellPixelsRequest" + toWire(); } + + public static final class Builder { + + public GetCellPixelsRequest build() { return new GetCellPixelsRequest(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsResult.java b/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsResult.java new file mode 100644 index 0000000000..29ad4d4bdb --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GetCellPixelsResult.java @@ -0,0 +1,91 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class GetCellPixelsResult implements WireValue { + private final int heightPx; + private final List surfaces; + private final int widthPx; + + private GetCellPixelsResult(Builder builder) { + if (!builder.heightPxSet) throw new IllegalArgumentException("height_px is required"); + this.heightPx = builder.heightPx; + if (!builder.surfacesSet) throw new IllegalArgumentException("surfaces is required"); + this.surfaces = List.copyOf(Wire.nonNull(builder.surfaces, "surfaces")); + if (!builder.widthPxSet) throw new IllegalArgumentException("width_px is required"); + this.widthPx = builder.widthPx; + } + + public static Builder builder() { return new Builder(); } + + public int heightPx() { return heightPx; } + public List surfaces() { return surfaces; } + public int widthPx() { return widthPx; } + + public static GetCellPixelsResult fromWire(Object value) { + Map object = Wire.object(value, "GetCellPixelsResult"); + Builder builder = builder(); + Object rawHeightPx = Wire.required(object, "height_px"); + builder.heightPx(Wire.uint16(rawHeightPx, "GetCellPixelsResult.height_px")); + Object rawSurfaces = Wire.required(object, "surfaces"); + builder.surfaces(Wire.array(rawSurfaces, "GetCellPixelsResult.surfaces", item -> CellPixelSurface.fromWire(item))); + Object rawWidthPx = Wire.required(object, "width_px"); + builder.widthPx(Wire.uint16(rawWidthPx, "GetCellPixelsResult.width_px")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "height_px", heightPx); + Wire.put(object, "surfaces", surfaces); + Wire.put(object, "width_px", widthPx); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GetCellPixelsResult that)) return false; + return Objects.equals(heightPx, that.heightPx) && Objects.equals(surfaces, that.surfaces) && Objects.equals(widthPx, that.widthPx); + } + + @Override + public int hashCode() { return Objects.hash(heightPx, surfaces, widthPx); } + + @Override + public String toString() { return "GetCellPixelsResult" + toWire(); } + + public static final class Builder { + private Integer heightPx; + private boolean heightPxSet; + private List surfaces; + private boolean surfacesSet; + private Integer widthPx; + private boolean widthPxSet; + + public Builder heightPx(int value) { + this.heightPx = value; + this.heightPxSet = true; + return this; + } + public Builder surfaces(List value) { + this.surfaces = value; + this.surfacesSet = true; + return this; + } + public Builder widthPx(int value) { + this.widthPx = value; + this.widthPxSet = true; + return this; + } + public GetCellPixelsResult build() { return new GetCellPixelsResult(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEvent.java new file mode 100644 index 0000000000..e35ac66bae --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEvent.java @@ -0,0 +1,158 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +/** Immutable graphics-status event. Protocol v10; streams: subscribe. */ +public final class GraphicsStatusEvent implements WireValue, DeltaStreamEvent, ProtocolEvent, SubscribeEvent { + private final Field attempts; + private final Field cellHeight; + private final Field cellWidth; + private final Field error; + private final GraphicsStatusEventKind kind; + private final Field remaining; + private final Field retryExhausted; + private final Field summary; + + private GraphicsStatusEvent(Builder builder) { + this.attempts = builder.attempts; + this.cellHeight = builder.cellHeight; + this.cellWidth = builder.cellWidth; + this.error = builder.error; + if (!builder.kindSet) throw new IllegalArgumentException("kind is required"); + this.kind = Wire.nonNull(builder.kind, "kind"); + this.remaining = builder.remaining; + this.retryExhausted = builder.retryExhausted; + this.summary = builder.summary; + } + + public static Builder builder() { return new Builder(); } + + public Field attempts() { return attempts; } + public Field cellHeight() { return cellHeight; } + public Field cellWidth() { return cellWidth; } + public Field error() { return error; } + public GraphicsStatusEventKind kind() { return kind; } + public Field remaining() { return remaining; } + public Field retryExhausted() { return retryExhausted; } + public Field summary() { return summary; } + @Override public String event() { return "graphics-status"; } + + public static GraphicsStatusEvent fromWire(Object value) { + Map object = Wire.object(value, "GraphicsStatusEvent"); + Builder builder = builder(); + ProtocolSupport.literal(Wire.required(object, "event"), "graphics-status", "GraphicsStatusEvent.event"); + Object rawAttempts = Wire.optional(object, "attempts"); + if (!Wire.isMissing(rawAttempts)) { + builder.attempts(Wire.uint16(rawAttempts, "GraphicsStatusEvent.attempts")); + } + Object rawCellHeight = Wire.optional(object, "cell_height"); + if (!Wire.isMissing(rawCellHeight)) { + builder.cellHeight(Wire.uint16(rawCellHeight, "GraphicsStatusEvent.cell_height")); + } + Object rawCellWidth = Wire.optional(object, "cell_width"); + if (!Wire.isMissing(rawCellWidth)) { + builder.cellWidth(Wire.uint16(rawCellWidth, "GraphicsStatusEvent.cell_width")); + } + Object rawError = Wire.optional(object, "error"); + if (!Wire.isMissing(rawError)) { + builder.error(Wire.string(rawError, "GraphicsStatusEvent.error")); + } + Object rawKind = Wire.required(object, "kind"); + builder.kind(GraphicsStatusEventKind.fromWire(rawKind)); + Object rawRemaining = Wire.optional(object, "remaining"); + if (!Wire.isMissing(rawRemaining)) { + builder.remaining(Wire.uint64(rawRemaining, "GraphicsStatusEvent.remaining")); + } + Object rawRetryExhausted = Wire.optional(object, "retry_exhausted"); + if (!Wire.isMissing(rawRetryExhausted)) { + builder.retryExhausted(Wire.bool(rawRetryExhausted, "GraphicsStatusEvent.retry_exhausted")); + } + Object rawSummary = Wire.optional(object, "summary"); + if (!Wire.isMissing(rawSummary)) { + builder.summary(Wire.string(rawSummary, "GraphicsStatusEvent.summary")); + } + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + object.put("event", "graphics-status"); + Wire.put(object, "attempts", attempts); + Wire.put(object, "cell_height", cellHeight); + Wire.put(object, "cell_width", cellWidth); + Wire.put(object, "error", error); + Wire.put(object, "kind", kind); + Wire.put(object, "remaining", remaining); + Wire.put(object, "retry_exhausted", retryExhausted); + Wire.put(object, "summary", summary); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GraphicsStatusEvent that)) return false; + return Objects.equals(attempts, that.attempts) && Objects.equals(cellHeight, that.cellHeight) && Objects.equals(cellWidth, that.cellWidth) && Objects.equals(error, that.error) && Objects.equals(kind, that.kind) && Objects.equals(remaining, that.remaining) && Objects.equals(retryExhausted, that.retryExhausted) && Objects.equals(summary, that.summary); + } + + @Override + public int hashCode() { return Objects.hash(attempts, cellHeight, cellWidth, error, kind, remaining, retryExhausted, summary); } + + @Override + public String toString() { return "GraphicsStatusEvent" + toWire(); } + + public static final class Builder { + private Field attempts = Field.omitted(); + private Field cellHeight = Field.omitted(); + private Field cellWidth = Field.omitted(); + private Field error = Field.omitted(); + private GraphicsStatusEventKind kind; + private boolean kindSet; + private Field remaining = Field.omitted(); + private Field retryExhausted = Field.omitted(); + private Field summary = Field.omitted(); + + public Builder attempts(Integer value) { + this.attempts = Field.of(value); + return this; + } + public Builder cellHeight(Integer value) { + this.cellHeight = Field.of(value); + return this; + } + public Builder cellWidth(Integer value) { + this.cellWidth = Field.of(value); + return this; + } + public Builder error(String value) { + this.error = Field.of(value); + return this; + } + public Builder kind(GraphicsStatusEventKind value) { + this.kind = value; + this.kindSet = true; + return this; + } + public Builder remaining(UInt64 value) { + this.remaining = Field.of(value); + return this; + } + public Builder retryExhausted(Boolean value) { + this.retryExhausted = Field.of(value); + return this; + } + public Builder summary(String value) { + this.summary = Field.of(value); + return this; + } + public GraphicsStatusEvent build() { return new GraphicsStatusEvent(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEventKind.java b/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEventKind.java new file mode 100644 index 0000000000..87ef1b5745 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/GraphicsStatusEventKind.java @@ -0,0 +1,35 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + +import java.util.Objects; + +public enum GraphicsStatusEventKind implements WireEnum { + KITTY_IMAGE_BUDGET_WORKER_START_FAILED("kitty-image-budget-worker-start-failed"), + KITTY_IMAGE_BUDGET_UPDATE_FAILED("kitty-image-budget-update-failed"), + CELL_PIXEL_UPDATE_RETRIES_EXHAUSTED("cell-pixel-update-retries-exhausted"); + + private final Object wireValue; + + GraphicsStatusEventKind(Object wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return String.valueOf(wireValue); + } + + public Object rawWireValue() { + return wireValue; + } + + public static GraphicsStatusEventKind fromWire(Object value) { + for (GraphicsStatusEventKind candidate : values()) { + if (Objects.equals(candidate.wireValue, value) + || Objects.equals(String.valueOf(candidate.wireValue), value)) { + return candidate; + } + } + throw new CmuxDecodeException("unknown GraphicsStatusEventKind value " + value, null); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/KittyGraphicsState.java b/cmux-tui/bindings/java/src/com/cmux/raw/KittyGraphicsState.java new file mode 100644 index 0000000000..14f4a631db --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/KittyGraphicsState.java @@ -0,0 +1,175 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class KittyGraphicsState implements WireValue { + private final long alternateNextImageId; + private final long alternateReplayNextImageId; + private final UInt64 imageBytes; + private final UInt64 images; + private final UInt64 inflightBytes; + private final UInt64 placements; + private final long primaryNextImageId; + private final long primaryReplayNextImageId; + private final long replayCursorOffset; + + private KittyGraphicsState(Builder builder) { + if (!builder.alternateNextImageIdSet) throw new IllegalArgumentException("alternate_next_image_id is required"); + this.alternateNextImageId = builder.alternateNextImageId; + if (!builder.alternateReplayNextImageIdSet) throw new IllegalArgumentException("alternate_replay_next_image_id is required"); + this.alternateReplayNextImageId = builder.alternateReplayNextImageId; + if (!builder.imageBytesSet) throw new IllegalArgumentException("image_bytes is required"); + this.imageBytes = Wire.nonNull(builder.imageBytes, "image_bytes"); + if (!builder.imagesSet) throw new IllegalArgumentException("images is required"); + this.images = Wire.nonNull(builder.images, "images"); + if (!builder.inflightBytesSet) throw new IllegalArgumentException("inflight_bytes is required"); + this.inflightBytes = Wire.nonNull(builder.inflightBytes, "inflight_bytes"); + if (!builder.placementsSet) throw new IllegalArgumentException("placements is required"); + this.placements = Wire.nonNull(builder.placements, "placements"); + if (!builder.primaryNextImageIdSet) throw new IllegalArgumentException("primary_next_image_id is required"); + this.primaryNextImageId = builder.primaryNextImageId; + if (!builder.primaryReplayNextImageIdSet) throw new IllegalArgumentException("primary_replay_next_image_id is required"); + this.primaryReplayNextImageId = builder.primaryReplayNextImageId; + if (!builder.replayCursorOffsetSet) throw new IllegalArgumentException("replay_cursor_offset is required"); + this.replayCursorOffset = builder.replayCursorOffset; + } + + public static Builder builder() { return new Builder(); } + + public long alternateNextImageId() { return alternateNextImageId; } + public long alternateReplayNextImageId() { return alternateReplayNextImageId; } + public UInt64 imageBytes() { return imageBytes; } + public UInt64 images() { return images; } + public UInt64 inflightBytes() { return inflightBytes; } + public UInt64 placements() { return placements; } + public long primaryNextImageId() { return primaryNextImageId; } + public long primaryReplayNextImageId() { return primaryReplayNextImageId; } + public long replayCursorOffset() { return replayCursorOffset; } + + public static KittyGraphicsState fromWire(Object value) { + Map object = Wire.object(value, "KittyGraphicsState"); + Builder builder = builder(); + Object rawAlternateNextImageId = Wire.required(object, "alternate_next_image_id"); + builder.alternateNextImageId(Wire.uint32(rawAlternateNextImageId, "KittyGraphicsState.alternate_next_image_id")); + Object rawAlternateReplayNextImageId = Wire.required(object, "alternate_replay_next_image_id"); + builder.alternateReplayNextImageId(Wire.uint32(rawAlternateReplayNextImageId, "KittyGraphicsState.alternate_replay_next_image_id")); + Object rawImageBytes = Wire.required(object, "image_bytes"); + builder.imageBytes(Wire.uint64(rawImageBytes, "KittyGraphicsState.image_bytes")); + Object rawImages = Wire.required(object, "images"); + builder.images(Wire.uint64(rawImages, "KittyGraphicsState.images")); + Object rawInflightBytes = Wire.required(object, "inflight_bytes"); + builder.inflightBytes(Wire.uint64(rawInflightBytes, "KittyGraphicsState.inflight_bytes")); + Object rawPlacements = Wire.required(object, "placements"); + builder.placements(Wire.uint64(rawPlacements, "KittyGraphicsState.placements")); + Object rawPrimaryNextImageId = Wire.required(object, "primary_next_image_id"); + builder.primaryNextImageId(Wire.uint32(rawPrimaryNextImageId, "KittyGraphicsState.primary_next_image_id")); + Object rawPrimaryReplayNextImageId = Wire.required(object, "primary_replay_next_image_id"); + builder.primaryReplayNextImageId(Wire.uint32(rawPrimaryReplayNextImageId, "KittyGraphicsState.primary_replay_next_image_id")); + Object rawReplayCursorOffset = Wire.required(object, "replay_cursor_offset"); + builder.replayCursorOffset(Wire.uint32(rawReplayCursorOffset, "KittyGraphicsState.replay_cursor_offset")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "alternate_next_image_id", alternateNextImageId); + Wire.put(object, "alternate_replay_next_image_id", alternateReplayNextImageId); + Wire.put(object, "image_bytes", imageBytes); + Wire.put(object, "images", images); + Wire.put(object, "inflight_bytes", inflightBytes); + Wire.put(object, "placements", placements); + Wire.put(object, "primary_next_image_id", primaryNextImageId); + Wire.put(object, "primary_replay_next_image_id", primaryReplayNextImageId); + Wire.put(object, "replay_cursor_offset", replayCursorOffset); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof KittyGraphicsState that)) return false; + return Objects.equals(alternateNextImageId, that.alternateNextImageId) && Objects.equals(alternateReplayNextImageId, that.alternateReplayNextImageId) && Objects.equals(imageBytes, that.imageBytes) && Objects.equals(images, that.images) && Objects.equals(inflightBytes, that.inflightBytes) && Objects.equals(placements, that.placements) && Objects.equals(primaryNextImageId, that.primaryNextImageId) && Objects.equals(primaryReplayNextImageId, that.primaryReplayNextImageId) && Objects.equals(replayCursorOffset, that.replayCursorOffset); + } + + @Override + public int hashCode() { return Objects.hash(alternateNextImageId, alternateReplayNextImageId, imageBytes, images, inflightBytes, placements, primaryNextImageId, primaryReplayNextImageId, replayCursorOffset); } + + @Override + public String toString() { return "KittyGraphicsState" + toWire(); } + + public static final class Builder { + private Long alternateNextImageId; + private boolean alternateNextImageIdSet; + private Long alternateReplayNextImageId; + private boolean alternateReplayNextImageIdSet; + private UInt64 imageBytes; + private boolean imageBytesSet; + private UInt64 images; + private boolean imagesSet; + private UInt64 inflightBytes; + private boolean inflightBytesSet; + private UInt64 placements; + private boolean placementsSet; + private Long primaryNextImageId; + private boolean primaryNextImageIdSet; + private Long primaryReplayNextImageId; + private boolean primaryReplayNextImageIdSet; + private Long replayCursorOffset; + private boolean replayCursorOffsetSet; + + public Builder alternateNextImageId(long value) { + this.alternateNextImageId = value; + this.alternateNextImageIdSet = true; + return this; + } + public Builder alternateReplayNextImageId(long value) { + this.alternateReplayNextImageId = value; + this.alternateReplayNextImageIdSet = true; + return this; + } + public Builder imageBytes(UInt64 value) { + this.imageBytes = value; + this.imageBytesSet = true; + return this; + } + public Builder images(UInt64 value) { + this.images = value; + this.imagesSet = true; + return this; + } + public Builder inflightBytes(UInt64 value) { + this.inflightBytes = value; + this.inflightBytesSet = true; + return this; + } + public Builder placements(UInt64 value) { + this.placements = value; + this.placementsSet = true; + return this; + } + public Builder primaryNextImageId(long value) { + this.primaryNextImageId = value; + this.primaryNextImageIdSet = true; + return this; + } + public Builder primaryReplayNextImageId(long value) { + this.primaryReplayNextImageId = value; + this.primaryReplayNextImageIdSet = true; + return this; + } + public Builder replayCursorOffset(long value) { + this.replayCursorOffset = value; + this.replayCursorOffsetSet = true; + return this; + } + public KittyGraphicsState build() { return new KittyGraphicsState(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/KittyImageAlias.java b/cmux-tui/bindings/java/src/com/cmux/raw/KittyImageAlias.java new file mode 100644 index 0000000000..1090c2ef81 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/KittyImageAlias.java @@ -0,0 +1,77 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class KittyImageAlias implements WireValue { + private final long imageId; + private final long imageNumber; + + private KittyImageAlias(Builder builder) { + if (!builder.imageIdSet) throw new IllegalArgumentException("image_id is required"); + this.imageId = builder.imageId; + if (!builder.imageNumberSet) throw new IllegalArgumentException("image_number is required"); + this.imageNumber = builder.imageNumber; + } + + public static Builder builder() { return new Builder(); } + + public long imageId() { return imageId; } + public long imageNumber() { return imageNumber; } + + public static KittyImageAlias fromWire(Object value) { + Map object = Wire.object(value, "KittyImageAlias"); + Builder builder = builder(); + Object rawImageId = Wire.required(object, "image_id"); + builder.imageId(Wire.uint32(rawImageId, "KittyImageAlias.image_id")); + Object rawImageNumber = Wire.required(object, "image_number"); + builder.imageNumber(Wire.uint32(rawImageNumber, "KittyImageAlias.image_number")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "image_id", imageId); + Wire.put(object, "image_number", imageNumber); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof KittyImageAlias that)) return false; + return Objects.equals(imageId, that.imageId) && Objects.equals(imageNumber, that.imageNumber); + } + + @Override + public int hashCode() { return Objects.hash(imageId, imageNumber); } + + @Override + public String toString() { return "KittyImageAlias" + toWire(); } + + public static final class Builder { + private Long imageId; + private boolean imageIdSet; + private Long imageNumber; + private boolean imageNumberSet; + + public Builder imageId(long value) { + this.imageId = value; + this.imageIdSet = true; + return this; + } + public Builder imageNumber(long value) { + this.imageNumber = value; + this.imageNumberSet = true; + return this; + } + public KittyImageAlias build() { return new KittyImageAlias(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java index d499f58c68..ee7e3416d3 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java @@ -9,7 +9,7 @@ public final class Protocol { public static final String SDK_VERSION = "1.0.0"; public static final int VERSION = 10; public static final int SCHEMA_VERSION = 2; - public static final String IR_SHA256 = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e"; + public static final String IR_SHA256 = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350"; private Protocol() {} public static ProtocolEvent decodeEvent(Object value) { @@ -28,6 +28,7 @@ public final class Protocol { case "empty" -> EmptyEvent.fromWire(value); case "frame" -> FrameEvent.fromWire(value); case "frontend-projection-changed" -> FrontendProjectionChangedEvent.fromWire(value); + case "graphics-status" -> GraphicsStatusEvent.fromWire(value); case "layout-changed" -> LayoutChangedEvent.fromWire(value); case "notification" -> NotificationEvent.fromWire(value); case "output" -> OutputEvent.fromWire(value); diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/ReadScrollbackResult.java b/cmux-tui/bindings/java/src/com/cmux/raw/ReadScrollbackResult.java index bb4f272c37..e96361c45a 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/ReadScrollbackResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/ReadScrollbackResult.java @@ -11,11 +11,14 @@ import java.util.Objects; public final class ReadScrollbackResult implements WireValue { + private final UInt64 epoch; private final List rows; private final long start; private final long total; private ReadScrollbackResult(Builder builder) { + if (!builder.epochSet) throw new IllegalArgumentException("epoch is required"); + this.epoch = Wire.nonNull(builder.epoch, "epoch"); if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = List.copyOf(Wire.nonNull(builder.rows, "rows")); if (!builder.startSet) throw new IllegalArgumentException("start is required"); @@ -26,6 +29,7 @@ public final class ReadScrollbackResult implements WireValue { public static Builder builder() { return new Builder(); } + public UInt64 epoch() { return epoch; } public List rows() { return rows; } public long start() { return start; } public long total() { return total; } @@ -33,6 +37,8 @@ public final class ReadScrollbackResult implements WireValue { public static ReadScrollbackResult fromWire(Object value) { Map object = Wire.object(value, "ReadScrollbackResult"); Builder builder = builder(); + Object rawEpoch = Wire.required(object, "epoch"); + builder.epoch(Wire.uint64(rawEpoch, "ReadScrollbackResult.epoch")); Object rawRows = Wire.required(object, "rows"); builder.rows(Wire.array(rawRows, "ReadScrollbackResult.rows", item -> RenderRow.fromWire(item))); Object rawStart = Wire.required(object, "start"); @@ -45,6 +51,7 @@ public final class ReadScrollbackResult implements WireValue { @Override public Map toWire() { LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "epoch", epoch); Wire.put(object, "rows", rows); Wire.put(object, "start", start); Wire.put(object, "total", total); @@ -54,16 +61,18 @@ public final class ReadScrollbackResult implements WireValue { @Override public boolean equals(Object other) { if (!(other instanceof ReadScrollbackResult that)) return false; - return Objects.equals(rows, that.rows) && Objects.equals(start, that.start) && Objects.equals(total, that.total); + return Objects.equals(epoch, that.epoch) && Objects.equals(rows, that.rows) && Objects.equals(start, that.start) && Objects.equals(total, that.total); } @Override - public int hashCode() { return Objects.hash(rows, start, total); } + public int hashCode() { return Objects.hash(epoch, rows, start, total); } @Override public String toString() { return "ReadScrollbackResult" + toWire(); } public static final class Builder { + private UInt64 epoch; + private boolean epochSet; private List rows; private boolean rowsSet; private Long start; @@ -71,6 +80,11 @@ public final class ReadScrollbackResult implements WireValue { private Long total; private boolean totalSet; + public Builder epoch(UInt64 value) { + this.epoch = value; + this.epochSet = true; + return this; + } public Builder rows(List value) { this.rows = value; this.rowsSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderDeltaEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderDeltaEvent.java index 0929b67ec8..357bca7eb7 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/RenderDeltaEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderDeltaEvent.java @@ -16,6 +16,8 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA private final Field defaultBg; private final Field defaultFg; private final boolean full; + private final Field graphics; + private final Field historyEpoch; private final List rows; private final Field scrollbackRows; private final Field size; @@ -28,6 +30,8 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA this.defaultFg = builder.defaultFg; if (!builder.fullSet) throw new IllegalArgumentException("full is required"); this.full = builder.full; + this.graphics = builder.graphics; + this.historyEpoch = builder.historyEpoch; if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = List.copyOf(Wire.nonNull(builder.rows, "rows")); this.scrollbackRows = builder.scrollbackRows; @@ -42,6 +46,8 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA public Field defaultBg() { return defaultBg; } public Field defaultFg() { return defaultFg; } public boolean full() { return full; } + public Field graphics() { return graphics; } + public Field historyEpoch() { return historyEpoch; } public List rows() { return rows; } public Field scrollbackRows() { return scrollbackRows; } public Field size() { return size; } @@ -64,6 +70,14 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA } Object rawFull = Wire.required(object, "full"); builder.full(Wire.bool(rawFull, "RenderDeltaEvent.full")); + Object rawGraphics = Wire.optional(object, "graphics"); + if (!Wire.isMissing(rawGraphics)) { + builder.graphics(RenderGraphicsDelta.fromWire(rawGraphics)); + } + Object rawHistoryEpoch = Wire.optional(object, "history_epoch"); + if (!Wire.isMissing(rawHistoryEpoch)) { + builder.historyEpoch(Wire.uint64(rawHistoryEpoch, "RenderDeltaEvent.history_epoch")); + } Object rawRows = Wire.required(object, "rows"); builder.rows(Wire.array(rawRows, "RenderDeltaEvent.rows", item -> RenderRow.fromWire(item))); Object rawScrollbackRows = Wire.optional(object, "scrollback_rows"); @@ -87,6 +101,8 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA Wire.put(object, "default_bg", defaultBg); Wire.put(object, "default_fg", defaultFg); Wire.put(object, "full", full); + Wire.put(object, "graphics", graphics); + Wire.put(object, "history_epoch", historyEpoch); Wire.put(object, "rows", rows); Wire.put(object, "scrollback_rows", scrollbackRows); Wire.put(object, "size", size); @@ -97,11 +113,11 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA @Override public boolean equals(Object other) { if (!(other instanceof RenderDeltaEvent that)) return false; - return Objects.equals(cursor, that.cursor) && Objects.equals(defaultBg, that.defaultBg) && Objects.equals(defaultFg, that.defaultFg) && Objects.equals(full, that.full) && Objects.equals(rows, that.rows) && Objects.equals(scrollbackRows, that.scrollbackRows) && Objects.equals(size, that.size) && Objects.equals(surface, that.surface); + return Objects.equals(cursor, that.cursor) && Objects.equals(defaultBg, that.defaultBg) && Objects.equals(defaultFg, that.defaultFg) && Objects.equals(full, that.full) && Objects.equals(graphics, that.graphics) && Objects.equals(historyEpoch, that.historyEpoch) && Objects.equals(rows, that.rows) && Objects.equals(scrollbackRows, that.scrollbackRows) && Objects.equals(size, that.size) && Objects.equals(surface, that.surface); } @Override - public int hashCode() { return Objects.hash(cursor, defaultBg, defaultFg, full, rows, scrollbackRows, size, surface); } + public int hashCode() { return Objects.hash(cursor, defaultBg, defaultFg, full, graphics, historyEpoch, rows, scrollbackRows, size, surface); } @Override public String toString() { return "RenderDeltaEvent" + toWire(); } @@ -113,6 +129,8 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA private Field defaultFg = Field.omitted(); private Boolean full; private boolean fullSet; + private Field graphics = Field.omitted(); + private Field historyEpoch = Field.omitted(); private List rows; private boolean rowsSet; private Field scrollbackRows = Field.omitted(); @@ -138,6 +156,14 @@ public final class RenderDeltaEvent implements WireValue, ProtocolEvent, RenderA this.fullSet = true; return this; } + public Builder graphics(RenderGraphicsDelta value) { + this.graphics = Field.of(value); + return this; + } + public Builder historyEpoch(UInt64 value) { + this.historyEpoch = Field.of(value); + return this; + } public Builder rows(List value) { this.rows = value; this.rowsSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicFormat.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicFormat.java new file mode 100644 index 0000000000..aee23f2b83 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicFormat.java @@ -0,0 +1,34 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + +import java.util.Objects; + +public enum RenderGraphicFormat implements WireEnum { + RGB("rgb"), + RGBA("rgba"); + + private final Object wireValue; + + RenderGraphicFormat(Object wireValue) { + this.wireValue = wireValue; + } + + @Override + public String wireValue() { + return String.valueOf(wireValue); + } + + public Object rawWireValue() { + return wireValue; + } + + public static RenderGraphicFormat fromWire(Object value) { + for (RenderGraphicFormat candidate : values()) { + if (Objects.equals(candidate.wireValue, value) + || Objects.equals(String.valueOf(candidate.wireValue), value)) { + return candidate; + } + } + throw new CmuxDecodeException("unknown RenderGraphicFormat value " + value, null); + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicImage.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicImage.java new file mode 100644 index 0000000000..bd6e0dadda --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicImage.java @@ -0,0 +1,133 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class RenderGraphicImage implements WireValue { + private final Bytes data; + private final RenderGraphicFormat format; + private final UInt64 generation; + private final long height; + private final long id; + private final long width; + + private RenderGraphicImage(Builder builder) { + if (!builder.dataSet) throw new IllegalArgumentException("data is required"); + this.data = Wire.nonNull(builder.data, "data"); + if (!builder.formatSet) throw new IllegalArgumentException("format is required"); + this.format = Wire.nonNull(builder.format, "format"); + if (!builder.generationSet) throw new IllegalArgumentException("generation is required"); + this.generation = Wire.nonNull(builder.generation, "generation"); + if (!builder.heightSet) throw new IllegalArgumentException("height is required"); + this.height = builder.height; + if (!builder.idSet) throw new IllegalArgumentException("id is required"); + this.id = builder.id; + if (!builder.widthSet) throw new IllegalArgumentException("width is required"); + this.width = builder.width; + } + + public static Builder builder() { return new Builder(); } + + public Bytes data() { return data; } + public RenderGraphicFormat format() { return format; } + public UInt64 generation() { return generation; } + public long height() { return height; } + public long id() { return id; } + public long width() { return width; } + + public static RenderGraphicImage fromWire(Object value) { + Map object = Wire.object(value, "RenderGraphicImage"); + Builder builder = builder(); + Object rawData = Wire.required(object, "data"); + builder.data(Wire.bytes(rawData, "RenderGraphicImage.data")); + Object rawFormat = Wire.required(object, "format"); + builder.format(RenderGraphicFormat.fromWire(rawFormat)); + Object rawGeneration = Wire.required(object, "generation"); + builder.generation(Wire.uint64(rawGeneration, "RenderGraphicImage.generation")); + Object rawHeight = Wire.required(object, "height"); + builder.height(Wire.uint32(rawHeight, "RenderGraphicImage.height")); + Object rawId = Wire.required(object, "id"); + builder.id(Wire.uint32(rawId, "RenderGraphicImage.id")); + Object rawWidth = Wire.required(object, "width"); + builder.width(Wire.uint32(rawWidth, "RenderGraphicImage.width")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "data", data); + Wire.put(object, "format", format); + Wire.put(object, "generation", generation); + Wire.put(object, "height", height); + Wire.put(object, "id", id); + Wire.put(object, "width", width); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RenderGraphicImage that)) return false; + return Objects.equals(data, that.data) && Objects.equals(format, that.format) && Objects.equals(generation, that.generation) && Objects.equals(height, that.height) && Objects.equals(id, that.id) && Objects.equals(width, that.width); + } + + @Override + public int hashCode() { return Objects.hash(data, format, generation, height, id, width); } + + @Override + public String toString() { return "RenderGraphicImage" + toWire(); } + + public static final class Builder { + private Bytes data; + private boolean dataSet; + private RenderGraphicFormat format; + private boolean formatSet; + private UInt64 generation; + private boolean generationSet; + private Long height; + private boolean heightSet; + private Long id; + private boolean idSet; + private Long width; + private boolean widthSet; + + public Builder data(Bytes value) { + this.data = value; + this.dataSet = true; + return this; + } + public Builder format(RenderGraphicFormat value) { + this.format = value; + this.formatSet = true; + return this; + } + public Builder generation(UInt64 value) { + this.generation = value; + this.generationSet = true; + return this; + } + public Builder height(long value) { + this.height = value; + this.heightSet = true; + return this; + } + public Builder id(long value) { + this.id = value; + this.idSet = true; + return this; + } + public Builder width(long value) { + this.width = value; + this.widthSet = true; + return this; + } + public RenderGraphicImage build() { return new RenderGraphicImage(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicPlacement.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicPlacement.java new file mode 100644 index 0000000000..5d68705ff1 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicPlacement.java @@ -0,0 +1,341 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class RenderGraphicPlacement implements WireValue { + private final Field anchorCol; + private final Field anchorRow; + private final long columns; + private final long gridCols; + private final long gridRows; + private final long imageId; + private final long ordinal; + private final long pixelHeight; + private final long pixelWidth; + private final long placementId; + private final long rows; + private final long sourceHeight; + private final long sourceWidth; + private final long sourceX; + private final long sourceY; + private final int viewportCol; + private final int viewportRow; + private final boolean viewportVisible; + private final long xOffset; + private final long yOffset; + private final int z; + + private RenderGraphicPlacement(Builder builder) { + this.anchorCol = builder.anchorCol; + this.anchorRow = builder.anchorRow; + if (!builder.columnsSet) throw new IllegalArgumentException("columns is required"); + this.columns = builder.columns; + if (!builder.gridColsSet) throw new IllegalArgumentException("grid_cols is required"); + this.gridCols = builder.gridCols; + if (!builder.gridRowsSet) throw new IllegalArgumentException("grid_rows is required"); + this.gridRows = builder.gridRows; + if (!builder.imageIdSet) throw new IllegalArgumentException("image_id is required"); + this.imageId = builder.imageId; + if (!builder.ordinalSet) throw new IllegalArgumentException("ordinal is required"); + this.ordinal = builder.ordinal; + if (!builder.pixelHeightSet) throw new IllegalArgumentException("pixel_height is required"); + this.pixelHeight = builder.pixelHeight; + if (!builder.pixelWidthSet) throw new IllegalArgumentException("pixel_width is required"); + this.pixelWidth = builder.pixelWidth; + if (!builder.placementIdSet) throw new IllegalArgumentException("placement_id is required"); + this.placementId = builder.placementId; + if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); + this.rows = builder.rows; + if (!builder.sourceHeightSet) throw new IllegalArgumentException("source_height is required"); + this.sourceHeight = builder.sourceHeight; + if (!builder.sourceWidthSet) throw new IllegalArgumentException("source_width is required"); + this.sourceWidth = builder.sourceWidth; + if (!builder.sourceXSet) throw new IllegalArgumentException("source_x is required"); + this.sourceX = builder.sourceX; + if (!builder.sourceYSet) throw new IllegalArgumentException("source_y is required"); + this.sourceY = builder.sourceY; + if (!builder.viewportColSet) throw new IllegalArgumentException("viewport_col is required"); + this.viewportCol = builder.viewportCol; + if (!builder.viewportRowSet) throw new IllegalArgumentException("viewport_row is required"); + this.viewportRow = builder.viewportRow; + if (!builder.viewportVisibleSet) throw new IllegalArgumentException("viewport_visible is required"); + this.viewportVisible = builder.viewportVisible; + if (!builder.xOffsetSet) throw new IllegalArgumentException("x_offset is required"); + this.xOffset = builder.xOffset; + if (!builder.yOffsetSet) throw new IllegalArgumentException("y_offset is required"); + this.yOffset = builder.yOffset; + if (!builder.zSet) throw new IllegalArgumentException("z is required"); + this.z = builder.z; + } + + public static Builder builder() { return new Builder(); } + + public Field anchorCol() { return anchorCol; } + public Field anchorRow() { return anchorRow; } + public long columns() { return columns; } + public long gridCols() { return gridCols; } + public long gridRows() { return gridRows; } + public long imageId() { return imageId; } + public long ordinal() { return ordinal; } + public long pixelHeight() { return pixelHeight; } + public long pixelWidth() { return pixelWidth; } + public long placementId() { return placementId; } + public long rows() { return rows; } + public long sourceHeight() { return sourceHeight; } + public long sourceWidth() { return sourceWidth; } + public long sourceX() { return sourceX; } + public long sourceY() { return sourceY; } + public int viewportCol() { return viewportCol; } + public int viewportRow() { return viewportRow; } + public boolean viewportVisible() { return viewportVisible; } + public long xOffset() { return xOffset; } + public long yOffset() { return yOffset; } + public int z() { return z; } + + public static RenderGraphicPlacement fromWire(Object value) { + Map object = Wire.object(value, "RenderGraphicPlacement"); + Builder builder = builder(); + Object rawAnchorCol = Wire.optional(object, "anchor_col"); + if (!Wire.isMissing(rawAnchorCol)) { + builder.anchorCol(Wire.uint16(rawAnchorCol, "RenderGraphicPlacement.anchor_col")); + } + Object rawAnchorRow = Wire.optional(object, "anchor_row"); + if (!Wire.isMissing(rawAnchorRow)) { + builder.anchorRow(Wire.uint32(rawAnchorRow, "RenderGraphicPlacement.anchor_row")); + } + Object rawColumns = Wire.required(object, "columns"); + builder.columns(Wire.uint32(rawColumns, "RenderGraphicPlacement.columns")); + Object rawGridCols = Wire.required(object, "grid_cols"); + builder.gridCols(Wire.uint32(rawGridCols, "RenderGraphicPlacement.grid_cols")); + Object rawGridRows = Wire.required(object, "grid_rows"); + builder.gridRows(Wire.uint32(rawGridRows, "RenderGraphicPlacement.grid_rows")); + Object rawImageId = Wire.required(object, "image_id"); + builder.imageId(Wire.uint32(rawImageId, "RenderGraphicPlacement.image_id")); + Object rawOrdinal = Wire.required(object, "ordinal"); + builder.ordinal(Wire.uint32(rawOrdinal, "RenderGraphicPlacement.ordinal")); + Object rawPixelHeight = Wire.required(object, "pixel_height"); + builder.pixelHeight(Wire.uint32(rawPixelHeight, "RenderGraphicPlacement.pixel_height")); + Object rawPixelWidth = Wire.required(object, "pixel_width"); + builder.pixelWidth(Wire.uint32(rawPixelWidth, "RenderGraphicPlacement.pixel_width")); + Object rawPlacementId = Wire.required(object, "placement_id"); + builder.placementId(Wire.uint32(rawPlacementId, "RenderGraphicPlacement.placement_id")); + Object rawRows = Wire.required(object, "rows"); + builder.rows(Wire.uint32(rawRows, "RenderGraphicPlacement.rows")); + Object rawSourceHeight = Wire.required(object, "source_height"); + builder.sourceHeight(Wire.uint32(rawSourceHeight, "RenderGraphicPlacement.source_height")); + Object rawSourceWidth = Wire.required(object, "source_width"); + builder.sourceWidth(Wire.uint32(rawSourceWidth, "RenderGraphicPlacement.source_width")); + Object rawSourceX = Wire.required(object, "source_x"); + builder.sourceX(Wire.uint32(rawSourceX, "RenderGraphicPlacement.source_x")); + Object rawSourceY = Wire.required(object, "source_y"); + builder.sourceY(Wire.uint32(rawSourceY, "RenderGraphicPlacement.source_y")); + Object rawViewportCol = Wire.required(object, "viewport_col"); + builder.viewportCol(Wire.int32(rawViewportCol, "RenderGraphicPlacement.viewport_col")); + Object rawViewportRow = Wire.required(object, "viewport_row"); + builder.viewportRow(Wire.int32(rawViewportRow, "RenderGraphicPlacement.viewport_row")); + Object rawViewportVisible = Wire.required(object, "viewport_visible"); + builder.viewportVisible(Wire.bool(rawViewportVisible, "RenderGraphicPlacement.viewport_visible")); + Object rawXOffset = Wire.required(object, "x_offset"); + builder.xOffset(Wire.uint32(rawXOffset, "RenderGraphicPlacement.x_offset")); + Object rawYOffset = Wire.required(object, "y_offset"); + builder.yOffset(Wire.uint32(rawYOffset, "RenderGraphicPlacement.y_offset")); + Object rawZ = Wire.required(object, "z"); + builder.z(Wire.int32(rawZ, "RenderGraphicPlacement.z")); + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "anchor_col", anchorCol); + Wire.put(object, "anchor_row", anchorRow); + Wire.put(object, "columns", columns); + Wire.put(object, "grid_cols", gridCols); + Wire.put(object, "grid_rows", gridRows); + Wire.put(object, "image_id", imageId); + Wire.put(object, "ordinal", ordinal); + Wire.put(object, "pixel_height", pixelHeight); + Wire.put(object, "pixel_width", pixelWidth); + Wire.put(object, "placement_id", placementId); + Wire.put(object, "rows", rows); + Wire.put(object, "source_height", sourceHeight); + Wire.put(object, "source_width", sourceWidth); + Wire.put(object, "source_x", sourceX); + Wire.put(object, "source_y", sourceY); + Wire.put(object, "viewport_col", viewportCol); + Wire.put(object, "viewport_row", viewportRow); + Wire.put(object, "viewport_visible", viewportVisible); + Wire.put(object, "x_offset", xOffset); + Wire.put(object, "y_offset", yOffset); + Wire.put(object, "z", z); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RenderGraphicPlacement that)) return false; + return Objects.equals(anchorCol, that.anchorCol) && Objects.equals(anchorRow, that.anchorRow) && Objects.equals(columns, that.columns) && Objects.equals(gridCols, that.gridCols) && Objects.equals(gridRows, that.gridRows) && Objects.equals(imageId, that.imageId) && Objects.equals(ordinal, that.ordinal) && Objects.equals(pixelHeight, that.pixelHeight) && Objects.equals(pixelWidth, that.pixelWidth) && Objects.equals(placementId, that.placementId) && Objects.equals(rows, that.rows) && Objects.equals(sourceHeight, that.sourceHeight) && Objects.equals(sourceWidth, that.sourceWidth) && Objects.equals(sourceX, that.sourceX) && Objects.equals(sourceY, that.sourceY) && Objects.equals(viewportCol, that.viewportCol) && Objects.equals(viewportRow, that.viewportRow) && Objects.equals(viewportVisible, that.viewportVisible) && Objects.equals(xOffset, that.xOffset) && Objects.equals(yOffset, that.yOffset) && Objects.equals(z, that.z); + } + + @Override + public int hashCode() { return Objects.hash(anchorCol, anchorRow, columns, gridCols, gridRows, imageId, ordinal, pixelHeight, pixelWidth, placementId, rows, sourceHeight, sourceWidth, sourceX, sourceY, viewportCol, viewportRow, viewportVisible, xOffset, yOffset, z); } + + @Override + public String toString() { return "RenderGraphicPlacement" + toWire(); } + + public static final class Builder { + private Field anchorCol = Field.omitted(); + private Field anchorRow = Field.omitted(); + private Long columns; + private boolean columnsSet; + private Long gridCols; + private boolean gridColsSet; + private Long gridRows; + private boolean gridRowsSet; + private Long imageId; + private boolean imageIdSet; + private Long ordinal; + private boolean ordinalSet; + private Long pixelHeight; + private boolean pixelHeightSet; + private Long pixelWidth; + private boolean pixelWidthSet; + private Long placementId; + private boolean placementIdSet; + private Long rows; + private boolean rowsSet; + private Long sourceHeight; + private boolean sourceHeightSet; + private Long sourceWidth; + private boolean sourceWidthSet; + private Long sourceX; + private boolean sourceXSet; + private Long sourceY; + private boolean sourceYSet; + private Integer viewportCol; + private boolean viewportColSet; + private Integer viewportRow; + private boolean viewportRowSet; + private Boolean viewportVisible; + private boolean viewportVisibleSet; + private Long xOffset; + private boolean xOffsetSet; + private Long yOffset; + private boolean yOffsetSet; + private Integer z; + private boolean zSet; + + public Builder anchorCol(Integer value) { + this.anchorCol = Field.of(value); + return this; + } + public Builder anchorRow(Long value) { + this.anchorRow = Field.of(value); + return this; + } + public Builder columns(long value) { + this.columns = value; + this.columnsSet = true; + return this; + } + public Builder gridCols(long value) { + this.gridCols = value; + this.gridColsSet = true; + return this; + } + public Builder gridRows(long value) { + this.gridRows = value; + this.gridRowsSet = true; + return this; + } + public Builder imageId(long value) { + this.imageId = value; + this.imageIdSet = true; + return this; + } + public Builder ordinal(long value) { + this.ordinal = value; + this.ordinalSet = true; + return this; + } + public Builder pixelHeight(long value) { + this.pixelHeight = value; + this.pixelHeightSet = true; + return this; + } + public Builder pixelWidth(long value) { + this.pixelWidth = value; + this.pixelWidthSet = true; + return this; + } + public Builder placementId(long value) { + this.placementId = value; + this.placementIdSet = true; + return this; + } + public Builder rows(long value) { + this.rows = value; + this.rowsSet = true; + return this; + } + public Builder sourceHeight(long value) { + this.sourceHeight = value; + this.sourceHeightSet = true; + return this; + } + public Builder sourceWidth(long value) { + this.sourceWidth = value; + this.sourceWidthSet = true; + return this; + } + public Builder sourceX(long value) { + this.sourceX = value; + this.sourceXSet = true; + return this; + } + public Builder sourceY(long value) { + this.sourceY = value; + this.sourceYSet = true; + return this; + } + public Builder viewportCol(int value) { + this.viewportCol = value; + this.viewportColSet = true; + return this; + } + public Builder viewportRow(int value) { + this.viewportRow = value; + this.viewportRowSet = true; + return this; + } + public Builder viewportVisible(boolean value) { + this.viewportVisible = value; + this.viewportVisibleSet = true; + return this; + } + public Builder xOffset(long value) { + this.xOffset = value; + this.xOffsetSet = true; + return this; + } + public Builder yOffset(long value) { + this.yOffset = value; + this.yOffsetSet = true; + return this; + } + public Builder z(int value) { + this.z = value; + this.zSet = true; + return this; + } + public RenderGraphicPlacement build() { return new RenderGraphicPlacement(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphics.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphics.java new file mode 100644 index 0000000000..280811323e --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphics.java @@ -0,0 +1,103 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class RenderGraphics implements WireValue { + private final UInt64 generation; + private final Field> images; + private final List placements; + private final Field> removedImageIds; + + private RenderGraphics(Builder builder) { + if (!builder.generationSet) throw new IllegalArgumentException("generation is required"); + this.generation = Wire.nonNull(builder.generation, "generation"); + this.images = builder.images.map(value -> List.copyOf(value)); + if (!builder.placementsSet) throw new IllegalArgumentException("placements is required"); + this.placements = List.copyOf(Wire.nonNull(builder.placements, "placements")); + this.removedImageIds = builder.removedImageIds.map(value -> List.copyOf(value)); + } + + public static Builder builder() { return new Builder(); } + + public UInt64 generation() { return generation; } + public Field> images() { return images; } + public List placements() { return placements; } + public Field> removedImageIds() { return removedImageIds; } + + public static RenderGraphics fromWire(Object value) { + Map object = Wire.object(value, "RenderGraphics"); + Builder builder = builder(); + Object rawGeneration = Wire.required(object, "generation"); + builder.generation(Wire.uint64(rawGeneration, "RenderGraphics.generation")); + Object rawImages = Wire.optional(object, "images"); + if (!Wire.isMissing(rawImages)) { + builder.images(Wire.array(rawImages, "RenderGraphics.images", item -> RenderGraphicImage.fromWire(item))); + } + Object rawPlacements = Wire.required(object, "placements"); + builder.placements(Wire.array(rawPlacements, "RenderGraphics.placements", item -> RenderGraphicPlacement.fromWire(item))); + Object rawRemovedImageIds = Wire.optional(object, "removed_image_ids"); + if (!Wire.isMissing(rawRemovedImageIds)) { + builder.removedImageIds(Wire.array(rawRemovedImageIds, "RenderGraphics.removed_image_ids", item -> Wire.uint32(item, "RenderGraphics.removed_image_ids item"))); + } + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "generation", generation); + Wire.put(object, "images", images); + Wire.put(object, "placements", placements); + Wire.put(object, "removed_image_ids", removedImageIds); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RenderGraphics that)) return false; + return Objects.equals(generation, that.generation) && Objects.equals(images, that.images) && Objects.equals(placements, that.placements) && Objects.equals(removedImageIds, that.removedImageIds); + } + + @Override + public int hashCode() { return Objects.hash(generation, images, placements, removedImageIds); } + + @Override + public String toString() { return "RenderGraphics" + toWire(); } + + public static final class Builder { + private UInt64 generation; + private boolean generationSet; + private Field> images = Field.omitted(); + private List placements; + private boolean placementsSet; + private Field> removedImageIds = Field.omitted(); + + public Builder generation(UInt64 value) { + this.generation = value; + this.generationSet = true; + return this; + } + public Builder images(List value) { + this.images = Field.of(value); + return this; + } + public Builder placements(List value) { + this.placements = value; + this.placementsSet = true; + return this; + } + public Builder removedImageIds(List value) { + this.removedImageIds = Field.of(value); + return this; + } + public RenderGraphics build() { return new RenderGraphics(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicsDelta.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicsDelta.java new file mode 100644 index 0000000000..f9e73b1227 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderGraphicsDelta.java @@ -0,0 +1,102 @@ +// Generated from cmux-tui/spec/sdk-schema.json. DO NOT EDIT. +package com.cmux.raw; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public final class RenderGraphicsDelta implements WireValue { + private final UInt64 generation; + private final Field> images; + private final Field> placements; + private final Field> removedImageIds; + + private RenderGraphicsDelta(Builder builder) { + if (!builder.generationSet) throw new IllegalArgumentException("generation is required"); + this.generation = Wire.nonNull(builder.generation, "generation"); + this.images = builder.images.map(value -> List.copyOf(value)); + this.placements = builder.placements.map(value -> List.copyOf(value)); + this.removedImageIds = builder.removedImageIds.map(value -> List.copyOf(value)); + } + + public static Builder builder() { return new Builder(); } + + public UInt64 generation() { return generation; } + public Field> images() { return images; } + public Field> placements() { return placements; } + public Field> removedImageIds() { return removedImageIds; } + + public static RenderGraphicsDelta fromWire(Object value) { + Map object = Wire.object(value, "RenderGraphicsDelta"); + Builder builder = builder(); + Object rawGeneration = Wire.required(object, "generation"); + builder.generation(Wire.uint64(rawGeneration, "RenderGraphicsDelta.generation")); + Object rawImages = Wire.optional(object, "images"); + if (!Wire.isMissing(rawImages)) { + builder.images(Wire.array(rawImages, "RenderGraphicsDelta.images", item -> RenderGraphicImage.fromWire(item))); + } + Object rawPlacements = Wire.optional(object, "placements"); + if (!Wire.isMissing(rawPlacements)) { + builder.placements(Wire.array(rawPlacements, "RenderGraphicsDelta.placements", item -> RenderGraphicPlacement.fromWire(item))); + } + Object rawRemovedImageIds = Wire.optional(object, "removed_image_ids"); + if (!Wire.isMissing(rawRemovedImageIds)) { + builder.removedImageIds(Wire.array(rawRemovedImageIds, "RenderGraphicsDelta.removed_image_ids", item -> Wire.uint32(item, "RenderGraphicsDelta.removed_image_ids item"))); + } + return builder.build(); + } + + @Override + public Map toWire() { + LinkedHashMap object = new LinkedHashMap<>(); + Wire.put(object, "generation", generation); + Wire.put(object, "images", images); + Wire.put(object, "placements", placements); + Wire.put(object, "removed_image_ids", removedImageIds); + return Collections.unmodifiableMap(object); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RenderGraphicsDelta that)) return false; + return Objects.equals(generation, that.generation) && Objects.equals(images, that.images) && Objects.equals(placements, that.placements) && Objects.equals(removedImageIds, that.removedImageIds); + } + + @Override + public int hashCode() { return Objects.hash(generation, images, placements, removedImageIds); } + + @Override + public String toString() { return "RenderGraphicsDelta" + toWire(); } + + public static final class Builder { + private UInt64 generation; + private boolean generationSet; + private Field> images = Field.omitted(); + private Field> placements = Field.omitted(); + private Field> removedImageIds = Field.omitted(); + + public Builder generation(UInt64 value) { + this.generation = value; + this.generationSet = true; + return this; + } + public Builder images(List value) { + this.images = Field.of(value); + return this; + } + public Builder placements(List value) { + this.placements = Field.of(value); + return this; + } + public Builder removedImageIds(List value) { + this.removedImageIds = Field.of(value); + return this; + } + public RenderGraphicsDelta build() { return new RenderGraphicsDelta(this); } + } +} diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/RenderStateEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/RenderStateEvent.java index bf068b8f82..39ded4de8e 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/RenderStateEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/RenderStateEvent.java @@ -15,6 +15,8 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA private final RenderCursor cursor; private final String defaultBg; private final String defaultFg; + private final Field graphics; + private final UInt64 historyEpoch; private final List rows; private final long scrollbackRows; private final Size size; @@ -27,6 +29,9 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA this.defaultBg = Wire.nonNull(builder.defaultBg, "default_bg"); if (!builder.defaultFgSet) throw new IllegalArgumentException("default_fg is required"); this.defaultFg = Wire.nonNull(builder.defaultFg, "default_fg"); + this.graphics = builder.graphics; + if (!builder.historyEpochSet) throw new IllegalArgumentException("history_epoch is required"); + this.historyEpoch = Wire.nonNull(builder.historyEpoch, "history_epoch"); if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = List.copyOf(Wire.nonNull(builder.rows, "rows")); if (!builder.scrollbackRowsSet) throw new IllegalArgumentException("scrollback_rows is required"); @@ -42,6 +47,8 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA public RenderCursor cursor() { return cursor; } public String defaultBg() { return defaultBg; } public String defaultFg() { return defaultFg; } + public Field graphics() { return graphics; } + public UInt64 historyEpoch() { return historyEpoch; } public List rows() { return rows; } public long scrollbackRows() { return scrollbackRows; } public Size size() { return size; } @@ -58,6 +65,12 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA builder.defaultBg(Wire.string(rawDefaultBg, "RenderStateEvent.default_bg")); Object rawDefaultFg = Wire.required(object, "default_fg"); builder.defaultFg(Wire.string(rawDefaultFg, "RenderStateEvent.default_fg")); + Object rawGraphics = Wire.optional(object, "graphics"); + if (!Wire.isMissing(rawGraphics)) { + builder.graphics(RenderGraphics.fromWire(rawGraphics)); + } + Object rawHistoryEpoch = Wire.required(object, "history_epoch"); + builder.historyEpoch(Wire.uint64(rawHistoryEpoch, "RenderStateEvent.history_epoch")); Object rawRows = Wire.required(object, "rows"); builder.rows(Wire.array(rawRows, "RenderStateEvent.rows", item -> RenderRow.fromWire(item))); Object rawScrollbackRows = Wire.required(object, "scrollback_rows"); @@ -76,6 +89,8 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA Wire.put(object, "cursor", cursor); Wire.put(object, "default_bg", defaultBg); Wire.put(object, "default_fg", defaultFg); + Wire.put(object, "graphics", graphics); + Wire.put(object, "history_epoch", historyEpoch); Wire.put(object, "rows", rows); Wire.put(object, "scrollback_rows", scrollbackRows); Wire.put(object, "size", size); @@ -86,11 +101,11 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA @Override public boolean equals(Object other) { if (!(other instanceof RenderStateEvent that)) return false; - return Objects.equals(cursor, that.cursor) && Objects.equals(defaultBg, that.defaultBg) && Objects.equals(defaultFg, that.defaultFg) && Objects.equals(rows, that.rows) && Objects.equals(scrollbackRows, that.scrollbackRows) && Objects.equals(size, that.size) && Objects.equals(surface, that.surface); + return Objects.equals(cursor, that.cursor) && Objects.equals(defaultBg, that.defaultBg) && Objects.equals(defaultFg, that.defaultFg) && Objects.equals(graphics, that.graphics) && Objects.equals(historyEpoch, that.historyEpoch) && Objects.equals(rows, that.rows) && Objects.equals(scrollbackRows, that.scrollbackRows) && Objects.equals(size, that.size) && Objects.equals(surface, that.surface); } @Override - public int hashCode() { return Objects.hash(cursor, defaultBg, defaultFg, rows, scrollbackRows, size, surface); } + public int hashCode() { return Objects.hash(cursor, defaultBg, defaultFg, graphics, historyEpoch, rows, scrollbackRows, size, surface); } @Override public String toString() { return "RenderStateEvent" + toWire(); } @@ -102,6 +117,9 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA private boolean defaultBgSet; private String defaultFg; private boolean defaultFgSet; + private Field graphics = Field.omitted(); + private UInt64 historyEpoch; + private boolean historyEpochSet; private List rows; private boolean rowsSet; private Long scrollbackRows; @@ -126,6 +144,15 @@ public final class RenderStateEvent implements WireValue, ProtocolEvent, RenderA this.defaultFgSet = true; return this; } + public Builder graphics(RenderGraphics value) { + this.graphics = Field.of(value); + return this; + } + public Builder historyEpoch(UInt64 value) { + this.historyEpoch = value; + this.historyEpochSet = true; + return this; + } public Builder rows(List value) { this.rows = value; this.rowsSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/ResizedEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/ResizedEvent.java index 7384e210e1..d3c474e159 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/ResizedEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/ResizedEvent.java @@ -16,6 +16,8 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE private final int cols; /** Protocol 6 compatibility field. */ private final Field data; + private final Field kittyGraphicsState; + private final Field> kittyImageAliases; private final Field replay; private final int rows; private final UInt64 surface; @@ -25,6 +27,8 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE if (!builder.colsSet) throw new IllegalArgumentException("cols is required"); this.cols = builder.cols; this.data = builder.data; + this.kittyGraphicsState = builder.kittyGraphicsState; + this.kittyImageAliases = builder.kittyImageAliases.map(value -> List.copyOf(value)); this.replay = builder.replay; if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = builder.rows; @@ -37,6 +41,8 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE public Field colors() { return colors; } public int cols() { return cols; } public Field data() { return data; } + public Field kittyGraphicsState() { return kittyGraphicsState; } + public Field> kittyImageAliases() { return kittyImageAliases; } public Field replay() { return replay; } public int rows() { return rows; } public UInt64 surface() { return surface; } @@ -56,6 +62,14 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE if (!Wire.isMissing(rawData)) { builder.data(Wire.bytes(rawData, "ResizedEvent.data")); } + Object rawKittyGraphicsState = Wire.optional(object, "kitty_graphics_state"); + if (!Wire.isMissing(rawKittyGraphicsState)) { + builder.kittyGraphicsState(KittyGraphicsState.fromWire(rawKittyGraphicsState)); + } + Object rawKittyImageAliases = Wire.optional(object, "kitty_image_aliases"); + if (!Wire.isMissing(rawKittyImageAliases)) { + builder.kittyImageAliases(Wire.array(rawKittyImageAliases, "ResizedEvent.kitty_image_aliases", item -> KittyImageAlias.fromWire(item))); + } Object rawReplay = Wire.optional(object, "replay"); if (!Wire.isMissing(rawReplay)) { builder.replay(Wire.bytes(rawReplay, "ResizedEvent.replay")); @@ -74,6 +88,8 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE Wire.put(object, "colors", colors); Wire.put(object, "cols", cols); Wire.put(object, "data", data); + Wire.put(object, "kitty_graphics_state", kittyGraphicsState); + Wire.put(object, "kitty_image_aliases", kittyImageAliases); Wire.put(object, "replay", replay); Wire.put(object, "rows", rows); Wire.put(object, "surface", surface); @@ -83,11 +99,11 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE @Override public boolean equals(Object other) { if (!(other instanceof ResizedEvent that)) return false; - return Objects.equals(colors, that.colors) && Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(replay, that.replay) && Objects.equals(rows, that.rows) && Objects.equals(surface, that.surface); + return Objects.equals(colors, that.colors) && Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(kittyGraphicsState, that.kittyGraphicsState) && Objects.equals(kittyImageAliases, that.kittyImageAliases) && Objects.equals(replay, that.replay) && Objects.equals(rows, that.rows) && Objects.equals(surface, that.surface); } @Override - public int hashCode() { return Objects.hash(colors, cols, data, replay, rows, surface); } + public int hashCode() { return Objects.hash(colors, cols, data, kittyGraphicsState, kittyImageAliases, replay, rows, surface); } @Override public String toString() { return "ResizedEvent" + toWire(); } @@ -97,6 +113,8 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE private Integer cols; private boolean colsSet; private Field data = Field.omitted(); + private Field kittyGraphicsState = Field.omitted(); + private Field> kittyImageAliases = Field.omitted(); private Field replay = Field.omitted(); private Integer rows; private boolean rowsSet; @@ -116,6 +134,14 @@ public final class ResizedEvent implements WireValue, ByteAttachEvent, ProtocolE this.data = Field.of(value); return this; } + public Builder kittyGraphicsState(KittyGraphicsState value) { + this.kittyGraphicsState = Field.of(value); + return this; + } + public Builder kittyImageAliases(List value) { + this.kittyImageAliases = Field.of(value); + return this; + } public Builder replay(Bytes value) { this.replay = Field.of(value); return this; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/VtStateEvent.java b/cmux-tui/bindings/java/src/com/cmux/raw/VtStateEvent.java index 005fb99609..5c6d5ae296 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/VtStateEvent.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/VtStateEvent.java @@ -15,6 +15,8 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE private final Field colors; private final int cols; private final Bytes data; + private final Field kittyGraphicsState; + private final Field> kittyImageAliases; private final int rows; private final UInt64 surface; @@ -24,6 +26,8 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE this.cols = builder.cols; if (!builder.dataSet) throw new IllegalArgumentException("data is required"); this.data = Wire.nonNull(builder.data, "data"); + this.kittyGraphicsState = builder.kittyGraphicsState; + this.kittyImageAliases = builder.kittyImageAliases.map(value -> List.copyOf(value)); if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = builder.rows; if (!builder.surfaceSet) throw new IllegalArgumentException("surface is required"); @@ -35,6 +39,8 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE public Field colors() { return colors; } public int cols() { return cols; } public Bytes data() { return data; } + public Field kittyGraphicsState() { return kittyGraphicsState; } + public Field> kittyImageAliases() { return kittyImageAliases; } public int rows() { return rows; } public UInt64 surface() { return surface; } @Override public String event() { return "vt-state"; } @@ -51,6 +57,14 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE builder.cols(Wire.uint16(rawCols, "VtStateEvent.cols")); Object rawData = Wire.required(object, "data"); builder.data(Wire.bytes(rawData, "VtStateEvent.data")); + Object rawKittyGraphicsState = Wire.optional(object, "kitty_graphics_state"); + if (!Wire.isMissing(rawKittyGraphicsState)) { + builder.kittyGraphicsState(KittyGraphicsState.fromWire(rawKittyGraphicsState)); + } + Object rawKittyImageAliases = Wire.optional(object, "kitty_image_aliases"); + if (!Wire.isMissing(rawKittyImageAliases)) { + builder.kittyImageAliases(Wire.array(rawKittyImageAliases, "VtStateEvent.kitty_image_aliases", item -> KittyImageAlias.fromWire(item))); + } Object rawRows = Wire.required(object, "rows"); builder.rows(Wire.uint16(rawRows, "VtStateEvent.rows")); Object rawSurface = Wire.required(object, "surface"); @@ -65,6 +79,8 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE Wire.put(object, "colors", colors); Wire.put(object, "cols", cols); Wire.put(object, "data", data); + Wire.put(object, "kitty_graphics_state", kittyGraphicsState); + Wire.put(object, "kitty_image_aliases", kittyImageAliases); Wire.put(object, "rows", rows); Wire.put(object, "surface", surface); return Collections.unmodifiableMap(object); @@ -73,11 +89,11 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE @Override public boolean equals(Object other) { if (!(other instanceof VtStateEvent that)) return false; - return Objects.equals(colors, that.colors) && Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(rows, that.rows) && Objects.equals(surface, that.surface); + return Objects.equals(colors, that.colors) && Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(kittyGraphicsState, that.kittyGraphicsState) && Objects.equals(kittyImageAliases, that.kittyImageAliases) && Objects.equals(rows, that.rows) && Objects.equals(surface, that.surface); } @Override - public int hashCode() { return Objects.hash(colors, cols, data, rows, surface); } + public int hashCode() { return Objects.hash(colors, cols, data, kittyGraphicsState, kittyImageAliases, rows, surface); } @Override public String toString() { return "VtStateEvent" + toWire(); } @@ -88,6 +104,8 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE private boolean colsSet; private Bytes data; private boolean dataSet; + private Field kittyGraphicsState = Field.omitted(); + private Field> kittyImageAliases = Field.omitted(); private Integer rows; private boolean rowsSet; private UInt64 surface; @@ -107,6 +125,14 @@ public final class VtStateEvent implements WireValue, ByteAttachEvent, ProtocolE this.dataSet = true; return this; } + public Builder kittyGraphicsState(KittyGraphicsState value) { + this.kittyGraphicsState = Field.of(value); + return this; + } + public Builder kittyImageAliases(List value) { + this.kittyImageAliases = Field.of(value); + return this; + } public Builder rows(int value) { this.rows = value; this.rowsSet = true; diff --git a/cmux-tui/bindings/java/src/com/cmux/raw/VtStateResult.java b/cmux-tui/bindings/java/src/com/cmux/raw/VtStateResult.java index 489498e344..661434f892 100644 --- a/cmux-tui/bindings/java/src/com/cmux/raw/VtStateResult.java +++ b/cmux-tui/bindings/java/src/com/cmux/raw/VtStateResult.java @@ -13,6 +13,8 @@ import java.util.Objects; public final class VtStateResult implements WireValue { private final int cols; private final Bytes data; + private final Field kittyGraphicsState; + private final Field> kittyImageAliases; private final int rows; private VtStateResult(Builder builder) { @@ -20,6 +22,8 @@ public final class VtStateResult implements WireValue { this.cols = builder.cols; if (!builder.dataSet) throw new IllegalArgumentException("data is required"); this.data = Wire.nonNull(builder.data, "data"); + this.kittyGraphicsState = builder.kittyGraphicsState; + this.kittyImageAliases = builder.kittyImageAliases.map(value -> List.copyOf(value)); if (!builder.rowsSet) throw new IllegalArgumentException("rows is required"); this.rows = builder.rows; } @@ -28,6 +32,8 @@ public final class VtStateResult implements WireValue { public int cols() { return cols; } public Bytes data() { return data; } + public Field kittyGraphicsState() { return kittyGraphicsState; } + public Field> kittyImageAliases() { return kittyImageAliases; } public int rows() { return rows; } public static VtStateResult fromWire(Object value) { @@ -37,6 +43,14 @@ public final class VtStateResult implements WireValue { builder.cols(Wire.uint16(rawCols, "VtStateResult.cols")); Object rawData = Wire.required(object, "data"); builder.data(Wire.bytes(rawData, "VtStateResult.data")); + Object rawKittyGraphicsState = Wire.optional(object, "kitty_graphics_state"); + if (!Wire.isMissing(rawKittyGraphicsState)) { + builder.kittyGraphicsState(KittyGraphicsState.fromWire(rawKittyGraphicsState)); + } + Object rawKittyImageAliases = Wire.optional(object, "kitty_image_aliases"); + if (!Wire.isMissing(rawKittyImageAliases)) { + builder.kittyImageAliases(Wire.array(rawKittyImageAliases, "VtStateResult.kitty_image_aliases", item -> KittyImageAlias.fromWire(item))); + } Object rawRows = Wire.required(object, "rows"); builder.rows(Wire.uint16(rawRows, "VtStateResult.rows")); return builder.build(); @@ -47,6 +61,8 @@ public final class VtStateResult implements WireValue { LinkedHashMap object = new LinkedHashMap<>(); Wire.put(object, "cols", cols); Wire.put(object, "data", data); + Wire.put(object, "kitty_graphics_state", kittyGraphicsState); + Wire.put(object, "kitty_image_aliases", kittyImageAliases); Wire.put(object, "rows", rows); return Collections.unmodifiableMap(object); } @@ -54,11 +70,11 @@ public final class VtStateResult implements WireValue { @Override public boolean equals(Object other) { if (!(other instanceof VtStateResult that)) return false; - return Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(rows, that.rows); + return Objects.equals(cols, that.cols) && Objects.equals(data, that.data) && Objects.equals(kittyGraphicsState, that.kittyGraphicsState) && Objects.equals(kittyImageAliases, that.kittyImageAliases) && Objects.equals(rows, that.rows); } @Override - public int hashCode() { return Objects.hash(cols, data, rows); } + public int hashCode() { return Objects.hash(cols, data, kittyGraphicsState, kittyImageAliases, rows); } @Override public String toString() { return "VtStateResult" + toWire(); } @@ -68,6 +84,8 @@ public final class VtStateResult implements WireValue { private boolean colsSet; private Bytes data; private boolean dataSet; + private Field kittyGraphicsState = Field.omitted(); + private Field> kittyImageAliases = Field.omitted(); private Integer rows; private boolean rowsSet; @@ -81,6 +99,14 @@ public final class VtStateResult implements WireValue { this.dataSet = true; return this; } + public Builder kittyGraphicsState(KittyGraphicsState value) { + this.kittyGraphicsState = Field.of(value); + return this; + } + public Builder kittyImageAliases(List value) { + this.kittyImageAliases = Field.of(value); + return this; + } public Builder rows(int value) { this.rows = value; this.rowsSet = true; diff --git a/cmux-tui/bindings/java/tests/com/cmux/raw/ErgonomicsTest.java b/cmux-tui/bindings/java/tests/com/cmux/raw/ErgonomicsTest.java index d1c7b8710e..c5cdbda58e 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/raw/ErgonomicsTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/raw/ErgonomicsTest.java @@ -62,6 +62,7 @@ public final class ErgonomicsTest { writeObject( connection, success(probe.get("id"), Map.of( + "epoch", 1L, "rows", List.of(), "start", 0L, "total", 10L @@ -76,6 +77,7 @@ public final class ErgonomicsTest { writeObject( connection, success(page.get("id"), Map.of( + "epoch", 1L, "rows", List.of( renderRow(0, "eight"), renderRow(1, "nine"), diff --git a/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java b/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java index 571f664656..09054f98f7 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java @@ -20,8 +20,8 @@ public final class GeneratedCoverageTest { public static void main(String[] args) throws Exception { check(Protocol.VERSION == 10, "protocol version"); check("1.0.0".equals(Protocol.SDK_VERSION), "SDK release version"); - check(Commands.ALL.size() == 91, "all 91 commands generated"); - check(Events.ALL.size() == 44, "all 44 events generated"); + check(Commands.ALL.size() == 92, "all 92 commands generated"); + check(Events.ALL.size() == 45, "all 45 events generated"); Map methods = Arrays.stream(GeneratedCmuxClient.class.getDeclaredMethods()) .filter(method -> Modifier.isPublic(method.getModifiers())) diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json index a631ed2f56..cf56abd4e1 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json @@ -7,32 +7,32 @@ }, { "path": "_schema.py", - "sha256": "c0be4080fa1bf06841cc8560d53eaaa9360e5867683553c03b1c4861f5d5a29b", - "size": 127251 + "sha256": "43f1495b6ecb8ab6848981aeddab98adc0e6bcb095dc60f40d4f8ca8497cfd75", + "size": 136058 }, { "path": "client.py", - "sha256": "1ae5c4ac44d1d2b5b33db4a400529cf63e7084d1833b86d557e19eff2aa8deb8", - "size": 33487 + "sha256": "c89f1a0cc1724d1ddaedd656082b9818aa6c62153a72bcac2e4d75c8ec566757", + "size": 33705 }, { "path": "codec.py", - "sha256": "065e6e4a2a39a6ca78c0f0c351237e36b6d7cdc5ac9b861eb768f15b01217c59", - "size": 25384 + "sha256": "f0d6c6220dbee14c3d125b144ea765d3b39b43eb4cd2a635c1d5ee7facce5a3e", + "size": 26046 }, { "path": "metadata.py", - "sha256": "0332e8cab5864222cb55edc006206059eba5c3338dd6e80579c52439756623ef", - "size": 40517 + "sha256": "cae9a6576b6b4c3494a7e3b85ab173b86846dd56a598af4c396b678781f21c49", + "size": 40788 }, { "path": "models.py", - "sha256": "d886e2badc473844bf19ae97572c82c49d03ccd96ac151d4ccbedbe58d32c916", - "size": 70223 + "sha256": "81c58f09ce4c59679a667c19d83504343634e00a4e71eea02440c28a3db19686", + "size": 74913 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "python", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py index e21bf86b22..eb4495487d 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/_schema.py @@ -5,4 +5,4 @@ from __future__ import annotations import json -SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":10},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":true,"presence":"required","type":{"kind":"literal","value":"running"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') +SCHEMA = json.loads('{"$schema":"./sdk-schema.schema.json","commands":{"apply-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["layout must contain at least one leaf or stack member.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ApplyLayoutResult"},"since":6,"stream":null},"attach-surface":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows must be supplied together.","Browser surfaces reject mode:render."],"fields":{"cols":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"mode":{"default":"bytes","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["bytes","render"]}},"rows":{"capability":"attach-initial-size","default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["browser-state","colors-changed","detached","frame","notification","output","overflow","render-delta","render-state","resized","scroll-changed","vt-state"],"kind":"attach","mode_field":"mode","modes":{"browser":["browser-state","frame","notification","scroll-changed","overflow","detached"],"bytes":["vt-state","output","resized","colors-changed","scroll-changed","notification","overflow","detached"],"render":["render-state","render-delta","scroll-changed","overflow","detached"]},"ordering":"Initial state precedes the command response. Later surface events preserve order. A resized replay replaces the previous byte mirror; overflow ends that surface stream.","terminal_event":"detached"}},"browser-activate":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-back":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-forward":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-frame-presented":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Acknowledges the exact rendered browser frame for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-insert-text":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up"]}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-key-press":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only.","One request preserves the atomic press sequence through the bounded input queue."],"request":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"modifiers":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"windows_virtual_key_code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-mouse":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; coordinates are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-mouse-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; coordinates are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"button":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"click_count":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["down","up","move"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"browser-navigate":{"authority":"frontend","capability":null,"constraints":["Queue acknowledgement only; observe browser-state for outcome.","Navigation is latest-wins."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-reload":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; queue acknowledgement is not page-load success."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel":{"authority":"frontend","capability":null,"constraints":["Browser surfaces only; values are CSS pixels.","The bounded disposable input queue drops newest input when full."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"browser-wheel-guarded":{"authority":"frontend","capability":"browser-pointer-frame-guard-v1","constraints":["Browser surfaces only; values are CSS pixels.","The frame sequence must be the exact presented token for this connection.","Requires browser-pointer-frame-guard-v1."],"request":{"additional_properties":false,"fields":{"delta_y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"frame_seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"x_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}},"y_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"clear-history":{"authority":"control","capability":"clear-history-v1","constraints":["PTY surfaces only.","Failed responses classify error_delivery as known-not-delivered or ambiguous."],"request":{"additional_properties":false,"fields":{"fallback_key":{"capability":"clear-history-key-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"TerminalKeyInput"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"clear-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"close-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the close."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"close-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"close-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"CloseTerminalResult"},"since":9,"stream":null},"close-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"copy":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"CopyResult"},"since":6,"stream":null},"create-terminal":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; when both are supplied they must identify the same workspace.","argv and command are mutually exclusive and must be nonempty when supplied.","cols and rows must be supplied together.","origin and mutation_id are either both present or both absent."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"terminal_id":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalPlacement"},"since":7,"stream":null},"create-workspace":{"authority":"control","capability":"workspace-registry-v1","constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","At most 4096 live workspaces may exist; tombstoned keys cannot be reused."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"key":{"constraints":[{"format":"lowercase canonical UUID"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":7,"stream":null},"detach-client":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"export-layout":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"screen":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ExportLayoutResult"},"since":6,"stream":null},"focus-direction":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"FocusDirectionResult"},"since":6,"stream":null},"focus-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"get-cell-pixels":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"GetCellPixelsResult"},"since":6,"stream":null},"get-frontend-projection":{"authority":"control","capability":null,"constraints":["Each identifier is nonempty, contains no control character, and is at most 128 bytes."],"request":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"identify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"IdentifyResult"},"since":5,"stream":null},"ids":{"authority":"control","capability":null,"constraints":["Short ids are snapshot-local labels; command parameters accept numeric ids only."],"request":{"additional_properties":false,"fields":{"kind":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}}},"kind":"object"},"result":{"kind":"ref","name":"IdsResult"},"since":6,"stream":null},"list-agents":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"state":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"AgentState"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ListAgentsResult"},"since":6,"stream":null},"list-clients":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"items":{"kind":"ref","name":"ClientInfo"},"kind":"array"},"since":6,"stream":null},"list-terminals":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"ListTerminalsResult"},"since":9,"stream":null},"list-workspaces":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"Tree"},"since":5,"stream":null},"mark-workspaces-provider-managed":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Authority must match the value provisioned before this mux generation accepted control clients."],"request":{"additional_properties":false,"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"mint-terminal-renderer":{"authority":"frontend","capability":null,"constraints":["Only terminal-host-backed PTYs can mint one-use renderer credentials."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ttl_ms":{"constraints":[{"maximum":60000,"minimum":1}],"default":30000,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"MintTerminalRendererResult"},"since":9,"stream":null},"move-tab":{"authority":"control","capability":null,"constraints":["An out-of-range index clamps to the destination end."],"request":{"additional_properties":false,"fields":{"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"move-terminal":{"authority":"control","capability":null,"constraints":["A move to the current workspace still commits a terminal revision with changed:false."],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"MoveTerminalResult"},"since":9,"stream":null},"move-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"new-browser-tab":{"authority":"control","capability":null,"constraints":["Bootstrap and navigation failures are asynchronous browser-state outcomes."],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-pane-right":{"authority":"control","capability":"viewport-splits-v1","constraints":[],"request":{"additional_properties":false,"constraints":["Omitted width defaults to two thirds.","cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":9,"stream":null},"new-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"new-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"notify":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"level":{"default":"info","nullable":true,"presence":"optional","type":{"kind":"ref","name":"NotificationLevel"}},"surface":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"title":{"constraints":[{"min_length":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"NotifyResult"},"since":6,"stream":null},"pairing-response":{"authority":"local-admin","capability":null,"constraints":["The request id must identify a live, unexpired pairing challenge."],"request":{"additional_properties":false,"fields":{"approve":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"pane-neighbor":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"PaneNeighborResult"},"since":6,"stream":null},"ping":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"kind":"ref","name":"PingResult"},"since":6,"stream":null},"process-info":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProcessInfoResult"},"since":6,"stream":null},"put-frontend-projection":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent.","Serialized projection must be at most 1048576 bytes."],"fields":{"expected_generation":{"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"expected_projection_revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"description":"Accepted by the current decoder but ignored for projection writes.","nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"FrontendProjection"},"since":7,"stream":null},"read-screen":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScreenResult"},"since":5,"stream":null},"read-scrollback":{"authority":"control","capability":null,"constraints":["PTY surfaces only; row indexes are snapshot-relative and not durable."],"request":{"additional_properties":false,"fields":{"count":{"constraints":[{"maximum":65535,"minimum":0}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReadScrollbackResult"},"since":7,"stream":null},"release-surface-size":{"authority":"control","capability":null,"constraints":["An absent lease is a successful no-op."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":7,"stream":null},"reload-config":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{},"kind":"object"},"result":{"additional_properties":false,"fields":{"path":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"reloaded":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"since":6,"stream":null},"rename-pane":{"authority":"control","capability":null,"constraints":["An empty name clears the pane name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-provider-managed-workspace":{"authority":"provider-authority","capability":"provider-managed-workspace-authority-v2","constraints":["Call only after the external provider durably accepts the rename."],"request":{"additional_properties":false,"constraints":["workspace and key must identify the same live provider-managed workspace."],"fields":{"authority":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ProviderWorkspaceMutationResult"},"since":9,"stream":null},"rename-screen":{"authority":"control","capability":null,"constraints":["An empty name clears the screen name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-surface":{"authority":"control","capability":null,"constraints":["An empty name clears the surface name."],"request":{"additional_properties":false,"fields":{"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"rename-workspace":{"authority":"control","capability":null,"constraints":["Provider-managed workspaces reject this ordinary mutation."],"request":{"additional_properties":false,"constraints":["At least one of workspace and key must be supplied; both must identify the same workspace when supplied.","origin and mutation_id are either both present or both absent."],"fields":{"expected_generation":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"expected_revision":{"aliases":["expected_terminal_revision"],"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"key":{"capability":"workspace-registry-v1","default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"mutation_id":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"constraints":[{"max_utf8_bytes":1024}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"default":null,"nullable":true,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"workspace":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"WorkspaceMutationResult"},"since":5,"stream":null},"report-agent":{"authority":"control","capability":null,"constraints":["A stored hook report outranks later socket reports until another hook report or surface close."],"request":{"additional_properties":false,"fields":{"session":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ReportAgentResult"},"since":6,"stream":null},"resize-surface":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"clamped_maximum":10000,"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ResizeSurfaceResult"},"since":5,"stream":null},"resolve-terminal":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"ResolveTerminalResult"},"since":9,"stream":null},"run":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of argv and command must be supplied.","pane and new_workspace:true are mutually exclusive.","key is valid only with new_workspace:true.","cols and rows affect sizing only when both are present."],"fields":{"argv":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"command":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"key":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"name":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"new_workspace":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"RunResult"},"since":6,"stream":null},"scroll-surface":{"authority":"control","capability":null,"constraints":["PTY surfaces only; negative values scroll up."],"request":{"additional_properties":false,"fields":{"delta":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-screen":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-tab":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"select-workspace":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both index and delta are supplied, index wins."],"fields":{"delta":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"int64"}},"index":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["When both are present, UTF-8 text bytes precede decoded bytes."],"fields":{"bytes":{"constraints":[{"encoding":"standard base64"}],"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"paste":{"default":false,"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"text":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"send-key":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"keys":{"constraints":[{"syntax":"lowercase modifier+key chords"}],"nullable":false,"presence":"required","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-cell-pixels":{"authority":"frontend","capability":null,"constraints":["Accepted browser resizes complete asynchronously."],"request":{"additional_properties":false,"fields":{"height_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"width_px":{"constraints":[{"clamped_minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SetCellPixelsResult"},"since":6,"stream":null},"set-client-info":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"capabilities":{"constraints":["Advertises additive client capabilities for this connection."],"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"kind":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"name":{"constraints":["Control characters become spaces; at most 64 Unicode characters are retained."],"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"set-client-sizing":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["exclusive:true requires client and enabled:true.","Omitting client is valid only with enabled:true and restores all clients for the surface."],"fields":{"client":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"enabled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"exclusive":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":10,"stream":null},"set-default-colors":{"authority":"control","capability":null,"constraints":["Color strings are exactly #rrggbb.","With complete:true, absent optional values reset to built-in defaults."],"request":{"additional_properties":false,"fields":{"bg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"complete":{"default":false,"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":null},"set-split-ratio":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":8,"stream":null},"set-viewport-pane-width":{"authority":"control","capability":"viewport-column-resize-v1","constraints":["width must be finite."],"request":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"transaction":{"capability":"layout-undo-v1","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"width":{"constraints":[{"maximum":1.0,"minimum":0.1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":9,"stream":null},"set-window-title":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"title":{"constraints":["C0 controls are sanitized before OSC output."],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"shutdown-daemon":{"authority":"local-admin","capability":null,"constraints":["pid and generation must match the latest identify result.","force bypasses native-browser ownership only; the identity fence and trusted-local authority still apply.","Clients must require daemon-handoff-force-v1 before sending force:true.","The daemon exits only after the success response is queued."],"request":{"additional_properties":false,"fields":{"force":{"capability":"daemon-handoff-force-v1","default":false,"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"result":{"kind":"ref","name":"ShutdownDaemonResult"},"since":9,"stream":null},"sidebar-plugin":{"authority":"frontend","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"relaunch":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SidebarPluginResult"},"since":6,"stream":null},"split":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["cols and rows affect sizing only when both are present."],"fields":{"cols":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"rows":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"result":{"kind":"ref","name":"SurfaceResult"},"since":5,"stream":null},"subscribe":{"authority":"frontend","capability":null,"constraints":["subscribe sends no initial tree snapshot.","surface filtering occurs before the bounded mailbox."],"request":{"additional_properties":false,"fields":{"surface":{"capability":"surface-subscribe-filter","default":null,"nullable":true,"presence":"optional","since":9,"type":{"kind":"ref","name":"Id"}},"tree_events":{"default":"coarse","nullable":true,"presence":"optional","since":7,"type":{"kind":"enum","values":["coarse","deltas"]}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":5,"stream":{"event_names":["bell","client-attached","client-changed","client-detached","client-list-invalidated","config-reload-requested","empty","frontend-projection-changed","layout-changed","notification","overflow","pairing-requested","pairing-resolved","pane-added","pane-closed","screen-added","screen-closed","screen-renamed","scroll-changed","status","surface-exited","surface-output","surface-resize-failed","surface-resized","tab-added","tab-closed","tab-renamed","terminal-registry-changed","title-changed","tree-changed","window-title-requested","workspace-added","workspace-closed","workspace-moved","workspace-renamed"],"kind":"subscribe","mode_field":"tree_events","modes":{"coarse":["tree-changed"],"deltas":["workspace-added","workspace-closed","workspace-renamed","workspace-moved","screen-added","screen-closed","screen-renamed","pane-added","pane-closed","tab-added","tab-closed","tab-renamed","tree-changed"]},"ordering":"Response and event objects may interleave. Events preserve enqueue order per subscription. Delta workspace revisions are serialized in durable commit order; overflow ends the stream and requires resubscribe plus snapshot.","terminal_event":null}},"swap-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"constraints":["Exactly one of dir and target must be supplied."],"fields":{"dir":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"PaneDirection"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"target":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"EmptyResult"},"since":6,"stream":null},"terminal-events":{"authority":"control","capability":null,"constraints":["Consumers apply only contiguous revisions for one registry_id and generation."],"request":{"additional_properties":false,"fields":{"after_revision":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"TerminalEventsResult"},"since":9,"stream":null},"undo-layout":{"authority":"control","capability":"layout-undo-v1","constraints":["Clients must reject incomplete or contradictory result variants."],"request":{"additional_properties":false,"constraints":["confirm_close requires the exact preview revision."],"fields":{"confirm_close":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"revision":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"LayoutUndoResult"},"since":9,"stream":null},"vt-state":{"authority":"control","capability":null,"constraints":["PTY surfaces only."],"request":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"VtStateResult"},"since":5,"stream":null},"wait-for":{"authority":"control","capability":null,"constraints":["Blocks subsequent requests on this connection; SDKs should use a dedicated connection."],"request":{"additional_properties":false,"fields":{"pattern":{"constraints":[{"syntax":"Rust regex"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"timeout_ms":{"description":"Zero performs one immediate check.","nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"result":{"kind":"ref","name":"WaitForResult"},"since":6,"stream":null},"zoom-pane":{"authority":"control","capability":null,"constraints":[],"request":{"additional_properties":false,"fields":{"mode":{"default":"toggle","nullable":true,"presence":"optional","type":{"kind":"enum","values":["toggle","on","off"]}},"pane":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"result":{"kind":"ref","name":"ZoomPaneResult"},"since":6,"stream":null}},"events":{"bell":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"bell"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"browser-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"browser-state"}},"frame":{"description":"The initial browser-state includes the latest frame when one exists; later state updates omit it.","nullable":true,"presence":"optional","type":{"kind":"ref","name":"BrowserFrame"}},"frames_stalled":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"status":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["starting","live","failed"]}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"url":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"client-attached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-attached"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["unix","ws"]}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-changed"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-detached"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"client-list-invalidated":{"capability":null,"emission":"serialized-never-emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"client-list-invalidated"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"colors-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","type":{"kind":"ref","name":"CursorStyle"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"colors-changed"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"surface":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"config-reload-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"config-reload-requested"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"detached":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"detached"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte","attach-render","attach-browser"]},"empty":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"empty"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"frame":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frame"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"since":6,"streams":["attach-browser"]},"frontend-projection-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"frontend-projection-changed"}},"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"graphics-status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["kitty-image-budget-worker-start-failed carries error.","kitty-image-budget-update-failed carries retry_exhausted and summary.","cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height."],"fields":{"attempts":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_height":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"cell_width":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"graphics-status"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["kitty-image-budget-worker-start-failed","kitty-image-budget-update-failed","cell-pixel-update-retries-exhausted"]}},"remaining":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"retry_exhausted":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"summary":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":10,"streams":["subscribe"]},"layout-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"layout-changed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"notification":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"body":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"notification"}},"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-browser"]},"output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"overflow":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["scope and surface are either both present for attach overflow or both absent for subscribe overflow."],"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"overflow"}},"scope":{"nullable":false,"presence":"optional","type":{"kind":"literal","value":"surface"}},"surface":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"pairing-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"code":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-requested"}},"expires_in":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"peer":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pairing-resolved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pairing-resolved"}},"request":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"pane-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"pane-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Pane"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"pane-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"render-delta":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["size is present if and only if the surface resized; every resize has full:true."],"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-delta"}},"full":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphicsDelta"}},"history_epoch":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"render-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cursor":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderCursor"}},"default_bg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"default_fg":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"render-state"}},"graphics":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderGraphics"}},"history_epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"scrollback_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"size":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Size"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["attach-render"]},"resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["At least one of replay and data is present; replay is canonical from protocol 7."],"fields":{"colors":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"description":"Protocol 6 compatibility field.","nullable":false,"presence":"optional","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"resized"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"replay":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"ref","name":"Base64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["attach-byte"]},"screen-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"screen-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Screen"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"screen-renamed"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"scroll-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"at_bottom":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"scroll-changed"}},"offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":6,"streams":["subscribe","attach-byte","attach-render","attach-browser"]},"status":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"status"}},"message":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-exited":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-exited"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-output":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-output"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"surface-resize-failed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resize-failed"}},"reservation_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe"]},"surface-resized":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"surface-resized"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tab-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-added"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-closed"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"tab-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Tab"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tab-renamed"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"terminal-registry-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-registry-changed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"refetch":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"terminal-events-or-list-terminals"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":9,"streams":["subscribe"]},"title-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"title-changed"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"title":{"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"tree-changed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"tree-changed"}}},"kind":"object"},"since":5,"streams":["subscribe"]},"vt-state":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"colors":{"nullable":false,"presence":"optional","since":6,"type":{"kind":"ref","name":"TerminalColors"}},"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"vt-state"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"since":5,"streams":["attach-byte"]},"window-title-requested":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"fields":{"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"window-title-requested"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"since":6,"streams":["subscribe"]},"workspace-added":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-added"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-closed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-closed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-moved":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-moved"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]},"workspace-renamed":{"capability":null,"emission":"emitted","payload":{"additional_properties":false,"constraints":["origin and mutation_id are either both present or both absent."],"fields":{"entity":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Workspace"}},"event":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"workspace-renamed"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"since":7,"streams":["subscribe-deltas"]}},"ir_sha256":"17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350","profiles":{"control":{"description":"Base authenticated session-control commands available to ordinary SDK clients.","inherits":[]},"frontend":{"description":"Rendering, input, presentation, subscribe, and attach commands.","inherits":["control"]},"local-admin":{"description":"Trusted local administration commands.","inherits":["control"],"transport":"Unix-classified transport, including direct Unix and the current stdio relay"},"provider-authority":{"description":"Provider-owned workspace mutation commands.","inherits":["control"],"requires_authority":true}},"protocol":{"id_type":"uint64","javascript_id_policy":"All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.","name":"cmux-tui-mux","version":10},"schema_version":2,"types":{"AgentRecord":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"updated_at_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"AgentReportSource":{"kind":"enum","values":["socket","hook"]},"AgentSource":{"kind":"enum","values":["detected","socket","hook"]},"AgentState":{"kind":"enum","values":["working","blocked","idle","done","unknown"]},"AppliedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ApplyLayoutResult":{"additional_properties":false,"fields":{"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AppliedPane"},"kind":"array"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Base64":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"BrowserFrame":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"seq":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"CellPixelFailure":{"additional_properties":false,"fields":{"error":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelResize":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"reservation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"CellPixelSurface":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"ClientInfo":{"additional_properties":false,"fields":{"attached":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"client":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"connected_seconds":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"kind":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"self":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"sizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ClientSize"},"kind":"array"}},"transport":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"ClientTransport"}}},"kind":"object"},"ClientSize":{"additional_properties":false,"fields":{"cols":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"size_participating":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ClientTransport":{"kind":"enum","values":["local","unix","ws"]},"CloseTerminalResult":{"additional_properties":false,"fields":{"already_closed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"closed":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ColorHex":{"kind":"alias","target":{"kind":"scalar","name":"string"}},"CopyResult":{"additional_properties":false,"fields":{"mode":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["screen","selection","scrollback"]}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"CursorStyle":{"kind":"enum","values":["block","underline","bar"]},"DeadPane":{"additional_properties":false,"fields":{"dead":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"DeclarativeLayout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"command":{"default":null,"nullable":true,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array","min_items":1}},"cwd":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"DeclarativeLayout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"clamped_maximum":0.95,"clamped_minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"EmptyResult":{"additional_properties":false,"fields":{},"kind":"object"},"ExportLayoutResult":{"additional_properties":false,"fields":{"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"ExportedPane"},"kind":"array"}}},"kind":"object"},"ExportedPane":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}}},"kind":"object"},"FocusDirectionResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"FrontendProjection":{"additional_properties":false,"fields":{"frontend":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"projection":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"projection_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"replayed":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"schema_version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"scope":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"subject_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"GetCellPixelsResult":{"additional_properties":false,"fields":{"height_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"surfaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelSurface"},"kind":"array"}},"width_px":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"Id":{"kind":"alias","target":{"kind":"scalar","name":"uint64"}},"IdMapping":{"additional_properties":false,"fields":{"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["workspace","screen","pane","surface"]}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"IdentifyResult":{"additional_properties":false,"fields":{"app":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"cmux-tui"}},"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"capabilities":{"default":[],"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"string"},"kind":"array"}},"daemon_handoff":{"nullable":false,"presence":"required","since":9,"type":{"kind":"literal","value":1}},"generation":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"registry_id":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"string"}},"session":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","since":9,"type":{"kind":"scalar","name":"uint64"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_revision":{"nullable":false,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"IdsResult":{"additional_properties":false,"fields":{"ids":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"IdMapping"},"kind":"array"}}},"kind":"object"},"JsonValue":{"kind":"opaque_json","reason":"The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document."},"KittyGraphicsState":{"additional_properties":false,"fields":{"alternate_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"alternate_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"inflight_bytes":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"placements":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"primary_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"primary_replay_next_image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"replay_cursor_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"KittyImageAlias":{"additional_properties":false,"fields":{"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_number":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"Layout":{"kind":"tagged_union","tag":"type","variants":{"leaf":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"leaf"}}},"kind":"object"},"split":{"additional_properties":false,"fields":{"a":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"b":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"dir":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"SplitDirection"}},"ratio":{"constraints":[{"maximum":0.95,"minimum":0.05}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"float32"}},"split":{"description":"Stable for the lifetime of this split node.","nullable":false,"presence":"optional","since":8,"type":{"kind":"ref","name":"Id"}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"split"}}},"kind":"object"},"stack":{"additional_properties":false,"fields":{"expanded":{"constraints":["Must identify a member of panes."],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array","min_items":1}},"type":{"nullable":false,"presence":"required","type":{"kind":"literal","value":"stack"}}},"kind":"object"}}},"LayoutUndoConfirmationRequired":{"additional_properties":false,"fields":{"closes_panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Id"},"kind":"array"}},"confirmation_required":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":false}}},"kind":"object"},"LayoutUndoResult":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LayoutUndoUndone"},{"kind":"ref","name":"LayoutUndoConfirmationRequired"}]},"LayoutUndoUndone":{"additional_properties":false,"fields":{"confirmation_required":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"literal","value":false}},"revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"undone":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}}},"kind":"object"},"ListAgentsResult":{"additional_properties":false,"fields":{"agents":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"AgentRecord"},"kind":"array"}}},"kind":"object"},"ListTerminalsResult":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"terminals":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRecord"},"kind":"array"}}},"kind":"object"},"LivePane":{"additional_properties":false,"fields":{"active_tab":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"focused_at":{"default":0,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint64"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"tabs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Tab"},"kind":"array"}}},"kind":"object"},"MintTerminalRendererResult":{"additional_properties":false,"fields":{"endpoint":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"incarnation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"rights":{"constraints":[{"current_value":7}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"token":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"ttl_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"MoveTerminalResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"NotificationLevel":{"kind":"enum","values":["info","warning","error"]},"NotificationMarker":{"additional_properties":false,"fields":{"level":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"NotificationLevel"}},"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"unread":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"NotifyResult":{"additional_properties":false,"fields":{"notification":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Pane":{"kind":"untagged_union","variants":[{"kind":"ref","name":"LivePane"},{"kind":"ref","name":"DeadPane"}]},"PaneDirection":{"kind":"enum","values":["left","right","up","down"]},"PaneNeighborResult":{"additional_properties":false,"fields":{"pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"PingResult":{"additional_properties":false,"fields":{"build_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ghostty_commit":{"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"ok":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"protocol":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"version":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ProcessInfoResult":{"additional_properties":false,"fields":{"command":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"cwd":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"ProviderWorkspaceMutationResult":{"additional_properties":false,"fields":{"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ReadScreenResult":{"additional_properties":false,"fields":{"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"ReadScrollbackResult":{"additional_properties":false,"fields":{"epoch":{"nullable":false,"presence":"required","since":10,"type":{"kind":"scalar","name":"uint64"}},"rows":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRow"},"kind":"array"}},"start":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"total":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderCursor":{"additional_properties":false,"fields":{"blink":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"color":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"style":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"CursorStyle"}},"visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderGraphicFormat":{"kind":"enum","values":["rgb","rgba"]},"RenderGraphicImage":{"additional_properties":false,"fields":{"data":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"format":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"RenderGraphicFormat"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"RenderGraphicPlacement":{"additional_properties":false,"fields":{"anchor_col":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}},"anchor_row":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint32"}},"columns":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"grid_rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"image_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"ordinal":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"pixel_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"placement_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_height":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_width":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_x":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"source_y":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"viewport_col":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}},"viewport_visible":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"x_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"y_offset":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"z":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"int32"}}},"kind":"object"},"RenderGraphics":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderGraphicsDelta":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"images":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicImage"},"kind":"array"}},"placements":{"nullable":false,"presence":"optional","type":{"items":{"kind":"ref","name":"RenderGraphicPlacement"},"kind":"array"}},"removed_image_ids":{"nullable":false,"presence":"optional","type":{"items":{"kind":"scalar","name":"uint32"},"kind":"array"}}},"kind":"object"},"RenderRow":{"additional_properties":false,"fields":{"row":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"runs":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"RenderRun"},"kind":"array"}}},"kind":"object"},"RenderRun":{"additional_properties":false,"fields":{"attrs":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}},"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"underline":{"nullable":false,"presence":"optional","type":{"kind":"ref","name":"RenderUnderline"}},"width_hint":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"RenderUnderline":{"kind":"enum","values":["single","double","curly","dotted","dashed"]},"ReportAgentResult":{"additional_properties":false,"fields":{"session":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"source":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentReportSource"}},"state":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"AgentState"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"ResizeSurfaceResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"reservation_id":{"nullable":true,"presence":"required","since":7,"type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ResolveTerminalResult":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"RunResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","since":9,"type":{"kind":"scalar","name":"string"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Screen":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"active_pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"layout":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Layout"}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"panes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Pane"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"SetCellPixelsResult":{"additional_properties":false,"fields":{"failures":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelFailure"},"kind":"array"}},"resizes":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"CellPixelResize"},"kind":"array"}}},"kind":"object"},"ShutdownDaemonResult":{"additional_properties":false,"fields":{"accepted":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"pid":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint32"}}},"kind":"object"},"SidebarPluginResult":{"additional_properties":false,"fields":{"error":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"retry_after_ms":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"surface":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"Size":{"additional_properties":false,"fields":{"cols":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"rows":{"constraints":[{"maximum":10000,"minimum":1}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"SplitDirection":{"kind":"enum","values":["right","down"]},"SurfaceResult":{"additional_properties":false,"fields":{"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tab":{"additional_properties":false,"fields":{"browser_error":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"browser_frames_stalled":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"browser_source":{"nullable":true,"presence":"required","type":{"kind":"enum","values":["external","launched"]}},"browser_status":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"enum","values":["starting","live","failed"]}},"dead":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"kind":{"nullable":false,"presence":"required","type":{"kind":"enum","values":["pty","browser"]}},"name":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"notification":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"NotificationMarker"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}},"size":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Size"}},"supports_clear_history_key_fallback":{"capability":"clear-history-key-v1","nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"boolean"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"optional","since":9,"type":{"kind":"scalar","name":"string"}},"terminal_resource_id":{"constraints":[{"pattern":"^term_[0-9a-f]{32}$"}],"nullable":true,"presence":"optional","since":10,"type":{"kind":"scalar","name":"string"}},"title":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalColors":{"additional_properties":false,"fields":{"bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"cursor":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"ColorHex"}},"cursor_blink":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"scalar","name":"boolean"}},"cursor_style":{"nullable":true,"presence":"optional","since":6,"type":{"kind":"ref","name":"CursorStyle"}},"fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"palette":{"constraints":["Decimal string keys are palette indexes 0 through 255."],"nullable":false,"presence":"optional","since":7,"type":{"kind":"map","values":{"kind":"ref","name":"ColorHex"}}},"selection_bg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}},"selection_fg":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"ColorHex"}}},"kind":"object"},"TerminalEventsResult":{"additional_properties":false,"fields":{"events":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"TerminalRegistryEvent"},"kind":"array"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"TerminalKey":{"kind":"enum","values":["unidentified","backquote","backslash","bracket-left","bracket-right","comma","digit0","digit1","digit2","digit3","digit4","digit5","digit6","digit7","digit8","digit9","equal","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","minus","period","quote","semicolon","slash","backspace","enter","space","tab","delete","end","home","insert","page-down","page-up","arrow-down","arrow-left","arrow-right","arrow-up","numpad0","numpad1","numpad2","numpad3","numpad4","numpad5","numpad6","numpad7","numpad8","numpad9","numpad-add","numpad-backspace","numpad-comma","numpad-decimal","numpad-divide","numpad-enter","numpad-equal","numpad-multiply","numpad-subtract","numpad-up","numpad-down","numpad-right","numpad-left","numpad-begin","numpad-home","numpad-end","numpad-insert","numpad-delete","numpad-page-up","numpad-page-down","escape","f1","f2","f3","f4","f5","f6","f7","f8","f9","f10","f11","f12","f13","f14","f15","f16","f17","f18","f19","f20"]},"TerminalKeyAction":{"kind":"enum","values":["press","release","repeat"]},"TerminalKeyInput":{"additional_properties":false,"constraints":["consumed_mods must be a subset of mods.","unshifted_codepoint, shifted_codepoint, and base_layout_codepoint contain exactly one Unicode scalar when present.","utf8 contains no control characters.","macos_option_as_alt may be false only when Alt is active and consumed."],"fields":{"action":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"ref","name":"TerminalKeyAction"}},"base_layout_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"composing":{"default":false,"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"consumed_mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"key":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalKey"}},"macos_option_as_alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"mods":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalModifiers"}},"shifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"unshifted_codepoint":{"default":null,"nullable":true,"presence":"optional","type":{"kind":"scalar","name":"string"}},"utf8":{"constraints":[{"max_length":4096}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalLifecycle":{"kind":"enum","values":["launching","adopting","running","exited","tombstoned"]},"TerminalModifiers":{"additional_properties":false,"fields":{"alt":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"caps_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"control":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"num_lock":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"shift":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"super":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}}},"kind":"object"},"TerminalPlacement":{"additional_properties":false,"fields":{"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"lifecycle":{"nullable":true,"presence":"required","type":{"kind":"literal","value":"running"}},"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"screen":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"surface":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"terminal_id":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"},"TerminalRecord":{"additional_properties":false,"fields":{"exit":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"launch_spec":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"lifecycle":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"TerminalLifecycle"}},"terminal_id":{"constraints":[{"format":"UUIDv4 hex without dashes","pattern":"^[0-9a-f]{32}$"}],"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_incarnation":{"nullable":true,"presence":"required","type":{"kind":"scalar","name":"string"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"TerminalRegistryEvent":{"additional_properties":false,"fields":{"kind":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"mutation_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"origin":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"result":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"JsonValue"}},"terminal_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"workspace_key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Tree":{"additional_properties":false,"fields":{"generation":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"pane_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"registry_id":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"terminal_revision":{"nullable":false,"presence":"optional","since":9,"type":{"kind":"scalar","name":"uint64"}},"workspace_revision":{"capability":"workspace-registry-v1","nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"uint64"}},"workspaces":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Workspace"},"kind":"array"}}},"kind":"object"},"VtStateResult":{"additional_properties":false,"fields":{"cols":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}},"data":{"constraints":[{"encoding":"standard base64"}],"nullable":false,"presence":"required","type":{"kind":"ref","name":"Base64"}},"kitty_graphics_state":{"nullable":false,"presence":"optional","since":10,"type":{"kind":"ref","name":"KittyGraphicsState"}},"kitty_image_aliases":{"nullable":false,"presence":"optional","since":9,"type":{"items":{"kind":"ref","name":"KittyImageAlias"},"kind":"array"}},"rows":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint16"}}},"kind":"object"},"WaitForResult":{"additional_properties":false,"fields":{"elapsed_ms":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"matched":{"nullable":false,"presence":"required","type":{"kind":"literal","value":true}},"text":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}}},"kind":"object"},"Workspace":{"additional_properties":false,"fields":{"active":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"id":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"key":{"capability":"workspace-registry-v1","constraints":[{"format":"lowercase canonical UUID"}],"nullable":false,"presence":"optional","since":7,"type":{"kind":"scalar","name":"string"}},"name":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"screens":{"nullable":false,"presence":"required","type":{"items":{"kind":"ref","name":"Screen"},"kind":"array"}},"short_id":{"constraints":[{"pattern":"^[a-z0-9]{6}$"}],"nullable":false,"presence":"optional","since":6,"type":{"kind":"scalar","name":"string"}}},"kind":"object"},"WorkspaceMutationResult":{"additional_properties":false,"fields":{"changed":{"nullable":false,"presence":"optional","type":{"kind":"scalar","name":"boolean"}},"generation":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"index":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}},"key":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"registry_id":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"string"}},"replayed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"workspace":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"workspace_revision":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"uint64"}}},"kind":"object"},"ZoomPaneResult":{"additional_properties":false,"fields":{"pane":{"nullable":false,"presence":"required","type":{"kind":"ref","name":"Id"}},"zoomed":{"nullable":false,"presence":"required","type":{"kind":"scalar","name":"boolean"}},"zoomed_pane":{"nullable":true,"presence":"required","type":{"kind":"ref","name":"Id"}}},"kind":"object"}}}') diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/client.py b/cmux-tui/bindings/python/cmux/raw/_generated/client.py index 2620944aec..dc268f2e8e 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/client.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/client.py @@ -102,6 +102,9 @@ class GeneratedClientMixin: def focus_pane(self, pane: Id) -> EmptyResult: return self._invoke_command('focus-pane', FocusPaneRequest(pane=pane)) + def get_cell_pixels(self) -> GetCellPixelsResult: + return self._invoke_command('get-cell-pixels', GetCellPixelsRequest()) + def get_frontend_projection(self, frontend: str, scope: str, subject_key: str) -> FrontendProjection: return self._invoke_command('get-frontend-projection', GetFrontendProjectionRequest(frontend=frontend, scope=scope, subject_key=subject_key)) @@ -316,6 +319,7 @@ GeneratedClientMixin.detach_client.__cmux_command__ = COMMANDS['detach-client'] GeneratedClientMixin.export_layout.__cmux_command__ = COMMANDS['export-layout'] GeneratedClientMixin.focus_direction.__cmux_command__ = COMMANDS['focus-direction'] GeneratedClientMixin.focus_pane.__cmux_command__ = COMMANDS['focus-pane'] +GeneratedClientMixin.get_cell_pixels.__cmux_command__ = COMMANDS['get-cell-pixels'] GeneratedClientMixin.get_frontend_projection.__cmux_command__ = COMMANDS['get-frontend-projection'] GeneratedClientMixin.identify.__cmux_command__ = COMMANDS['identify'] GeneratedClientMixin.ids.__cmux_command__ = COMMANDS['ids'] diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/codec.py b/cmux-tui/bindings/python/cmux/raw/_generated/codec.py index a6d105e75a..ba9c4bba50 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/codec.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/codec.py @@ -21,6 +21,7 @@ MODEL_BY_PATH = { 'types/BrowserFrame': models.BrowserFrame, 'types/CellPixelFailure': models.CellPixelFailure, 'types/CellPixelResize': models.CellPixelResize, + 'types/CellPixelSurface': models.CellPixelSurface, 'types/ClientInfo': models.ClientInfo, 'types/ClientSize': models.ClientSize, 'types/CloseTerminalResult': models.CloseTerminalResult, @@ -34,9 +35,12 @@ MODEL_BY_PATH = { 'types/ExportedPane': models.ExportedPane, 'types/FocusDirectionResult': models.FocusDirectionResult, 'types/FrontendProjection': models.FrontendProjection, + 'types/GetCellPixelsResult': models.GetCellPixelsResult, 'types/IdMapping': models.IdMapping, 'types/IdentifyResult': models.IdentifyResult, 'types/IdsResult': models.IdsResult, + 'types/KittyGraphicsState': models.KittyGraphicsState, + 'types/KittyImageAlias': models.KittyImageAlias, 'types/Layout/variants/leaf': models.LayoutLeaf, 'types/Layout/variants/split': models.LayoutSplit, 'types/Layout/variants/stack': models.LayoutStack, @@ -56,6 +60,10 @@ MODEL_BY_PATH = { 'types/ReadScreenResult': models.ReadScreenResult, 'types/ReadScrollbackResult': models.ReadScrollbackResult, 'types/RenderCursor': models.RenderCursor, + 'types/RenderGraphicImage': models.RenderGraphicImage, + 'types/RenderGraphicPlacement': models.RenderGraphicPlacement, + 'types/RenderGraphics': models.RenderGraphics, + 'types/RenderGraphicsDelta': models.RenderGraphicsDelta, 'types/RenderRow': models.RenderRow, 'types/RenderRun': models.RenderRun, 'types/ReportAgentResult': models.ReportAgentResult, @@ -112,6 +120,7 @@ MODEL_BY_PATH = { 'commands/export-layout/request': models.ExportLayoutRequest, 'commands/focus-direction/request': models.FocusDirectionRequest, 'commands/focus-pane/request': models.FocusPaneRequest, + 'commands/get-cell-pixels/request': models.GetCellPixelsRequest, 'commands/get-frontend-projection/request': models.GetFrontendProjectionRequest, 'commands/identify/request': models.IdentifyRequest, 'commands/ids/request': models.IdsRequest, @@ -186,6 +195,7 @@ MODEL_BY_PATH = { 'events/empty/payload': models.EmptyEvent, 'events/frame/payload': models.FrameEvent, 'events/frontend-projection-changed/payload': models.FrontendProjectionChangedEvent, + 'events/graphics-status/payload': models.GraphicsStatusEvent, 'events/layout-changed/payload': models.LayoutChangedEvent, 'events/notification/payload': models.NotificationEvent, 'events/output/payload': models.OutputEvent, @@ -228,6 +238,7 @@ ENUM_BY_PATH = { 'types/CursorStyle': models.CursorStyle, 'types/NotificationLevel': models.NotificationLevel, 'types/PaneDirection': models.PaneDirection, + 'types/RenderGraphicFormat': models.RenderGraphicFormat, 'types/RenderUnderline': models.RenderUnderline, 'types/SplitDirection': models.SplitDirection, 'types/TerminalKey': models.TerminalKey, diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py index a07f1a583a..9b37317848 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/metadata.py @@ -8,7 +8,7 @@ from typing import Mapping, Optional, Tuple SCHEMA_VERSION = 2 MUX_PROTOCOL = 10 -IR_SHA256 = '4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e' +IR_SHA256 = '17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350' @dataclass(frozen=True) @@ -440,6 +440,16 @@ COMMANDS = { 'pane': CommandFieldMetadata(None, None), }, ), + 'get-cell-pixels': CommandMetadata( + 'get-cell-pixels', + 'frontend', + 6, + None, + ('frontend',), + None, + { + }, + ), 'get-frontend-projection': CommandMetadata( 'get-frontend-projection', 'control', @@ -1238,6 +1248,7 @@ EVENTS = { 'empty': EventMetadata('empty', 5, None, ('subscribe',), 'emitted'), 'frame': EventMetadata('frame', 6, None, ('attach-browser',), 'emitted'), 'frontend-projection-changed': EventMetadata('frontend-projection-changed', 7, None, ('subscribe',), 'emitted'), + 'graphics-status': EventMetadata('graphics-status', 10, None, ('subscribe',), 'emitted'), 'layout-changed': EventMetadata('layout-changed', 6, None, ('subscribe',), 'emitted'), 'notification': EventMetadata('notification', 6, None, ('subscribe', 'attach-byte', 'attach-browser'), 'emitted'), 'output': EventMetadata('output', 5, None, ('attach-byte',), 'emitted'), diff --git a/cmux-tui/bindings/python/cmux/raw/_generated/models.py b/cmux-tui/bindings/python/cmux/raw/_generated/models.py index 3a17f6b378..051bcdc9fd 100644 --- a/cmux-tui/bindings/python/cmux/raw/_generated/models.py +++ b/cmux-tui/bindings/python/cmux/raw/_generated/models.py @@ -74,6 +74,10 @@ class PaneDirection(str, Enum): UP = 'up' DOWN = 'down' +class RenderGraphicFormat(str, Enum): + RGB = 'rgb' + RGBA = 'rgba' + class RenderUnderline(str, Enum): SINGLE = 'single' DOUBLE = 'double' @@ -262,6 +266,14 @@ class CellPixelResize: rows: int +@dataclass(frozen=True) +class CellPixelSurface: + __cmux_schema_path__: ClassVar[str] = 'types/CellPixelSurface' + surface: Id + height_px: int + width_px: int + + @dataclass(frozen=True) class ClientInfo: __cmux_schema_path__: ClassVar[str] = 'types/ClientInfo' @@ -375,6 +387,14 @@ class FrontendProjection: replayed: Union[bool, MissingType] = field(default=MISSING) +@dataclass(frozen=True) +class GetCellPixelsResult: + __cmux_schema_path__: ClassVar[str] = 'types/GetCellPixelsResult' + height_px: int + surfaces: List[CellPixelSurface] + width_px: int + + @dataclass(frozen=True) class IdMapping: __cmux_schema_path__: ClassVar[str] = 'types/IdMapping' @@ -407,6 +427,27 @@ class IdsResult: ids: List[IdMapping] +@dataclass(frozen=True) +class KittyGraphicsState: + __cmux_schema_path__: ClassVar[str] = 'types/KittyGraphicsState' + alternate_next_image_id: int + alternate_replay_next_image_id: int + image_bytes: int + images: int + inflight_bytes: int + placements: int + primary_next_image_id: int + primary_replay_next_image_id: int + replay_cursor_offset: int + + +@dataclass(frozen=True) +class KittyImageAlias: + __cmux_schema_path__: ClassVar[str] = 'types/KittyImageAlias' + image_id: int + image_number: int + + @dataclass(frozen=True) class LayoutLeaf: __cmux_schema_path__: ClassVar[str] = 'types/Layout/variants/leaf' @@ -562,6 +603,7 @@ class ReadScreenResult: @dataclass(frozen=True) class ReadScrollbackResult: __cmux_schema_path__: ClassVar[str] = 'types/ReadScrollbackResult' + epoch: int rows: List[RenderRow] start: int total: int @@ -578,6 +620,61 @@ class RenderCursor: y: int +@dataclass(frozen=True) +class RenderGraphicImage: + __cmux_schema_path__: ClassVar[str] = 'types/RenderGraphicImage' + data: Base64 + format: RenderGraphicFormat + generation: int + height: int + id: int + width: int + + +@dataclass(frozen=True) +class RenderGraphicPlacement: + __cmux_schema_path__: ClassVar[str] = 'types/RenderGraphicPlacement' + columns: int + grid_cols: int + grid_rows: int + image_id: int + ordinal: int + pixel_height: int + pixel_width: int + placement_id: int + rows: int + source_height: int + source_width: int + source_x: int + source_y: int + viewport_col: int + viewport_row: int + viewport_visible: bool + x_offset: int + y_offset: int + z: int + anchor_col: Union[int, MissingType] = field(default=MISSING) + anchor_row: Union[int, MissingType] = field(default=MISSING) + + +@dataclass(frozen=True) +class RenderGraphics: + __cmux_schema_path__: ClassVar[str] = 'types/RenderGraphics' + generation: int + placements: List[RenderGraphicPlacement] + images: Union[List[RenderGraphicImage], MissingType] = field(default=MISSING) + removed_image_ids: Union[List[int], MissingType] = field(default=MISSING) + + +@dataclass(frozen=True) +class RenderGraphicsDelta: + __cmux_schema_path__: ClassVar[str] = 'types/RenderGraphicsDelta' + generation: int + images: Union[List[RenderGraphicImage], MissingType] = field(default=MISSING) + placements: Union[List[RenderGraphicPlacement], MissingType] = field(default=MISSING) + removed_image_ids: Union[List[int], MissingType] = field(default=MISSING) + + @dataclass(frozen=True) class RenderRow: __cmux_schema_path__: ClassVar[str] = 'types/RenderRow' @@ -815,6 +912,8 @@ class VtStateResult: cols: int data: Base64 rows: int + kitty_graphics_state: Union[KittyGraphicsState, MissingType] = field(default=MISSING) + kitty_image_aliases: Union[List[KittyImageAlias], MissingType] = field(default=MISSING) @dataclass(frozen=True) @@ -1110,6 +1209,12 @@ class FocusPaneRequest: pane: Id +@dataclass(frozen=True) +class GetCellPixelsRequest: + __cmux_schema_path__: ClassVar[str] = 'commands/get-cell-pixels/request' + pass + + @dataclass(frozen=True) class GetFrontendProjectionRequest: __cmux_schema_path__: ClassVar[str] = 'commands/get-frontend-projection/request' @@ -1724,6 +1829,21 @@ class FrontendProjectionChangedEvent(EventBase): raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) +@dataclass(frozen=True) +class GraphicsStatusEvent(EventBase): + __cmux_schema_path__: ClassVar[str] = 'events/graphics-status/payload' + event: Literal['graphics-status'] + kind: Literal['kitty-image-budget-worker-start-failed', 'kitty-image-budget-update-failed', 'cell-pixel-update-retries-exhausted'] + attempts: Union[int, MissingType] = field(default=MISSING) + cell_height: Union[int, MissingType] = field(default=MISSING) + cell_width: Union[int, MissingType] = field(default=MISSING) + error: Union[str, MissingType] = field(default=MISSING) + remaining: Union[int, MissingType] = field(default=MISSING) + retry_exhausted: Union[bool, MissingType] = field(default=MISSING) + summary: Union[str, MissingType] = field(default=MISSING) + raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) + + @dataclass(frozen=True) class LayoutChangedEvent(EventBase): __cmux_schema_path__: ClassVar[str] = 'events/layout-changed/payload' @@ -1817,6 +1937,8 @@ class RenderDeltaEvent(EventBase): rows: List[RenderRow] default_bg: Union[ColorHex, MissingType] = field(default=MISSING) default_fg: Union[ColorHex, MissingType] = field(default=MISSING) + graphics: Union[RenderGraphicsDelta, MissingType] = field(default=MISSING) + history_epoch: Union[int, MissingType] = field(default=MISSING) scrollback_rows: Union[int, MissingType] = field(default=MISSING) size: Union[Size, MissingType] = field(default=MISSING) raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) @@ -1830,9 +1952,11 @@ class RenderStateEvent(EventBase): default_bg: ColorHex default_fg: ColorHex event: Literal['render-state'] + history_epoch: int rows: List[RenderRow] scrollback_rows: int size: Size + graphics: Union[RenderGraphics, MissingType] = field(default=MISSING) raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) @@ -1845,6 +1969,8 @@ class ResizedEvent(EventBase): rows: int colors: Union[TerminalColors, MissingType] = field(default=MISSING) data: Union[Base64, MissingType] = field(default=MISSING) + kitty_graphics_state: Union[KittyGraphicsState, MissingType] = field(default=MISSING) + kitty_image_aliases: Union[List[KittyImageAlias], MissingType] = field(default=MISSING) replay: Union[Base64, MissingType] = field(default=MISSING) raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) @@ -2013,6 +2139,8 @@ class VtStateEvent(EventBase): event: Literal['vt-state'] rows: int colors: Union[TerminalColors, MissingType] = field(default=MISSING) + kitty_graphics_state: Union[KittyGraphicsState, MissingType] = field(default=MISSING) + kitty_image_aliases: Union[List[KittyImageAlias], MissingType] = field(default=MISSING) raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False, metadata={'cmux_skip': True}) @@ -2093,7 +2221,7 @@ Layout = Union[LayoutLeaf, LayoutSplit, LayoutStack] LayoutUndoResult = Union[LayoutUndoUndone, LayoutUndoConfirmationRequired] Pane = Union[LivePane, DeadPane] -KnownEvent = Union[BellEvent, BrowserStateEvent, ClientAttachedEvent, ClientChangedEvent, ClientDetachedEvent, ClientListInvalidatedEvent, ColorsChangedEvent, ConfigReloadRequestedEvent, DetachedEvent, EmptyEvent, FrameEvent, FrontendProjectionChangedEvent, LayoutChangedEvent, NotificationEvent, OutputEvent, OverflowEvent, PairingRequestedEvent, PairingResolvedEvent, PaneAddedEvent, PaneClosedEvent, RenderDeltaEvent, RenderStateEvent, ResizedEvent, ScreenAddedEvent, ScreenClosedEvent, ScreenRenamedEvent, ScrollChangedEvent, StatusEvent, SurfaceExitedEvent, SurfaceOutputEvent, SurfaceResizeFailedEvent, SurfaceResizedEvent, TabAddedEvent, TabClosedEvent, TabRenamedEvent, TerminalRegistryChangedEvent, TitleChangedEvent, TreeChangedEvent, VtStateEvent, WindowTitleRequestedEvent, WorkspaceAddedEvent, WorkspaceClosedEvent, WorkspaceMovedEvent, WorkspaceRenamedEvent] +KnownEvent = Union[BellEvent, BrowserStateEvent, ClientAttachedEvent, ClientChangedEvent, ClientDetachedEvent, ClientListInvalidatedEvent, ColorsChangedEvent, ConfigReloadRequestedEvent, DetachedEvent, EmptyEvent, FrameEvent, FrontendProjectionChangedEvent, GraphicsStatusEvent, LayoutChangedEvent, NotificationEvent, OutputEvent, OverflowEvent, PairingRequestedEvent, PairingResolvedEvent, PaneAddedEvent, PaneClosedEvent, RenderDeltaEvent, RenderStateEvent, ResizedEvent, ScreenAddedEvent, ScreenClosedEvent, ScreenRenamedEvent, ScrollChangedEvent, StatusEvent, SurfaceExitedEvent, SurfaceOutputEvent, SurfaceResizeFailedEvent, SurfaceResizedEvent, TabAddedEvent, TabClosedEvent, TabRenamedEvent, TerminalRegistryChangedEvent, TitleChangedEvent, TreeChangedEvent, VtStateEvent, WindowTitleRequestedEvent, WorkspaceAddedEvent, WorkspaceClosedEvent, WorkspaceMovedEvent, WorkspaceRenamedEvent] AnyEvent = Union[KnownEvent, UnknownEvent] __all__ = [ @@ -2110,6 +2238,7 @@ __all__ = [ 'CursorStyle', 'NotificationLevel', 'PaneDirection', + 'RenderGraphicFormat', 'RenderUnderline', 'SplitDirection', 'TerminalKey', @@ -2121,6 +2250,7 @@ __all__ = [ 'BrowserFrame', 'CellPixelFailure', 'CellPixelResize', + 'CellPixelSurface', 'ClientInfo', 'ClientSize', 'CloseTerminalResult', @@ -2134,9 +2264,12 @@ __all__ = [ 'ExportedPane', 'FocusDirectionResult', 'FrontendProjection', + 'GetCellPixelsResult', 'IdMapping', 'IdentifyResult', 'IdsResult', + 'KittyGraphicsState', + 'KittyImageAlias', 'LayoutLeaf', 'LayoutSplit', 'LayoutStack', @@ -2156,6 +2289,10 @@ __all__ = [ 'ReadScreenResult', 'ReadScrollbackResult', 'RenderCursor', + 'RenderGraphicImage', + 'RenderGraphicPlacement', + 'RenderGraphics', + 'RenderGraphicsDelta', 'RenderRow', 'RenderRun', 'ReportAgentResult', @@ -2212,6 +2349,7 @@ __all__ = [ 'ExportLayoutRequest', 'FocusDirectionRequest', 'FocusPaneRequest', + 'GetCellPixelsRequest', 'GetFrontendProjectionRequest', 'IdentifyRequest', 'IdsRequest', @@ -2286,6 +2424,7 @@ __all__ = [ 'EmptyEvent', 'FrameEvent', 'FrontendProjectionChangedEvent', + 'GraphicsStatusEvent', 'LayoutChangedEvent', 'NotificationEvent', 'OutputEvent', diff --git a/cmux-tui/bindings/python/tests/test_convenience.py b/cmux-tui/bindings/python/tests/test_convenience.py index 4bc421b2c4..773920e7c7 100644 --- a/cmux-tui/bindings/python/tests/test_convenience.py +++ b/cmux-tui/bindings/python/tests/test_convenience.py @@ -175,9 +175,9 @@ class RenderAndScrollbackTests(unittest.TestCase): def test_read_scrollback_tail_probes_then_reads_tail(self) -> None: client = CmuxClient.__new__(CmuxClient) calls = [] - tail = ReadScrollbackResult(rows=[], start=7, total=10) + tail = ReadScrollbackResult(epoch=1, rows=[], start=7, total=10) responses = [ - ReadScrollbackResult(rows=[], start=0, total=10), + ReadScrollbackResult(epoch=1, rows=[], start=0, total=10), tail, ] @@ -193,7 +193,7 @@ class RenderAndScrollbackTests(unittest.TestCase): def test_read_scrollback_tail_reuses_probe_when_it_contains_tail(self) -> None: client = CmuxClient.__new__(CmuxClient) calls = [] - probe = ReadScrollbackResult(rows=[], start=0, total=2) + probe = ReadScrollbackResult(epoch=1, rows=[], start=0, total=2) def read_scrollback(surface, start, count): calls.append((surface, start, count)) diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py index ec861c08f1..3b745a12a5 100644 --- a/cmux-tui/bindings/python/tests/test_protocol.py +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -22,7 +22,7 @@ from cmux.raw._generated.metadata import COMMANDS, EVENTS, IR_SHA256 class GeneratedProtocolTests(unittest.TestCase): def test_protocol_ten_inventory_is_exhaustive(self) -> None: self.assertEqual(MUX_PROTOCOL, 10) - self.assertEqual(len(COMMANDS), 91) + self.assertEqual(len(COMMANDS), 92) self.assertEqual(set(COMMANDS), set(SCHEMA["commands"])) self.assertEqual(set(EVENTS), set(SCHEMA["events"])) self.assertEqual(len(IR_SHA256), 64) diff --git a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json index e15ae73d07..2b38bc17b9 100644 --- a/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.rs", - "sha256": "6c2d23f1b0e6464744e55c667a1c4c4f28fceb0a6f21ce82d30fbebd4740346f", - "size": 56086 + "sha256": "6e161bf28e80dfae38fa688f02ecbeb116d56f4f02cc5c6cc6e583695420f387", + "size": 56378 }, { "path": "events.rs", - "sha256": "750b3731ae011fca22fcd2dd08976e2b29b0530d2448eb0e94e5b2011347dcfe", - "size": 37107 + "sha256": "6655c910e4838b75b7708e237dc772803838c9f601cd7baeea7e7ffcdc5625f1", + "size": 40682 }, { "path": "metadata.rs", - "sha256": "48f6c26fcb938ba336c87338634034409df73e60e206578bbce648f7655506b0", - "size": 33786 + "sha256": "7241ba9c80314eeeacdb5078c1f074b02264eec397bcdaa868da2f687a1b39ba", + "size": 34262 }, { "path": "mod.rs", - "sha256": "c6ff9bc6750f92315d1036d82b0379f5fec91327ea3354304a0a5366ed7a3ba9", + "sha256": "124b3acb2e08e6545b87104a6fc333c001d285b1b040bde6e7d0c7e9d4e4972f", "size": 365 }, { "path": "types.rs", - "sha256": "2a4394049238dbcc8276459227ad629e46effc63b758de7595ec773c609587ba", - "size": 30346 + "sha256": "c004e8dbc59fdaa092196a572313aea19ee78cc4ab00a8e172c3b7573793980e", + "size": 34273 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "rust", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/rust/src/generated/commands.rs b/cmux-tui/bindings/rust/src/generated/commands.rs index fdb662c68d..bb35fad8dc 100644 --- a/cmux-tui/bindings/rust/src/generated/commands.rs +++ b/cmux-tui/bindings/rust/src/generated/commands.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; @@ -434,6 +434,11 @@ pub struct FocusPaneRequest { #[rustfmt::skip] pub type FocusPaneResult = T::EmptyResult; +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct GetCellPixelsRequest { +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct GetFrontendProjectionRequest { @@ -1315,6 +1320,10 @@ impl CmuxClient { self.execute(&FOCUS_PANE_METADATA, &request) } + pub fn get_cell_pixels(&mut self, request: GetCellPixelsRequest) -> Result { + self.execute(&GET_CELL_PIXELS_METADATA, &request) + } + pub fn get_frontend_projection(&mut self, request: GetFrontendProjectionRequest) -> Result { self.execute(&GET_FRONTEND_PROJECTION_METADATA, &request) } diff --git a/cmux-tui/bindings/rust/src/generated/events.rs b/cmux-tui/bindings/rust/src/generated/events.rs index d73bf334a8..7cbd6e12e2 100644 --- a/cmux-tui/bindings/rust/src/generated/events.rs +++ b/cmux-tui/bindings/rust/src/generated/events.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. // The emitter owns this layout so generation is independent of the installed rustfmt. use super::metadata::*; @@ -135,6 +135,37 @@ pub struct FrontendProjectionChangedEvent { pub subject_key: String, } +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GraphicsStatusEventKind { + #[serde(rename = "kitty-image-budget-worker-start-failed")] + KittyImageBudgetWorkerStartFailed, + #[serde(rename = "kitty-image-budget-update-failed")] + KittyImageBudgetUpdateFailed, + #[serde(rename = "cell-pixel-update-retries-exhausted")] + CellPixelUpdateRetriesExhausted, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GraphicsStatusEvent { + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub attempts: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub cell_height: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub cell_width: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub error: Option, + pub kind: GraphicsStatusEventKind, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub remaining: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub retry_exhausted: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LayoutChangedEvent { @@ -214,6 +245,10 @@ pub struct RenderDeltaEvent { #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] pub default_fg: Option, pub full: bool, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub graphics: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub history_epoch: Option, pub rows: Vec, #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] pub scrollback_rows: Option, @@ -228,6 +263,9 @@ pub struct RenderStateEvent { pub cursor: T::RenderCursor, pub default_bg: T::ColorHex, pub default_fg: T::ColorHex, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub graphics: Option, + pub history_epoch: u64, pub rows: Vec, pub scrollback_rows: u32, pub size: T::Size, @@ -244,6 +282,10 @@ pub struct ResizedEvent { #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] pub data: Option, #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_graphics_state: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_image_aliases: Option>, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] pub replay: Option, pub rows: u16, pub surface: T::Id, @@ -382,6 +424,10 @@ pub struct VtStateEvent { pub colors: Option, pub cols: u16, pub data: T::Base64, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_graphics_state: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_image_aliases: Option>, pub rows: u16, pub surface: T::Id, } @@ -475,6 +521,7 @@ pub enum Event { Empty(EmptyEvent), Frame(FrameEvent), FrontendProjectionChanged(FrontendProjectionChangedEvent), + GraphicsStatus(GraphicsStatusEvent), LayoutChanged(LayoutChangedEvent), Notification(NotificationEvent), Output(OutputEvent), @@ -526,6 +573,7 @@ impl Event { Self::Empty(_) => Some("empty"), Self::Frame(_) => Some("frame"), Self::FrontendProjectionChanged(_) => Some("frontend-projection-changed"), + Self::GraphicsStatus(_) => Some("graphics-status"), Self::LayoutChanged(_) => Some("layout-changed"), Self::Notification(_) => Some("notification"), Self::Output(_) => Some("output"), @@ -576,6 +624,7 @@ impl Event { Self::Empty(_) => Some(&EMPTY_EVENT_METADATA), Self::Frame(_) => Some(&FRAME_EVENT_METADATA), Self::FrontendProjectionChanged(_) => Some(&FRONTEND_PROJECTION_CHANGED_EVENT_METADATA), + Self::GraphicsStatus(_) => Some(&GRAPHICS_STATUS_EVENT_METADATA), Self::LayoutChanged(_) => Some(&LAYOUT_CHANGED_EVENT_METADATA), Self::Notification(_) => Some(&NOTIFICATION_EVENT_METADATA), Self::Output(_) => Some(&OUTPUT_EVENT_METADATA), @@ -713,6 +762,14 @@ pub fn decode_event(raw: Value) -> Event { decode_error: Some(error.to_string()), }), }, + Some("graphics-status") => match serde_json::from_value::(raw.clone()) { + Ok(event) => Event::GraphicsStatus(event), + Err(error) => Event::Unknown(UnknownEvent { + name, + raw, + decode_error: Some(error.to_string()), + }), + }, Some("layout-changed") => match serde_json::from_value::(raw.clone()) { Ok(event) => Event::LayoutChanged(event), Err(error) => Event::Unknown(UnknownEvent { diff --git a/cmux-tui/bindings/rust/src/generated/metadata.rs b/cmux-tui/bindings/rust/src/generated/metadata.rs index 9cc6b093c5..9b534ed683 100644 --- a/cmux-tui/bindings/rust/src/generated/metadata.rs +++ b/cmux-tui/bindings/rust/src/generated/metadata.rs @@ -1,12 +1,12 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{CommandMetadata, EventMetadata, ProfileMetadata, StreamMetadata}; pub const SDK_SCHEMA_VERSION: u32 = 2; pub const MUX_PROTOCOL_VERSION: u32 = 10; -pub const SDK_IR_SHA256: &str = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e"; +pub const SDK_IR_SHA256: &str = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350"; #[rustfmt::skip] pub const CONTROL_PROFILE: ProfileMetadata = ProfileMetadata { @@ -314,6 +314,15 @@ pub const FOCUS_PANE_METADATA: CommandMetadata = CommandMetadata { stream: None, }; +#[rustfmt::skip] +pub const GET_CELL_PIXELS_METADATA: CommandMetadata = CommandMetadata { + name: "get-cell-pixels", + since: 6, + capability: None, + authority: "frontend", + stream: None, +}; + #[rustfmt::skip] pub const GET_FRONTEND_PROJECTION_METADATA: CommandMetadata = CommandMetadata { name: "get-frontend-projection", @@ -971,6 +980,15 @@ pub const FRONTEND_PROJECTION_CHANGED_EVENT_METADATA: EventMetadata = EventMetad emission: "emitted", }; +#[rustfmt::skip] +pub const GRAPHICS_STATUS_EVENT_METADATA: EventMetadata = EventMetadata { + name: "graphics-status", + since: 10, + capability: None, + streams: &["subscribe"], + emission: "emitted", +}; + #[rustfmt::skip] pub const LAYOUT_CHANGED_EVENT_METADATA: EventMetadata = EventMetadata { name: "layout-changed", @@ -1262,6 +1280,6 @@ pub const WORKSPACE_RENAMED_EVENT_METADATA: EventMetadata = EventMetadata { #[rustfmt::skip] pub static PROFILES: &[ProfileMetadata] = &[CONTROL_PROFILE, FRONTEND_PROFILE, LOCAL_ADMIN_PROFILE, PROVIDER_AUTHORITY_PROFILE]; #[rustfmt::skip] -pub static COMMANDS: &[CommandMetadata] = &[APPLY_LAYOUT_METADATA, ATTACH_SURFACE_METADATA, BROWSER_ACTIVATE_METADATA, BROWSER_BACK_METADATA, BROWSER_FORWARD_METADATA, BROWSER_FRAME_PRESENTED_METADATA, BROWSER_INSERT_TEXT_METADATA, BROWSER_KEY_METADATA, BROWSER_KEY_PRESS_METADATA, BROWSER_MOUSE_METADATA, BROWSER_MOUSE_GUARDED_METADATA, BROWSER_NAVIGATE_METADATA, BROWSER_RELOAD_METADATA, BROWSER_WHEEL_METADATA, BROWSER_WHEEL_GUARDED_METADATA, CLEAR_HISTORY_METADATA, CLEAR_WINDOW_TITLE_METADATA, CLOSE_PANE_METADATA, CLOSE_PROVIDER_MANAGED_WORKSPACE_METADATA, CLOSE_SCREEN_METADATA, CLOSE_SURFACE_METADATA, CLOSE_TERMINAL_METADATA, CLOSE_WORKSPACE_METADATA, COPY_METADATA, CREATE_TERMINAL_METADATA, CREATE_WORKSPACE_METADATA, DETACH_CLIENT_METADATA, EXPORT_LAYOUT_METADATA, FOCUS_DIRECTION_METADATA, FOCUS_PANE_METADATA, GET_FRONTEND_PROJECTION_METADATA, IDENTIFY_METADATA, IDS_METADATA, LIST_AGENTS_METADATA, LIST_CLIENTS_METADATA, LIST_TERMINALS_METADATA, LIST_WORKSPACES_METADATA, MARK_WORKSPACES_PROVIDER_MANAGED_METADATA, MINT_TERMINAL_RENDERER_METADATA, MOVE_TAB_METADATA, MOVE_TERMINAL_METADATA, MOVE_WORKSPACE_METADATA, NEW_BROWSER_TAB_METADATA, NEW_PANE_METADATA, NEW_PANE_RIGHT_METADATA, NEW_SCREEN_METADATA, NEW_TAB_METADATA, NEW_WORKSPACE_METADATA, NOTIFY_METADATA, PAIRING_RESPONSE_METADATA, PANE_NEIGHBOR_METADATA, PING_METADATA, PROCESS_INFO_METADATA, PUT_FRONTEND_PROJECTION_METADATA, READ_SCREEN_METADATA, READ_SCROLLBACK_METADATA, RELEASE_SURFACE_SIZE_METADATA, RELOAD_CONFIG_METADATA, RENAME_PANE_METADATA, RENAME_PROVIDER_MANAGED_WORKSPACE_METADATA, RENAME_SCREEN_METADATA, RENAME_SURFACE_METADATA, RENAME_WORKSPACE_METADATA, REPORT_AGENT_METADATA, RESIZE_SURFACE_METADATA, RESOLVE_TERMINAL_METADATA, RUN_METADATA, SCROLL_SURFACE_METADATA, SELECT_SCREEN_METADATA, SELECT_TAB_METADATA, SELECT_WORKSPACE_METADATA, SEND_METADATA, SEND_KEY_METADATA, SET_CELL_PIXELS_METADATA, SET_CLIENT_INFO_METADATA, SET_CLIENT_SIZING_METADATA, SET_DEFAULT_COLORS_METADATA, SET_RATIO_METADATA, SET_SPLIT_RATIO_METADATA, SET_VIEWPORT_PANE_WIDTH_METADATA, SET_WINDOW_TITLE_METADATA, SHUTDOWN_DAEMON_METADATA, SIDEBAR_PLUGIN_METADATA, SPLIT_METADATA, SUBSCRIBE_METADATA, SWAP_PANE_METADATA, TERMINAL_EVENTS_METADATA, UNDO_LAYOUT_METADATA, VT_STATE_METADATA, WAIT_FOR_METADATA, ZOOM_PANE_METADATA]; +pub static COMMANDS: &[CommandMetadata] = &[APPLY_LAYOUT_METADATA, ATTACH_SURFACE_METADATA, BROWSER_ACTIVATE_METADATA, BROWSER_BACK_METADATA, BROWSER_FORWARD_METADATA, BROWSER_FRAME_PRESENTED_METADATA, BROWSER_INSERT_TEXT_METADATA, BROWSER_KEY_METADATA, BROWSER_KEY_PRESS_METADATA, BROWSER_MOUSE_METADATA, BROWSER_MOUSE_GUARDED_METADATA, BROWSER_NAVIGATE_METADATA, BROWSER_RELOAD_METADATA, BROWSER_WHEEL_METADATA, BROWSER_WHEEL_GUARDED_METADATA, CLEAR_HISTORY_METADATA, CLEAR_WINDOW_TITLE_METADATA, CLOSE_PANE_METADATA, CLOSE_PROVIDER_MANAGED_WORKSPACE_METADATA, CLOSE_SCREEN_METADATA, CLOSE_SURFACE_METADATA, CLOSE_TERMINAL_METADATA, CLOSE_WORKSPACE_METADATA, COPY_METADATA, CREATE_TERMINAL_METADATA, CREATE_WORKSPACE_METADATA, DETACH_CLIENT_METADATA, EXPORT_LAYOUT_METADATA, FOCUS_DIRECTION_METADATA, FOCUS_PANE_METADATA, GET_CELL_PIXELS_METADATA, GET_FRONTEND_PROJECTION_METADATA, IDENTIFY_METADATA, IDS_METADATA, LIST_AGENTS_METADATA, LIST_CLIENTS_METADATA, LIST_TERMINALS_METADATA, LIST_WORKSPACES_METADATA, MARK_WORKSPACES_PROVIDER_MANAGED_METADATA, MINT_TERMINAL_RENDERER_METADATA, MOVE_TAB_METADATA, MOVE_TERMINAL_METADATA, MOVE_WORKSPACE_METADATA, NEW_BROWSER_TAB_METADATA, NEW_PANE_METADATA, NEW_PANE_RIGHT_METADATA, NEW_SCREEN_METADATA, NEW_TAB_METADATA, NEW_WORKSPACE_METADATA, NOTIFY_METADATA, PAIRING_RESPONSE_METADATA, PANE_NEIGHBOR_METADATA, PING_METADATA, PROCESS_INFO_METADATA, PUT_FRONTEND_PROJECTION_METADATA, READ_SCREEN_METADATA, READ_SCROLLBACK_METADATA, RELEASE_SURFACE_SIZE_METADATA, RELOAD_CONFIG_METADATA, RENAME_PANE_METADATA, RENAME_PROVIDER_MANAGED_WORKSPACE_METADATA, RENAME_SCREEN_METADATA, RENAME_SURFACE_METADATA, RENAME_WORKSPACE_METADATA, REPORT_AGENT_METADATA, RESIZE_SURFACE_METADATA, RESOLVE_TERMINAL_METADATA, RUN_METADATA, SCROLL_SURFACE_METADATA, SELECT_SCREEN_METADATA, SELECT_TAB_METADATA, SELECT_WORKSPACE_METADATA, SEND_METADATA, SEND_KEY_METADATA, SET_CELL_PIXELS_METADATA, SET_CLIENT_INFO_METADATA, SET_CLIENT_SIZING_METADATA, SET_DEFAULT_COLORS_METADATA, SET_RATIO_METADATA, SET_SPLIT_RATIO_METADATA, SET_VIEWPORT_PANE_WIDTH_METADATA, SET_WINDOW_TITLE_METADATA, SHUTDOWN_DAEMON_METADATA, SIDEBAR_PLUGIN_METADATA, SPLIT_METADATA, SUBSCRIBE_METADATA, SWAP_PANE_METADATA, TERMINAL_EVENTS_METADATA, UNDO_LAYOUT_METADATA, VT_STATE_METADATA, WAIT_FOR_METADATA, ZOOM_PANE_METADATA]; #[rustfmt::skip] -pub static EVENTS: &[EventMetadata] = &[BELL_EVENT_METADATA, BROWSER_STATE_EVENT_METADATA, CLIENT_ATTACHED_EVENT_METADATA, CLIENT_CHANGED_EVENT_METADATA, CLIENT_DETACHED_EVENT_METADATA, CLIENT_LIST_INVALIDATED_EVENT_METADATA, COLORS_CHANGED_EVENT_METADATA, CONFIG_RELOAD_REQUESTED_EVENT_METADATA, DETACHED_EVENT_METADATA, EMPTY_EVENT_METADATA, FRAME_EVENT_METADATA, FRONTEND_PROJECTION_CHANGED_EVENT_METADATA, LAYOUT_CHANGED_EVENT_METADATA, NOTIFICATION_EVENT_METADATA, OUTPUT_EVENT_METADATA, OVERFLOW_EVENT_METADATA, PAIRING_REQUESTED_EVENT_METADATA, PAIRING_RESOLVED_EVENT_METADATA, PANE_ADDED_EVENT_METADATA, PANE_CLOSED_EVENT_METADATA, RENDER_DELTA_EVENT_METADATA, RENDER_STATE_EVENT_METADATA, RESIZED_EVENT_METADATA, SCREEN_ADDED_EVENT_METADATA, SCREEN_CLOSED_EVENT_METADATA, SCREEN_RENAMED_EVENT_METADATA, SCROLL_CHANGED_EVENT_METADATA, STATUS_EVENT_METADATA, SURFACE_EXITED_EVENT_METADATA, SURFACE_OUTPUT_EVENT_METADATA, SURFACE_RESIZE_FAILED_EVENT_METADATA, SURFACE_RESIZED_EVENT_METADATA, TAB_ADDED_EVENT_METADATA, TAB_CLOSED_EVENT_METADATA, TAB_RENAMED_EVENT_METADATA, TERMINAL_REGISTRY_CHANGED_EVENT_METADATA, TITLE_CHANGED_EVENT_METADATA, TREE_CHANGED_EVENT_METADATA, VT_STATE_EVENT_METADATA, WINDOW_TITLE_REQUESTED_EVENT_METADATA, WORKSPACE_ADDED_EVENT_METADATA, WORKSPACE_CLOSED_EVENT_METADATA, WORKSPACE_MOVED_EVENT_METADATA, WORKSPACE_RENAMED_EVENT_METADATA]; +pub static EVENTS: &[EventMetadata] = &[BELL_EVENT_METADATA, BROWSER_STATE_EVENT_METADATA, CLIENT_ATTACHED_EVENT_METADATA, CLIENT_CHANGED_EVENT_METADATA, CLIENT_DETACHED_EVENT_METADATA, CLIENT_LIST_INVALIDATED_EVENT_METADATA, COLORS_CHANGED_EVENT_METADATA, CONFIG_RELOAD_REQUESTED_EVENT_METADATA, DETACHED_EVENT_METADATA, EMPTY_EVENT_METADATA, FRAME_EVENT_METADATA, FRONTEND_PROJECTION_CHANGED_EVENT_METADATA, GRAPHICS_STATUS_EVENT_METADATA, LAYOUT_CHANGED_EVENT_METADATA, NOTIFICATION_EVENT_METADATA, OUTPUT_EVENT_METADATA, OVERFLOW_EVENT_METADATA, PAIRING_REQUESTED_EVENT_METADATA, PAIRING_RESOLVED_EVENT_METADATA, PANE_ADDED_EVENT_METADATA, PANE_CLOSED_EVENT_METADATA, RENDER_DELTA_EVENT_METADATA, RENDER_STATE_EVENT_METADATA, RESIZED_EVENT_METADATA, SCREEN_ADDED_EVENT_METADATA, SCREEN_CLOSED_EVENT_METADATA, SCREEN_RENAMED_EVENT_METADATA, SCROLL_CHANGED_EVENT_METADATA, STATUS_EVENT_METADATA, SURFACE_EXITED_EVENT_METADATA, SURFACE_OUTPUT_EVENT_METADATA, SURFACE_RESIZE_FAILED_EVENT_METADATA, SURFACE_RESIZED_EVENT_METADATA, TAB_ADDED_EVENT_METADATA, TAB_CLOSED_EVENT_METADATA, TAB_RENAMED_EVENT_METADATA, TERMINAL_REGISTRY_CHANGED_EVENT_METADATA, TITLE_CHANGED_EVENT_METADATA, TREE_CHANGED_EVENT_METADATA, VT_STATE_EVENT_METADATA, WINDOW_TITLE_REQUESTED_EVENT_METADATA, WORKSPACE_ADDED_EVENT_METADATA, WORKSPACE_CLOSED_EVENT_METADATA, WORKSPACE_MOVED_EVENT_METADATA, WORKSPACE_RENAMED_EVENT_METADATA]; diff --git a/cmux-tui/bindings/rust/src/generated/mod.rs b/cmux-tui/bindings/rust/src/generated/mod.rs index 770b947763..a48a54e45c 100644 --- a/cmux-tui/bindings/rust/src/generated/mod.rs +++ b/cmux-tui/bindings/rust/src/generated/mod.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. // The emitter owns this layout so generation is independent of the installed rustfmt. mod commands; diff --git a/cmux-tui/bindings/rust/src/generated/types.rs b/cmux-tui/bindings/rust/src/generated/types.rs index 142ecdc08f..1b40d1fa34 100644 --- a/cmux-tui/bindings/rust/src/generated/types.rs +++ b/cmux-tui/bindings/rust/src/generated/types.rs @@ -1,5 +1,5 @@ // This file is generated. Do not edit by hand. -// cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. +// cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. // The emitter owns this layout so generation is independent of the installed rustfmt. use crate::{Nullable, Optional}; @@ -99,6 +99,14 @@ pub struct CellPixelResize { pub surface: Id, } +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CellPixelSurface { + pub height_px: u16, + pub surface: Id, + pub width_px: u16, +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ClientInfo { @@ -245,6 +253,14 @@ pub struct FrontendProjection { pub subject_key: String, } +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetCellPixelsResult { + pub height_px: u16, + pub surfaces: Vec, + pub width_px: u16, +} + #[rustfmt::skip] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum IdMappingKind { @@ -293,6 +309,27 @@ pub struct IdsResult { pub ids: Vec, } +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KittyGraphicsState { + pub alternate_next_image_id: u32, + pub alternate_replay_next_image_id: u32, + pub image_bytes: u64, + pub images: u64, + pub inflight_bytes: u64, + pub placements: u64, + pub primary_next_image_id: u32, + pub primary_replay_next_image_id: u32, + pub replay_cursor_offset: u32, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KittyImageAlias { + pub image_id: u32, + pub image_number: u32, +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] @@ -491,6 +528,7 @@ pub struct ReadScreenResult { #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ReadScrollbackResult { + pub epoch: u64, pub rows: Vec, pub start: u32, pub total: u32, @@ -507,6 +545,77 @@ pub struct RenderCursor { pub y: u16, } +#[rustfmt::skip] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderGraphicFormat { + #[serde(rename = "rgb")] + Rgb, + #[serde(rename = "rgba")] + Rgba, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderGraphicImage { + pub data: Base64, + pub format: RenderGraphicFormat, + pub generation: u64, + pub height: u32, + pub id: u32, + pub width: u32, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderGraphicPlacement { + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub anchor_col: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub anchor_row: Option, + pub columns: u32, + pub grid_cols: u32, + pub grid_rows: u32, + pub image_id: u32, + pub ordinal: u32, + pub pixel_height: u32, + pub pixel_width: u32, + pub placement_id: u32, + pub rows: u32, + pub source_height: u32, + pub source_width: u32, + pub source_x: u32, + pub source_y: u32, + pub viewport_col: i32, + pub viewport_row: i32, + pub viewport_visible: bool, + pub x_offset: u32, + pub y_offset: u32, + pub z: i32, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderGraphics { + pub generation: u64, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub images: Option>, + pub placements: Vec, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub removed_image_ids: Option>, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderGraphicsDelta { + pub generation: u64, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub images: Option>, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub placements: Option>, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub removed_image_ids: Option>, +} + #[rustfmt::skip] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RenderRow { @@ -1082,6 +1191,10 @@ pub struct Tree { pub struct VtStateResult { pub cols: u16, pub data: Base64, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_graphics_state: Option, + #[serde(default, deserialize_with = "crate::presence::deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] + pub kitty_image_aliases: Option>, pub rows: u16, } diff --git a/cmux-tui/bindings/typescript/README.md b/cmux-tui/bindings/typescript/README.md index a292029a8b..22049e0f0b 100644 --- a/cmux-tui/bindings/typescript/README.md +++ b/cmux-tui/bindings/typescript/README.md @@ -121,6 +121,12 @@ from `cmux/raw`: import { CmuxClient, COMMAND_METADATA } from "cmux/raw"; ``` +Raw render graphics follow one budget chain: 10,000,000 decoded image bytes +become at most 13,333,336 base64 characters. The 16,384-placement limit adds +at most 7,258,113 JSON characters. Their 20,591,449-character subtotal leaves +12,962,983 characters of the 33,554,432-character attach limit for image +metadata, rows, and the JSON wrapper. + Package verification builds all entry points, installs the tarball into a clean TypeScript consumer, and checks that browser imports cannot reach Node, raw, or generated modules: diff --git a/cmux-tui/bindings/typescript/package.json b/cmux-tui/bindings/typescript/package.json index 25a1a57458..5d89b9c1ec 100644 --- a/cmux-tui/bindings/typescript/package.json +++ b/cmux-tui/bindings/typescript/package.json @@ -33,6 +33,11 @@ "default": "./dist/src/node.js" }, "./raw": { + "browser": { + "types": "./dist/src/raw/browser.d.ts", + "import": "./dist/src/raw/browser.js", + "default": "./dist/src/raw/browser.js" + }, "types": "./dist/src/raw/index.d.ts", "import": "./dist/src/raw/index.js", "default": "./dist/src/raw/index.js" diff --git a/cmux-tui/bindings/typescript/scripts/verify-packaged-consumer.mjs b/cmux-tui/bindings/typescript/scripts/verify-packaged-consumer.mjs index 5302197c72..9351ff5c88 100644 --- a/cmux-tui/bindings/typescript/scripts/verify-packaged-consumer.mjs +++ b/cmux-tui/bindings/typescript/scripts/verify-packaged-consumer.mjs @@ -180,6 +180,12 @@ void write; assert.ok(!graph.some((path) => path.endsWith("/node.js"))); } + const rawBrowserGraph = dependencyGraph( + join(installedRoot, "dist/src/raw/browser.js"), + ); + assert.ok(!rawBrowserGraph.some((path) => path.endsWith("/node-transport.js"))); + assert.ok(!rawBrowserGraph.some((path) => path.endsWith("/node-client.js"))); + const runtimeTypes = execFileSync(process.execPath, [ "--input-type=module", "--eval", diff --git a/cmux-tui/bindings/typescript/src/raw/browser.ts b/cmux-tui/bindings/typescript/src/raw/browser.ts new file mode 100644 index 0000000000..47ab62c3a0 --- /dev/null +++ b/cmux-tui/bindings/typescript/src/raw/browser.ts @@ -0,0 +1,11 @@ +/** Browser-safe legacy generated protocol and numeric mux-ID API. */ +export * from "./client.js"; +export * from "./types.js"; +export * from "./errors.js"; +export * from "./protocol-codec.js"; +export * from "./protocol/index.js"; +export * from "./websocket-transport.js"; +export * from "../base64.js"; +export * from "../transport.js"; +export * from "../transport-limits.js"; +export * from "../wire-json.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/client.ts b/cmux-tui/bindings/typescript/src/raw/client.ts index 8136b1f8ca..e0f11567c7 100644 --- a/cmux-tui/bindings/typescript/src/raw/client.ts +++ b/cmux-tui/bindings/typescript/src/raw/client.ts @@ -84,6 +84,13 @@ import { } from "./generated/metadata.js"; import type { Transport, Unsubscribe } from "../transport.js"; import { validateRequestTimeout } from "../internal/request-timeout.js"; +import { + RENDER_ATTACH_MAX_ENCODED_CHARS, + RENDER_GRAPHIC_MAX_ENCODED_CHARS, + RENDER_GRAPHIC_MAX_IMAGES, + RENDER_GRAPHIC_MAX_PLACEMENTS, + utf8ByteLength, +} from "../transport-limits.js"; import { parseWireJson, stringifyWireJson } from "../wire-json.js"; export interface CmuxClientOptions { @@ -103,6 +110,8 @@ export interface CmuxClientOptions { * It does not limit idle event streams. */ timeoutMs?: number; + /** Maximum time to establish an attach stream. */ + attachHandshakeTimeoutMs?: number; /** Optional default timeout for an idle stream read. The default waits indefinitely. */ streamIdleTimeoutMs?: number; allowProtocolV6Attach?: boolean; @@ -128,11 +137,26 @@ export interface SendRawOptions { } export const DEFAULT_MAX_BUFFERED_EVENTS = 256; -export const DEFAULT_MAX_ATTACH_ENCODED_CHARS = 16 * 1024 * 1024; +export const DEFAULT_MAX_ATTACH_ENCODED_CHARS = RENDER_ATTACH_MAX_ENCODED_CHARS; export const DEFAULT_MAX_PENDING_RESPONSES = 256; +export const MIN_ATTACH_HANDSHAKE_BYTES_PER_SECOND = 64 * 1024; +export const MAX_ATTACH_HANDSHAKE_TIMEOUT_MS = 15 * 60 * 1_000; const DEFAULT_CLIENT_AUTHORITIES = Object.freeze(["control", "frontend"] as const satisfies readonly CmuxAuthority[]); +export function defaultAttachHandshakeTimeoutMs( + requestTimeoutMs: number, + maxAttachEncodedChars: number, +): number { + const transferMs = Math.ceil( + maxAttachEncodedChars * 1_000 / MIN_ATTACH_HANDSHAKE_BYTES_PER_SECOND, + ); + return Math.max( + requestTimeoutMs, + Math.min(MAX_ATTACH_HANDSHAKE_TIMEOUT_MS, requestTimeoutMs + transferMs), + ); +} + function workspaceMutationResult(result: WorkspaceMutation): WorkspaceMutation { return result; } @@ -218,7 +242,8 @@ interface PendingResponse { class MessageRouter { private readonly pending = new Map(); - private readonly eventHandlers = new Set<(event: UnknownEvent) => void>(); + private readonly eventHandlers = + new Set<(event: UnknownEvent, receivedBytes: number) => void>(); private readonly terminalHandlers = new Set<(error: Error) => void>(); private terminalError: Error | null = null; private decodeContext: ProtocolDecodeContext | undefined; @@ -314,7 +339,7 @@ class MessageRouter { }); } - onEvent(handler: (event: UnknownEvent) => void): Unsubscribe { + onEvent(handler: (event: UnknownEvent, receivedBytes: number) => void): Unsubscribe { this.eventHandlers.add(handler); return () => this.eventHandlers.delete(handler); } @@ -355,7 +380,8 @@ class MessageRouter { ); return; } - for (const handler of this.eventHandlers) handler(event); + const receivedBytes = utf8ByteLength(json); + for (const handler of this.eventHandlers) handler(event, receivedBytes); return; } @@ -405,9 +431,14 @@ interface StreamWaiter { reject: (error: Error) => void; } +interface BufferedStreamEvent { + event: T; + retainedBytes: number; +} + /** A closeable async event stream that waits indefinitely unless an idle timeout is configured. */ export class CmuxStream implements AsyncIterable { - private readonly buffered: T[] = []; + private readonly buffered: BufferedStreamEvent[] = []; private bufferedBytes = 0; private readonly waiters: StreamWaiter[] = []; private closed = false; @@ -432,10 +463,10 @@ export class CmuxStream implements AsyncIterable if (timeoutMs !== undefined) this.validateIdleTimeout(timeoutMs); if (options.signal?.aborted) throw new CmuxAbortError("stream read aborted"); if (this.buffered.length > 0) { - const event = this.buffered.shift()!; - this.bufferedBytes = Math.max(0, this.bufferedBytes - this.retainedBytes(event)); + const buffered = this.buffered.shift()!; + this.bufferedBytes = Math.max(0, this.bufferedBytes - buffered.retainedBytes); if (this.endsAfterDrain && this.buffered.length === 0) this.finish(); - return event; + return buffered.event; } if (this.terminalError) throw this.terminalError; if (this.closed) throw new CmuxConnectionError("stream is closed"); @@ -497,8 +528,17 @@ export class CmuxStream implements AsyncIterable this.rejectWaiters(new CmuxConnectionError("stream is closed")); } - push(event: T, terminal = false): void { + push(event: T, terminal = false, retainedBytesOverride?: number): void { if (this.closed) return; + const retainedBytes = retainedBytesOverride ?? this.retainedBytes(event); + if (retainedBytes > this.maxBufferedBytes) { + this.fail( + new CmuxProtocolError( + `stream event data exceeds ${this.maxBufferedBytes} bytes`, + ), + ); + return; + } let delivered = false; while (this.waiters.length > 0) { const waiter = this.waiters.shift()!; @@ -512,7 +552,6 @@ export class CmuxStream implements AsyncIterable this.fail(new CmuxProtocolError("stream event buffer overflow")); return; } - const retainedBytes = this.retainedBytes(event); if (retainedBytes > this.maxBufferedBytes - this.bufferedBytes) { this.fail( new CmuxProtocolError( @@ -521,7 +560,7 @@ export class CmuxStream implements AsyncIterable ); return; } - this.buffered.push(event); + this.buffered.push({ event, retainedBytes }); this.bufferedBytes += retainedBytes; } if (terminal) this.endsAfterDrain = true; @@ -575,6 +614,7 @@ export class CmuxStream implements AsyncIterable /** Promise-based typed client for any cmux JSON transport. */ export class CmuxClient { readonly timeoutMs: number; + readonly attachHandshakeTimeoutMs: number; readonly streamIdleTimeoutMs: number | undefined; readonly allowProtocolV6Attach: boolean; readonly maxBufferedEvents: number; @@ -623,6 +663,20 @@ export class CmuxClient { options.maxAttachEncodedChars, DEFAULT_MAX_ATTACH_ENCODED_CHARS, ); + const defaultAttachTimeout = defaultAttachHandshakeTimeoutMs( + this.timeoutMs, + this.maxAttachEncodedChars, + ); + this.attachHandshakeTimeoutMs = options.attachHandshakeTimeoutMs === undefined + ? defaultAttachTimeout + : Math.max( + this.timeoutMs, + this.securityLimit( + "attachHandshakeTimeoutMs", + options.attachHandshakeTimeoutMs, + MAX_ATTACH_HANDSHAKE_TIMEOUT_MS, + ), + ); this.streamTransportFactory = options.streamTransportFactory; this.router = new MessageRouter(this.transport, this.maxPendingResponses); } @@ -767,7 +821,22 @@ export class CmuxClient { ): Promise> { return this.request("set-cell-pixels", params); } - vtState(surface: Id): Promise { return this.request("vt-state", { surface }); } + async vtState(surface: Id): Promise { + await this.ensureCommandAvailable("vt-state", { surface }); + const params = encodeCommandParams("vt-state", { surface }); + const response = await this.sendRaw( + { cmd: "vt-state", ...params }, + { timeoutMs: this.attachHandshakeTimeoutMs }, + ); + if (response.ok) { + return decodeCommandResult( + "vt-state", + response.data, + this.protocolDecodeContext(), + ) as VtStateResult; + } + throw new CmuxCommandError(response.error || "unknown error", response.id, response); + } resolveTerminal(terminalId: string): Promise { return this.request("resolve-terminal", { terminal_id: terminalId }); } @@ -1007,11 +1076,14 @@ export class CmuxClient { if (mode === "render") { return this.openStream( request, - (event) => event as RenderAttachEvent, + (event) => this.validateRenderAttachEvent(event), (event, dedicated) => dedicated || this.matchesAttachEvent(event, surface, mode), (event) => event.event === "detached" || this.isSurfaceOverflow(event, surface), false, - undefined, + { + maxBytes: this.maxAttachEncodedChars, + retainedBytes: (_event, receivedBytes) => receivedBytes, + }, options, ); } @@ -1223,7 +1295,10 @@ export class CmuxClient { accept: (event: UnknownEvent, dedicated: boolean) => boolean, terminal: (event: T) => boolean = () => false, exclusiveSharedSubscription = false, - buffering?: { maxBytes: number; retainedBytes: (event: T) => number }, + buffering?: { + maxBytes: number; + retainedBytes: (event: T, receivedBytes: number) => number; + }, streamOptions: StreamOpenOptions = {}, ): Promise> { if (streamOptions.signal?.aborted) { @@ -1273,12 +1348,16 @@ export class CmuxClient { this.sharedSubscriptionActive = false; } if (dedicated) transport.close(); - }, this.maxBufferedEvents, buffering?.maxBytes, buffering?.retainedBytes); - eventSubscription = router.onEvent((event) => { + }, this.maxBufferedEvents, buffering?.maxBytes); + eventSubscription = router.onEvent((event, receivedBytes) => { if (!accept(event, dedicated)) return; try { const mapped = map(event); - stream.push(mapped, terminal(mapped)); + stream.push( + mapped, + terminal(mapped), + buffering?.retainedBytes(mapped, receivedBytes), + ); streamError ??= stream.error; } catch (error) { streamError = error instanceof CmuxProtocolError @@ -1297,7 +1376,7 @@ export class CmuxClient { }); const response = await router.send( payload, - this.timeoutMs, + buffering === undefined ? this.timeoutMs : this.attachHandshakeTimeoutMs, streamOptions.signal, ).catch((error) => { stream.fail(error as Error); @@ -1392,17 +1471,86 @@ export class CmuxClient { } } + private validateRenderAttachEvent(event: UnknownEvent): RenderAttachEvent { + if (event.event !== "render-state" && event.event !== "render-delta") { + return event as RenderAttachEvent; + } + const graphics = event.graphics; + if (graphics === undefined) return event as RenderAttachEvent; + if (graphics === null || typeof graphics !== "object" || Array.isArray(graphics)) { + throw new CmuxProtocolError(`${event.event} graphics is not an object`); + } + const placements = (graphics as { placements?: unknown }).placements; + if (placements === undefined && event.event === "render-state") { + throw new CmuxProtocolError(`${event.event} graphics placements is not an array`); + } + if (placements !== undefined && !Array.isArray(placements)) { + throw new CmuxProtocolError(`${event.event} graphics placements is not an array`); + } + if (Array.isArray(placements) && placements.length > RENDER_GRAPHIC_MAX_PLACEMENTS) { + throw new CmuxProtocolError( + `${event.event} graphics exceeds ${RENDER_GRAPHIC_MAX_PLACEMENTS} placements`, + ); + } + const removedImageIds = + (graphics as { removed_image_ids?: unknown }).removed_image_ids; + if (removedImageIds !== undefined) { + if (!Array.isArray(removedImageIds)) { + throw new CmuxProtocolError( + `${event.event} graphics removed_image_ids is not an array`, + ); + } + if (removedImageIds.length > RENDER_GRAPHIC_MAX_IMAGES) { + throw new CmuxProtocolError( + `${event.event} graphics exceeds ${RENDER_GRAPHIC_MAX_IMAGES} removed image IDs`, + ); + } + if (removedImageIds.some((id) => + !Number.isSafeInteger(id) || id <= 0 || id > 0xffff_ffff + )) { + throw new CmuxProtocolError( + `${event.event} graphics removed_image_ids contains an invalid image ID`, + ); + } + } + const images = (graphics as { images?: unknown }).images; + if (images === undefined) return event as RenderAttachEvent; + if (!Array.isArray(images)) { + throw new CmuxProtocolError(`${event.event} graphics images is not an array`); + } + if (images.length > RENDER_GRAPHIC_MAX_IMAGES) { + throw new CmuxProtocolError( + `${event.event} graphics exceeds ${RENDER_GRAPHIC_MAX_IMAGES} images`, + ); + } + for (const image of images) { + if (image === null || typeof image !== "object" || Array.isArray(image)) { + throw new CmuxProtocolError(`${event.event} graphics image is not an object`); + } + this.validateAttachEncodedData( + (image as { data?: unknown }).data, + `${event.event} graphics image`, + Math.min(this.maxAttachEncodedChars, RENDER_GRAPHIC_MAX_ENCODED_CHARS), + ); + } + return event as RenderAttachEvent; + } + private decodeAttachData(value: unknown, eventName: string): Uint8Array { return decodeBase64(this.validateAttachEncodedData(value, eventName)); } - private validateAttachEncodedData(value: unknown, eventName: string): string { + private validateAttachEncodedData( + value: unknown, + eventName: string, + maxEncodedChars = this.maxAttachEncodedChars, + ): string { if (typeof value !== "string") { throw new CmuxProtocolError(`${eventName} data is not base64 text`); } - if (value.length > this.maxAttachEncodedChars) { + if (value.length > maxEncodedChars) { throw new CmuxProtocolError( - `${eventName} data exceeds ${this.maxAttachEncodedChars} encoded characters`, + `${eventName} data exceeds ${maxEncodedChars} encoded characters`, ); } return value; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json index c401ce4205..ed8439da00 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json @@ -2,32 +2,32 @@ "files": [ { "path": "commands.ts", - "sha256": "09def0b59e218447b444b081b71e5eef7a627abae10c8d3e634fbab7c2894cc7", - "size": 43304 + "sha256": "ee62f3d6615086de16d5067a9fe8b29d6bcaf2f0260fe0a776b425ace185bbbd", + "size": 43642 }, { "path": "events.ts", - "sha256": "44e2c1cc16bd9807422d10320146a27473003970f6e3cdd42bc09d6d0edb33e9", - "size": 14043 + "sha256": "9533ef7351d3e35ff9373bef9548c3aef3786fa7d230e8c4722daf2c7b10c4d2", + "size": 14850 }, { "path": "index.ts", - "sha256": "a66cd747098c1e4cd8c88fb745918b85ec54a682a585eb7ebb03fb6bb22f2d70", + "sha256": "cfacfceed3bbea6e8fcb05a63c5cabf50157c044f711ba1554a79197e08dd68e", "size": 272 }, { "path": "metadata.ts", - "sha256": "1107deb6081c0aa1fab0f7013a42ff147bfe09ad9550561b581980d6db816ce9", - "size": 230550 + "sha256": "87f7394850a23aa7c83e940000475cbe98c45473ac5a296b7dc0e2f32b05c5bf", + "size": 246038 }, { "path": "types.ts", - "sha256": "74600b4bb03a84aa073b23e28c98fb6aa39da3f5a1978c3136423766d43aadb1", - "size": 12971 + "sha256": "b73d29218166c8533274aa24a8bd36a19f77136c804be621788c3c962471ea96", + "size": 14855 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "typescript", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts index dd906b3183..f777e889a6 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/commands.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/commands.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. */ +/* cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. */ import type * as T from "./types.js"; @@ -300,6 +300,11 @@ export interface FocusPaneRequest extends CmuxRequestBase { } export type FocusPaneResult = T.EmptyResult; +/** Protocol v6; authority: frontend. */ +export interface GetCellPixelsRequest extends CmuxRequestBase { + cmd: "get-cell-pixels"; +} + /** Protocol v7; authority: control. */ export interface GetFrontendProjectionRequest extends CmuxRequestBase { cmd: "get-frontend-projection"; @@ -854,6 +859,7 @@ export type CmuxRequest = | ExportLayoutRequest | FocusDirectionRequest | FocusPaneRequest + | GetCellPixelsRequest | GetFrontendProjectionRequest | IdentifyRequest | IdsRequest @@ -1158,6 +1164,14 @@ export interface CmuxCommandDefinitionMap { capability: null; stream: null; }; + "get-cell-pixels": { + request: GetCellPixelsRequest; + result: T.GetCellPixelsResult; + authority: "frontend"; + since: 6; + capability: null; + stream: null; + }; "get-frontend-projection": { request: GetFrontendProjectionRequest; result: GetFrontendProjectionResult; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/events.ts b/cmux-tui/bindings/typescript/src/raw/generated/events.ts index ceb67c8010..cbe5de3372 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/events.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/events.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. */ +/* cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. */ import type * as T from "./types.js"; @@ -92,6 +92,18 @@ export type FrontendProjectionChangedEvent = { event: "frontend-projection-chang "subject_key": string; }; +/** Protocol v10; emission: emitted; streams: subscribe. */ +export type GraphicsStatusEvent = { event: "graphics-status" } & { + "attempts"?: number; + "cell_height"?: number; + "cell_width"?: number; + "error"?: string; + "kind": "kitty-image-budget-worker-start-failed" | "kitty-image-budget-update-failed" | "cell-pixel-update-retries-exhausted"; + "remaining"?: bigint; + "retry_exhausted"?: boolean; + "summary"?: string; +}; + /** Protocol v6; emission: emitted; streams: subscribe. */ export type LayoutChangedEvent = { event: "layout-changed" } & { "screen": T.Id; @@ -157,6 +169,8 @@ export type RenderDeltaEvent = { event: "render-delta" } & { "default_bg"?: T.ColorHex; "default_fg"?: T.ColorHex; "full": boolean; + "graphics"?: T.RenderGraphicsDelta; + "history_epoch"?: bigint; "rows": Array; "scrollback_rows"?: number; "size"?: T.Size; @@ -168,6 +182,8 @@ export type RenderStateEvent = { event: "render-state" } & { "cursor": T.RenderCursor; "default_bg": T.ColorHex; "default_fg": T.ColorHex; + "graphics"?: T.RenderGraphics; + "history_epoch": bigint; "rows": Array; "scrollback_rows": number; "size": T.Size; @@ -180,6 +196,8 @@ export type ResizedEvent = { event: "resized" } & { "cols": number; /** Protocol 6 compatibility field. */ "data"?: T.Base64; + "kitty_graphics_state"?: T.KittyGraphicsState; + "kitty_image_aliases"?: Array; "replay"?: T.Base64; "rows": number; "surface": T.Id; @@ -300,6 +318,8 @@ export type VtStateEvent = { event: "vt-state" } & { "colors"?: T.TerminalColors; "cols": number; "data": T.Base64; + "kitty_graphics_state"?: T.KittyGraphicsState; + "kitty_image_aliases"?: Array; "rows": number; "surface": T.Id; }; @@ -375,6 +395,7 @@ export type KnownCmuxEvent = | EmptyEvent | FrameEvent | FrontendProjectionChangedEvent + | GraphicsStatusEvent | LayoutChangedEvent | NotificationEvent | OutputEvent @@ -421,6 +442,7 @@ export type KnownSubscribeEvent = | ConfigReloadRequestedEvent | EmptyEvent | FrontendProjectionChangedEvent + | GraphicsStatusEvent | LayoutChangedEvent | NotificationEvent | OverflowEvent diff --git a/cmux-tui/bindings/typescript/src/raw/generated/index.ts b/cmux-tui/bindings/typescript/src/raw/generated/index.ts index 17b03b1226..5dfe6e2e66 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/index.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/index.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. */ +/* cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. */ export * from "./types.js"; export * from "./commands.js"; diff --git a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts index bdaf3fd56e..add0a8ec0c 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/metadata.ts @@ -1,10 +1,10 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. */ +/* cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. */ export const SDK_SCHEMA_VERSION = 2 as const; export const MUX_PROTOCOL_VERSION = 10 as const; -export const SDK_IR_SHA256 = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e" as const; +export const SDK_IR_SHA256 = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350" as const; export const PROTOCOL = { "id_type": "uint64", "javascript_id_policy": "All protocol identifiers are uint64 JSON numbers. JavaScript and TypeScript SDKs must decode them losslessly as bigint (or validated decimal strings at their public boundary), and must not expose IEEE-754 number ids. Pairing request ids, revisions, timestamps, frame sequences, and reservation ids follow the same rule.", @@ -414,6 +414,14 @@ export const COMMAND_METADATA = { "stream": null, "constraints": [] }, + "get-cell-pixels": { + "authority": "frontend", + "since": 6, + "capability": null, + "fields": {}, + "stream": null, + "constraints": [] + }, "get-frontend-projection": { "authority": "control", "since": 7, @@ -1232,6 +1240,14 @@ export const EVENT_METADATA = { ], "emission": "emitted" }, + "graphics-status": { + "since": 10, + "capability": null, + "streams": [ + "subscribe" + ], + "emission": "emitted" + }, "layout-changed": { "since": 6, "capability": null, @@ -1726,6 +1742,36 @@ export const TYPE_SCHEMAS: Readonly> = { }, "kind": "object" }, + "CellPixelSurface": { + "additional_properties": false, + "fields": { + "height_px": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "surface": { + "nullable": false, + "presence": "required", + "type": { + "kind": "ref", + "name": "Id" + } + }, + "width_px": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint16" + } + } + }, + "kind": "object" + }, "ClientInfo": { "additional_properties": false, "fields": { @@ -2245,6 +2291,39 @@ export const TYPE_SCHEMAS: Readonly> = { }, "kind": "object" }, + "GetCellPixelsResult": { + "additional_properties": false, + "fields": { + "height_px": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "surfaces": { + "nullable": false, + "presence": "required", + "type": { + "items": { + "kind": "ref", + "name": "CellPixelSurface" + }, + "kind": "array" + } + }, + "width_px": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint16" + } + } + }, + "kind": "object" + }, "Id": { "kind": "alias", "target": { @@ -2432,6 +2511,106 @@ export const TYPE_SCHEMAS: Readonly> = { "kind": "opaque_json", "reason": "The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document." }, + "KittyGraphicsState": { + "additional_properties": false, + "fields": { + "alternate_next_image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "alternate_replay_next_image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "image_bytes": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "images": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "inflight_bytes": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "placements": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "primary_next_image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "primary_replay_next_image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "replay_cursor_offset": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + } + }, + "kind": "object" + }, + "KittyImageAlias": { + "additional_properties": false, + "fields": { + "image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "image_number": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + } + }, + "kind": "object" + }, "Layout": { "kind": "tagged_union", "tag": "type", @@ -3162,6 +3341,15 @@ export const TYPE_SCHEMAS: Readonly> = { "ReadScrollbackResult": { "additional_properties": false, "fields": { + "epoch": { + "nullable": false, + "presence": "required", + "since": 10, + "type": { + "kind": "scalar", + "name": "uint64" + } + }, "rows": { "nullable": false, "presence": "required", @@ -3246,6 +3434,335 @@ export const TYPE_SCHEMAS: Readonly> = { }, "kind": "object" }, + "RenderGraphicFormat": { + "kind": "enum", + "values": [ + "rgb", + "rgba" + ] + }, + "RenderGraphicImage": { + "additional_properties": false, + "fields": { + "data": { + "nullable": false, + "presence": "required", + "type": { + "kind": "ref", + "name": "Base64" + } + }, + "format": { + "nullable": false, + "presence": "required", + "type": { + "kind": "ref", + "name": "RenderGraphicFormat" + } + }, + "generation": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "height": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "width": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + } + }, + "kind": "object" + }, + "RenderGraphicPlacement": { + "additional_properties": false, + "fields": { + "anchor_col": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "anchor_row": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "columns": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "grid_cols": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "grid_rows": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "image_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "ordinal": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "pixel_height": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "pixel_width": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "placement_id": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "rows": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "source_height": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "source_width": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "source_x": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "source_y": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "viewport_col": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "int32" + } + }, + "viewport_row": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "int32" + } + }, + "viewport_visible": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "boolean" + } + }, + "x_offset": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "y_offset": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint32" + } + }, + "z": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "int32" + } + } + }, + "kind": "object" + }, + "RenderGraphics": { + "additional_properties": false, + "fields": { + "generation": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "images": { + "nullable": false, + "presence": "optional", + "type": { + "items": { + "kind": "ref", + "name": "RenderGraphicImage" + }, + "kind": "array" + } + }, + "placements": { + "nullable": false, + "presence": "required", + "type": { + "items": { + "kind": "ref", + "name": "RenderGraphicPlacement" + }, + "kind": "array" + } + }, + "removed_image_ids": { + "nullable": false, + "presence": "optional", + "type": { + "items": { + "kind": "scalar", + "name": "uint32" + }, + "kind": "array" + } + } + }, + "kind": "object" + }, + "RenderGraphicsDelta": { + "additional_properties": false, + "fields": { + "generation": { + "nullable": false, + "presence": "required", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "images": { + "nullable": false, + "presence": "optional", + "type": { + "items": { + "kind": "ref", + "name": "RenderGraphicImage" + }, + "kind": "array" + } + }, + "placements": { + "nullable": false, + "presence": "optional", + "type": { + "items": { + "kind": "ref", + "name": "RenderGraphicPlacement" + }, + "kind": "array" + } + }, + "removed_image_ids": { + "nullable": false, + "presence": "optional", + "type": { + "items": { + "kind": "scalar", + "name": "uint32" + }, + "kind": "array" + } + } + }, + "kind": "object" + }, "RenderRow": { "additional_properties": false, "fields": { @@ -4668,6 +5185,27 @@ export const TYPE_SCHEMAS: Readonly> = { "name": "Base64" } }, + "kitty_graphics_state": { + "nullable": false, + "presence": "optional", + "since": 10, + "type": { + "kind": "ref", + "name": "KittyGraphicsState" + } + }, + "kitty_image_aliases": { + "nullable": false, + "presence": "optional", + "since": 9, + "type": { + "items": { + "kind": "ref", + "name": "KittyImageAlias" + }, + "kind": "array" + } + }, "rows": { "nullable": false, "presence": "required", @@ -6188,6 +6726,17 @@ export const COMMAND_SCHEMAS: Readonly> = { "name": "EmptyResult" } }, + "get-cell-pixels": { + "request": { + "additional_properties": false, + "fields": {}, + "kind": "object" + }, + "result": { + "kind": "ref", + "name": "GetCellPixelsResult" + } + }, "get-frontend-projection": { "request": { "additional_properties": false, @@ -9162,6 +9711,93 @@ export const EVENT_SCHEMAS: Readonly> = { }, "kind": "object" }, + "graphics-status": { + "additional_properties": false, + "constraints": [ + "kitty-image-budget-worker-start-failed carries error.", + "kitty-image-budget-update-failed carries retry_exhausted and summary.", + "cell-pixel-update-retries-exhausted carries attempts, remaining, cell_width, and cell_height." + ], + "fields": { + "attempts": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "cell_height": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "cell_width": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint16" + } + }, + "error": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "string" + } + }, + "event": { + "nullable": false, + "presence": "required", + "type": { + "kind": "literal", + "value": "graphics-status" + } + }, + "kind": { + "nullable": false, + "presence": "required", + "type": { + "kind": "enum", + "values": [ + "kitty-image-budget-worker-start-failed", + "kitty-image-budget-update-failed", + "cell-pixel-update-retries-exhausted" + ] + } + }, + "remaining": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "uint64" + } + }, + "retry_exhausted": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "boolean" + } + }, + "summary": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "scalar", + "name": "string" + } + } + }, + "kind": "object" + }, "layout-changed": { "additional_properties": false, "fields": { @@ -9540,6 +10176,23 @@ export const EVENT_SCHEMAS: Readonly> = { "name": "boolean" } }, + "graphics": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "ref", + "name": "RenderGraphicsDelta" + } + }, + "history_epoch": { + "nullable": false, + "presence": "optional", + "since": 10, + "type": { + "kind": "scalar", + "name": "uint64" + } + }, "rows": { "nullable": false, "presence": "required", @@ -9613,6 +10266,23 @@ export const EVENT_SCHEMAS: Readonly> = { "value": "render-state" } }, + "graphics": { + "nullable": false, + "presence": "optional", + "type": { + "kind": "ref", + "name": "RenderGraphics" + } + }, + "history_epoch": { + "nullable": false, + "presence": "required", + "since": 10, + "type": { + "kind": "scalar", + "name": "uint64" + } + }, "rows": { "nullable": false, "presence": "required", @@ -9691,6 +10361,27 @@ export const EVENT_SCHEMAS: Readonly> = { "value": "resized" } }, + "kitty_graphics_state": { + "nullable": false, + "presence": "optional", + "since": 10, + "type": { + "kind": "ref", + "name": "KittyGraphicsState" + } + }, + "kitty_image_aliases": { + "nullable": false, + "presence": "optional", + "since": 9, + "type": { + "items": { + "kind": "ref", + "name": "KittyImageAlias" + }, + "kind": "array" + } + }, "replay": { "nullable": false, "presence": "optional", @@ -10367,6 +11058,27 @@ export const EVENT_SCHEMAS: Readonly> = { "value": "vt-state" } }, + "kitty_graphics_state": { + "nullable": false, + "presence": "optional", + "since": 10, + "type": { + "kind": "ref", + "name": "KittyGraphicsState" + } + }, + "kitty_image_aliases": { + "nullable": false, + "presence": "optional", + "since": 9, + "type": { + "items": { + "kind": "ref", + "name": "KittyImageAlias" + }, + "kind": "array" + } + }, "rows": { "nullable": false, "presence": "required", diff --git a/cmux-tui/bindings/typescript/src/raw/generated/types.ts b/cmux-tui/bindings/typescript/src/raw/generated/types.ts index ecd3bf68f5..560c2af356 100644 --- a/cmux-tui/bindings/typescript/src/raw/generated/types.ts +++ b/cmux-tui/bindings/typescript/src/raw/generated/types.ts @@ -1,5 +1,5 @@ /* This file is generated. Do not edit by hand. */ -/* cmux-tui mux protocol 10, IR 4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e. */ +/* cmux-tui mux protocol 10, IR 17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350. */ /** JSON accepted by the wire codec. bigint is serialized as an exact JSON integer. */ @@ -51,6 +51,12 @@ export type CellPixelResize = { "surface": Id; }; +export type CellPixelSurface = { + "height_px": number; + "surface": Id; + "width_px": number; +}; + export type ClientInfo = { "attached": Array; "client": bigint; @@ -139,6 +145,12 @@ export type FrontendProjection = { "subject_key": string; }; +export type GetCellPixelsResult = { + "height_px": number; + "surfaces": Array; + "width_px": number; +}; + export type Id = bigint; export type IdMapping = { @@ -167,6 +179,23 @@ export type IdsResult = { "ids": Array; }; +export type KittyGraphicsState = { + "alternate_next_image_id": number; + "alternate_replay_next_image_id": number; + "image_bytes": bigint; + "images": bigint; + "inflight_bytes": bigint; + "placements": bigint; + "primary_next_image_id": number; + "primary_replay_next_image_id": number; + "replay_cursor_offset": number; +}; + +export type KittyImageAlias = { + "image_id": number; + "image_number": number; +}; + export type Layout = ({ "type": "leaf" } & { "pane": Id; "type": "leaf"; @@ -291,6 +320,7 @@ export type ReadScreenResult = { }; export type ReadScrollbackResult = { + "epoch": bigint; "rows": Array; "start": number; "total": number; @@ -305,6 +335,55 @@ export type RenderCursor = { "y": number; }; +export type RenderGraphicFormat = "rgb" | "rgba"; + +export type RenderGraphicImage = { + "data": Base64; + "format": RenderGraphicFormat; + "generation": bigint; + "height": number; + "id": number; + "width": number; +}; + +export type RenderGraphicPlacement = { + "anchor_col"?: number; + "anchor_row"?: number; + "columns": number; + "grid_cols": number; + "grid_rows": number; + "image_id": number; + "ordinal": number; + "pixel_height": number; + "pixel_width": number; + "placement_id": number; + "rows": number; + "source_height": number; + "source_width": number; + "source_x": number; + "source_y": number; + "viewport_col": number; + "viewport_row": number; + "viewport_visible": boolean; + "x_offset": number; + "y_offset": number; + "z": number; +}; + +export type RenderGraphics = { + "generation": bigint; + "images"?: Array; + "placements": Array; + "removed_image_ids"?: Array; +}; + +export type RenderGraphicsDelta = { + "generation": bigint; + "images"?: Array; + "placements"?: Array; + "removed_image_ids"?: Array; +}; + export type RenderRow = { "row": number; "runs": Array; @@ -507,6 +586,8 @@ export type Tree = { export type VtStateResult = { "cols": number; "data": Base64; + "kitty_graphics_state"?: KittyGraphicsState; + "kitty_image_aliases"?: Array; "rows": number; }; diff --git a/cmux-tui/bindings/typescript/src/raw/node-client.ts b/cmux-tui/bindings/typescript/src/raw/node-client.ts index f57eb98430..425edd1a57 100644 --- a/cmux-tui/bindings/typescript/src/raw/node-client.ts +++ b/cmux-tui/bindings/typescript/src/raw/node-client.ts @@ -6,6 +6,7 @@ import type { CmuxAuthority } from "./generated/metadata.js"; import { defaultSocketPath, envSocketPath, UnixSocketTransport } from "../node-transport.js"; import { validateRequestTimeout } from "../internal/request-timeout.js"; import type { Transport } from "../transport.js"; +import { RENDER_ATTACH_MAX_ENCODED_CHARS } from "../transport-limits.js"; /** Node.js client configuration, including Unix-socket defaults. */ export interface ClientOptions { @@ -13,6 +14,7 @@ export interface ClientOptions { session?: string; /** Command acknowledgement timeout. */ timeoutMs?: number; + attachHandshakeTimeoutMs?: number; /** * Command authorities enabled for this client. * Node Unix clients default to control, frontend, and local-admin. @@ -39,18 +41,22 @@ export class CmuxClient extends TransportCmuxClient { constructor(options: ClientOptions = {}) { const timeoutMs = validateRequestTimeout(options.timeoutMs ?? 10_000); const socketPath = options.socketPath ?? envSocketPath() ?? defaultSocketPath(options.session ?? "main"); + const rawTransport = (): UnixSocketTransport => new UnixSocketTransport(socketPath, { + maxInboundMessageBytes: RENDER_ATTACH_MAX_ENCODED_CHARS, + }); const shared: CmuxClientOptions = { - transport: options.transport ?? new UnixSocketTransport(socketPath), + transport: options.transport ?? rawTransport(), authorities: options.authorities ?? ["control", "frontend", "local-admin"], enableProviderAuthority: options.enableProviderAuthority, timeoutMs, + attachHandshakeTimeoutMs: options.attachHandshakeTimeoutMs, streamIdleTimeoutMs: options.streamIdleTimeoutMs, allowProtocolV6Attach: options.allowProtocolV6Attach, maxBufferedEvents: options.maxBufferedEvents, maxAttachEncodedChars: options.maxAttachEncodedChars, maxPendingResponses: options.maxPendingResponses, streamTransportFactory: options.streamTransportFactory - ?? (options.transport ? undefined : () => new UnixSocketTransport(socketPath)), + ?? (options.transport ? undefined : rawTransport), }; super(shared); this.socketPath = socketPath; diff --git a/cmux-tui/bindings/typescript/src/transport-limits.ts b/cmux-tui/bindings/typescript/src/transport-limits.ts index 6f6f476efe..035acfff29 100644 --- a/cmux-tui/bindings/typescript/src/transport-limits.ts +++ b/cmux-tui/bindings/typescript/src/transport-limits.ts @@ -9,6 +9,16 @@ export const MAX_PENDING_MESSAGES = 256; /** Matches the server's bounded regular-message bytes. */ export const MAX_PENDING_BYTES = 16_777_216; +/** Maximum decoded Kitty pixel bytes retained by one render attach. */ +export const RENDER_GRAPHIC_MAX_DECODED_BYTES = 10_000_000; +/** Maximum base64 characters for the decoded Kitty pixel budget. */ +export const RENDER_GRAPHIC_MAX_ENCODED_CHARS = + Math.ceil(RENDER_GRAPHIC_MAX_DECODED_BYTES / 3) * 4; +export const RENDER_GRAPHIC_MAX_IMAGES = 4_096; +export const RENDER_GRAPHIC_MAX_PLACEMENTS = 16_384; +/** Maximum encoded JSON characters accepted for a raw attach event. */ +export const RENDER_ATTACH_MAX_ENCODED_CHARS = 32 * 1024 * 1024; + const encoder = new TextEncoder(); export function utf8ByteLength(value: string): number { diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index 1386255654..864b67304d 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -3,6 +3,10 @@ import test from "node:test"; import { CmuxClient, CmuxStream, + DEFAULT_MAX_ATTACH_ENCODED_CHARS, + MAX_ATTACH_HANDSHAKE_TIMEOUT_MS, + MIN_ATTACH_HANDSHAKE_BYTES_PER_SECOND, + defaultAttachHandshakeTimeoutMs, type BrowserStreamEvent, } from "../src/raw/client.js"; import { @@ -13,9 +17,17 @@ import { } from "../src/raw/errors.js"; import type { DecodedResizedEvent, + RenderStateEvent, TreeDeltaEvent, } from "../src/raw/protocol/index.js"; import type { Transport, Unsubscribe } from "../src/transport.js"; +import { + RENDER_ATTACH_MAX_ENCODED_CHARS, + RENDER_GRAPHIC_MAX_DECODED_BYTES, + RENDER_GRAPHIC_MAX_ENCODED_CHARS, + RENDER_GRAPHIC_MAX_IMAGES, + RENDER_GRAPHIC_MAX_PLACEMENTS, +} from "../src/transport-limits.js"; import { stringifyWireJson } from "../src/wire-json.js"; class ScriptedTransport implements Transport { @@ -476,6 +488,21 @@ test("async iteration reports buffered-event overflow before the first pull", as await assert.rejects(() => iterator.next(), /stream event buffer overflow/); }); +test("stream rejects an oversized event while a reader is already waiting", async () => { + const stream = new CmuxStream<{ event: string; bytes: number }>( + 100, + () => undefined, + 256, + 4, + (event) => event.bytes, + ); + const waiting = stream.next(); + + stream.push({ event: "oversized", bytes: 5 }); + + await assert.rejects(() => waiting, /stream event data exceeds 4 bytes/); +}); + test("attachSurface rejects oversized encoded data before decoding", async () => { const main = new ScriptedTransport((request, transport) => { transport.emit({ @@ -593,6 +620,71 @@ type CmuxClientOptionsWithSecurityLimits = ConstructorParameters { + assert.equal(MIN_ATTACH_HANDSHAKE_BYTES_PER_SECOND, 64 * 1024); + assert.equal(MAX_ATTACH_HANDSHAKE_TIMEOUT_MS, 15 * 60 * 1_000); + assert.equal( + defaultAttachHandshakeTimeoutMs(10_000, RENDER_ATTACH_MAX_ENCODED_CHARS), + 522_000, + ); +}); + +test("attach stream can acknowledge after the ordinary request deadline", async () => { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics: { generation: 0, images: [], placements: [] }, + }); + setTimeout(() => transport.emit({ id: request.id, ok: true, data: {} }), 30); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 10, + attachHandshakeTimeoutMs: 100, + }); + + const stream = await client.attachSurface(7n, { mode: "render" }); + assert.equal((await stream.next()).event, "render-state"); + stream.close(); + await client.close(); +}); + +test("vtState uses the size-aware snapshot deadline", async () => { + const transport = new ScriptedTransport((request, connection) => { + assert.equal(request.cmd, "vt-state"); + setTimeout(() => { + connection.emit({ + id: request.id, + ok: true, + data: { cols: 80, rows: 24, data: "" }, + }); + }, 30); + }); + const client = new CmuxClient({ + transport, + timeoutMs: 10, + attachHandshakeTimeoutMs: 100, + }); + + assert.deepEqual(await client.vtState(7n), { cols: 80, rows: 24, data: "" }); + await client.close(); +}); + test("resize response rejects a missing required accepted field", async () => { const transport = new ScriptedTransport((request, connection) => { connection.emit({ @@ -756,6 +848,7 @@ test("attachSurface decodes VT colors, output, and resized payloads", async () = cols: 80, rows: 24, data: "G1s/bA==", + kitty_image_aliases: [{ image_id: 7, image_number: 70 }], colors: { fg: "#d8d9da", bg: "#131415", @@ -775,6 +868,7 @@ test("attachSurface decodes VT colors, output, and resized payloads", async () = cols: 100, rows: 30, data: "AQID", + kitty_image_aliases: [{ image_id: 8, image_number: 80 }], colors: { fg: null, bg: null, @@ -798,6 +892,7 @@ test("attachSurface decodes VT colors, output, and resized payloads", async () = assert.equal(initial.event, "vt-state"); if (initial.event === "vt-state") { assert.deepEqual(initial.data, Uint8Array.from([27, 91, 63, 108])); + assert.deepEqual(initial.kitty_image_aliases, [{ image_id: 7, image_number: 70 }]); assert.deepEqual(initial.colors, { fg: "#d8d9da", bg: "#131415", @@ -819,6 +914,7 @@ test("attachSurface decodes VT colors, output, and resized payloads", async () = const decoded = resized as DecodedResizedEvent; assert.deepEqual(decoded.data, Uint8Array.from([1, 2, 3])); assert.deepEqual(decoded.replay, decoded.data); + assert.deepEqual(decoded.kitty_image_aliases, [{ image_id: 8, image_number: 80 }]); assert.deepEqual(decoded.colors?.palette, { "5": "#112233" }); } stream.close(); @@ -929,7 +1025,40 @@ test("attachSurface routes colors-changed events without a surface field", async await client.close(); }); -test("attachSurface render mode yields render-state and render-delta from cached protocol v7", async () => { +const renderGraphics = { + generation: 4n, + images: [{ + id: 9, + generation: 2n, + width: 1, + height: 1, + format: "rgba", + data: "/wAA/w==", + }], + placements: [{ + image_id: 9, + placement_id: 3, + ordinal: 0, + x_offset: 0, + y_offset: 0, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + columns: 1, + rows: 1, + grid_cols: 1, + grid_rows: 1, + pixel_width: 8, + pixel_height: 16, + viewport_col: 0, + viewport_row: 0, + viewport_visible: true, + z: 0, + }], +}; + +test("attachSurface render mode yields Kitty pixels and placements with render events", async () => { let identifyRequests = 0; const main = new ScriptedTransport((request, transport) => { assert.equal(request.cmd, "identify"); @@ -975,6 +1104,7 @@ test("attachSurface render mode yields render-state and render-delta from cached width_hint: 3, }], }], + graphics: renderGraphics, }); transport.emit({ id: request.id, ok: true, data: {} }); transport.emit({ @@ -984,6 +1114,22 @@ test("attachSurface render mode yields render-state and render-delta from cached full: false, scrollback_rows: 43, rows: [{ row: 0, runs: [{ text: "ok ", fg: "#00ff00", bg: null, attrs: 0 }] }], + graphics: { + generation: 4n, + removed_image_ids: [99], + placements: [{ ...renderGraphics.placements[0], viewport_col: 1 }], + }, + }); + transport.emit({ + event: "render-delta", + surface: 7, + cursor: { x: 0, y: 0, style: "bar", blink: false, visible: false, color: null }, + full: false, + rows: [], + graphics: { + generation: 5n, + images: [{ ...renderGraphics.images[0], generation: 3n }], + }, }); }); const client = new CmuxClient({ @@ -1015,6 +1161,7 @@ test("attachSurface render mode yields render-state and render-delta from cached width_hint: 3, }], }], + graphics: renderGraphics, }); assert.deepEqual(await stream.next(), { event: "render-delta", @@ -1023,11 +1170,361 @@ test("attachSurface render mode yields render-state and render-delta from cached full: false, scrollback_rows: 43, rows: [{ row: 0, runs: [{ text: "ok ", fg: "#00ff00", bg: null, attrs: 0 }] }], + graphics: { + generation: 4n, + removed_image_ids: [99], + placements: [{ ...renderGraphics.placements[0], viewport_col: 1 }], + }, + }); + assert.deepEqual(await stream.next(), { + event: "render-delta", + surface: 7n, + cursor: { x: 0, y: 0, style: "bar", blink: false, visible: false, color: null }, + full: false, + rows: [], + graphics: { + generation: 5n, + images: [{ ...renderGraphics.images[0], generation: 3n }], + }, }); stream.close(); await client.close(); }); +test("attachSurface render mode rejects oversized Kitty image data before buffering it", async () => { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics: { + ...renderGraphics, + images: [{ ...renderGraphics.images[0], data: "AAAAA" }], + }, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 100, + maxAttachEncodedChars: 4, + } as CmuxClientOptionsWithSecurityLimits); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + /render-state graphics image data exceeds 4 encoded characters/, + ); + await client.close(); +}); + +test("attachSurface render mode requires a bounded Kitty placement array", async () => { + const missingPlacements = { + generation: renderGraphics.generation, + images: renderGraphics.images, + }; + for (const [graphics, expected] of [ + [missingPlacements, /event render-state\.graphics\.placements is required/], + [{ ...renderGraphics, placements: {} }, /event render-state\.graphics\.placements must be an array/], + [ + { + ...renderGraphics, + placements: new Array(RENDER_GRAPHIC_MAX_PLACEMENTS + 1) + .fill(renderGraphics.placements[0]), + }, + new RegExp(`render-state graphics exceeds ${RENDER_GRAPHIC_MAX_PLACEMENTS} placements`), + ], + ]) { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 100, + }); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + expected as RegExp, + ); + await client.close(); + } +}); + +test("attachSurface render mode requires a bounded Kitty image array", async () => { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics: { + ...renderGraphics, + images: new Array(RENDER_GRAPHIC_MAX_IMAGES + 1).fill(renderGraphics.images[0]), + }, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 100, + }); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + new RegExp(`render-state graphics exceeds ${RENDER_GRAPHIC_MAX_IMAGES} images`), + ); + await client.close(); +}); + +test("attachSurface render mode validates bounded removed Kitty image IDs", async () => { + const cases: Array<[unknown, RegExp]> = [ + [{}, /event render-delta\.graphics\.removed_image_ids must be an array/], + [ + new Array(RENDER_GRAPHIC_MAX_IMAGES + 1).fill(1), + new RegExp( + `render-delta graphics exceeds ${RENDER_GRAPHIC_MAX_IMAGES} removed image IDs`, + ), + ], + [[0], /render-delta graphics removed_image_ids contains an invalid image ID/], + [[-1], /event render-delta\.graphics\.removed_image_ids\[0\] must be in uint32 range/], + [[1.5], /event render-delta\.graphics\.removed_image_ids\[0\] must be a safe integer number/], + [["1"], /event render-delta\.graphics\.removed_image_ids\[0\] must be a safe integer number/], + ]; + for (const [removedImageIds, expected] of cases) { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-delta", + surface: 7, + cursor: { + x: 0, + y: 0, + style: "block", + blink: false, + visible: false, + color: null, + }, + full: false, + rows: [], + graphics: { + generation: 2n, + removed_image_ids: removedImageIds, + }, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 100, + }); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + expected, + ); + await client.close(); + } +}); + +test("render attach counts non-image JSON bytes against the retained buffer cap", async () => { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const renderDelta = { + event: "render-delta", + surface: 7, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + full: false, + rows: [{ row: 0, runs: [{ text: "界", fg: null, bg: null, attrs: 0 }] }], + graphics: { + generation: 5n, + removed_image_ids: [9], + placements: [renderGraphics.placements[0]], + }, + }; + const encoded = stringifyWireJson(renderDelta); + const encodedChars = encoded.length; + assert.ok(new TextEncoder().encode(encoded).byteLength > encodedChars); + const attach = new ScriptedTransport((request, transport) => { + transport.emit(renderDelta); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 100, + maxAttachEncodedChars: encodedChars, + } as CmuxClientOptionsWithSecurityLimits); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + new RegExp(`stream event data exceeds ${encodedChars} bytes`), + ); + await client.close(); +}); + +test("render attach accepts the full decoded-image budget below its encoded limit", async () => { + assert.equal(RENDER_GRAPHIC_MAX_DECODED_BYTES, 10_000_000); + assert.equal(RENDER_GRAPHIC_MAX_ENCODED_CHARS, 13_333_336); + assert.equal(RENDER_GRAPHIC_MAX_IMAGES, 4_096); + assert.equal(RENDER_GRAPHIC_MAX_PLACEMENTS, 16_384); + assert.equal(RENDER_ATTACH_MAX_ENCODED_CHARS, 33_554_432); + assert.equal(DEFAULT_MAX_ATTACH_ENCODED_CHARS, RENDER_ATTACH_MAX_ENCODED_CHARS); + assert.ok(RENDER_GRAPHIC_MAX_ENCODED_CHARS < RENDER_ATTACH_MAX_ENCODED_CHARS); + + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const encoded = `${"A".repeat(RENDER_GRAPHIC_MAX_ENCODED_CHARS - 2)}==`; + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics: { + generation: 1n, + images: [{ + id: 1, + generation: 1n, + width: RENDER_GRAPHIC_MAX_DECODED_BYTES / 4, + height: 1, + format: "rgba", + data: encoded, + }], + placements: [], + }, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 1_000, + }); + + await client.identify(); + const stream = await client.attachSurface(7n, { mode: "render" }); + const event = await stream.next() as RenderStateEvent; + assert.equal( + event.graphics?.images?.[0]?.data.length, + RENDER_GRAPHIC_MAX_ENCODED_CHARS, + ); + stream.close(); + await client.close(); +}); + +test("render attach rejects an image above its protocol limit under the larger attach cap", async () => { + const main = new ScriptedTransport((request, transport) => { + transport.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 7, session: "main", pid: 1 }, + }); + }); + const attach = new ScriptedTransport((request, transport) => { + transport.emit({ + event: "render-state", + surface: 7, + size: { cols: 1, rows: 1 }, + cursor: { x: 0, y: 0, style: "block", blink: false, visible: false, color: null }, + default_fg: "#ffffff", + default_bg: "#000000", + scrollback_rows: 0, + rows: [], + graphics: { + generation: 1n, + images: [{ + id: 1, + generation: 1n, + width: 1, + height: 1, + format: "rgba", + data: "A".repeat(RENDER_GRAPHIC_MAX_ENCODED_CHARS + 1), + }], + placements: [], + }, + }); + transport.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport: main, + streamTransportFactory: () => attach, + timeoutMs: 1_000, + }); + + await assert.rejects( + () => client.attachSurface(7n, { mode: "render" }), + new RegExp( + `render-state graphics image data exceeds ${RENDER_GRAPHIC_MAX_ENCODED_CHARS} encoded characters`, + ), + ); + await client.close(); +}); + test("attachSurface render mode accepts a newer additive protocol", async () => { const main = new ScriptedTransport((request, transport) => { assert.equal(request.cmd, "identify"); diff --git a/cmux-tui/bindings/typescript/test/generated.test.ts b/cmux-tui/bindings/typescript/test/generated.test.ts index 986f999123..7e3931ce98 100644 --- a/cmux-tui/bindings/typescript/test/generated.test.ts +++ b/cmux-tui/bindings/typescript/test/generated.test.ts @@ -12,8 +12,8 @@ import { test("generated protocol coverage matches the canonical v10 IR", () => { assert.equal(MUX_PROTOCOL_VERSION, 10); assert.equal(SDK_SCHEMA_VERSION, 2); - assert.equal(Object.keys(COMMAND_METADATA).length, 91); - assert.equal(Object.keys(EVENT_METADATA).length, 44); + assert.equal(Object.keys(COMMAND_METADATA).length, 92); + assert.equal(Object.keys(EVENT_METADATA).length, 45); assert.equal(SDK_IR_SHA256.length, 64); assert.deepEqual(Object.keys(PROFILES).sort(), [ "control", @@ -27,7 +27,7 @@ test("generated active events exclude serialized-only shapes", () => { const emitted = Object.entries(EVENT_METADATA) .filter(([, metadata]) => metadata.emission === "emitted") .map(([name]) => name); - assert.equal(emitted.length, 43); + assert.equal(emitted.length, 44); assert.equal(EVENT_METADATA["client-list-invalidated"].emission, "serialized-never-emitted"); assert.equal(emitted.includes("client-list-invalidated"), false); }); diff --git a/cmux-tui/bindings/zig/examples/watch.zig b/cmux-tui/bindings/zig/examples/watch.zig index 85ee016107..fa3d606074 100644 --- a/cmux-tui/bindings/zig/examples/watch.zig +++ b/cmux-tui/bindings/zig/examples/watch.zig @@ -126,11 +126,11 @@ test "package consumer imports handwritten root and generated raw module" { wheel.pointer_frame_seq, ); try std.testing.expectEqual( - @as(usize, 91), + @as(usize, 92), cmux.raw.protocol.command_count, ); try std.testing.expectEqual( - @as(usize, 44), + @as(usize, 45), cmux.raw.protocol.event_count, ); try std.testing.expect( diff --git a/cmux-tui/bindings/zig/src/raw.zig b/cmux-tui/bindings/zig/src/raw.zig index 06dccbd578..779ceb0b2c 100644 --- a/cmux-tui/bindings/zig/src/raw.zig +++ b/cmux-tui/bindings/zig/src/raw.zig @@ -44,7 +44,7 @@ test { _ = @import("raw/wire_presence_test.zig"); _ = @import("raw/generated/presence_test.zig"); std.testing.refAllDecls(protocol); - try std.testing.expectEqual(@as(usize, 91), protocol.command_count); + try std.testing.expectEqual(@as(usize, 92), protocol.command_count); for ([_][]const u8{ "browser-frame-presented", "browser-key-press", @@ -64,5 +64,5 @@ test { } try std.testing.expect(found); } - try std.testing.expectEqual(@as(usize, 44), protocol.event_count); + try std.testing.expectEqual(@as(usize, 45), protocol.event_count); } diff --git a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json index de6ec2d963..486ea1e872 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json +++ b/cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json @@ -2,17 +2,17 @@ "files": [ { "path": "presence_test.zig", - "sha256": "5e2ea6b296a2b20e7f6c636b40b23f1e911d81c470eec8873108e8c8f1c1578e", - "size": 4501 + "sha256": "d8bd1c7beac5e0fd9389a20f4eab35a2f22c3292f05145b58a89d7279ec4ceeb", + "size": 6345 }, { "path": "protocol.zig", - "sha256": "5a15b6e96bab857c60dbe73e54ada9b27b584343ff80dbb3fda3b30394954ce3", - "size": 136168 + "sha256": "910136e09c76845dd9f0dbc8d0742423916fa8ad4036b9cb84385e6b218937fa", + "size": 142044 } ], "format": 1, - "ir_sha256": "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e", + "ir_sha256": "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350", "language": "zig", "mux_protocol": 10, "schema_version": 2 diff --git a/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig b/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig index 5b897db9e0..d3ea65abac 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig +++ b/cmux-tui/bindings/zig/src/raw/generated/presence_test.zig @@ -30,18 +30,32 @@ test "every generated optional non-null field rejects explicit null" { try expectExplicitNullRejected(protocol.UndoLayoutRequest, "confirm_close"); try expectExplicitNullRejected(protocol.ColorsChangedEvent, "palette"); try expectExplicitNullRejected(protocol.ColorsChangedEvent, "surface"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "attempts"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "cell_height"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "cell_width"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "error"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "remaining"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "retry_exhausted"); + try expectExplicitNullRejected(protocol.GraphicsStatusEvent, "summary"); try expectExplicitNullRejected(protocol.OutputEvent, "colors"); try expectExplicitNullRejected(protocol.OverflowEvent, "scope"); try expectExplicitNullRejected(protocol.OverflowEvent, "surface"); try expectExplicitNullRejected(protocol.RenderDeltaEvent, "default_bg"); try expectExplicitNullRejected(protocol.RenderDeltaEvent, "default_fg"); + try expectExplicitNullRejected(protocol.RenderDeltaEvent, "graphics"); + try expectExplicitNullRejected(protocol.RenderDeltaEvent, "history_epoch"); try expectExplicitNullRejected(protocol.RenderDeltaEvent, "scrollback_rows"); try expectExplicitNullRejected(protocol.RenderDeltaEvent, "size"); + try expectExplicitNullRejected(protocol.RenderStateEvent, "graphics"); try expectExplicitNullRejected(protocol.ResizedEvent, "colors"); try expectExplicitNullRejected(protocol.ResizedEvent, "data"); + try expectExplicitNullRejected(protocol.ResizedEvent, "kitty_graphics_state"); + try expectExplicitNullRejected(protocol.ResizedEvent, "kitty_image_aliases"); try expectExplicitNullRejected(protocol.ResizedEvent, "replay"); try expectExplicitNullRejected(protocol.TitleChangedEvent, "title"); try expectExplicitNullRejected(protocol.VtStateEvent, "colors"); + try expectExplicitNullRejected(protocol.VtStateEvent, "kitty_graphics_state"); + try expectExplicitNullRejected(protocol.VtStateEvent, "kitty_image_aliases"); try expectExplicitNullRejected(protocol.WorkspaceAddedEvent, "mutation_id"); try expectExplicitNullRejected(protocol.WorkspaceAddedEvent, "origin"); try expectExplicitNullRejected(protocol.WorkspaceClosedEvent, "mutation_id"); @@ -56,6 +70,13 @@ test "every generated optional non-null field rejects explicit null" { try expectExplicitNullRejected(protocol.LayoutUndoUndone, "confirmation_required"); try expectExplicitNullRejected(protocol.LivePane, "focused_at"); try expectExplicitNullRejected(protocol.LivePane, "short_id"); + try expectExplicitNullRejected(protocol.RenderGraphicPlacement, "anchor_col"); + try expectExplicitNullRejected(protocol.RenderGraphicPlacement, "anchor_row"); + try expectExplicitNullRejected(protocol.RenderGraphics, "images"); + try expectExplicitNullRejected(protocol.RenderGraphics, "removed_image_ids"); + try expectExplicitNullRejected(protocol.RenderGraphicsDelta, "images"); + try expectExplicitNullRejected(protocol.RenderGraphicsDelta, "placements"); + try expectExplicitNullRejected(protocol.RenderGraphicsDelta, "removed_image_ids"); try expectExplicitNullRejected(protocol.RenderRun, "underline"); try expectExplicitNullRejected(protocol.RenderRun, "width_hint"); try expectExplicitNullRejected(protocol.Screen, "short_id"); @@ -68,6 +89,8 @@ test "every generated optional non-null field rejects explicit null" { try expectExplicitNullRejected(protocol.Tree, "registry_id"); try expectExplicitNullRejected(protocol.Tree, "terminal_revision"); try expectExplicitNullRejected(protocol.Tree, "workspace_revision"); + try expectExplicitNullRejected(protocol.VtStateResult, "kitty_graphics_state"); + try expectExplicitNullRejected(protocol.VtStateResult, "kitty_image_aliases"); try expectExplicitNullRejected(protocol.Workspace, "key"); try expectExplicitNullRejected(protocol.Workspace, "short_id"); try expectExplicitNullRejected(protocol.WorkspaceMutationResult, "changed"); diff --git a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig index c00a017948..ae1909c649 100644 --- a/cmux-tui/bindings/zig/src/raw/generated/protocol.zig +++ b/cmux-tui/bindings/zig/src/raw/generated/protocol.zig @@ -7,7 +7,7 @@ const client_runtime = @import("../client.zig"); pub const schema_version: u16 = 2; pub const mux_protocol: u16 = 10; -pub const ir_sha256 = "4248b1dd1a8640da3f983118ddce2e8d5ff94db5dc2e18cb9ebc5ba41052284e"; +pub const ir_sha256 = "17f8e86213cd09bd9ae05960964c3240f2a92aa4e086f7542bf6211bce9ff350"; pub const AgentRecord = struct { session: wire.Nullable([]const u8), @@ -114,6 +114,12 @@ pub const CellPixelResize = struct { surface: Id, }; +pub const CellPixelSurface = struct { + height_px: u16, + surface: Id, + width_px: u16, +}; + pub const ClientInfo = struct { attached: []const Id, client: u64, @@ -295,6 +301,12 @@ pub const FrontendProjection = struct { }; }; +pub const GetCellPixelsResult = struct { + height_px: u16, + surfaces: []const CellPixelSurface, + width_px: u16, +}; + pub const Id = u64; pub const IdMappingKind = enum { @@ -354,6 +366,23 @@ pub const IdsResult = struct { /// The wire field intentionally carries a frontend-authored or runtime-authored arbitrary JSON document. pub const JsonValue = wire.Value; +pub const KittyGraphicsState = struct { + alternate_next_image_id: u32, + alternate_replay_next_image_id: u32, + image_bytes: u64, + images: u64, + inflight_bytes: u64, + placements: u64, + primary_next_image_id: u32, + primary_replay_next_image_id: u32, + replay_cursor_offset: u32, +}; + +pub const KittyImageAlias = struct { + image_id: u32, + image_number: u32, +}; + pub const LayoutLeaf = struct { pane: Id, }; @@ -600,6 +629,7 @@ pub const ReadScreenResult = struct { }; pub const ReadScrollbackResult = struct { + epoch: u64, rows: []const RenderRow, start: u32, total: u32, @@ -614,6 +644,87 @@ pub const RenderCursor = struct { y: u16, }; +pub const RenderGraphicFormat = enum { + rgb, + rgba, + + pub fn fromWire(value: []const u8) !@This() { + if (std.mem.eql(u8, value, "rgb")) return .rgb; + if (std.mem.eql(u8, value, "rgba")) return .rgba; + return error.UnknownEnumValue; + } + + pub fn toWire(self: @This()) []const u8 { + return switch (self) { + .rgb => "rgb", + .rgba => "rgba", + }; + } +}; + +pub const RenderGraphicImage = struct { + data: Base64, + format: RenderGraphicFormat, + generation: u64, + height: u32, + id: u32, + width: u32, +}; + +pub const RenderGraphicPlacement = struct { + anchor_col: ?u16 = null, + anchor_row: ?u32 = null, + columns: u32, + grid_cols: u32, + grid_rows: u32, + image_id: u32, + ordinal: u32, + pixel_height: u32, + pixel_width: u32, + placement_id: u32, + rows: u32, + source_height: u32, + source_width: u32, + source_x: u32, + source_y: u32, + viewport_col: i32, + viewport_row: i32, + viewport_visible: bool, + x_offset: u32, + y_offset: u32, + z: i32, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "anchor_col", + "anchor_row", + }; +}; + +pub const RenderGraphics = struct { + generation: u64, + images: ?[]const RenderGraphicImage = null, + placements: []const RenderGraphicPlacement, + removed_image_ids: ?[]const u32 = null, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "images", + "removed_image_ids", + }; +}; + +pub const RenderGraphicsDelta = struct { + generation: u64, + images: ?[]const RenderGraphicImage = null, + placements: ?[]const RenderGraphicPlacement = null, + removed_image_ids: ?[]const u32 = null, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "images", + "placements", + "removed_image_ids", + }; +}; + pub const RenderRow = struct { row: u32, runs: []const RenderRun, @@ -1337,7 +1448,14 @@ pub const Tree = struct { pub const VtStateResult = struct { cols: u16, data: Base64, + kitty_graphics_state: ?KittyGraphicsState = null, + kitty_image_aliases: ?[]const KittyImageAlias = null, rows: u16, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "kitty_graphics_state", + "kitty_image_aliases", + }; }; pub const WaitForResult = struct { @@ -2129,6 +2247,21 @@ pub fn focusPane(client: anytype, request: FocusPaneRequest) !wire.Decoded(Focus ); } +pub const GetCellPixelsRequest = struct {}; + +pub fn getCellPixels(client: anytype, request: GetCellPixelsRequest) !wire.Decoded(GetCellPixelsResult) { + return client.callTyped( + GetCellPixelsResult, + .{ + .name = "get-cell-pixels", + .authority = "frontend", + .since = 6, + .capability = null, + }, + request, + ); +} + pub const GetFrontendProjectionRequest = struct { frontend: []const u8, scope: []const u8, @@ -3627,6 +3760,49 @@ pub const FrontendProjectionChangedEvent = struct { subject_key: []const u8, }; +pub const GraphicsStatusEventKind = enum { + kitty_image_budget_worker_start_failed, + kitty_image_budget_update_failed, + cell_pixel_update_retries_exhausted, + + pub fn fromWire(value: []const u8) !@This() { + if (std.mem.eql(u8, value, "kitty-image-budget-worker-start-failed")) return .kitty_image_budget_worker_start_failed; + if (std.mem.eql(u8, value, "kitty-image-budget-update-failed")) return .kitty_image_budget_update_failed; + if (std.mem.eql(u8, value, "cell-pixel-update-retries-exhausted")) return .cell_pixel_update_retries_exhausted; + return error.UnknownEnumValue; + } + + pub fn toWire(self: @This()) []const u8 { + return switch (self) { + .kitty_image_budget_worker_start_failed => "kitty-image-budget-worker-start-failed", + .kitty_image_budget_update_failed => "kitty-image-budget-update-failed", + .cell_pixel_update_retries_exhausted => "cell-pixel-update-retries-exhausted", + }; + } +}; + +pub const GraphicsStatusEvent = struct { + attempts: ?u16 = null, + cell_height: ?u16 = null, + cell_width: ?u16 = null, + @"error": ?[]const u8 = null, + event: []const u8, + kind: GraphicsStatusEventKind, + remaining: ?u64 = null, + retry_exhausted: ?bool = null, + summary: ?[]const u8 = null, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "attempts", + "cell_height", + "cell_width", + "error", + "remaining", + "retry_exhausted", + "summary", + }; +}; + pub const LayoutChangedEvent = struct { event: []const u8, screen: Id, @@ -3701,6 +3877,8 @@ pub const RenderDeltaEvent = struct { default_fg: ?ColorHex = null, event: []const u8, full: bool, + graphics: ?RenderGraphicsDelta = null, + history_epoch: ?u64 = null, rows: []const RenderRow, scrollback_rows: ?u32 = null, size: ?Size = null, @@ -3709,6 +3887,8 @@ pub const RenderDeltaEvent = struct { pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ "default_bg", "default_fg", + "graphics", + "history_epoch", "scrollback_rows", "size", }; @@ -3719,10 +3899,16 @@ pub const RenderStateEvent = struct { default_bg: ColorHex, default_fg: ColorHex, event: []const u8, + graphics: ?RenderGraphics = null, + history_epoch: u64, rows: []const RenderRow, scrollback_rows: u32, size: Size, surface: Id, + + pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ + "graphics", + }; }; pub const ResizedEvent = struct { @@ -3731,6 +3917,8 @@ pub const ResizedEvent = struct { /// Protocol 6 compatibility field. data: ?Base64 = null, event: []const u8, + kitty_graphics_state: ?KittyGraphicsState = null, + kitty_image_aliases: ?[]const KittyImageAlias = null, replay: ?Base64 = null, rows: u16, surface: Id, @@ -3738,6 +3926,8 @@ pub const ResizedEvent = struct { pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ "colors", "data", + "kitty_graphics_state", + "kitty_image_aliases", "replay", }; }; @@ -3861,11 +4051,15 @@ pub const VtStateEvent = struct { cols: u16, data: Base64, event: []const u8, + kitty_graphics_state: ?KittyGraphicsState = null, + kitty_image_aliases: ?[]const KittyImageAlias = null, rows: u16, surface: Id, pub const cmux_wire_optional_nonnull_fields = [_][]const u8{ "colors", + "kitty_graphics_state", + "kitty_image_aliases", }; }; @@ -3959,6 +4153,7 @@ pub const Event = union(enum) { empty: EmptyEvent, frame: FrameEvent, frontend_projection_changed: FrontendProjectionChangedEvent, + graphics_status: GraphicsStatusEvent, layout_changed: LayoutChangedEvent, notification: NotificationEvent, output: OutputEvent, @@ -4008,6 +4203,7 @@ pub fn eventWireName(event: Event) []const u8 { .empty => "empty", .frame => "frame", .frontend_projection_changed => "frontend-projection-changed", + .graphics_status => "graphics-status", .layout_changed => "layout-changed", .notification => "notification", .output => "output", @@ -4106,6 +4302,10 @@ pub fn decodeEvent(allocator: std.mem.Allocator, value: wire.Value) !DecodedEven const decoded = try wire.decodeLeaky(FrontendProjectionChangedEvent, arena.allocator(), value); return .{ .arena = arena, .value = .{ .frontend_projection_changed = decoded } }; } + if (std.mem.eql(u8, name, "graphics-status")) { + const decoded = try wire.decodeLeaky(GraphicsStatusEvent, arena.allocator(), value); + return .{ .arena = arena, .value = .{ .graphics_status = decoded } }; + } if (std.mem.eql(u8, name, "layout-changed")) { const decoded = try wire.decodeLeaky(LayoutChangedEvent, arena.allocator(), value); return .{ .arena = arena, .value = .{ .layout_changed = decoded } }; @@ -4259,7 +4459,7 @@ pub const CommandDescriptor = struct { stream: ?[]const u8, }; -pub const command_count: usize = 91; +pub const command_count: usize = 92; pub const commands = [_]CommandDescriptor{ .{ .name = "apply-layout", .authority = "control", .since = 6, .capability = null, .stream = null }, .{ .name = "attach-surface", .authority = "frontend", .since = 5, .capability = null, .stream = "attach" }, @@ -4291,6 +4491,7 @@ pub const commands = [_]CommandDescriptor{ .{ .name = "export-layout", .authority = "control", .since = 6, .capability = null, .stream = null }, .{ .name = "focus-direction", .authority = "control", .since = 6, .capability = null, .stream = null }, .{ .name = "focus-pane", .authority = "control", .since = 5, .capability = null, .stream = null }, + .{ .name = "get-cell-pixels", .authority = "frontend", .since = 6, .capability = null, .stream = null }, .{ .name = "get-frontend-projection", .authority = "control", .since = 7, .capability = null, .stream = null }, .{ .name = "identify", .authority = "control", .since = 5, .capability = null, .stream = null }, .{ .name = "ids", .authority = "control", .since = 6, .capability = null, .stream = null }, @@ -4374,39 +4575,40 @@ const event_streams_9 = [_][]const u8{"subscribe"}; const event_streams_10 = [_][]const u8{"attach-browser"}; const event_streams_11 = [_][]const u8{"subscribe"}; const event_streams_12 = [_][]const u8{"subscribe"}; -const event_streams_13 = [_][]const u8{ "subscribe", "attach-byte", "attach-browser" }; -const event_streams_14 = [_][]const u8{"attach-byte"}; -const event_streams_15 = [_][]const u8{ "subscribe", "attach-byte", "attach-render", "attach-browser" }; -const event_streams_16 = [_][]const u8{"subscribe"}; +const event_streams_13 = [_][]const u8{"subscribe"}; +const event_streams_14 = [_][]const u8{ "subscribe", "attach-byte", "attach-browser" }; +const event_streams_15 = [_][]const u8{"attach-byte"}; +const event_streams_16 = [_][]const u8{ "subscribe", "attach-byte", "attach-render", "attach-browser" }; const event_streams_17 = [_][]const u8{"subscribe"}; -const event_streams_18 = [_][]const u8{"subscribe-deltas"}; +const event_streams_18 = [_][]const u8{"subscribe"}; const event_streams_19 = [_][]const u8{"subscribe-deltas"}; -const event_streams_20 = [_][]const u8{"attach-render"}; +const event_streams_20 = [_][]const u8{"subscribe-deltas"}; const event_streams_21 = [_][]const u8{"attach-render"}; -const event_streams_22 = [_][]const u8{"attach-byte"}; -const event_streams_23 = [_][]const u8{"subscribe-deltas"}; +const event_streams_22 = [_][]const u8{"attach-render"}; +const event_streams_23 = [_][]const u8{"attach-byte"}; const event_streams_24 = [_][]const u8{"subscribe-deltas"}; const event_streams_25 = [_][]const u8{"subscribe-deltas"}; -const event_streams_26 = [_][]const u8{ "subscribe", "attach-byte", "attach-render", "attach-browser" }; -const event_streams_27 = [_][]const u8{"subscribe"}; +const event_streams_26 = [_][]const u8{"subscribe-deltas"}; +const event_streams_27 = [_][]const u8{ "subscribe", "attach-byte", "attach-render", "attach-browser" }; const event_streams_28 = [_][]const u8{"subscribe"}; const event_streams_29 = [_][]const u8{"subscribe"}; const event_streams_30 = [_][]const u8{"subscribe"}; const event_streams_31 = [_][]const u8{"subscribe"}; -const event_streams_32 = [_][]const u8{"subscribe-deltas"}; +const event_streams_32 = [_][]const u8{"subscribe"}; const event_streams_33 = [_][]const u8{"subscribe-deltas"}; const event_streams_34 = [_][]const u8{"subscribe-deltas"}; -const event_streams_35 = [_][]const u8{"subscribe"}; +const event_streams_35 = [_][]const u8{"subscribe-deltas"}; const event_streams_36 = [_][]const u8{"subscribe"}; const event_streams_37 = [_][]const u8{"subscribe"}; -const event_streams_38 = [_][]const u8{"attach-byte"}; -const event_streams_39 = [_][]const u8{"subscribe"}; -const event_streams_40 = [_][]const u8{"subscribe-deltas"}; +const event_streams_38 = [_][]const u8{"subscribe"}; +const event_streams_39 = [_][]const u8{"attach-byte"}; +const event_streams_40 = [_][]const u8{"subscribe"}; const event_streams_41 = [_][]const u8{"subscribe-deltas"}; const event_streams_42 = [_][]const u8{"subscribe-deltas"}; const event_streams_43 = [_][]const u8{"subscribe-deltas"}; +const event_streams_44 = [_][]const u8{"subscribe-deltas"}; -pub const event_count: usize = 44; +pub const event_count: usize = 45; pub const events = [_]EventDescriptor{ .{ .name = "bell", .since = 5, .capability = null, .streams = &event_streams_0 }, .{ .name = "browser-state", .since = 6, .capability = null, .streams = &event_streams_1 }, @@ -4420,36 +4622,37 @@ pub const events = [_]EventDescriptor{ .{ .name = "empty", .since = 5, .capability = null, .streams = &event_streams_9 }, .{ .name = "frame", .since = 6, .capability = null, .streams = &event_streams_10 }, .{ .name = "frontend-projection-changed", .since = 7, .capability = null, .streams = &event_streams_11 }, - .{ .name = "layout-changed", .since = 6, .capability = null, .streams = &event_streams_12 }, - .{ .name = "notification", .since = 6, .capability = null, .streams = &event_streams_13 }, - .{ .name = "output", .since = 5, .capability = null, .streams = &event_streams_14 }, - .{ .name = "overflow", .since = 7, .capability = null, .streams = &event_streams_15 }, - .{ .name = "pairing-requested", .since = 7, .capability = null, .streams = &event_streams_16 }, - .{ .name = "pairing-resolved", .since = 7, .capability = null, .streams = &event_streams_17 }, - .{ .name = "pane-added", .since = 7, .capability = null, .streams = &event_streams_18 }, - .{ .name = "pane-closed", .since = 7, .capability = null, .streams = &event_streams_19 }, - .{ .name = "render-delta", .since = 7, .capability = null, .streams = &event_streams_20 }, - .{ .name = "render-state", .since = 7, .capability = null, .streams = &event_streams_21 }, - .{ .name = "resized", .since = 6, .capability = null, .streams = &event_streams_22 }, - .{ .name = "screen-added", .since = 7, .capability = null, .streams = &event_streams_23 }, - .{ .name = "screen-closed", .since = 7, .capability = null, .streams = &event_streams_24 }, - .{ .name = "screen-renamed", .since = 7, .capability = null, .streams = &event_streams_25 }, - .{ .name = "scroll-changed", .since = 6, .capability = null, .streams = &event_streams_26 }, - .{ .name = "status", .since = 5, .capability = null, .streams = &event_streams_27 }, - .{ .name = "surface-exited", .since = 5, .capability = null, .streams = &event_streams_28 }, - .{ .name = "surface-output", .since = 5, .capability = null, .streams = &event_streams_29 }, - .{ .name = "surface-resize-failed", .since = 7, .capability = null, .streams = &event_streams_30 }, - .{ .name = "surface-resized", .since = 5, .capability = null, .streams = &event_streams_31 }, - .{ .name = "tab-added", .since = 7, .capability = null, .streams = &event_streams_32 }, - .{ .name = "tab-closed", .since = 7, .capability = null, .streams = &event_streams_33 }, - .{ .name = "tab-renamed", .since = 7, .capability = null, .streams = &event_streams_34 }, - .{ .name = "terminal-registry-changed", .since = 9, .capability = null, .streams = &event_streams_35 }, - .{ .name = "title-changed", .since = 5, .capability = null, .streams = &event_streams_36 }, - .{ .name = "tree-changed", .since = 5, .capability = null, .streams = &event_streams_37 }, - .{ .name = "vt-state", .since = 5, .capability = null, .streams = &event_streams_38 }, - .{ .name = "window-title-requested", .since = 6, .capability = null, .streams = &event_streams_39 }, - .{ .name = "workspace-added", .since = 7, .capability = null, .streams = &event_streams_40 }, - .{ .name = "workspace-closed", .since = 7, .capability = null, .streams = &event_streams_41 }, - .{ .name = "workspace-moved", .since = 7, .capability = null, .streams = &event_streams_42 }, - .{ .name = "workspace-renamed", .since = 7, .capability = null, .streams = &event_streams_43 }, + .{ .name = "graphics-status", .since = 10, .capability = null, .streams = &event_streams_12 }, + .{ .name = "layout-changed", .since = 6, .capability = null, .streams = &event_streams_13 }, + .{ .name = "notification", .since = 6, .capability = null, .streams = &event_streams_14 }, + .{ .name = "output", .since = 5, .capability = null, .streams = &event_streams_15 }, + .{ .name = "overflow", .since = 7, .capability = null, .streams = &event_streams_16 }, + .{ .name = "pairing-requested", .since = 7, .capability = null, .streams = &event_streams_17 }, + .{ .name = "pairing-resolved", .since = 7, .capability = null, .streams = &event_streams_18 }, + .{ .name = "pane-added", .since = 7, .capability = null, .streams = &event_streams_19 }, + .{ .name = "pane-closed", .since = 7, .capability = null, .streams = &event_streams_20 }, + .{ .name = "render-delta", .since = 7, .capability = null, .streams = &event_streams_21 }, + .{ .name = "render-state", .since = 7, .capability = null, .streams = &event_streams_22 }, + .{ .name = "resized", .since = 6, .capability = null, .streams = &event_streams_23 }, + .{ .name = "screen-added", .since = 7, .capability = null, .streams = &event_streams_24 }, + .{ .name = "screen-closed", .since = 7, .capability = null, .streams = &event_streams_25 }, + .{ .name = "screen-renamed", .since = 7, .capability = null, .streams = &event_streams_26 }, + .{ .name = "scroll-changed", .since = 6, .capability = null, .streams = &event_streams_27 }, + .{ .name = "status", .since = 5, .capability = null, .streams = &event_streams_28 }, + .{ .name = "surface-exited", .since = 5, .capability = null, .streams = &event_streams_29 }, + .{ .name = "surface-output", .since = 5, .capability = null, .streams = &event_streams_30 }, + .{ .name = "surface-resize-failed", .since = 7, .capability = null, .streams = &event_streams_31 }, + .{ .name = "surface-resized", .since = 5, .capability = null, .streams = &event_streams_32 }, + .{ .name = "tab-added", .since = 7, .capability = null, .streams = &event_streams_33 }, + .{ .name = "tab-closed", .since = 7, .capability = null, .streams = &event_streams_34 }, + .{ .name = "tab-renamed", .since = 7, .capability = null, .streams = &event_streams_35 }, + .{ .name = "terminal-registry-changed", .since = 9, .capability = null, .streams = &event_streams_36 }, + .{ .name = "title-changed", .since = 5, .capability = null, .streams = &event_streams_37 }, + .{ .name = "tree-changed", .since = 5, .capability = null, .streams = &event_streams_38 }, + .{ .name = "vt-state", .since = 5, .capability = null, .streams = &event_streams_39 }, + .{ .name = "window-title-requested", .since = 6, .capability = null, .streams = &event_streams_40 }, + .{ .name = "workspace-added", .since = 7, .capability = null, .streams = &event_streams_41 }, + .{ .name = "workspace-closed", .since = 7, .capability = null, .streams = &event_streams_42 }, + .{ .name = "workspace-moved", .since = 7, .capability = null, .streams = &event_streams_43 }, + .{ .name = "workspace-renamed", .since = 7, .capability = null, .streams = &event_streams_44 }, }; diff --git a/cmux-tui/crates/cmux-remote-protocol/src/lib.rs b/cmux-tui/crates/cmux-remote-protocol/src/lib.rs index 85ebd495ee..79f05b24bd 100644 --- a/cmux-tui/crates/cmux-remote-protocol/src/lib.rs +++ b/cmux-tui/crates/cmux-remote-protocol/src/lib.rs @@ -32,3 +32,7 @@ pub use rpc::{ StructuredDiffLineV1, StructuredDiffV1, StructuredFileDiffV1, WorkspaceId, WorkspaceRequest, WorkspaceResponse, }; + +/// Maximum serialized server-to-client message accepted by remote session +/// transports. Render attach and VT replay responses share this budget. +pub const REMOTE_SESSION_MESSAGE_MAX_BYTES: usize = 32 * 1024 * 1024; diff --git a/cmux-tui/crates/cmux-remote/src/bridge.rs b/cmux-tui/crates/cmux-remote/src/bridge.rs index e43f448ab0..c54480bfa1 100644 --- a/cmux-tui/crates/cmux-remote/src/bridge.rs +++ b/cmux-tui/crates/cmux-remote/src/bridge.rs @@ -8,10 +8,10 @@ use cmux_remote_protocol::{MUX_INPUT_V1_FEATURE, RouteId, Service, ServiceContro use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::{Semaphore, oneshot}; +use crate::mux_codec::MAX_MUX_LINE_BYTES; use crate::service::{ServiceError, ServiceMultiplexer, ServiceStream}; use crate::services::ServicesError; -const MAX_MUX_LINE_BYTES: usize = 16 * 1024 * 1024; pub const DEFAULT_MAX_FORWARD_CONNECTIONS: usize = 128; pub struct LocalPortForward { diff --git a/cmux-tui/crates/cmux-remote/src/mux_codec.rs b/cmux-tui/crates/cmux-remote/src/mux_codec.rs index 79aeeef02c..8090fca8ab 100644 --- a/cmux-tui/crates/cmux-remote/src/mux_codec.rs +++ b/cmux-tui/crates/cmux-remote/src/mux_codec.rs @@ -2,17 +2,18 @@ use std::collections::HashMap; use std::fmt; use bytes::{BufMut, Bytes, BytesMut}; -use cmux_remote_protocol::{Lane, MAX_FRAME_PAYLOAD}; +use cmux_remote_protocol::{Lane, MAX_FRAME_PAYLOAD, REMOTE_SESSION_MESSAGE_MAX_BYTES}; const MAGIC: [u8; 4] = *b"CMXL"; const HEADER_BYTES: usize = 4 + 8 + 4 + 4; const CHUNK_BYTES: usize = MAX_FRAME_PAYLOAD - HEADER_BYTES; -const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; +// The local mux transport appends a newline after each serialized message. +pub(crate) const MAX_MUX_LINE_BYTES: usize = REMOTE_SESSION_MESSAGE_MAX_BYTES + 1; const MAX_IN_FLIGHT_LINES: usize = 256; -const MAX_IN_FLIGHT_BYTES: usize = 32 * 1024 * 1024; +const MAX_IN_FLIGHT_BYTES: usize = MAX_MUX_LINE_BYTES * 2; pub(crate) fn encode_line(message: u64, line: &[u8]) -> Result, MuxCodecError> { - if line.len() > MAX_LINE_BYTES { + if line.len() > MAX_MUX_LINE_BYTES { return Err(MuxCodecError::LineTooLarge(line.len())); } let parts = line.len().max(1).div_ceil(CHUNK_BYTES); @@ -63,7 +64,8 @@ impl MuxLineAssembler { let message = u64::from_be_bytes(packet[4..12].try_into().unwrap()); let part = u32::from_be_bytes(packet[12..16].try_into().unwrap()); let parts = u32::from_be_bytes(packet[16..20].try_into().unwrap()); - if parts == 0 || part >= parts || parts as usize > MAX_LINE_BYTES.div_ceil(CHUNK_BYTES) { + if parts == 0 || part >= parts || parts as usize > MAX_MUX_LINE_BYTES.div_ceil(CHUNK_BYTES) + { return Err(MuxCodecError::InvalidPacket); } if !self.lines.contains_key(&message) { @@ -83,7 +85,7 @@ impl MuxLineAssembler { return Err(MuxCodecError::InvalidPacket); } let payload = packet.slice(HEADER_BYTES..); - if line.bytes.saturating_add(payload.len()) > MAX_LINE_BYTES + if line.bytes.saturating_add(payload.len()) > MAX_MUX_LINE_BYTES || self.bytes.saturating_add(payload.len()) > MAX_IN_FLIGHT_BYTES { return Err(MuxCodecError::LineTooLarge(line.bytes.saturating_add(payload.len()))); diff --git a/cmux-tui/crates/cmux-tui-core/Cargo.toml b/cmux-tui/crates/cmux-tui-core/Cargo.toml index eb32bc7db4..4a20285f6a 100644 --- a/cmux-tui/crates/cmux-tui-core/Cargo.toml +++ b/cmux-tui/crates/cmux-tui-core/Cargo.toml @@ -14,6 +14,7 @@ workspace = true ghostty-vt.workspace = true cmux-tui-cdp.workspace = true cmux-pty.workspace = true +cmux-remote-protocol.workspace = true portable-pty.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 899b9eba32..e53eadc62a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -703,7 +703,9 @@ fn fail_surface_route(state: &mut SurfaceRouteState, reason: &str) { pub struct BrowserSurface { pub(crate) meta: SurfaceMeta, session: Mutex>, - state: Mutex, + // Navigation and pointer lifecycle state grows independently of the + // Surface enum. Keep that payload out of line. + state: Mutex>, frame_epoch: Arc, dirty: AtomicBool, dead: AtomicBool, @@ -976,7 +978,7 @@ pub(crate) fn new_surface_with_resource_identity( selection: Mutex::new(None), }, session: Mutex::new(None), - state: Mutex::new(BrowserState { + state: Mutex::new(Box::new(BrowserState { latest_frame: None, accepted_frame_epoch: frame_epoch.current(), accepted_navigation_epoch: frame_epoch.latest_navigation(), @@ -1018,7 +1020,7 @@ pub(crate) fn new_surface_with_resource_identity( last_frame_at: None, stall_nudged: false, not_responding_reported: false, - }), + })), frame_epoch, dirty: AtomicBool::new(true), dead: AtomicBool::new(false), @@ -1967,6 +1969,19 @@ impl BrowserSurface { } } + pub fn latest_frame_metadata(&self) -> Option<(u64, u32, u32, Option)> { + let state = self.state.lock().unwrap(); + if matches!(state.status, BrowserStatus::Failed(_)) { + None + } else { + let pointer_frame_seq = self.exported_pointer_frame_seq_locked(&state); + state + .latest_frame + .as_ref() + .map(|frame| (frame.seq, frame.css_width, frame.css_height, pointer_frame_seq)) + } + } + /// Return the opaque authority token for guarded pointer input. The token /// identifies the latest admitted bitmap and rotates on every later bitmap. pub fn latest_frame_seq(&self) -> Option { @@ -2151,6 +2166,10 @@ impl BrowserSurface { .map(|reservation_id| reservation_id.is_some()) } + pub(crate) fn cell_pixel_size(&self) -> (u16, u16) { + *self.cell_pixels.lock().unwrap() + } + pub fn set_cell_pixel_size_reporting( &self, width_px: u16, @@ -5682,11 +5701,13 @@ mod tests { browser.store_frame(test_frame(2)); assert_eq!(browser.status(), BrowserStatus::Failed("nope".into())); assert_eq!(browser.latest_frame(), None); + assert_eq!(browser.latest_frame_metadata(), None); // Clearing the error restores the retained frame. browser.clear_error(); assert_eq!(browser.status(), BrowserStatus::Live); assert_eq!(browser.latest_frame().map(|frame| frame.seq), Some(2)); + assert_eq!(browser.latest_frame_metadata(), Some((2, 80, 48, None))); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs index 376d3ed152..64ce124912 100644 --- a/cmux-tui/crates/cmux-tui-core/src/event_bus.rs +++ b/cmux-tui/crates/cmux-tui-core/src/event_bus.rs @@ -159,7 +159,8 @@ impl SurfaceSessionScope { | MuxEvent::ClientListInvalidated | MuxEvent::TreeChanged | MuxEvent::TreeSelectionChanged => false, - MuxEvent::Status(_) + MuxEvent::GraphicsStatus(_) + | MuxEvent::Status(_) | MuxEvent::ConfigReloadRequested | MuxEvent::WindowTitleRequested(_) | MuxEvent::FrontendProjectionChanged { .. } diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 1744d42001..8e3567887a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -43,9 +43,9 @@ pub use layout::{ pub use model::{Node, Pane, Screen, State, ViewportColumn, Workspace}; pub use mux::{ AgentRecord, AgentSource, AgentState, AppliedLayout, AppliedPane, CellPixelUpdate, - CellPixelUpdateFailure, Direction, LayoutLeafSpec, LayoutRatioError, LayoutSpec, - LayoutUndoError, LayoutUndoResult, Mux, MuxEvent, NotificationEvent, NotificationLevel, - ProviderWorkspaceAuthority, ProviderWorkspaceAuthorityStatus, + CellPixelUpdateFailure, Direction, GraphicsStatus, LayoutLeafSpec, LayoutRatioError, + LayoutSpec, LayoutUndoError, LayoutUndoResult, Mux, MuxEvent, NotificationEvent, + NotificationLevel, ProviderWorkspaceAuthority, ProviderWorkspaceAuthorityStatus, ProviderWorkspaceAuthorityUpdateError, ResourceNotification, RunPlacement, SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, SurfaceResizeReporter, TreeDelta, TreeDeltaKind, ViewportWidthError, WorkspaceMutationResult, WorkspacePlacement, @@ -70,6 +70,7 @@ pub use workspace_registry::{ RegistryWorkspace, UnsupportedWorkspaceRegistrySchema, WorkspaceMutation, WorkspaceRegistry, }; +pub use cmux_remote_protocol::REMOTE_SESSION_MESSAGE_MAX_BYTES; pub use cmux_tui_cdp::BrowserMode; pub use ghostty_vt::{CursorShape, Rgb}; diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 1875f63324..4386fe83a8 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -11,12 +11,13 @@ use public_projections::{RestoredPublicProjections, restore_public_projections}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt; use std::path::Path; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, SyncSender}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, Weak}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::Context; +use ghostty_vt::KittyGraphicsLimits; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use zeroize::Zeroize; @@ -98,6 +99,179 @@ const WORKSPACE_KEY_MAX_BYTES: usize = 256; const WORKSPACE_NAME_MAX_BYTES: usize = 1_024; const PROVIDER_WORKSPACE_AUTHORITY_MIN_BYTES: usize = 32; const PROVIDER_WORKSPACE_AUTHORITY_MAX_BYTES: usize = 512; +const CELL_PIXEL_FANOUT_MAX_WORKERS: usize = 32; +const DEADLINE_FANOUT_IDLE_TIMEOUT: Duration = Duration::from_millis(250); +const CELL_PIXEL_RETRY_INITIAL: Duration = Duration::from_millis(25); +const CELL_PIXEL_RETRY_MAX: Duration = Duration::from_millis(250); +const CELL_PIXEL_RETRY_MAX_ATTEMPTS: u8 = 4; +const KITTY_IMAGE_BUDGET_RETRY_INITIAL: Duration = Duration::from_millis(25); +const KITTY_IMAGE_BUDGET_RETRY_MAX: Duration = Duration::from_secs(1); +const KITTY_IMAGE_BUDGET_RETRY_MAX_ATTEMPTS: u32 = 4; +pub(crate) const RENDER_ATTACHMENT_LIMIT: usize = 64; +const KITTY_IMAGE_PROCESS_BUDGET_BYTES: u64 = 128 * 1024 * 1024; +// libghostty owns independent primary and alternate screen stores. cmux also +// keeps one replay pixel cache and one render pixel cache per PTY surface. +// A grayscale native image expands by up to 3x in either RGB pixel cache. +const KITTY_IMAGE_PERSISTENT_COPIES_PER_SURFACE: u64 = 2 + 3 + 3; +// Image and placement limits are independent on the primary and alternate screens. +const KITTY_OBJECT_OWNERS_PER_SURFACE: u64 = 2; +const KITTY_IMAGE_PROCESS_BUDGET_COUNT: u64 = ghostty_vt::MAX_KITTY_IMAGES; +const KITTY_PLACEMENT_PROCESS_BUDGET_COUNT: u64 = ghostty_vt::MAX_KITTY_PLACEMENTS; +const KITTY_IMAGE_BUDGET_OWNER_LIMIT: usize = { + let image_limit = KITTY_IMAGE_PROCESS_BUDGET_COUNT / KITTY_OBJECT_OWNERS_PER_SURFACE; + let placement_limit = KITTY_PLACEMENT_PROCESS_BUDGET_COUNT / KITTY_OBJECT_OWNERS_PER_SURFACE; + if image_limit < placement_limit { image_limit as usize } else { placement_limit as usize } +}; + +fn cell_pixel_retry_delay(attempts: u8) -> Duration { + let multiplier = 1_u32.checked_shl(u32::from(attempts.saturating_sub(1))).unwrap_or(u32::MAX); + CELL_PIXEL_RETRY_INITIAL.saturating_mul(multiplier).min(CELL_PIXEL_RETRY_MAX) +} + +fn kitty_image_budget_capacity(surface_count: usize, current: usize) -> usize { + if surface_count == 0 { + return 0; + } + // Keep hysteresis for larger buckets, but always restore the sole + // survivor's full share instead of stranding it in the two-surface bucket. + if current == 0 + || surface_count > current + || surface_count <= current / 4 + || (surface_count == 1 && current > 1) + { + return surface_count.checked_next_power_of_two().unwrap_or(usize::MAX); + } + current +} + +fn kitty_surface_byte_reservation(image_bytes: u64) -> u64 { + image_bytes + .saturating_mul(KITTY_IMAGE_PERSISTENT_COPIES_PER_SURFACE) + .saturating_add(ghostty_vt::kitty_inflight_replay_limit_for_image_bytes(image_bytes)) +} + +fn kitty_image_bytes_for_process_share(process_share: u64) -> u64 { + let mut lower = 0; + let mut upper = process_share.min(ghostty_vt::MAX_KITTY_IMAGE_BYTES as u64); + while lower < upper { + let candidate = lower + (upper - lower).div_ceil(2); + if kitty_surface_byte_reservation(candidate) <= process_share { + lower = candidate; + } else { + upper = candidate - 1; + } + } + lower +} + +fn kitty_image_limits_for_capacity(capacity: usize) -> KittyGraphicsLimits { + if capacity == 0 { + return KittyGraphicsLimits::disabled(); + } + let surface_count = u64::try_from(capacity).unwrap_or(u64::MAX); + let process_share = KITTY_IMAGE_PROCESS_BUDGET_BYTES.checked_div(surface_count).unwrap_or(0); + let image_bytes = kitty_image_bytes_for_process_share(process_share); + let inflight_bytes = ghostty_vt::kitty_inflight_replay_limit_for_image_bytes(image_bytes); + let object_owners = surface_count.saturating_mul(KITTY_OBJECT_OWNERS_PER_SURFACE); + let images = KITTY_IMAGE_PROCESS_BUDGET_COUNT + .checked_div(object_owners) + .unwrap_or(0) + .min(ghostty_vt::MAX_KITTY_IMAGES); + let placements = KITTY_PLACEMENT_PROCESS_BUDGET_COUNT + .checked_div(object_owners) + .unwrap_or(0) + .min(ghostty_vt::MAX_KITTY_PLACEMENTS); + KittyGraphicsLimits { image_bytes, inflight_bytes, images, placements } +} + +fn kitty_image_limits_within(candidate: KittyGraphicsLimits, ceiling: KittyGraphicsLimits) -> bool { + candidate.image_bytes <= ceiling.image_bytes + && candidate.inflight_bytes <= ceiling.inflight_bytes + && candidate.images <= ceiling.images + && candidate.placements <= ceiling.placements +} + +fn kitty_image_limits_exceed(candidate: KittyGraphicsLimits, ceiling: KittyGraphicsLimits) -> bool { + !kitty_image_limits_within(candidate, ceiling) +} + +fn kitty_image_limits_enabled(limits: KittyGraphicsLimits) -> bool { + limits.image_bytes > 0 + && limits.inflight_bytes > 0 + && limits.images > 0 + && limits.placements > 0 +} + +#[derive(Clone)] +struct KittyImageBudgetEntry { + surface: Option>, + applied: KittyGraphicsLimits, + owns_quota: bool, + removing: bool, +} + +#[derive(Default)] +struct KittyImageBudgetState { + entries: HashMap, + blocked_surfaces: HashSet, + capacity: usize, + worker_running: bool, + expansion_in_flight: bool, +} + +struct PendingKittyImageBudgetOperation { + surface_id: SurfaceId, + surface: Weak, + limits: KittyGraphicsLimits, + expanding: bool, + result: DeadlinePending>, +} + +pub(crate) struct KittyImageBudgetReservation { + mux: Weak, + surface: SurfaceId, + initial_limits: KittyGraphicsLimits, + committed: bool, +} + +impl KittyImageBudgetReservation { + pub(crate) fn initial_limits(&self) -> KittyGraphicsLimits { + self.initial_limits + } + + pub(crate) fn commit( + mut self, + surface: &Arc, + applied: KittyGraphicsLimits, + ) -> anyhow::Result<()> { + if let Some(mux) = self.mux.upgrade() { + mux.commit_kitty_image_surface(self.surface, surface, applied)?; + } + self.committed = true; + Ok(()) + } +} + +impl Drop for KittyImageBudgetReservation { + fn drop(&mut self) { + if self.committed { + return; + } + if let Some(mux) = self.mux.upgrade() { + mux.cancel_kitty_image_surface_reservation(self.surface); + } + } +} + +pub(crate) struct RenderAttachmentPermit { + active: Arc, +} + +impl Drop for RenderAttachmentPermit { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} fn workspace_resource_upsert( sequence: usize, @@ -238,6 +412,189 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) { (cols.clamp(1, TERMINAL_DIMENSION_MAX), rows.clamp(1, TERMINAL_DIMENSION_MAX)) } +type DeadlineFanoutJob = Box; + +#[derive(Default)] +struct DeadlineFanoutState { + jobs: VecDeque, + worker_count: usize, + admitted_jobs: usize, + next_worker: u64, + shutdown: bool, +} + +#[derive(Default)] +struct DeadlineFanoutInner { + state: Mutex, + changed: Condvar, +} + +struct DeadlineFanoutPool { + inner: Arc, +} + +impl DeadlineFanoutPool { + fn new() -> Self { + Self { inner: Arc::new(DeadlineFanoutInner::default()) } + } + + fn submit(&self, job: DeadlineFanoutJob) -> bool { + let mut state = self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.shutdown || state.admitted_jobs >= CELL_PIXEL_FANOUT_MAX_WORKERS { + return false; + } + + let active_jobs = state.admitted_jobs.saturating_sub(state.jobs.len()); + let available_workers = state.worker_count.saturating_sub(active_jobs); + if state.jobs.len() + 1 > available_workers + && state.worker_count < CELL_PIXEL_FANOUT_MAX_WORKERS + { + let worker_index = state.next_worker; + state.next_worker = state.next_worker.wrapping_add(1); + state.worker_count += 1; + let inner = self.inner.clone(); + if std::thread::Builder::new() + .name(format!("mux-deadline-{worker_index}")) + .spawn(move || deadline_fanout_worker(inner)) + .is_err() + { + state.worker_count -= 1; + if state.worker_count == 0 { + return false; + } + } + } + + state.jobs.push_back(job); + state.admitted_jobs += 1; + self.inner.changed.notify_one(); + true + } + + #[cfg(test)] + fn worker_count(&self) -> usize { + self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).worker_count + } +} + +impl Drop for DeadlineFanoutPool { + fn drop(&mut self) { + let mut state = self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + state.shutdown = true; + state.admitted_jobs = state.admitted_jobs.saturating_sub(state.jobs.len()); + state.jobs.clear(); + self.inner.changed.notify_all(); + } +} + +fn deadline_fanout_worker(inner: Arc) { + loop { + let job = { + let mut state = inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.shutdown { + state.worker_count = state.worker_count.saturating_sub(1); + inner.changed.notify_all(); + return; + } + let (mut state, _) = inner + .changed + .wait_timeout_while(state, DEADLINE_FANOUT_IDLE_TIMEOUT, |state| { + !state.shutdown && state.jobs.is_empty() + }) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.shutdown || state.jobs.is_empty() { + state.worker_count = state.worker_count.saturating_sub(1); + inner.changed.notify_all(); + return; + } + state.jobs.pop_front().expect("deadline fanout queue was checked as non-empty") + }; + + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)); + let mut state = inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + state.admitted_jobs = state.admitted_jobs.saturating_sub(1); + } +} + +struct DeadlinePending { + result: Arc>>, +} + +impl DeadlinePending { + fn try_take(&self) -> Option { + self.result.lock().unwrap().take() + } +} + +enum DeadlineMapResult { + Complete(R), + Pending(DeadlinePending), + Unscheduled, +} + +fn bounded_deadline_map( + pool: &DeadlineFanoutPool, + items: &[T], + deadline: Instant, + operation: F, +) -> Vec> +where + T: Clone + Send + 'static, + R: Send + 'static, + F: Fn(&T, Instant) -> R + Send + Sync + 'static, +{ + if items.is_empty() { + return Vec::new(); + } + + let mut ordered = std::iter::repeat_with(|| DeadlineMapResult::Unscheduled) + .take(items.len()) + .collect::>(); + let operation = Arc::new(operation); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut submitted = 0; + for (index, item) in items.iter().cloned().enumerate() { + let sender = sender.clone(); + let operation = operation.clone(); + let result = Arc::new(Mutex::new(None)); + let job_result = result.clone(); + if pool.submit(Box::new(move || { + *job_result.lock().unwrap() = Some(operation(&item, deadline)); + let _ = sender.send(index); + })) { + submitted += 1; + ordered[index] = DeadlineMapResult::Pending(DeadlinePending { result }); + } + } + drop(sender); + + while submitted > 0 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match receiver.recv_timeout(remaining) { + Ok(index) => { + let pending = + std::mem::replace(&mut ordered[index], DeadlineMapResult::Unscheduled); + match pending { + DeadlineMapResult::Pending(pending) => { + if let Some(result) = pending.try_take() { + ordered[index] = DeadlineMapResult::Complete(result); + submitted -= 1; + } else { + ordered[index] = DeadlineMapResult::Pending(pending); + } + } + result => ordered[index] = result, + } + } + Err(_) => break, + } + } + ordered +} + #[derive(Debug, Default)] pub struct CellPixelUpdate { pub resizes: Vec<(SurfaceId, (u16, u16), u64)>, @@ -248,6 +605,30 @@ pub struct CellPixelUpdate { pub struct CellPixelUpdateFailure { pub surface: SurfaceId, pub error: String, + pub deferred: bool, +} + +#[derive(Debug)] +struct PendingCellPixelUpdate { + generation: u64, + target: (u16, u16), + failures: HashSet, + use_for_creation: bool, +} + +struct CellPixelCompletionTracker { + generation: u64, + target: (u16, u16), + publishing: AtomicBool, + completed: Mutex>, +} + +/// Structured graphics failures localized by the presentation frontend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GraphicsStatus { + KittyImageBudgetWorkerStartFailed { error: Arc }, + KittyImageBudgetUpdateFailed { retry_exhausted: bool, summary: Arc }, + CellPixelUpdateRetriesExhausted { attempts: u8, remaining: usize, cell_pixels: (u16, u16) }, } /// Events pushed to subscribed frontends. @@ -281,6 +662,7 @@ pub enum MuxEvent { }, Bell(SurfaceId), Notification(NotificationEvent), + GraphicsStatus(GraphicsStatus), Status(String), /// A frontend should reload its local mux configuration and redraw. ConfigReloadRequested, @@ -967,6 +1349,91 @@ impl ClientSizingState { } } +#[cfg(test)] +type CellPixelBeforePublishHook = Arc; +#[cfg(test)] +type CellPixelOperationHook = + Arc, (u16, u16), Instant) -> anyhow::Result> + Send + Sync>; +#[cfg(test)] +type KittyImageBudgetOperationHook = + Arc, KittyGraphicsLimits, Instant) -> anyhow::Result<()> + Send + Sync>; +#[cfg(test)] +type TerminalSpawnAfterCellPixelSnapshotHook = Arc; + +type CellPixelSurfaceResult = (SurfaceId, (u16, u16), anyhow::Result>, bool); + +struct PendingCellPixelOperation { + surface: Weak, + result: DeadlinePending, +} + +struct CellPixelRetryTask { + surfaces: Vec>, + pending: Vec, + attempts: u8, + generation: u64, + target: (u16, u16), + completion: Arc, + report: SurfaceResizeReporter, + timeout: Duration, + #[cfg(test)] + operation_hook: Option, +} + +#[derive(Default)] +struct CellPixelRetryQueue { + pending: Option, + worker_running: bool, +} + +fn apply_cell_pixel_size_until( + surface: &Arc, + target: (u16, u16), + deadline: Instant, + report: &SurfaceResizeReporter, + #[cfg(test)] operation_hook: Option<&CellPixelOperationHook>, +) -> CellPixelSurfaceResult { + let id = surface.id; + let size = surface.size(); + let callback = report.clone(); + #[cfg(test)] + if let Some(hook) = operation_hook { + let result = + validate_cell_pixel_convergence(surface, target, hook(surface, target, deadline)); + callback(id, size, result.as_ref().ok().copied().flatten()); + let deferred = result.as_ref().err().is_some_and(|error| { + error.downcast_ref::().is_some() + }); + return (id, size, result, deferred); + } + let result = validate_cell_pixel_convergence( + surface, + target, + surface.set_cell_pixel_size_reporting_until( + target.0, + target.1, + deadline, + Box::new(move |accepted| callback(id, size, accepted)), + ), + ); + let deferred = result.as_ref().err().is_some_and(|error| { + error.downcast_ref::().is_some() + }); + (id, size, result, deferred) +} + +fn validate_cell_pixel_convergence( + surface: &Surface, + target: (u16, u16), + result: anyhow::Result>, +) -> anyhow::Result> { + let reservation = result?; + if reservation.is_none() && surface.cell_pixel_size() != target { + anyhow::bail!("cell pixel update did not converge to {}x{} pixels", target.0, target.1); + } + Ok(reservation) +} + /// One-shot wakeup shared by a terminal-exit subscription and any /// connection-owned cancellation sources. The durable terminal registry /// remains authoritative; this only decides when a waiter should query it. @@ -1124,6 +1591,9 @@ pub struct Mux { #[cfg(test)] terminal_create_after_workspace_reservation: Mutex>>, #[cfg(test)] + terminal_spawn_after_cell_pixel_snapshot: + Mutex>, + #[cfg(test)] terminal_create_after_terminal_reservation: Mutex>, reserved_in_process_terminals: Mutex>, #[cfg(test)] @@ -1135,7 +1605,27 @@ pub struct Mux { #[cfg(test)] resource_close_after_commit: Mutex>>, browser_runtime: Mutex>>, + active_render_attachments: Arc, + deadline_fanout_pool: DeadlineFanoutPool, + kitty_image_budget: Mutex, + kitty_image_budget_changed: Condvar, + #[cfg(debug_assertions)] + terminal_host_reconnect_completion_failures: AtomicU64, + #[cfg(debug_assertions)] + terminal_host_test_disconnect_after_spawn_ms: AtomicU64, + #[cfg(test)] + kitty_image_budget_operation: Mutex>, + cell_pixel_lifecycle: Mutex<()>, + next_cell_pixel_generation: AtomicU64, cell_pixels: Mutex<(u16, u16)>, + pending_cell_pixels: Mutex>, + cell_pixel_retries: Mutex, + #[cfg(test)] + cell_pixel_before_publish: Mutex>, + #[cfg(test)] + cell_pixel_operation: Mutex>, + #[cfg(test)] + cell_pixel_fanout_timeout: Mutex>, default_colors: Mutex, durable_terminal_defaults: AtomicBool, sidebar_plugin: Mutex, @@ -1420,6 +1910,8 @@ impl Mux { #[cfg(test)] terminal_create_after_workspace_reservation: Mutex::new(None), #[cfg(test)] + terminal_spawn_after_cell_pixel_snapshot: Mutex::new(None), + #[cfg(test)] terminal_create_after_terminal_reservation: Mutex::new(None), reserved_in_process_terminals: Mutex::new(HashMap::new()), #[cfg(test)] @@ -1431,7 +1923,37 @@ impl Mux { #[cfg(test)] resource_close_after_commit: Mutex::new(None), browser_runtime: Mutex::new(None), + active_render_attachments: Arc::new(AtomicUsize::new(0)), + deadline_fanout_pool: DeadlineFanoutPool::new(), + kitty_image_budget: Mutex::new(KittyImageBudgetState::default()), + kitty_image_budget_changed: Condvar::new(), + #[cfg(debug_assertions)] + terminal_host_reconnect_completion_failures: AtomicU64::new( + std::env::var("CMUX_TUI_TEST_RECONNECT_COMPLETION_FAILURES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + ), + #[cfg(debug_assertions)] + terminal_host_test_disconnect_after_spawn_ms: AtomicU64::new( + std::env::var("CMUX_TUI_TEST_DISCONNECT_HOST_AFTER_SPAWN_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + ), + #[cfg(test)] + kitty_image_budget_operation: Mutex::new(None), + cell_pixel_lifecycle: Mutex::new(()), + next_cell_pixel_generation: AtomicU64::new(1), cell_pixels: Mutex::new((8, 16)), + pending_cell_pixels: Mutex::new(None), + cell_pixel_retries: Mutex::new(CellPixelRetryQueue::default()), + #[cfg(test)] + cell_pixel_before_publish: Mutex::new(None), + #[cfg(test)] + cell_pixel_operation: Mutex::new(None), + #[cfg(test)] + cell_pixel_fanout_timeout: Mutex::new(None), default_colors: Mutex::new(default_colors), durable_terminal_defaults: AtomicBool::new(has_terminal_defaults), sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), @@ -4165,18 +4687,19 @@ impl Mux { } pub(crate) fn terminal_host_reconnected( - &self, + self: &Arc, surface_id: SurfaceId, identity: &TerminalHostIdentity, + applied_kitty_limits: KittyGraphicsLimits, ) -> bool { if self.shutting_down.load(Ordering::Acquire) { return false; } let mut registry = self.workspace_registry.lock().unwrap(); let state = self.state.lock().unwrap(); - let identity_matches = state - .surfaces - .get(&surface_id) + let surface = state.surfaces.get(&surface_id).cloned(); + let identity_matches = surface + .as_ref() .and_then(|surface| surface.terminal_host_identity()) .is_some_and(|current| current == *identity); drop(state); @@ -4194,36 +4717,60 @@ impl Mux { { return false; } - if terminal.lifecycle == TerminalLifecycle::Running { - return true; - } - match commit_terminal_lifecycle( - &mut registry, - "terminal-ready", - "terminal-admin-stream-reconnected", - &identity.terminal_id, - TerminalLifecycle::Running, - Some(&identity.incarnation), - None, - ) { - Ok((_, revision)) => { - self.emit_terminal_registry_changed(®istry, revision); - true - } - Err(error) => { - self.emit(MuxEvent::Status(format!( - "could not persist terminal {} reconnect completion: {error}", - identity.terminal_id - ))); - false + let lifecycle_ready = if terminal.lifecycle == TerminalLifecycle::Running { + true + } else { + match commit_terminal_lifecycle( + &mut registry, + "terminal-ready", + "terminal-admin-stream-reconnected", + &identity.terminal_id, + TerminalLifecycle::Running, + Some(&identity.incarnation), + None, + ) { + Ok((_, revision)) => { + self.emit_terminal_registry_changed(®istry, revision); + true + } + Err(error) => { + self.emit(MuxEvent::Status(format!( + "could not persist terminal {} reconnect completion: {error}", + identity.terminal_id + ))); + false + } } + }; + drop(registry); + if !lifecycle_ready { + return false; } + #[cfg(debug_assertions)] + if self + .terminal_host_reconnect_completion_failures + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + (remaining > 0).then(|| remaining - 1) + }) + .is_ok() + { + return false; + } + surface.is_some_and(|surface| { + self.reconcile_reconnected_kitty_image_surface(&surface, applied_kitty_limits) + }) } pub(crate) fn lock_client_sizing_lifecycle(&self) -> MutexGuard<'_, ()> { self.client_sizing_lifecycle.lock().unwrap() } + #[cfg(debug_assertions)] + pub(crate) fn take_test_terminal_host_disconnect_after_spawn(&self) -> Option { + let delay_ms = self.terminal_host_test_disconnect_after_spawn_ms.swap(0, Ordering::AcqRel); + (delay_ms > 0).then(|| Duration::from_millis(delay_ms)) + } + pub fn begin_pairing( &self, peer: std::net::IpAddr, @@ -4397,6 +4944,23 @@ impl Mux { let (cols, rows) = self.resolve_client_size(size, (opts.cols, opts.rows)); opts.cols = cols; opts.rows = rows; + let cell_pixels = { + let cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let cell_pixels = self.cell_pixel_creation_size(); + drop(cell_pixel_lifecycle); + cell_pixels + }; + #[cfg(test)] + if let Some(hook) = self.terminal_spawn_after_cell_pixel_snapshot.lock().unwrap().clone() { + let unlocked = match self.cell_pixel_lifecycle.try_lock() { + Ok(lifecycle) => { + drop(lifecycle); + true + } + Err(_) => false, + }; + hook(unlocked); + } #[cfg(all(test, unix))] let use_host_runtime = !self.test_surface_runtime; #[cfg(all(not(test), unix))] @@ -4454,11 +5018,12 @@ impl Mux { if reserve_replayed { anyhow::bail!("terminal_create_replayed"); } - let surface = match Surface::spawn_with_terminal_id( + let surface = match Surface::spawn_with_terminal_id_at_cell_pixels( id, opts, Arc::downgrade(self), Some(terminal_id), + cell_pixels, ) { Ok(surface) => surface, Err(error) => { @@ -4482,7 +5047,7 @@ impl Mux { surface.kill(); anyhow::bail!("terminal host changed registry-reserved identity"); } - { + let ready_revision = { let mut registry = self.workspace_registry.lock().unwrap(); let ready = commit_terminal_lifecycle( &mut registry, @@ -4501,18 +5066,39 @@ impl Mux { } }; self.emit_terminal_registry_changed(®istry, ready_revision); - if let Err(error) = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()) - { - drop(registry); - let _ = self.persist_terminal_exit( - &terminal_hex, - Some(&identity.incarnation), - &TerminalExit::unknown("surface-insert-failed"), - ); - surface.kill(); - return Err(error); - } + ready_revision + }; + let cell_pixel_lifecycle = + match self.reconcile_surface_cell_pixels_for_publish(&surface) { + Ok(lifecycle) => lifecycle, + Err(error) => { + let _ = self.transition_terminal_lifecycle( + "terminal-exited", + "terminal-cell-pixel-reconcile-failed", + &terminal_hex, + TerminalLifecycle::Exited, + Some(&identity.incarnation), + Some(serde_json::json!({ + "reason":"cell-pixel-reconcile-failed", + "error":error.to_string(), + "ready_revision":ready_revision, + })), + ); + surface.kill(); + return Err(error); + } + }; + let insert_result = + insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + drop(cell_pixel_lifecycle); + if let Err(error) = insert_result { + let _ = self.persist_terminal_exit( + &terminal_hex, + Some(&identity.incarnation), + &TerminalExit::unknown("surface-insert-failed"), + ); + surface.kill(); + return Err(error); } // Deprecated recovery mirror only; SQLite is placement authority. let _ = surface.persist_host_workspace(workspace_key); @@ -4557,12 +5143,13 @@ impl Mux { } #[cfg(test)] let surface_result = if self.test_surface_runtime { - Surface::spawn_for_test(id, opts, Arc::downgrade(self)) + Surface::spawn_for_test_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels) } else { - Surface::spawn(id, opts, Arc::downgrade(self)) + Surface::spawn_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels) }; #[cfg(not(test))] - let surface_result = Surface::spawn(id, opts, Arc::downgrade(self)); + let surface_result = + Surface::spawn_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels); let surface = match surface_result { Ok(surface) => surface, Err(error) => { @@ -4598,9 +5185,23 @@ impl Mux { }; self.emit_terminal_registry_changed(®istry, revision); } - if let Err(error) = - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()) - { + let cell_pixel_lifecycle = + match self.reconcile_surface_cell_pixels_for_publish(&surface) { + Ok(lifecycle) => lifecycle, + Err(error) => { + let _ = self.persist_terminal_exit( + &terminal_hex, + Some(&incarnation), + &TerminalExit::unknown(format!("cell-pixel-reconcile-failed: {error}")), + ); + surface.kill(); + return Err(error); + } + }; + let insert_result = + insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + drop(cell_pixel_lifecycle); + if let Err(error) = insert_result { let _ = self.persist_terminal_exit( &terminal_hex, Some(&incarnation), @@ -4614,12 +5215,13 @@ impl Mux { } #[cfg(test)] let surface_result = if self.test_surface_runtime { - Surface::spawn_for_test(id, opts, Arc::downgrade(self)) + Surface::spawn_for_test_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels) } else { - Surface::spawn(id, opts, Arc::downgrade(self)) + Surface::spawn_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels) }; #[cfg(not(test))] - let surface_result = Surface::spawn(id, opts, Arc::downgrade(self)); + let surface_result = + Surface::spawn_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels); let surface = match surface_result { Ok(surface) => surface, Err(error) => { @@ -4627,8 +5229,18 @@ impl Mux { return Err(error); } }; - if let Err(error) = insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()) - { + let cell_pixel_lifecycle = match self.reconcile_surface_cell_pixels_for_publish(&surface) { + Ok(lifecycle) => lifecycle, + Err(error) => { + self.pending_workspace_surfaces.lock().unwrap().remove(&id); + surface.kill(); + return Err(error); + } + }; + let insert_result = + insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + drop(cell_pixel_lifecycle); + if let Err(error) = insert_result { self.pending_workspace_surfaces.lock().unwrap().remove(&id); surface.kill(); return Err(error); @@ -4651,15 +5263,41 @@ impl Mux { opts.cols = size.0.max(1); opts.rows = size.1.max(1); opts.extra_env.push(("CMUX_SIDEBAR".to_string(), "1".to_string())); + let cell_pixels = { + let cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let cell_pixels = self.cell_pixel_creation_size(); + drop(cell_pixel_lifecycle); + cell_pixels + }; #[cfg(test)] let surface = if self.test_surface_runtime { - Surface::spawn_for_test_with_resource_identity(id, opts, Arc::downgrade(self), None)? + Surface::spawn_for_test_with_resource_identity_at_cell_pixels( + id, + opts, + Arc::downgrade(self), + None, + cell_pixels, + )? } else { - Surface::spawn_auxiliary(id, opts, Arc::downgrade(self))? + Surface::spawn_auxiliary_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels)? }; #[cfg(not(test))] - let surface = Surface::spawn_auxiliary(id, opts, Arc::downgrade(self))?; - insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone())?; + let surface = + Surface::spawn_auxiliary_at_cell_pixels(id, opts, Arc::downgrade(self), cell_pixels)?; + let cell_pixel_lifecycle = match self.reconcile_surface_cell_pixels_for_publish(&surface) { + Ok(lifecycle) => lifecycle, + Err(error) => { + surface.kill(); + return Err(error); + } + }; + let insert_result = + insert_surface_checked(&mut self.state.lock().unwrap(), surface.clone()); + drop(cell_pixel_lifecycle); + if let Err(error) = insert_result { + surface.kill(); + return Err(error); + } Ok(surface) } @@ -4670,13 +5308,14 @@ impl Mux { pending_workspace: Option, resource_identity: Option, ) -> anyhow::Result> { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); let id = self.next_id(); if let Some(workspace) = pending_workspace { self.pending_workspace_surfaces.lock().unwrap().insert(id, workspace); } let opts = self.surface_options.lock().unwrap().clone(); let size = self.resolve_client_size(size, (opts.cols, opts.rows)); - let cell_pixels = *self.cell_pixels.lock().unwrap(); + let cell_pixels = self.cell_pixel_creation_size(); let surface = match resource_identity { Some(identity) => browser::new_surface_with_resource_identity( id, @@ -6079,6 +6718,18 @@ impl Mux { fn purge_surface_side_tables(&self, surface: SurfaceId) { self.agent_records.lock().unwrap().remove(&surface); self.surface_notifications.lock().unwrap().remove(&surface); + { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let mut pending = self.pending_cell_pixels.lock().unwrap(); + if let Some(update) = pending.as_mut() { + update.failures.remove(&surface); + if update.failures.is_empty() { + let target = update.target; + *self.cell_pixels.lock().unwrap() = target; + *pending = None; + } + } + } self.reserved_in_process_terminals.lock().unwrap().remove(&surface); let _lifecycle = self.lock_client_sizing_lifecycle(); let mut sizing = self.client_sizing.lock().unwrap(); @@ -6419,7 +7070,7 @@ impl Mux { Ok(commit) } - pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> CellPixelUpdate { + pub fn set_cell_pixel_size(self: &Arc, width_px: u16, height_px: u16) -> CellPixelUpdate { self.set_cell_pixel_size_reporting(width_px, height_px, Arc::new(|_, _, _| {})) } @@ -6427,6 +7078,153 @@ impl Mux { *self.cell_pixels.lock().unwrap() } + pub(crate) fn claim_render_attachment(&self) -> Option { + self.active_render_attachments + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < RENDER_ATTACHMENT_LIMIT).then_some(active + 1) + }) + .ok()?; + Some(RenderAttachmentPermit { active: self.active_render_attachments.clone() }) + } + + pub(crate) fn reserve_kitty_image_surface( + self: &Arc, + surface: SurfaceId, + ) -> anyhow::Result { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + anyhow::ensure!( + !budget.entries.contains_key(&surface), + "Kitty image budget already reserved for surface {surface}" + ); + let owner_count = Self::kitty_image_budget_owner_count(&budget); + let owns_quota = + budget.blocked_surfaces.is_empty() && owner_count < KITTY_IMAGE_BUDGET_OWNER_LIMIT; + if owns_quota { + budget.capacity = kitty_image_budget_capacity(owner_count + 1, budget.capacity); + } + budget.entries.insert( + surface, + KittyImageBudgetEntry { + surface: None, + applied: KittyGraphicsLimits::disabled(), + owns_quota, + removing: false, + }, + ); + } + self.start_kitty_image_budget_worker(); + let deadline = Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let initial_limits = + loop { + let mut budget = self.kitty_image_budget.lock().unwrap(); + if !budget.blocked_surfaces.is_empty() { + let entry = budget.entries.get_mut(&surface).ok_or_else(|| { + anyhow::anyhow!("Kitty image budget reservation disappeared") + })?; + entry.owns_quota = false; + entry.applied = KittyGraphicsLimits::disabled(); + Self::rebalance_kitty_image_budget_owners(&mut budget); + break KittyGraphicsLimits::disabled(); + } + let owns_quota = budget + .entries + .get(&surface) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))? + .owns_quota; + if !owns_quota { + break KittyGraphicsLimits::disabled(); + } + let target = kitty_image_limits_for_capacity(budget.capacity); + if !budget.expansion_in_flight + && kitty_image_limits_enabled(target) + && budget.entries.iter().all(|(&id, entry)| { + id == surface || kitty_image_limits_within(entry.applied, target) + }) + { + let entry = budget.entries.get_mut(&surface).ok_or_else(|| { + anyhow::anyhow!("Kitty image budget reservation disappeared") + })?; + entry.applied = target; + break target; + } + if self.shutting_down.load(Ordering::Acquire) { + drop(budget); + self.cancel_kitty_image_surface_reservation(surface); + anyhow::bail!("multiplexer shut down while reserving Kitty image quota"); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + drop(budget); + self.cancel_kitty_image_surface_reservation(surface); + anyhow::bail!( + "timed out waiting for existing terminals to release Kitty image quota" + ); + } + let (next, _) = + self.kitty_image_budget_changed.wait_timeout(budget, remaining).unwrap(); + drop(next); + }; + Ok(KittyImageBudgetReservation { + mux: Arc::downgrade(self), + surface, + initial_limits, + committed: false, + }) + } + + fn commit_kitty_image_surface( + self: &Arc, + id: SurfaceId, + surface: &Arc, + applied: KittyGraphicsLimits, + ) -> anyhow::Result<()> { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + let entry = budget + .entries + .get_mut(&id) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget reservation disappeared"))?; + anyhow::ensure!( + entry.surface.is_none() && !entry.removing, + "Kitty image budget reservation is no longer pending" + ); + entry.surface = Some(Arc::downgrade(surface)); + entry.applied = applied; + Self::rebalance_kitty_image_budget_owners(&mut budget); + } + self.start_kitty_image_budget_worker(); + Ok(()) + } + + fn cancel_kitty_image_surface_reservation(self: &Arc, id: SurfaceId) { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + if budget.entries.get(&id).is_some_and(|entry| entry.surface.is_none()) { + budget.entries.remove(&id); + Self::rebalance_kitty_image_budget_owners(&mut budget); + } + } + self.kitty_image_budget_changed.notify_all(); + self.start_kitty_image_budget_worker(); + } + + pub(crate) fn unregister_kitty_image_surface( + self: &Arc, + surface: &Surface, + ) -> anyhow::Result<()> { + { + let mut budget = self.kitty_image_budget.lock().unwrap(); + if let Some(entry) = budget.entries.get_mut(&surface.id) { + entry.removing = true; + } + budget.blocked_surfaces.remove(&surface.id); + } + self.start_kitty_image_budget_worker(); + Ok(()) + } + pub(crate) fn resource_terminal_host_identity( &self, surface: &Surface, @@ -6436,35 +7234,934 @@ impl Mux { }) } - pub fn set_cell_pixel_size_reporting( + pub(crate) fn kitty_image_limits_for_reconnect( &self, + surface: &Arc, + ) -> anyhow::Result { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let entry = budget + .entries + .get(&surface.id) + .ok_or_else(|| anyhow::anyhow!("Kitty image budget entry disappeared on reconnect"))?; + anyhow::ensure!( + entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)), + "Kitty image budget entry changed ownership on reconnect" + ); + Ok(if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }) + } + + fn reconcile_reconnected_kitty_image_surface( + self: &Arc, + surface: &Arc, + applied: KittyGraphicsLimits, + ) -> bool { + let reconciled = { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let Some(entry) = budget.entries.get_mut(&surface.id) else { return false }; + let owns_entry = entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)); + let desired = if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }; + if !owns_entry || !kitty_image_limits_within(applied, desired) { + false + } else { + entry.applied = applied; + budget.blocked_surfaces.remove(&surface.id); + true + } + }; + if reconciled { + self.kitty_image_budget_changed.notify_all(); + self.start_kitty_image_budget_worker(); + } + reconciled + } + + fn prune_dead_kitty_image_surfaces(budget: &mut KittyImageBudgetState) { + budget.entries.retain(|_, entry| { + entry.surface.as_ref().is_none_or(|surface| surface.strong_count() > 0) + }); + let live_ids = budget.entries.keys().copied().collect::>(); + budget.blocked_surfaces.retain(|id| live_ids.contains(id)); + Self::rebalance_kitty_image_budget_owners(budget); + } + + fn kitty_image_budget_owner_count(budget: &KittyImageBudgetState) -> usize { + budget.entries.values().filter(|entry| entry.owns_quota).count() + } + + fn rebalance_kitty_image_budget_owners(budget: &mut KittyImageBudgetState) { + let owner_count = Self::kitty_image_budget_owner_count(budget); + debug_assert!(owner_count <= KITTY_IMAGE_BUDGET_OWNER_LIMIT); + let available = KITTY_IMAGE_BUDGET_OWNER_LIMIT.saturating_sub(owner_count); + if budget.blocked_surfaces.is_empty() && available > 0 && owner_count < budget.entries.len() + { + let mut candidates = budget + .entries + .iter() + .filter_map(|(&id, entry)| { + (!entry.owns_quota + && !entry.removing + && entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0)) + .then_some(id) + }) + .collect::>(); + candidates.sort_unstable(); + for id in candidates.into_iter().take(available) { + if let Some(entry) = budget.entries.get_mut(&id) { + entry.owns_quota = true; + } + } + } + budget.capacity = kitty_image_budget_capacity( + Self::kitty_image_budget_owner_count(budget), + budget.capacity, + ); + } + + fn start_kitty_image_budget_worker(self: &Arc) { + let should_start = { + let mut budget = self.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let has_work = budget.entries.values().any(|entry| { + entry.surface.as_ref().is_some_and(|surface| surface.strong_count() > 0) + && entry.applied + != if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + } + }); + if budget.worker_running || !budget.blocked_surfaces.is_empty() || !has_work { + false + } else { + budget.worker_running = true; + true + } + }; + if !should_start { + return; + } + let mux = Arc::downgrade(self); + if let Err(error) = std::thread::Builder::new() + .name("kitty-image-budget".into()) + .spawn(move || Self::run_kitty_image_budget_worker(mux)) + { + self.kitty_image_budget.lock().unwrap().worker_running = false; + self.emit(MuxEvent::GraphicsStatus( + GraphicsStatus::KittyImageBudgetWorkerStartFailed { + error: Arc::::from(error.to_string()), + }, + )); + } + } + + fn run_kitty_image_budget_worker(mux: Weak) { + let mut failure_streak = 0_u32; + let mut pending_operations = Vec::::new(); + loop { + let Some(mux) = mux.upgrade() else { return }; + if mux.shutting_down.load(Ordering::Acquire) { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + budget.expansion_in_flight = false; + budget.worker_running = false; + return; + } + + let mut failures = Vec::new(); + let mut failed_operations = HashSet::new(); + let mut failed_surface_ids = HashSet::new(); + let mut retained_pending = Vec::new(); + let mut pending_completed = false; + { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + for pending in pending_operations.drain(..) { + let Some(result) = pending.result.try_take() else { + retained_pending.push(pending); + continue; + }; + pending_completed = true; + match result { + Ok(()) => { + if let Some(entry) = budget.entries.get_mut(&pending.surface_id) + && entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .zip(pending.surface.upgrade()) + .is_some_and(|(registered, completed)| { + Arc::ptr_eq(®istered, &completed) + }) + { + entry.applied = pending.limits; + } + } + Err(error) => { + failed_operations.insert(pending.surface_id); + failed_surface_ids.insert(pending.surface_id); + failures.push(format!("surface {}: {error}", pending.surface_id)); + } + } + } + budget.expansion_in_flight = + retained_pending.iter().any(|pending| pending.expanding); + } + pending_operations = retained_pending; + if pending_completed { + mux.kitty_image_budget_changed.notify_all(); + } + + let pending_ids = + pending_operations.iter().map(|pending| pending.surface_id).collect::>(); + let tasks = { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + Self::prune_dead_kitty_image_surfaces(&mut budget); + let target = kitty_image_limits_for_capacity(budget.capacity); + let mut tasks = Vec::new(); + for (&id, entry) in &budget.entries { + if pending_ids.contains(&id) || failed_operations.contains(&id) { + continue; + } + let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { + continue; + }; + let desired = if entry.removing || !entry.owns_quota { + KittyGraphicsLimits::disabled() + } else { + target + }; + if entry.applied != desired + && (entry.removing + || !entry.owns_quota + || kitty_image_limits_exceed(entry.applied, desired)) + { + tasks.push((id, surface, desired, false)); + } + } + if tasks.is_empty() { + budget.entries.retain(|id, entry| { + pending_ids.contains(id) + || !(entry.removing + && (entry.surface.is_none() + || entry.applied == KittyGraphicsLimits::disabled())) + }); + let previous_capacity = budget.capacity; + Self::rebalance_kitty_image_budget_owners(&mut budget); + if budget.capacity != previous_capacity { + continue; + } + let target = kitty_image_limits_for_capacity(budget.capacity); + for (&id, entry) in &budget.entries { + if pending_ids.contains(&id) || failed_operations.contains(&id) { + continue; + } + let Some(surface) = entry.surface.as_ref().and_then(Weak::upgrade) else { + continue; + }; + if entry.owns_quota && !entry.removing && entry.applied != target { + tasks.push(( + id, + surface, + target, + kitty_image_limits_exceed(target, entry.applied), + )); + } + } + } + budget.expansion_in_flight = + pending_operations.iter().any(|pending| pending.expanding) + || tasks.iter().any(|task| task.3); + if tasks.is_empty() && pending_operations.is_empty() && failed_operations.is_empty() + { + budget.expansion_in_flight = false; + budget.worker_running = false; + return; + } + tasks + }; + + if !tasks.is_empty() { + let deadline = + Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let operation_mux = Arc::downgrade(&mux); + let results = bounded_deadline_map( + &mux.deadline_fanout_pool, + &tasks, + deadline, + move |(_, surface, limits, _), deadline| { + let Some(mux) = operation_mux.upgrade() else { + anyhow::bail!("multiplexer shut down before Kitty quota update"); + }; + mux.apply_kitty_image_limits(surface, *limits, deadline) + }, + ); + let mut budget = mux.kitty_image_budget.lock().unwrap(); + for ((id, surface, limits, expanding), result) in tasks.iter().zip(results) { + match result { + DeadlineMapResult::Complete(Ok(())) => { + if let Some(entry) = budget.entries.get_mut(id) + && entry + .surface + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|registered| Arc::ptr_eq(®istered, surface)) + { + entry.applied = *limits; + } + } + DeadlineMapResult::Complete(Err(error)) => { + failed_surface_ids.insert(*id); + failures.push(format!("surface {id}: {error}")); + } + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingKittyImageBudgetOperation { + surface_id: *id, + surface: Arc::downgrade(surface), + limits: *limits, + expanding: *expanding, + result, + }); + } + DeadlineMapResult::Unscheduled => { + failed_surface_ids.insert(*id); + failures.push(format!( + "surface {id}: update was rejected because the deadline worker \ + pool is saturated" + )); + } + } + } + budget.expansion_in_flight = + pending_operations.iter().any(|pending| pending.expanding); + } + for pending in &pending_operations { + if failed_surface_ids.insert(pending.surface_id) { + failures.push(format!( + "surface {}: update did not complete before its deadline", + pending.surface_id + )); + } + } + mux.kitty_image_budget_changed.notify_all(); + if failures.is_empty() { + failure_streak = 0; + continue; + } + + failure_streak = failure_streak.saturating_add(1); + let retry_exhausted = failure_streak >= KITTY_IMAGE_BUDGET_RETRY_MAX_ATTEMPTS; + if failure_streak == 1 || failure_streak.is_power_of_two() || retry_exhausted { + let omitted = failures.len().saturating_sub(8); + let mut summary = failures.into_iter().take(8).collect::>().join("; "); + if omitted > 0 { + summary.push_str(&format!("; {omitted} more")); + } + mux.emit(MuxEvent::GraphicsStatus(GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted, + summary: Arc::::from(summary), + })); + } + if retry_exhausted { + let mut budget = mux.kitty_image_budget.lock().unwrap(); + let blocked = failed_surface_ids + .into_iter() + .filter(|id| budget.entries.contains_key(id)) + .collect::>(); + budget.blocked_surfaces.extend(blocked); + budget.expansion_in_flight = false; + budget.worker_running = false; + drop(budget); + mux.kitty_image_budget_changed.notify_all(); + return; + } + let multiplier = + 1_u32.checked_shl(failure_streak.saturating_sub(1).min(16)).unwrap_or(u32::MAX); + let delay = KITTY_IMAGE_BUDGET_RETRY_INITIAL + .saturating_mul(multiplier) + .min(KITTY_IMAGE_BUDGET_RETRY_MAX); + drop(mux); + std::thread::sleep(delay); + } + } + + fn apply_kitty_image_limits( + &self, + surface: &Arc, + limits: KittyGraphicsLimits, + deadline: Instant, + ) -> anyhow::Result<()> { + #[cfg(test)] + if let Some(operation) = self.kitty_image_budget_operation.lock().unwrap().clone() { + return operation(surface, limits, deadline); + } + surface.set_kitty_graphics_limits_until(limits, deadline) + } + + #[cfg(test)] + fn kitty_image_budget_idle_for_test(&self) -> bool { + let budget = self.kitty_image_budget.lock().unwrap(); + let target = kitty_image_limits_for_capacity(budget.capacity); + !budget.worker_running + && budget.entries.values().all(|entry| { + entry.surface.is_some() + && !entry.removing + && entry.applied + == if entry.owns_quota { target } else { KittyGraphicsLimits::disabled() } + }) + } + + pub(crate) fn cell_pixel_creation_size(&self) -> (u16, u16) { + if let Some(target) = self + .pending_cell_pixels + .lock() + .unwrap() + .as_ref() + .filter(|pending| pending.use_for_creation) + .map(|pending| pending.target) + { + return target; + } + self.cell_pixel_size() + } + + fn reconcile_surface_cell_pixels_for_publish<'a>( + &'a self, + surface: &Arc, + ) -> anyhow::Result> { + loop { + let lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let target = self.cell_pixel_creation_size(); + if surface.cell_pixel_size() == target { + return Ok(lifecycle); + } + drop(lifecycle); + validate_cell_pixel_convergence( + surface, + target, + surface.set_cell_pixel_size_reporting_until( + target.0, + target.1, + Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT, + Box::new(|_| {}), + ), + )?; + } + } + + pub(crate) fn reconcile_deferred_cell_pixel_ack(&self, surface: SurfaceId, target: (u16, u16)) { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + self.reconcile_cell_pixel_ack_locked(surface, target); + } + + pub(crate) fn submit_deferred_cell_pixel_ack( + &self, + task: impl FnOnce() + Send + 'static, + ) -> bool { + self.deadline_fanout_pool.submit(Box::new(task)) + } + + fn reconcile_cell_pixel_ack_locked(&self, surface: SurfaceId, target: (u16, u16)) { + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(update) = pending.as_mut().filter(|update| update.target == target) else { + return; + }; + update.failures.remove(&surface); + if !update.failures.is_empty() { + return; + } + *self.cell_pixels.lock().unwrap() = target; + *pending = None; + } + + fn reconcile_cell_pixel_completion_locked( + &self, + surface: SurfaceId, + generation: u64, + target: (u16, u16), + ) { + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(update) = pending + .as_mut() + .filter(|update| update.generation == generation && update.target == target) + else { + return; + }; + update.failures.remove(&surface); + if !update.failures.is_empty() { + return; + } + *self.cell_pixels.lock().unwrap() = target; + *pending = None; + } + + fn record_cell_pixel_completion( + self: &Arc, + completion: &Arc, + surface: SurfaceId, + ) { + completion.completed.lock().unwrap().insert(surface); + if completion.publishing.load(Ordering::Acquire) { + return; + } + let _lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + if completion.completed.lock().unwrap().remove(&surface) { + self.reconcile_cell_pixel_completion_locked( + surface, + completion.generation, + completion.target, + ); + } + } + + fn enqueue_cell_pixel_retries( + self: &Arc, + task: CellPixelRetryTask, + ) -> std::io::Result<()> { + let mut retries = self.cell_pixel_retries.lock().unwrap(); + if retries.pending.as_ref().is_some_and(|pending| pending.generation > task.generation) { + return Ok(()); + } + retries.pending = Some(task); + if retries.worker_running { + return Ok(()); + } + retries.worker_running = true; + let mux = Arc::downgrade(self); + match std::thread::Builder::new() + .name("cell-pixel-retry".to_string()) + .spawn(move || Self::run_cell_pixel_retry_worker(mux)) + { + Ok(_) => Ok(()), + Err(error) => { + retries.worker_running = false; + retries.pending = None; + Err(error) + } + } + } + + fn run_cell_pixel_retry_worker(mux: Weak) { + loop { + let task = { + let Some(mux) = mux.upgrade() else { return }; + let mut retries = mux.cell_pixel_retries.lock().unwrap(); + if mux.shutting_down.load(Ordering::Acquire) { + retries.pending = None; + retries.worker_running = false; + return; + } + match retries.pending.take() { + Some(task) => task, + None => { + retries.worker_running = false; + return; + } + } + }; + if let Some(mut task) = Self::run_cell_pixel_retry_task(&mux, task) { + task.attempts = task.attempts.saturating_add(1); + if task.attempts >= CELL_PIXEL_RETRY_MAX_ATTEMPTS { + if let Some(mux) = mux.upgrade() { + mux.finish_cell_pixel_retries(&task); + } + continue; + } + std::thread::sleep(cell_pixel_retry_delay(task.attempts)); + let Some(mux) = mux.upgrade() else { return }; + let mut retries = mux.cell_pixel_retries.lock().unwrap(); + if retries.pending.is_none() { + retries.pending = Some(task); + } + } + } + } + + fn finish_cell_pixel_retries(&self, task: &CellPixelRetryTask) { + let remaining = { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let mut pending = self.pending_cell_pixels.lock().unwrap(); + let Some(pending) = pending.as_mut().filter(|pending| { + pending.generation == task.generation && pending.target == task.target + }) else { + return; + }; + pending.use_for_creation = false; + pending.failures.len() + }; + self.emit(MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: task.attempts, + remaining, + cell_pixels: task.target, + })); + } + + fn run_cell_pixel_retry_task( + mux: &Weak, + task: CellPixelRetryTask, + ) -> Option { + let mut retry_candidates = task.surfaces; + let mut pending_operations = Vec::new(); + for pending in task.pending { + let Some(result) = pending.result.try_take() else { + pending_operations.push(pending); + continue; + }; + let mux = mux.upgrade()?; + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|update| { + update.generation == task.generation && update.target == task.target + }) { + return None; + } + let (surface_id, _, result, deferred) = result; + match result { + Ok(_) => mux.reconcile_cell_pixel_completion_locked( + surface_id, + task.generation, + task.target, + ), + Err(_) if deferred => {} + Err(_) => retry_candidates.push(pending.surface), + } + } + let mut unique = HashSet::new(); + retry_candidates + .retain(|surface| surface.upgrade().is_some_and(|surface| unique.insert(surface.id))); + let mut remaining = Vec::new(); + for retry_wave in retry_candidates.chunks(CELL_PIXEL_FANOUT_MAX_WORKERS) { + let mux = mux.upgrade()?; + let active = { + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + let pending = mux.pending_cell_pixels.lock().unwrap(); + let pending = pending.as_ref().filter(|pending| { + pending.generation == task.generation + && pending.target == task.target + && !pending.failures.is_empty() + })?; + retry_wave + .iter() + .filter_map(Weak::upgrade) + .filter(|surface| pending.failures.contains(&surface.id)) + .collect::>() + }; + let deadline = Instant::now() + task.timeout; + let report = task.report.clone(); + #[cfg(test)] + let operation_hook = task.operation_hook.clone(); + let target = task.target; + let completion = task.completion.clone(); + let completion_mux = Arc::downgrade(&mux); + let results = bounded_deadline_map( + &mux.deadline_fanout_pool, + &active, + deadline, + move |surface, deadline| { + let result = apply_cell_pixel_size_until( + surface, + target, + deadline, + &report, + #[cfg(test)] + operation_hook.as_ref(), + ); + if result.2.is_ok() + && let Some(mux) = completion_mux.upgrade() + { + mux.record_cell_pixel_completion(&completion, surface.id); + } + result + }, + ); + let _cell_pixel_lifecycle = mux.cell_pixel_lifecycle.lock().unwrap(); + if !mux.pending_cell_pixels.lock().unwrap().as_ref().is_some_and(|pending| { + pending.generation == task.generation && pending.target == task.target + }) { + return None; + } + for (surface, result) in active.iter().zip(results) { + let (surface_id, result, deferred) = match result { + DeadlineMapResult::Complete((surface_id, _, result, deferred)) => { + (surface_id, Some(result), deferred) + } + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingCellPixelOperation { + surface: Arc::downgrade(surface), + result, + }); + continue; + } + DeadlineMapResult::Unscheduled => { + remaining.push(Arc::downgrade(surface)); + continue; + } + }; + match result.expect("complete deadline result has an operation result") { + Ok(_) => mux.reconcile_cell_pixel_completion_locked( + surface_id, + task.generation, + task.target, + ), + Err(_) if deferred => {} + Err(error) + if error + .downcast_ref::< + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, + >() + .is_some() => + { + remaining.push(Arc::downgrade(surface)); + } + Err(_) => { + if let Some(pending) = mux + .pending_cell_pixels + .lock() + .unwrap() + .as_mut() + .filter(|pending| { + pending.generation == task.generation + && pending.target == task.target + }) + { + pending.use_for_creation = false; + } + } + } + } + } + let pending_ids = pending_operations + .iter() + .filter_map(|pending| pending.surface.upgrade()) + .map(|surface| surface.id) + .collect::>(); + let mut unique = HashSet::new(); + remaining.retain(|surface| { + surface.upgrade().is_some_and(|surface| { + !pending_ids.contains(&surface.id) && unique.insert(surface.id) + }) + }); + (!remaining.is_empty() || !pending_operations.is_empty()).then_some(CellPixelRetryTask { + surfaces: remaining, + pending: pending_operations, + attempts: task.attempts, + generation: task.generation, + target: task.target, + completion: task.completion, + report: task.report, + timeout: task.timeout, + #[cfg(test)] + operation_hook: task.operation_hook, + }) + } + + pub fn set_cell_pixel_size_reporting( + self: &Arc, width_px: u16, height_px: u16, report: SurfaceResizeReporter, ) -> CellPixelUpdate { + let cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let generation = self.next_cell_pixel_generation.fetch_add(1, Ordering::Relaxed); let next = (width_px.max(1), height_px.max(1)); - // This is the desired global metric used for new browser surfaces. - // Existing surfaces still check their settled geometry on every call, - // so a rejected queue submission can be retried with the same value. - *self.cell_pixels.lock().unwrap() = next; - let surfaces = self.state.lock().unwrap().surfaces.values().cloned().collect::>(); + let completion = Arc::new(CellPixelCompletionTracker { + generation, + target: next, + publishing: AtomicBool::new(true), + completed: Mutex::new(HashSet::new()), + }); + let mut surfaces = + self.state.lock().unwrap().surfaces.values().cloned().collect::>(); + surfaces.sort_unstable_by_key(|surface| surface.id); + #[cfg(test)] + let timeout = self + .cell_pixel_fanout_timeout + .lock() + .unwrap() + .unwrap_or(crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT); + #[cfg(not(test))] + let timeout = crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT; + let deadline = Instant::now() + timeout; + #[cfg(test)] + let operation_hook = self.cell_pixel_operation.lock().unwrap().clone(); + #[cfg(test)] + let fanout_operation_hook = operation_hook.clone(); + let operation_report = report.clone(); + let operation_completion = completion.clone(); + let operation_mux = Arc::downgrade(self); + let results = bounded_deadline_map( + &self.deadline_fanout_pool, + &surfaces, + deadline, + move |surface, deadline| { + let result = apply_cell_pixel_size_until( + surface, + next, + deadline, + &operation_report, + #[cfg(test)] + fanout_operation_hook.as_ref(), + ); + if result.2.is_ok() + && let Some(mux) = operation_mux.upgrade() + { + mux.record_cell_pixel_completion(&operation_completion, surface.id); + } + result + }, + ); let mut update = CellPixelUpdate::default(); - for surface in surfaces { - let id = surface.id; - let size = surface.size(); - let callback = report.clone(); - match surface.set_cell_pixel_size_reporting( - next.0, - next.1, - Box::new(move |accepted| callback(id, size, accepted)), - ) { + let mut retry_surfaces = Vec::new(); + let mut pending_operations = Vec::new(); + for (surface, result) in surfaces.iter().zip(results) { + let (id, size, result, deferred) = match result { + DeadlineMapResult::Complete(result) => result, + DeadlineMapResult::Pending(result) => { + pending_operations.push(PendingCellPixelOperation { + surface: Arc::downgrade(surface), + result, + }); + update.failures.push(CellPixelUpdateFailure { + surface: surface.id, + error: "cell pixel update is still running after the shared deadline" + .to_string(), + deferred: true, + }); + continue; + } + DeadlineMapResult::Unscheduled => { + retry_surfaces.push(Arc::downgrade(surface)); + update.failures.push(CellPixelUpdateFailure { + surface: surface.id, + error: "cell pixel update was deferred because the deadline worker pool \ + is saturated" + .to_string(), + deferred: true, + }); + continue; + } + }; + match result { Ok(Some(reservation_id)) => update.resizes.push((id, size, reservation_id)), Ok(None) => {} - Err(error) => update - .failures - .push(CellPixelUpdateFailure { surface: id, error: error.to_string() }), + Err(error) => { + let retry = error + .downcast_ref::< + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed, + >() + .is_some(); + if retry { + retry_surfaces.push(Arc::downgrade(surface)); + } + update.failures.push(CellPixelUpdateFailure { + surface: id, + error: error.to_string(), + deferred: deferred || retry, + }); + } } } + let completed = std::mem::take(&mut *completion.completed.lock().unwrap()); + if !completed.is_empty() { + update.failures.retain(|failure| !completed.contains(&failure.surface)); + retry_surfaces.retain(|surface| { + surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) + }); + pending_operations.retain(|pending| { + pending.surface.upgrade().is_some_and(|surface| !completed.contains(&surface.id)) + }); + } + #[cfg(test)] + if let Some(hook) = self.cell_pixel_before_publish.lock().unwrap().clone() { + hook(self.cell_pixel_size()); + } + // Keep the published value at the last fully converged metric. New + // surfaces use the pending target, and late hosted acknowledgements + // remove their exact failure before publishing it globally. + if update.failures.is_empty() { + *self.cell_pixels.lock().unwrap() = next; + *self.pending_cell_pixels.lock().unwrap() = None; + } else { + *self.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation, + target: next, + failures: update.failures.iter().map(|failure| failure.surface).collect(), + use_for_creation: update.failures.iter().all(|failure| failure.deferred), + }); + } + completion.publishing.store(false, Ordering::Release); + let raced_completions = std::mem::take(&mut *completion.completed.lock().unwrap()); + for surface in &raced_completions { + self.reconcile_cell_pixel_completion_locked(*surface, generation, next); + } + if !raced_completions.is_empty() { + update.failures.retain(|failure| !raced_completions.contains(&failure.surface)); + retry_surfaces.retain(|surface| { + surface.upgrade().is_some_and(|surface| !raced_completions.contains(&surface.id)) + }); + pending_operations.retain(|pending| { + pending + .surface + .upgrade() + .is_some_and(|surface| !raced_completions.contains(&surface.id)) + }); + } + let retry_ids = retry_surfaces + .iter() + .filter_map(Weak::upgrade) + .map(|surface| surface.id) + .chain( + pending_operations + .iter() + .filter_map(|pending| pending.surface.upgrade()) + .map(|surface| surface.id), + ) + .collect::>(); + let retry_spawn = if retry_surfaces.is_empty() && pending_operations.is_empty() { + Ok(()) + } else { + self.enqueue_cell_pixel_retries(CellPixelRetryTask { + surfaces: retry_surfaces, + pending: pending_operations, + attempts: 0, + generation, + target: next, + completion, + report, + timeout, + #[cfg(test)] + operation_hook, + }) + }; + if let Err(error) = retry_spawn { + for failure in &mut update.failures { + if retry_ids.contains(&failure.surface) { + failure.deferred = false; + failure.error = format!("{}; could not schedule retry: {error}", failure.error); + } + } + if let Some(pending) = self.pending_cell_pixels.lock().unwrap().as_mut() { + pending.use_for_creation = update.failures.iter().all(|failure| failure.deferred); + } + } + drop(cell_pixel_lifecycle); update } @@ -7832,6 +9529,7 @@ impl Mux { url: String, runtime: Arc, ) -> anyhow::Result { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); let (pane_id, size) = { let state = self.state.lock().unwrap(); let Some(pane_id) = state.pane_of(opener_surface) else { @@ -7843,7 +9541,7 @@ impl Mux { let id = self.next_id(); let opts = self.surface_options.lock().unwrap().clone(); let size = size.unwrap_or((opts.cols, opts.rows)); - let cell_pixels = *self.cell_pixels.lock().unwrap(); + let cell_pixels = self.cell_pixel_creation_size(); let surface = browser::new_surface(id, url.clone(), size, cell_pixels, &opts, Arc::downgrade(self))?; let active_at = self.next_active_at(); @@ -12386,6 +14084,14 @@ mod tests { Mux::new_for_test("test", SurfaceOptions::default()) } + fn wait_for_kitty_image_budget(mux: &Mux) { + let deadline = Instant::now() + Duration::from_secs(2); + while !mux.kitty_image_budget_idle_for_test() { + assert!(Instant::now() < deadline, "Kitty image budget worker did not converge"); + std::thread::sleep(Duration::from_millis(5)); + } + } + fn public_request( mux: &Arc, id: &str, @@ -14468,6 +16174,1144 @@ mod tests { assert_eq!(unique.0, 10); } + #[test] + fn cell_pixel_metric_publishes_only_after_existing_surface_fanout() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let observed_at_publish = Arc::new(Mutex::new(Vec::new())); + *mux.cell_pixel_before_publish.lock().unwrap() = Some(Arc::new({ + let observed_at_publish = observed_at_publish.clone(); + move |metric| observed_at_publish.lock().unwrap().push(metric) + })); + + let update = mux.set_cell_pixel_size(9, 18); + + assert_eq!(*observed_at_publish.lock().unwrap(), vec![(8, 16)]); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + assert_eq!(update.resizes, vec![(surface.id, (80, 24), 0)]); + assert!(update.failures.is_empty()); + } + + #[test] + fn unchanged_cell_pixel_result_must_match_the_requested_metric() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new(|_, _, _| Ok(None))); + + let update = mux.set_cell_pixel_size(9, 18); + + assert!(update.resizes.is_empty()); + assert_eq!(update.failures.len(), 1); + assert_eq!(update.failures[0].surface, surface.id); + assert!( + update.failures[0].error.contains("did not converge"), + "unexpected failure: {}", + update.failures[0].error + ); + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(surface.test_cell_pixel_size(), (8, 16)); + } + + #[test] + fn terminal_spawn_releases_cell_pixel_lifecycle_and_reconciles_before_publish() { + let mux = test_mux(); + let observed_unlocked = Arc::new(Mutex::new(Vec::new())); + *mux.terminal_spawn_after_cell_pixel_snapshot.lock().unwrap() = Some(Arc::new({ + let mux = Arc::downgrade(&mux); + let observed_unlocked = observed_unlocked.clone(); + move |unlocked| { + observed_unlocked.lock().unwrap().push(unlocked); + if unlocked { + mux.upgrade().unwrap().set_cell_pixel_size(9, 18); + } + } + })); + + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + + assert_eq!(*observed_unlocked.lock().unwrap(), vec![true]); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + } + + #[test] + fn kitty_image_storage_and_copied_pixels_share_one_process_budget() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + + let configured = surfaces + .iter() + .map(|surface| { + surface.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap() + }) + .map(|limits| { + limits + .image_bytes + .saturating_mul(KITTY_IMAGE_PERSISTENT_COPIES_PER_SURFACE) + .saturating_add(limits.inflight_bytes) + }) + .sum::(); + + assert!( + configured <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, + "per-terminal limits allow {configured} bytes across native screen storage, copied caches, and in-flight uploads" + ); + } + + #[test] + fn kitty_capacity_buckets_reserve_encoded_upload_bytes_inside_the_process_budget() { + let mut capacity = 1; + while capacity <= KITTY_IMAGE_BUDGET_OWNER_LIMIT { + let limits = kitty_image_limits_for_capacity(capacity); + assert_eq!( + limits.inflight_bytes, + ghostty_vt::kitty_inflight_replay_limit_for_image_bytes(limits.image_bytes) + ); + assert!( + kitty_surface_byte_reservation(limits.image_bytes).saturating_mul(capacity as u64) + <= KITTY_IMAGE_PROCESS_BUDGET_BYTES, + "capacity {capacity} exceeded the process byte budget with {limits:?}" + ); + capacity = capacity.saturating_mul(2); + } + } + + #[cfg(unix)] + #[test] + fn exited_terminal_placeholders_do_not_consume_kitty_quota() { + let mux = test_mux(); + let opts = mux.surface_options.lock().unwrap().clone(); + let live = + Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(); + wait_for_kitty_image_budget(&mux); + let unconstrained = + live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); + + let placeholder = Surface::exited_terminal_placeholder( + mux.next_id(), + opts, + Arc::downgrade(&mux), + TerminalHostIdentity { + terminal_id: "00112233445566778899aabbccddeeff".into(), + incarnation: "11111111111111111111111111111111".into(), + }, + ) + .unwrap(); + wait_for_kitty_image_budget(&mux); + + assert_eq!( + live.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(), + unconstrained, + "an exited placeholder reduced a live terminal's graphics share" + ); + assert_eq!( + placeholder + .with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()) + .unwrap(), + KittyGraphicsLimits::disabled(), + "an exited placeholder retained graphics resources it cannot use" + ); + } + + #[test] + fn kitty_object_limits_cover_primary_and_alternate_screens() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + let (configured_images, configured_placements) = surfaces + .iter() + .map(|surface| { + surface + .with_terminal(|terminal| { + let primary_images = terminal.kitty_image_count_limit().unwrap(); + let primary_placements = terminal.kitty_placement_count_limit().unwrap(); + terminal.vt_write(b"\x1b[?1049h"); + let alternate_images = terminal.kitty_image_count_limit().unwrap(); + let alternate_placements = terminal.kitty_placement_count_limit().unwrap(); + terminal.vt_write(b"\x1b[?1049l"); + ( + primary_images.saturating_add(alternate_images), + primary_placements.saturating_add(alternate_placements), + ) + }) + .unwrap() + }) + .fold((0u64, 0u64), |(images, placements), candidate| { + (images.saturating_add(candidate.0), placements.saturating_add(candidate.1)) + }); + assert!( + configured_images <= 4_096, + "primary and alternate screens allow {configured_images} native image records process-wide" + ); + assert!( + configured_placements <= 16_384, + "primary and alternate screens allow {configured_placements} native placements process-wide" + ); + } + + #[test] + fn kitty_inflight_uploads_share_the_process_byte_budget() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + let per_surface_limit = surfaces[0] + .with_terminal(|terminal| terminal.kitty_inflight_storage_limit()) + .unwrap() as usize; + let segment_bytes = (per_surface_limit.saturating_mul(3) / 5) / 4 * 4; + let first = + format!("\x1b_Ga=t,t=d,f=24,i=991,s=1,v=1,m=1,q=2;{}\x1b\\", "A".repeat(segment_bytes)); + let second = format!("\x1b_Ga=t,t=d,f=24,i=991,m=1,q=2;{}", "A".repeat(segment_bytes)); + let inflight_is_bounded = surfaces[0] + .with_terminal(|terminal| { + terminal.vt_write(first.as_bytes()); + terminal.vt_write(second.as_bytes()); + terminal.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES).is_err() + }) + .unwrap(); + assert!( + inflight_is_bounded, + "completed and current Kitty upload chunks retained more than one surface byte share" + ); + } + + #[test] + fn kitty_mux_quota_replays_a_maximum_size_inflight_upload() { + use base64::Engine as _; + + const IMAGE_WIDTH: usize = 2_500; + const IMAGE_HEIGHT: usize = 1_000; + const IMAGE_ID: u32 = 992; + + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let pixels = vec![0xff; IMAGE_WIDTH * IMAGE_HEIGHT * 4]; + assert_eq!(pixels.len(), ghostty_vt::MAX_KITTY_IMAGE_BYTES); + let payload = base64::engine::general_purpose::STANDARD.encode(&pixels); + let (first_payload, final_payload) = payload.split_at(payload.len() - 4); + let first_chunk = format!( + "\x1b_Ga=t,t=d,f=32,i={IMAGE_ID},s={IMAGE_WIDTH},v={IMAGE_HEIGHT},m=1,q=2;{first_payload}\x1b\\" + ); + let final_chunk = format!("\x1b_Gm=0,q=2;{final_payload}\x1b\\"); + assert!(first_chunk.len() <= ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES); + + surface + .with_terminal(|terminal| { + let limits = terminal.kitty_graphics_limits().unwrap(); + assert_eq!(limits.image_bytes, ghostty_vt::MAX_KITTY_IMAGE_BYTES as u64); + terminal.vt_write(first_chunk.as_bytes()); + terminal + .preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .expect("Mux quota rejected replay for a permitted maximum-size Kitty image"); + terminal.vt_write(final_chunk.as_bytes()); + assert_eq!( + terminal + .kitty_graphics_snapshot() + .unwrap() + .image(IMAGE_ID) + .expect("maximum-size Kitty image was not admitted") + .data + .len(), + pixels.len() + ); + }) + .unwrap(); + } + + #[test] + fn closing_kitty_surfaces_rebalances_the_survivors_process_share() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + let constrained = first + .with_terminal(|terminal| { + ( + terminal.kitty_image_storage_limit().unwrap(), + terminal.kitty_image_count_limit().unwrap(), + terminal.kitty_placement_count_limit().unwrap(), + ) + }) + .unwrap(); + + for surface in surfaces.iter().skip(1) { + assert!(mux.close_surface(surface.id).unwrap()); + } + wait_for_kitty_image_budget(&mux); + + let survivor_limit = first + .with_terminal(|terminal| { + ( + terminal.kitty_image_storage_limit().unwrap(), + terminal.kitty_image_count_limit().unwrap(), + terminal.kitty_placement_count_limit().unwrap(), + ) + }) + .unwrap(); + let expected = kitty_image_limits_for_capacity(1).image_bytes; + assert!( + survivor_limit.0 > constrained.0, + "surviving terminal kept its peak-surface quota of {} bytes", + survivor_limit.0 + ); + assert_eq!(survivor_limit.0, expected); + assert!( + survivor_limit.1 > constrained.1, + "surviving terminal kept its peak-surface image count of {}", + survivor_limit.1 + ); + assert!( + survivor_limit.2 > constrained.2, + "surviving terminal kept its peak-surface placement count of {}", + survivor_limit.2 + ); + assert_eq!(survivor_limit.1, 2_048); + assert_eq!(survivor_limit.2, 8_192); + } + + #[test] + fn kitty_quota_updates_delay_terminal_creation_until_startup_is_safe() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + move |surface, limits, _deadline| { + if surface.id == first.id { + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let result = creating_mux.new_tab(Some(pane), None, Some((80, 24))); + let _ = sender.send(result); + }); + + let created_without_waiting = receiver.recv_timeout(Duration::from_millis(250)).ok(); + let returned_before_release = created_without_waiting.is_some(); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let surface = match created_without_waiting { + Some(result) => result.unwrap(), + None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), + }; + creator.join().unwrap(); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + wait_for_kitty_image_budget(&mux); + + assert!( + !returned_before_release, + "terminal creation bypassed the in-flight Kitty quota shrink" + ); + assert!(mux.close_surface(surface.id).unwrap()); + } + + #[test] + fn kitty_quota_restoration_uses_linear_bucket_updates() { + let mux = test_mux(); + let applications = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let applications = applications.clone(); + move |surface, limits, _deadline| { + applications.fetch_add(1, Ordering::AcqRel); + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut surfaces = vec![first]; + for _ in 1..16 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + + wait_for_kitty_image_budget(&mux); + + let applied = applications.load(Ordering::Acquire); + assert!( + applied <= surfaces.len() * 4, + "restoring {} terminals applied {applied} Kitty quota updates", + surfaces.len() + ); + } + + #[test] + fn kitty_quota_expansion_delays_concurrent_terminal_until_reshrink() { + let mux = test_mux(); + let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); + let mut surfaces = vec![survivor.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + + let expansion = kitty_image_limits_for_capacity(1); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + let survivor_id = survivor.id; + move |surface, limits, _deadline| { + if surface.id == survivor_id && limits == expansion { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + for surface in surfaces.iter().skip(1) { + assert!(mux.close_surface(surface.id).unwrap()); + } + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); + }); + let returned_before_release = receiver.recv_timeout(Duration::from_millis(250)).ok(); + assert!( + returned_before_release.is_none(), + "terminal creation bypassed a stale expansion and consumed output without safe quota" + ); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let concurrent = receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(); + creator.join().unwrap(); + wait_for_kitty_image_budget(&mux); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + let settled = kitty_image_limits_for_capacity(2).image_bytes; + for surface in [&survivor, &concurrent] { + assert_eq!( + surface + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap(), + settled + ); + } + } + + #[test] + fn terminal_creation_never_exposes_a_disabled_kitty_quota() { + let mux = test_mux(); + let survivor = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(survivor.id).unwrap()); + let mut surfaces = vec![survivor.clone()]; + for _ in 1..8 { + surfaces.push(mux.new_tab(Some(pane), None, Some((80, 24))).unwrap()); + } + wait_for_kitty_image_budget(&mux); + + let expansion = kitty_image_limits_for_capacity(1); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + let survivor_id = survivor.id; + move |surface, limits, _deadline| { + if surface.id == survivor_id && limits == expansion { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + for surface in surfaces.iter().skip(1) { + assert!(mux.close_surface(surface.id).unwrap()); + } + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + + let (sender, receiver) = std::sync::mpsc::channel(); + let creating_mux = mux.clone(); + let creator = std::thread::spawn(move || { + let _ = sender.send(creating_mux.new_tab(Some(pane), None, Some((80, 24)))); + }); + let created_before_rebalance = receiver.recv_timeout(Duration::from_millis(250)).ok(); + let startup_limit = created_before_rebalance.as_ref().map(|result| { + result + .as_ref() + .unwrap() + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap() + }); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + let concurrent = match created_before_rebalance { + Some(result) => result.unwrap(), + None => receiver.recv_timeout(Duration::from_secs(2)).unwrap().unwrap(), + }; + creator.join().unwrap(); + *mux.kitty_image_budget_operation.lock().unwrap() = None; + wait_for_kitty_image_budget(&mux); + + let initial_limit = startup_limit.unwrap_or_else(|| { + concurrent + .with_terminal(|terminal| terminal.kitty_image_storage_limit().unwrap()) + .unwrap() + }); + assert!( + initial_limit > 0, + "a newly launched terminal could consume startup output while Kitty graphics were disabled" + ); + } + + #[test] + fn kitty_quota_exhaustion_disables_only_overflow_surfaces_and_promotes_them() { + let mux = test_mux(); + let opts = mux.surface_options.lock().unwrap().clone(); + let owner_limit = + usize::try_from(KITTY_IMAGE_PROCESS_BUDGET_COUNT / KITTY_OBJECT_OWNERS_PER_SURFACE) + .unwrap(); + assert_eq!(owner_limit, 2_048); + let mut surfaces = Vec::with_capacity(owner_limit + 1); + for _ in 0..=owner_limit { + surfaces.push( + Surface::spawn_for_test(mux.next_id(), opts.clone(), Arc::downgrade(&mux)).unwrap(), + ); + } + wait_for_kitty_image_budget(&mux); + + let participating_limit = surfaces[0] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(); + assert!(participating_limit > 0); + assert_eq!( + surfaces[owner_limit] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + 0, + "quota exhaustion disabled terminals that already owned a graphics share" + ); + + mux.unregister_kitty_image_surface(&surfaces[0]).unwrap(); + wait_for_kitty_image_budget(&mux); + assert_eq!( + surfaces[owner_limit] + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + participating_limit, + "an overflow terminal was not promoted when a graphics share became available" + ); + } + + #[test] + fn kitty_quota_worker_retries_a_transient_update_failure() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |surface, limits, _deadline| { + if attempts.fetch_add(1, Ordering::AcqRel) == 0 { + anyhow::bail!("injected transient Kitty quota failure"); + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + } + })); + + assert!(mux.close_surface(second.id).unwrap()); + let deadline = Instant::now() + Duration::from_secs(1); + while attempts.load(Ordering::Acquire) < 2 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + attempts.load(Ordering::Acquire) >= 2, + "Kitty quota worker stopped after a transient failure" + ); + wait_for_kitty_image_budget(&mux); + } + + #[test] + fn kitty_quota_worker_disables_graphics_but_admits_terminals_after_persistent_failure() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |_surface, _limits, _deadline| { + attempts.fetch_add(1, Ordering::AcqRel); + anyhow::bail!("injected persistent Kitty quota failure") + } + })); + + assert!(mux.close_surface(second.id).unwrap()); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + !mux.kitty_image_budget.lock().unwrap().worker_running, + "Kitty quota worker retried a permanent failure forever" + ); + assert!( + attempts.load(Ordering::Acquire) <= 4, + "Kitty quota worker exceeded its retry budget" + ); + + let started = Instant::now(); + let replacement = mux + .new_tab(Some(pane), None, Some((80, 24))) + .expect("an optional Kitty quota failure blocked terminal creation"); + assert!( + started.elapsed() < Duration::from_millis(250), + "a blocked Kitty quota transition waited for the control timeout" + ); + assert_eq!( + replacement + .with_terminal(|terminal| terminal.kitty_image_count_limit().unwrap()) + .unwrap(), + 0, + "a terminal admitted during a blocked quota transition retained graphics quota" + ); + let budget = mux.kitty_image_budget.lock().unwrap(); + assert!( + !budget.entries.get(&replacement.id).unwrap().owns_quota, + "a terminal admitted during a blocked quota transition became a quota owner" + ); + } + + #[test] + fn kitty_quota_reconnect_clears_its_block_and_rebalances() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let first_id = first.id; + *mux.kitty_image_budget_operation.lock().unwrap() = + Some(Arc::new(move |surface, limits, _deadline| { + if surface.id == first_id { + anyhow::bail!("injected disconnected Kitty quota surface"); + } + surface.set_kitty_graphics_limits( + limits.image_bytes, + limits.inflight_bytes, + limits.images, + limits.placements, + ) + })); + + assert!(mux.close_surface(second.id).unwrap()); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(mux.kitty_image_budget.lock().unwrap().blocked_surfaces.contains(&first.id)); + + *mux.kitty_image_budget_operation.lock().unwrap() = None; + let authoritative = + first.with_terminal(|terminal| terminal.kitty_graphics_limits().unwrap()).unwrap(); + assert!(mux.reconcile_reconnected_kitty_image_surface(&first, authoritative)); + wait_for_kitty_image_budget(&mux); + + let replacement = mux + .new_tab(Some(pane), None, Some((80, 24))) + .expect("a reconciled reconnect must admit later terminals"); + wait_for_kitty_image_budget(&mux); + assert!(mux.close_surface(replacement.id).unwrap()); + } + + #[test] + fn kitty_quota_worker_stops_waiting_for_an_operation_that_ignores_its_deadline() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (started_sender, started_receiver) = std::sync::mpsc::sync_channel(1); + let (finished_sender, finished_receiver) = std::sync::mpsc::sync_channel(1); + *mux.kitty_image_budget_operation.lock().unwrap() = Some(Arc::new({ + let gate = gate.clone(); + move |_surface, _limits, _deadline| { + let _ = started_sender.try_send(()); + let (released, changed) = &*gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + let _ = finished_sender.try_send(()); + anyhow::bail!("released persistent Kitty quota operation") + } + })); + + assert!(mux.close_surface(second.id).unwrap()); + started_receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(4); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let stopped = !mux.kitty_image_budget.lock().unwrap().worker_running; + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + finished_receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + + assert!(stopped, "Kitty quota worker waited forever for an operation past its deadline"); + } + + #[test] + fn kitty_quota_worker_exhausts_deadline_pool_admission_rejections() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + wait_for_kitty_image_budget(&mux); + + { + let mut state = mux + .deadline_fanout_pool + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(state.admitted_jobs, 0); + state.admitted_jobs = CELL_PIXEL_FANOUT_MAX_WORKERS; + } + + assert!(mux.close_surface(second.id).unwrap()); + let deadline = Instant::now() + Duration::from_secs(2); + while mux.kitty_image_budget.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let (stopped, blocked) = { + let budget = mux.kitty_image_budget.lock().unwrap(); + (!budget.worker_running, budget.blocked_surfaces.contains(&second.id)) + }; + + { + let mut state = mux + .deadline_fanout_pool + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.admitted_jobs = 0; + mux.deadline_fanout_pool.inner.changed.notify_all(); + } + let cleanup_deadline = Instant::now() + Duration::from_secs(1); + while mux.kitty_image_budget.lock().unwrap().worker_running + && Instant::now() < cleanup_deadline + { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!(stopped, "Kitty quota worker retried pool admission forever"); + assert!(blocked, "pool admission exhaustion did not fail closed for the surface"); + } + + #[test] + fn cell_pixel_fanout_runs_concurrently_with_one_shared_deadline() { + let pool = DeadlineFanoutPool::new(); + let (warm_tx, warm_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || warm_tx.send(()).unwrap()))); + warm_rx.recv_timeout(Duration::from_secs(1)).expect("warmup fanout job did not run"); + let items = (0..8).collect::>(); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let deadline = Instant::now() + Duration::from_secs(1); + let operation_active = active; + let operation_max_active = max_active.clone(); + + let results = + bounded_deadline_map(&pool, &items, deadline, move |item, observed_deadline| { + assert_eq!(observed_deadline, deadline); + let concurrent = operation_active.fetch_add(1, Ordering::AcqRel) + 1; + operation_max_active.fetch_max(concurrent, Ordering::AcqRel); + std::thread::sleep(Duration::from_millis(10)); + operation_active.fetch_sub(1, Ordering::AcqRel); + item * 2 + }); + + assert_eq!( + results + .into_iter() + .map(|result| match result { + DeadlineMapResult::Complete(result) => Some(result), + DeadlineMapResult::Pending(_) | DeadlineMapResult::Unscheduled => None, + }) + .collect::>>() + .unwrap(), + vec![0, 2, 4, 6, 8, 10, 12, 14] + ); + assert!(max_active.load(Ordering::Acquire) > 1); + } + + #[test] + fn deadline_fanout_workers_are_lazy_and_reclaim_idle_threads() { + let pool = DeadlineFanoutPool::new(); + assert_eq!(pool.worker_count(), 0, "fanout construction eagerly retained workers"); + + let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || { + finished_tx.send(()).unwrap(); + }))); + finished_rx.recv_timeout(Duration::from_secs(1)).expect("fanout job did not run"); + + let deadline = Instant::now() + Duration::from_secs(1); + while pool.worker_count() != 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(pool.worker_count(), 0, "idle fanout worker was retained"); + + let (teardown_tx, teardown_rx) = std::sync::mpsc::sync_channel(1); + assert!(pool.submit(Box::new(move || teardown_tx.send(()).unwrap()))); + teardown_rx.recv_timeout(Duration::from_secs(1)).expect("teardown fanout job did not run"); + let inner = Arc::downgrade(&pool.inner); + drop(pool); + let deadline = Instant::now() + Duration::from_secs(1); + while inner.upgrade().is_some() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(inner.upgrade().is_none(), "dropped fanout pool retained its executor state"); + } + + #[test] + fn cell_pixel_fanout_returns_when_an_operation_ignores_its_deadline() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let caller_gate = gate.clone(); + let caller = std::thread::spawn(move || { + let pool = DeadlineFanoutPool::new(); + let items = vec![1_u8]; + let deadline = Instant::now() + Duration::from_millis(30); + let results = bounded_deadline_map(&pool, &items, deadline, move |item, _| { + let (released, changed) = &*caller_gate; + let mut released = released.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + *item + }); + let _ = sender.send(results); + }); + + let returned_before_release = receiver.recv_timeout(Duration::from_millis(150)).is_ok(); + { + let (released, changed) = &*gate; + *released.lock().unwrap() = true; + changed.notify_all(); + } + caller.join().unwrap(); + + assert!( + returned_before_release, + "fanout joined an operation after its shared deadline elapsed" + ); + } + + #[test] + fn timed_out_cell_pixel_failure_is_retried_after_the_worker_finishes() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(20)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |surface, target, _deadline| { + if attempts.fetch_add(1, Ordering::AcqRel) == 0 { + std::thread::sleep(Duration::from_millis(60)); + anyhow::bail!("injected late cell-pixel failure"); + } + surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + } + })); + + let update = mux.set_cell_pixel_size(9, 18); + assert_eq!(update.failures.len(), 1); + assert!(update.failures[0].deferred); + + let deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_size() != (9, 18) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + attempts.load(Ordering::Acquire) >= 2, + "the failed operation that finished after the shared deadline was never retried" + ); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + } + + #[test] + fn cell_pixel_fanout_retries_the_same_metric_before_publishing_it() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.fail_next_test_master_resize(); + + let update = mux.set_cell_pixel_size(9, 18); + + assert!(update.resizes.is_empty()); + assert_eq!(update.failures.len(), 1); + assert_eq!(update.failures[0].surface, surface.id); + assert!(update.failures[0].error.contains("injected PTY master resize failure")); + assert_eq!( + mux.cell_pixel_size(), + (8, 16), + "published metric must remain at the last fully converged value" + ); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + assert_eq!(surface.test_cell_pixel_size(), (8, 16)); + let created_while_pending = mux.new_workspace(None, Some((80, 24))).unwrap(); + assert_eq!(created_while_pending.test_cell_pixel_size(), (8, 16)); + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (80, 24, 640, 384) + ); + + let retried = mux.set_cell_pixel_size(9, 18); + assert!(retried.failures.is_empty()); + assert_eq!( + retried.resizes, + vec![(surface.id, (80, 24), 0), (created_while_pending.id, (80, 24), 0)] + ); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(surface.test_cell_pixel_size(), (9, 18)); + } + + #[test] + fn cell_pixel_fanout_retries_work_skipped_after_the_shared_deadline() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + for _ in 1..=CELL_PIXEL_FANOUT_MAX_WORKERS { + mux.new_tab(Some(pane), None, Some((80, 24))).unwrap(); + } + let mut surface_ids = + mux.state.lock().unwrap().surfaces.keys().copied().collect::>(); + surface_ids.sort_unstable(); + assert_eq!(surface_ids.len(), CELL_PIXEL_FANOUT_MAX_WORKERS + 1); + let last_surface = *surface_ids.last().unwrap(); + let last_attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(50)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let last_attempts = last_attempts.clone(); + move |surface, target, deadline| { + if surface.id != last_surface { + std::thread::sleep( + deadline.saturating_duration_since(Instant::now()) + + Duration::from_millis(10), + ); + } else { + last_attempts.fetch_add(1, Ordering::AcqRel); + if Instant::now() >= deadline { + return Err( + crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into() + ); + } + } + surface.set_cell_pixel_size(target.0, target.1).map(|changed| changed.then_some(0)) + } + })); + + let update = mux.set_cell_pixel_size(9, 18); + + assert_eq!(update.failures.len(), surface_ids.len()); + assert!(update.failures.iter().all(|failure| failure.deferred)); + let retry_deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_size() != (9, 18) && Instant::now() < retry_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + mux.cell_pixel_size(), + (9, 18), + "work that missed the first worker wave was never reconciled" + ); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + assert_eq!(last_attempts.load(Ordering::Acquire), 1); + } + + #[test] + fn cell_pixel_deadline_retries_stop_and_report_terminal_failure() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + *mux.cell_pixel_fanout_timeout.lock().unwrap() = Some(Duration::from_millis(10)); + *mux.cell_pixel_operation.lock().unwrap() = Some(Arc::new({ + let attempts = attempts.clone(); + move |_, _, _| { + attempts.fetch_add(1, Ordering::AcqRel); + Err(crate::terminal_host_runtime::CellPixelRequestDeadlineElapsed.into()) + } + })); + let events = mux.subscribe(); + + let update = mux.set_cell_pixel_size(9, 18); + + assert_eq!(update.failures.len(), 1); + assert!(update.failures[0].deferred); + let deadline = Instant::now() + Duration::from_secs(1); + while mux.cell_pixel_retries.lock().unwrap().worker_running && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !mux.cell_pixel_retries.lock().unwrap().worker_running, + "deadline retries did not stop after {} attempts", + attempts.load(Ordering::Acquire) + ); + assert_eq!( + attempts.load(Ordering::Acquire), + usize::from(CELL_PIXEL_RETRY_MAX_ATTEMPTS) + 1 + ); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + assert!(events.try_iter().any(|event| matches!( + event, + MuxEvent::GraphicsStatus(GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: CELL_PIXEL_RETRY_MAX_ATTEMPTS, + remaining: 1, + cell_pixels: (9, 18), + }) + ))); + + assert!(surface.set_cell_pixel_size(9, 18).unwrap()); + mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + } + + #[test] + fn late_cell_pixel_ack_publishes_the_pending_creation_metric() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + surface.fail_next_test_master_resize(); + + let update = mux.set_cell_pixel_size(9, 18); + assert_eq!(update.failures.len(), 1); + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(mux.cell_pixel_creation_size(), (8, 16)); + + assert!(surface.set_cell_pixel_size(9, 18).unwrap()); + mux.reconcile_deferred_cell_pixel_ack(surface.id, (9, 18)); + + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert_eq!(mux.cell_pixel_creation_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + } + + #[test] + fn deferred_cell_pixel_target_sizes_new_surfaces_before_the_ack_arrives() { + let mux = test_mux(); + let pending_surface = 99_999; + *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation: 1, + target: (9, 18), + failures: HashSet::from([pending_surface]), + use_for_creation: true, + }); + + let created = mux.new_workspace(None, Some((80, 24))).unwrap(); + + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert_eq!(created.test_cell_pixel_size(), (9, 18)); + mux.reconcile_deferred_cell_pixel_ack(pending_surface, (9, 18)); + assert_eq!(mux.cell_pixel_size(), (9, 18)); + } + + #[test] + fn closing_the_last_failed_surface_publishes_pending_cell_pixels() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + *mux.pending_cell_pixels.lock().unwrap() = Some(PendingCellPixelUpdate { + generation: 1, + target: (9, 18), + failures: HashSet::from([surface.id]), + use_for_creation: false, + }); + + assert_eq!(mux.cell_pixel_size(), (8, 16)); + assert!(mux.close_surface(surface.id).unwrap()); + + assert_eq!(mux.cell_pixel_size(), (9, 18)); + assert!(mux.pending_cell_pixels.lock().unwrap().is_none()); + } + #[test] fn failed_viewer_resize_preserves_previous_report_and_creation_default() { let mux = test_mux(); @@ -14748,14 +17592,16 @@ mod tests { } #[test] - fn client_sizes_clamp_to_tmux_window_bounds() { + fn client_sizes_clamp_before_backend_rejects_unrepresentable_pixels() { let mux = test_mux(); let surface = mux.new_workspace(None, None).unwrap(); - mux.resize_surface_for_client(surface.id, 1, 0, u16::MAX).unwrap(); + assert_eq!(clamp_terminal_size(0, u16::MAX), (1, 10_000)); + let error = mux.resize_surface_for_client(surface.id, 1, 0, u16::MAX).unwrap_err(); - assert_eq!(mux.client_surface_size(surface.id, 1), Some((1, 10_000))); - assert_eq!(surface.size(), (1, 10_000)); + assert!(error.to_string().contains("PTY pixel height exceeds 65535")); + assert_eq!(mux.client_surface_size(surface.id, 1), None); + assert_eq!(surface.size(), (80, 24)); } #[test] @@ -19119,7 +21965,7 @@ mod tests { .unwrap(); let surface = insert_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &first.key); - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); let (entered_tx, entered_rx) = std::sync::mpsc::channel(); let (release_tx, release_rx) = std::sync::mpsc::channel(); let release_rx = Arc::new(Mutex::new(release_rx)); diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs index 24ea1e8048..cc4faf0d49 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs @@ -143,7 +143,7 @@ fn terminal_state_read( .map_err(resource_operation_error)? .map_err(|error| resource_operation_error(error.into()))?; Ok(json!({ - "state_base64":base64::engine::general_purpose::STANDARD.encode(state), + "state_base64":base64::engine::general_purpose::STANDARD.encode(state.bytes), "cols":cols, "rows":rows, })) diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index efd8ad20d7..1269c0b656 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -23,24 +23,25 @@ use std::io::{BufRead, BufReader, Read, Write}; #[cfg(unix)] use std::mem::{offset_of, size_of}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex, Weak}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; use base64::Engine; use ghostty_vt::{ - Dirty, KeyAction, KeyEncoder, KeyInput, Mods, StyledRun, UnderlineStyle, key_input_from_chord, - rows_to_runs, sys, + Dirty, KeyAction, KeyEncoder, KeyInput, KittyReplayState, Mods, StyledRun, UnderlineStyle, + key_input_from_chord, rows_to_runs, sys, }; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tungstenite::protocol::CloseFrame; +use tungstenite::protocol::WebSocketConfig; use tungstenite::protocol::frame::coding::CloseCode; -use tungstenite::protocol::{Role, WebSocketConfig}; use tungstenite::{Message, WebSocket, accept_with_config}; use zeroize::Zeroize; @@ -64,11 +65,11 @@ use crate::surface::{ }; use crate::{ AgentRecord, AgentSource, AgentState, AttachFrame, BrowserAttachState, BrowserFrameStream, - DefaultColors, Direction, LayoutLeafSpec, LayoutRatioError, LayoutSpec, LayoutUndoResult, Mux, - MuxEvent, Node, NotificationLevel, PairingDecision, PaneId, RenderAttachFrame, - RenderAttachStream, Rgb, ScreenId, SidebarPluginStatus, SplitDir, SplitId, SurfaceId, - SurfaceKind, SurfaceNotification, SurfaceRenderFrame, TerminalColors, TreeDelta, TreeDeltaKind, - ViewportWidthError, WorkspaceId, WorkspaceMutation, ZoomMode, assign_short_ids, + DefaultColors, Direction, GraphicsStatus, LayoutLeafSpec, LayoutRatioError, LayoutSpec, + LayoutUndoResult, Mux, MuxEvent, Node, NotificationLevel, PairingDecision, PaneId, + RenderAttachFrame, RenderAttachStream, Rgb, ScreenId, SidebarPluginStatus, SplitDir, SplitId, + SurfaceId, SurfaceKind, SurfaceNotification, SurfaceRenderFrame, TerminalColors, TreeDelta, + TreeDeltaKind, ViewportWidthError, WorkspaceId, WorkspaceMutation, ZoomMode, assign_short_ids, }; pub const ATTACH_INITIAL_SIZE_CAPABILITY: &str = "attach-initial-size"; @@ -699,6 +700,7 @@ enum Command { #[serde(alias = "height_px")] height_px: u16, }, + GetCellPixels, BrowserFramePresented { surface: SurfaceId, frame_seq: u64, @@ -1259,11 +1261,39 @@ const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(100); const MAX_SERVER_CONNECTIONS: usize = 64; const WEBSOCKET_AUTH_MAX_BYTES: usize = 4 * 1024; -const WEBSOCKET_MESSAGE_MAX_BYTES: usize = 4 * 1024 * 1024; +const WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES: usize = 4 * 1024 * 1024; +// One outbound render budget chain: +// 10,000,000 decoded image bytes -> 13,333,336 base64 bytes. +// 16,384 maximal placement objects -> 7,962,625 JSON bytes. +// Their 21,295,961-byte subtotal fits a 32 MiB attach message with +// 12,258,471 bytes left for image metadata, rows, and the JSON wrapper. +// Keep the TypeScript SDK and web decoder constants in sync. +const RENDER_GRAPHIC_MAX_DECODED_BYTES: usize = 10_000_000; +const RENDER_GRAPHIC_MAX_ENCODED_BYTES: usize = RENDER_GRAPHIC_MAX_DECODED_BYTES.div_ceil(3) * 4; +const RENDER_GRAPHIC_MAX_PLACEMENTS: usize = 16_384; +const RENDER_GRAPHIC_MAX_PLACEMENT_JSON_BYTES: usize = 485; +const RENDER_GRAPHIC_MAX_PLACEMENT_ARRAY_BYTES: usize = 2 + + RENDER_GRAPHIC_MAX_PLACEMENTS * RENDER_GRAPHIC_MAX_PLACEMENT_JSON_BYTES + + (RENDER_GRAPHIC_MAX_PLACEMENTS - 1); +const RENDER_ATTACH_MAX_BYTES: usize = crate::REMOTE_SESSION_MESSAGE_MAX_BYTES; +// Share expensive image encoding across render clients without retaining an +// unbounded second copy of terminal pixel state process-wide. +const RENDER_GRAPHIC_BASE64_CACHE_MAX_BYTES: usize = RENDER_GRAPHIC_MAX_ENCODED_BYTES * 2; +const RENDER_GRAPHIC_BASE64_CACHE_MAX_ENTRIES: usize = 4_096; +const _: () = assert!( + RENDER_GRAPHIC_MAX_ENCODED_BYTES + RENDER_GRAPHIC_MAX_PLACEMENT_ARRAY_BYTES + < RENDER_ATTACH_MAX_BYTES +); const OUTBOUND_CAPACITY: usize = 256; const OUTBOUND_CONTROL_RESERVE: usize = 256; -const OUTBOUND_BYTE_CAPACITY: usize = 16 * 1024 * 1024; -const OUTBOUND_CONTROL_BYTE_RESERVE: usize = 16 * 1024 * 1024; +const OUTBOUND_BYTE_CAPACITY: usize = RENDER_ATTACH_MAX_BYTES; +// The synchronous `vt-state` command returns the same bounded replay as an +// attach, encoded as base64 inside its response envelope. +const OUTBOUND_CONTROL_BYTE_RESERVE: usize = RENDER_ATTACH_MAX_BYTES; +const OUTBOUND_GLOBAL_BYTE_CAPACITY: usize = OUTBOUND_BYTE_CAPACITY * 4; +const OUTBOUND_GLOBAL_CONTROL_BYTE_CAPACITY: usize = OUTBOUND_CONTROL_BYTE_RESERVE * 4; +const _: () = + assert!(crate::surface::VT_REPLAY_MAX_BYTES.div_ceil(3) * 4 < OUTBOUND_CONTROL_BYTE_RESERVE); const OUTBOUND_CONNECTION_CAPACITY: usize = OUTBOUND_CAPACITY * 16; const OUTBOUND_CONNECTION_BYTE_CAPACITY: usize = OUTBOUND_BYTE_CAPACITY * 8; const CLIENT_DETACH_WRITE_TIMEOUT: Duration = Duration::from_millis(100); @@ -1477,16 +1507,436 @@ impl ConnectionSurfaceScheduler { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct RenderGraphicCacheKey { + data_ptr: usize, + data_len: usize, +} + +struct RenderGraphicCacheEntry { + source: Weak<[u8]>, + encoded: Arc, +} + +struct RenderGraphicBase64Cache { + entries: HashMap, + insertion_order: VecDeque, + retained_bytes: usize, + max_bytes: usize, + max_entries: usize, +} + +impl RenderGraphicBase64Cache { + fn new(max_bytes: usize, max_entries: usize) -> Self { + Self { + entries: HashMap::new(), + insertion_order: VecDeque::new(), + retained_bytes: 0, + max_bytes, + max_entries, + } + } + + fn encode(&mut self, data: &Arc<[u8]>) -> Arc { + let key = RenderGraphicCacheKey { data_ptr: data.as_ptr() as usize, data_len: data.len() }; + if let Some(entry) = self.entries.get(&key) + && entry.source.upgrade().is_some_and(|source| Arc::ptr_eq(&source, data)) + { + return entry.encoded.clone(); + } + if let Some(stale) = self.entries.remove(&key) { + self.retained_bytes = self.retained_bytes.saturating_sub(stale.encoded.len()); + self.insertion_order.retain(|candidate| *candidate != key); + } + + // Serialize while holding the cache lock. Competing render clients + // wait for this one bounded encode instead of allocating duplicates. + let encoded: Arc = + Arc::from(base64::engine::general_purpose::STANDARD.encode(data.as_ref())); + if encoded.len() > self.max_bytes || self.max_entries == 0 { + return encoded; + } + while self.entries.len() >= self.max_entries + || encoded.len() > self.max_bytes.saturating_sub(self.retained_bytes) + { + let Some(oldest) = self.insertion_order.pop_front() else { + break; + }; + if let Some(evicted) = self.entries.remove(&oldest) { + self.retained_bytes = self.retained_bytes.saturating_sub(evicted.encoded.len()); + } + } + self.retained_bytes += encoded.len(); + self.insertion_order.push_back(key); + self.entries.insert( + key, + RenderGraphicCacheEntry { source: Arc::downgrade(data), encoded: encoded.clone() }, + ); + encoded + } +} + +struct OutboundByteBudget { + retained_bytes: AtomicUsize, + max_bytes: usize, +} + +impl OutboundByteBudget { + fn new(max_bytes: usize) -> Self { + Self { retained_bytes: AtomicUsize::new(0), max_bytes } + } + + fn try_retain(&self, bytes: usize) -> bool { + self.retained_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |retained| { + retained.checked_add(bytes).filter(|next| *next <= self.max_bytes) + }) + .is_ok() + } + + fn release(&self, bytes: usize) { + let previous = self.retained_bytes.fetch_sub(bytes, Ordering::AcqRel); + debug_assert!(previous >= bytes, "outbound byte budget underflow"); + } +} + +struct BudgetedText { + text: String, + retained_bytes: usize, + budget: Arc, +} + +impl Deref for BudgetedText { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.text + } +} + +impl Drop for BudgetedText { + fn drop(&mut self) { + self.budget.release(self.retained_bytes); + } +} + +struct BudgetedJsonWriter { + bytes: Vec, + // Total quota charged while this writer is alive. A reserved writer + // starts with logical quota but grows its Vec only as bytes are written. + retained_bytes: usize, + reservation_bytes: usize, + budget: Arc, +} + +impl BudgetedJsonWriter { + fn new(budget: Arc) -> Self { + Self { bytes: Vec::new(), retained_bytes: 0, reservation_bytes: 0, budget } + } + + fn with_reservation( + budget: Arc, + reserved_bytes: usize, + ) -> std::io::Result { + let mut writer = Self::new(budget); + if !writer.budget.try_retain(reserved_bytes) { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "global outbound byte budget overflowed", + )); + } + writer.retained_bytes = reserved_bytes; + writer.reservation_bytes = reserved_bytes; + Ok(writer) + } + + fn ensure_capacity(&mut self, required_len: usize) -> std::io::Result<()> { + if required_len <= self.bytes.capacity() { + return Ok(()); + } + let target = required_len.checked_next_power_of_two().unwrap_or(required_len).max(8); + let previous_retained = self.retained_bytes; + let target_retained = target.max(self.reservation_bytes); + let additional_retained = target_retained.saturating_sub(previous_retained); + if additional_retained > 0 && !self.budget.try_retain(additional_retained) { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "global outbound byte budget overflowed", + )); + } + self.retained_bytes = target_retained; + if let Err(error) = self.bytes.try_reserve_exact(target.saturating_sub(self.bytes.len())) { + self.retained_bytes = previous_retained; + if additional_retained > 0 { + self.budget.release(additional_retained); + } + return Err(std::io::Error::other(error)); + } + let actual_capacity = self.bytes.capacity(); + let actual_retained = actual_capacity.max(self.reservation_bytes); + if actual_retained > self.retained_bytes { + let additional = actual_retained - self.retained_bytes; + if !self.budget.try_retain(additional) { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "global outbound byte budget overflowed", + )); + } + self.retained_bytes = actual_retained; + } else if actual_retained < self.retained_bytes { + let unused = self.retained_bytes - actual_retained; + self.retained_bytes = actual_retained; + self.budget.release(unused); + } + Ok(()) + } + + fn finish(mut self) -> Arc { + let bytes = std::mem::take(&mut self.bytes); + let retained_bytes = bytes.capacity(); + debug_assert!(retained_bytes <= self.retained_bytes); + if retained_bytes < self.retained_bytes { + self.budget.release(self.retained_bytes - retained_bytes); + } + self.retained_bytes = 0; + self.reservation_bytes = 0; + let text = String::from_utf8(bytes).expect("serde_json emits UTF-8"); + Arc::new(BudgetedText { text, retained_bytes, budget: self.budget.clone() }) + } +} + +impl Write for BudgetedJsonWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let required_len = self.bytes.len().checked_add(bytes.len()).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "serialized message is too large") + })?; + self.ensure_capacity(required_len)?; + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Drop for BudgetedJsonWriter { + fn drop(&mut self) { + if self.retained_bytes > 0 { + self.budget.release(self.retained_bytes); + } + } +} + +struct RenderService { + graphic_base64: Mutex, + outbound_budget: Arc, + control_budget: Arc, +} + +impl RenderService { + fn new() -> Self { + Self::new_with_outbound_budgets( + OUTBOUND_GLOBAL_BYTE_CAPACITY, + OUTBOUND_GLOBAL_CONTROL_BYTE_CAPACITY, + ) + } + + #[cfg(test)] + fn new_with_outbound_budget(max_bytes: usize) -> Self { + Self::new_with_outbound_budgets(max_bytes, OUTBOUND_GLOBAL_CONTROL_BYTE_CAPACITY) + } + + fn new_with_outbound_budgets(max_bytes: usize, control_max_bytes: usize) -> Self { + Self { + graphic_base64: Mutex::new(RenderGraphicBase64Cache::new( + RENDER_GRAPHIC_BASE64_CACHE_MAX_BYTES, + RENDER_GRAPHIC_BASE64_CACHE_MAX_ENTRIES, + )), + outbound_budget: Arc::new(OutboundByteBudget::new(max_bytes)), + control_budget: Arc::new(OutboundByteBudget::new(control_max_bytes)), + } + } + + fn encode_graphic(&self, data: &Arc<[u8]>) -> Arc { + self.graphic_base64.lock().unwrap().encode(data) + } + + fn serialize(&self, value: &T) -> std::io::Result> { + let mut writer = BudgetedJsonWriter::new(self.outbound_budget.clone()); + serde_json::to_writer(&mut writer, value).map_err(json_error_to_io)?; + Ok(writer.finish()) + } + + fn serialize_control( + &self, + value: &T, + ) -> std::io::Result> { + let mut writer = BudgetedJsonWriter::new(self.control_budget.clone()); + serde_json::to_writer(&mut writer, value).map_err(json_error_to_io)?; + Ok(writer.finish()) + } + + fn serialize_vt_state(&self, value: &VtStateMessage) -> std::io::Result> { + let mut writer = BudgetedJsonWriter::new(self.outbound_budget.clone()); + write!( + writer, + "{{\"event\":\"vt-state\",\"surface\":{},\"cols\":{},\"rows\":{},\"data\":\"", + value.surface, value.cols, value.rows + )?; + { + let mut encoder = base64::write::EncoderWriter::new( + &mut writer, + &base64::engine::general_purpose::STANDARD, + ); + encoder.write_all(&value.replay)?; + encoder.finish()?; + } + writer.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(&mut writer, &value.kitty_image_aliases)?; + writer.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(&mut writer, value.kitty_state)?; + writer.write_all(b",\"colors\":")?; + serde_json::to_writer(&mut writer, &value.colors).map_err(json_error_to_io)?; + writer.write_all(b"}")?; + Ok(writer.finish()) + } + + fn serialize_attach_frame( + &self, + surface: SurfaceId, + frame: &AttachFrame, + ) -> std::io::Result> { + let mut writer = BudgetedJsonWriter::new(self.outbound_budget.clone()); + match frame { + AttachFrame::Output(output) => { + write!(writer, "{{\"event\":\"output\",\"surface\":{surface},\"data\":\"")?; + write_base64_json_string(&mut writer, output)?; + writer.write_all(b"\"}")?; + } + AttachFrame::OutputWithColors { output, colors } => { + write!(writer, "{{\"event\":\"output\",\"surface\":{surface},\"data\":\"")?; + write_base64_json_string(&mut writer, output)?; + writer.write_all(b"\",\"colors\":")?; + serde_json::to_writer(&mut writer, &terminal_colors_json(**colors)) + .map_err(json_error_to_io)?; + writer.write_all(b"}")?; + } + AttachFrame::Resized { cols, rows, replay, kitty_image_aliases, kitty_state } => { + write!( + writer, + "{{\"event\":\"resized\",\"surface\":{surface},\"cols\":{cols},\"rows\":{rows},\"replay\":\"" + )?; + write_base64_json_string(&mut writer, replay)?; + writer.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(&mut writer, kitty_image_aliases)?; + writer.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(&mut writer, *kitty_state)?; + writer.write_all(b"}")?; + } + AttachFrame::ResizedWithColors { + cols, + rows, + replay, + kitty_image_aliases, + kitty_state, + colors, + } => { + write!( + writer, + "{{\"event\":\"resized\",\"surface\":{surface},\"cols\":{cols},\"rows\":{rows},\"replay\":\"" + )?; + write_base64_json_string(&mut writer, replay)?; + writer.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(&mut writer, kitty_image_aliases)?; + writer.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(&mut writer, *kitty_state)?; + writer.write_all(b",\"colors\":")?; + serde_json::to_writer(&mut writer, &terminal_colors_json(**colors)) + .map_err(json_error_to_io)?; + writer.write_all(b"}")?; + } + AttachFrame::ColorsChanged(colors) => { + let mut value = terminal_colors_json(**colors); + value["event"] = json!("colors-changed"); + value["surface"] = json!(surface); + serde_json::to_writer(&mut writer, &value).map_err(json_error_to_io)?; + } + } + Ok(writer.finish()) + } + + fn reserved_control_writer(&self) -> std::io::Result { + BudgetedJsonWriter::with_reservation( + self.control_budget.clone(), + OUTBOUND_CONTROL_BYTE_RESERVE, + ) + } +} + +fn json_error_to_io(error: serde_json::Error) -> std::io::Error { + std::io::Error::new(error.io_error_kind().unwrap_or(std::io::ErrorKind::InvalidData), error) +} + +fn write_base64_json_string(writer: &mut BudgetedJsonWriter, bytes: &[u8]) -> std::io::Result<()> { + let mut encoder = + base64::write::EncoderWriter::new(writer, &base64::engine::general_purpose::STANDARD); + encoder.write_all(bytes)?; + encoder.finish().map(|_| ()) +} + +fn write_kitty_image_aliases_json( + writer: &mut BudgetedJsonWriter, + aliases: &[ghostty_vt::KittyImageAlias], +) -> std::io::Result<()> { + writer.write_all(b"[")?; + for (index, alias) in aliases.iter().enumerate() { + if index != 0 { + writer.write_all(b",")?; + } + write!( + writer, + "{{\"image_id\":{},\"image_number\":{}}}", + alias.image_id, alias.image_number + )?; + } + writer.write_all(b"]") +} + +fn write_kitty_replay_state_json( + writer: &mut BudgetedJsonWriter, + state: KittyReplayState, +) -> std::io::Result<()> { + write!( + writer, + concat!( + "{{\"image_bytes\":{},\"inflight_bytes\":{},\"images\":{},\"placements\":{},", + "\"replay_cursor_offset\":{},", + "\"primary_replay_next_image_id\":{},\"primary_next_image_id\":{},", + "\"alternate_replay_next_image_id\":{},\"alternate_next_image_id\":{}}}" + ), + state.limits.image_bytes, + state.limits.inflight_bytes, + state.limits.images, + state.limits.placements, + state.replay_cursor_offset, + state.replay_next_image_ids.primary, + state.next_image_ids.primary, + state.replay_next_image_ids.alternate, + state.next_image_ids.alternate, + ) +} + #[derive(Clone)] struct OutboundStream { id: u64, open: Arc, terminal_enqueued: Arc, - overflow_text: Arc>, + overflow_text: Arc>>, } impl OutboundStream { - fn new(id: u64, overflow_text: String) -> Self { + fn new(id: u64, overflow_text: Arc) -> Self { Self { id, open: Arc::new(AtomicBool::new(true)), @@ -1503,17 +1953,21 @@ impl OutboundStream { self.open.store(false, Ordering::Release); } - fn update_overflow(&self, value: &Value) -> std::io::Result<()> { - *self.overflow_text.lock().unwrap() = serde_json::to_string(value)?; - Ok(()) + fn update_overflow(&self, text: Arc) { + *self.overflow_text.lock().unwrap() = text; } } trait MessageSink: Send + Sync { - fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; - fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; - fn send_control(&self, value: &Value) -> std::io::Result<()>; - fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()>; + fn send_initial(&self, text: Arc, stream: &OutboundStream) + -> std::io::Result<()>; + fn send_stream(&self, text: Arc, stream: &OutboundStream) -> std::io::Result<()>; + fn send_control(&self, text: Arc) -> std::io::Result<()>; + fn send_terminal( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()>; fn set_write_timeout(&self, _timeout: Option) -> std::io::Result<()> { Ok(()) } @@ -1527,15 +1981,25 @@ struct MessageWriter { sink: Arc, open: Arc, next_stream_id: Arc, + render_service: Arc, wait_wakeups: Arc>>>, } impl MessageWriter { + #[cfg(test)] fn new(sink: impl MessageSink + 'static) -> Self { + Self::new_with_render_service(sink, Arc::new(RenderService::new())) + } + + fn new_with_render_service( + sink: impl MessageSink + 'static, + render_service: Arc, + ) -> Self { Self { sink: Arc::new(sink), open: Arc::new(AtomicBool::new(true)), next_stream_id: Arc::new(AtomicU64::new(1)), + render_service, wait_wakeups: Arc::new(Mutex::new(Vec::new())), } } @@ -1543,48 +2007,129 @@ impl MessageWriter { fn start_stream(&self, overflow: &Value) -> std::io::Result { Ok(OutboundStream::new( self.next_stream_id.fetch_add(1, Ordering::Relaxed), - serde_json::to_string(overflow)?, + self.render_service.serialize_control(overflow)?, )) } - fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + fn update_stream_overflow( + &self, + stream: &OutboundStream, + overflow: &Value, + ) -> std::io::Result<()> { + stream.update_overflow(self.render_service.serialize_control(overflow)?); + Ok(()) + } + + fn send_stream( + &self, + value: &T, + stream: &OutboundStream, + ) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send_stream(value, stream); + let result = self + .render_service + .serialize(value) + .and_then(|text| self.sink.send_stream(text, stream)); if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { stream.close(); } result } - fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + fn send_initial( + &self, + value: &T, + stream: &OutboundStream, + ) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send_initial(value, stream); + let result = self + .render_service + .serialize(value) + .and_then(|text| self.sink.send_initial(text, stream)); if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { stream.close(); } result } - fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { + fn send_initial_vt_state( + &self, + value: &VtStateMessage, + stream: &OutboundStream, + ) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send_terminal(value, stream); + let result = self + .render_service + .serialize_vt_state(value) + .and_then(|text| self.sink.send_initial(text, stream)); + if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { + stream.close(); + } + result + } + + fn send_attach_frame( + &self, + surface: SurfaceId, + frame: &AttachFrame, + stream: &OutboundStream, + ) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self + .render_service + .serialize_attach_frame(surface, frame) + .and_then(|text| self.sink.send_stream(text, stream)); + if result.as_ref().is_err_and(|error| error.kind() != std::io::ErrorKind::WouldBlock) { + stream.close(); + } + result + } + + fn send_terminal( + &self, + value: &T, + stream: &OutboundStream, + ) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self + .render_service + .serialize_control(value) + .and_then(|text| self.sink.send_terminal(text, stream)); if result.is_err() { self.close(); } result } - fn send_control(&self, value: &Value) -> std::io::Result<()> { + fn send_control(&self, value: &T) -> std::io::Result<()> { if !self.is_open() { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } - let result = self.sink.send_control(value); + let result = self + .render_service + .serialize_control(value) + .and_then(|text| self.sink.send_control(text)); + if result.is_err() { + self.close(); + } + result + } + + fn send_serialized_control(&self, text: Arc) -> std::io::Result<()> { + if !self.is_open() { + return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); + } + let result = self.sink.send_control(text); if result.is_err() { self.close(); } @@ -1945,7 +2490,7 @@ struct BoundedOutbound { #[derive(Default)] struct BoundedOutboundState { initial: VecDeque, - control: VecDeque, + control: VecDeque>, regular: VecDeque, stream_usage: HashMap, control_bytes: usize, @@ -1960,7 +2505,7 @@ struct StreamOutboundUsage { } struct RegularOutbound { - text: String, + text: Arc, stream: OutboundStream, } @@ -1987,17 +2532,25 @@ fn claim_connection(active: &Arc) -> Option { } impl BoundedOutbound { - fn push_regular(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + fn push_regular( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { self.push_regular_with_priority(text, stream, false) } - fn push_initial(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + fn push_initial( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { self.push_regular_with_priority(text, stream, true) } fn push_regular_with_priority( &self, - text: String, + text: Arc, stream: &OutboundStream, initial: bool, ) -> std::io::Result<()> { @@ -2076,14 +2629,18 @@ impl BoundedOutbound { Ok(()) } - fn push_control(&self, text: String) -> std::io::Result<()> { + fn push_control(&self, text: Arc) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); Self::push_control_locked(&mut state, text)?; self.changed.notify_one(); Ok(()) } - fn push_terminal(&self, text: String, stream: &OutboundStream) -> std::io::Result<()> { + fn push_terminal( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { let mut state = self.state.lock().unwrap(); stream.close(); Self::purge_stream_locked(&mut state, stream.id); @@ -2131,7 +2688,10 @@ impl BoundedOutbound { .map(|usage| usage.stream.clone()) } - fn push_control_locked(state: &mut BoundedOutboundState, text: String) -> std::io::Result<()> { + fn push_control_locked( + state: &mut BoundedOutboundState, + text: Arc, + ) -> std::io::Result<()> { if state.closed { return Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed")); } @@ -2152,10 +2712,10 @@ impl BoundedOutbound { #[cfg(test)] fn try_pop(&self) -> Option { let mut state = self.state.lock().unwrap(); - Self::pop_locked(&mut state) + Self::pop_locked(&mut state).map(|text| text.to_string()) } - fn recv(&self) -> Option { + fn recv(&self) -> Option> { let mut state = self.state.lock().unwrap(); loop { if let Some(text) = Self::pop_locked(&mut state) { @@ -2168,7 +2728,7 @@ impl BoundedOutbound { } } - fn pop_locked(state: &mut BoundedOutboundState) -> Option { + fn pop_locked(state: &mut BoundedOutboundState) -> Option> { if let Some(message) = state.initial.pop_front() { Self::record_stream_pop(state, &message); return Some(message.text); @@ -2243,6 +2803,49 @@ impl SynchronizedTcpStream { fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { self.stream.set_write_timeout(timeout) } + + fn write_websocket_text(&mut self, text: &str) -> std::io::Result<()> { + if text.len() > RENDER_ATTACH_MAX_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "WebSocket outbound message exceeds the protocol limit", + )); + } + self.write_websocket_frame(0x1, text.as_bytes()) + } + + fn write_websocket_close(&mut self) -> std::io::Result<()> { + self.write_websocket_frame(0x8, &[]) + } + + fn write_websocket_frame(&mut self, opcode: u8, payload: &[u8]) -> std::io::Result<()> { + let (header, header_len) = websocket_server_frame_header(opcode, payload.len()); + let _guard = self.write_lock.lock().unwrap(); + self.stream.write_all(&header[..header_len])?; + self.stream.write_all(payload)?; + self.stream.flush() + } +} + +fn websocket_server_frame_header(opcode: u8, payload_len: usize) -> ([u8; 10], usize) { + let mut header = [0_u8; 10]; + header[0] = 0x80 | (opcode & 0x0f); + match payload_len { + 0..=125 => { + header[1] = payload_len as u8; + (header, 2) + } + 126..=65_535 => { + header[1] = 126; + header[2..4].copy_from_slice(&(payload_len as u16).to_be_bytes()); + (header, 4) + } + _ => { + header[1] = 127; + header[2..10].copy_from_slice(&(payload_len as u64).to_be_bytes()); + (header, 10) + } + } } impl Read for SynchronizedTcpStream { @@ -2274,23 +2877,27 @@ impl SinkControl { } impl MessageSink for QueuedSink { - fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - let text = serde_json::to_string(value)?; + fn send_initial( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { self.outbound.push_initial(text, stream) } - fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - let text = serde_json::to_string(value)?; + fn send_stream(&self, text: Arc, stream: &OutboundStream) -> std::io::Result<()> { self.outbound.push_regular(text, stream) } - fn send_control(&self, value: &Value) -> std::io::Result<()> { - let text = serde_json::to_string(value)?; + fn send_control(&self, text: Arc) -> std::io::Result<()> { self.outbound.push_control(text) } - fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - let text = serde_json::to_string(value)?; + fn send_terminal( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { self.outbound.push_terminal(text, stream) } @@ -3176,14 +3783,16 @@ pub fn serve(mux: Arc, path: Option) -> anyhow::Result { let listener = transport::listen(&path)?; platform::restrict_file(&path)?; let active_connections = Arc::new(AtomicU64::new(0)); + let render_service = Arc::new(RenderService::new()); std::thread::Builder::new().name("mux-server".into()).spawn(move || { loop { let Ok(stream) = listener.accept() else { continue }; let Some(permit) = claim_connection(&active_connections) else { continue }; let mux = mux.clone(); + let render_service = render_service.clone(); let _ = std::thread::Builder::new().name("mux-conn".into()).spawn(move || { - handle_connection_with_permit(mux, stream, Some(permit)); + handle_connection_with_permit(mux, stream, render_service, Some(permit)); }); } })?; @@ -3249,6 +3858,7 @@ pub fn serve_websocket( let active_connections = Arc::new(AtomicU64::new(0)); let thread_shutdown = shutdown.clone(); let thread_connections = connections.clone(); + let render_service = Arc::new(RenderService::new()); let thread = std::thread::Builder::new().name("mux-ws-server".into()).spawn(move || { while !thread_shutdown.load(Ordering::Acquire) { let (stream, peer) = match listener.accept() { @@ -3276,6 +3886,7 @@ pub fn serve_websocket( } let mux = mux.clone(); let token = token.clone(); + let render_service = render_service.clone(); let connections = thread_connections.clone(); let cleanup_connections = thread_connections.clone(); if std::thread::Builder::new() @@ -3286,6 +3897,7 @@ pub fn serve_websocket( stream, peer, token.as_deref(), + render_service, Some(permit), ); connections.lock().unwrap().remove(&id); @@ -3316,12 +3928,13 @@ fn sanitize_window_title(title: &str) -> String { #[cfg(test)] fn handle_connection(mux: Arc, stream: Box) { - handle_connection_with_permit(mux, stream, None); + handle_connection_with_permit(mux, stream, Arc::new(RenderService::new()), None); } fn handle_connection_with_permit( mux: Arc, stream: Box, + render_service: Arc, connection_permit: Option, ) { let Ok(mut write_half) = stream.try_clone_box() else { return }; @@ -3330,10 +3943,10 @@ fn handle_connection_with_permit( return; } let outbound = Arc::new(BoundedOutbound::default()); - let writer = MessageWriter::new(QueuedSink { - outbound: outbound.clone(), - control: Some(SinkControl::Unix(control)), - }); + let writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: outbound.clone(), control: Some(SinkControl::Unix(control)) }, + render_service, + ); let writer_outbound = outbound; let writer_close = writer.clone(); let Ok(writer_thread) = @@ -3396,8 +4009,9 @@ fn handle_websocket_connection( stream: TcpStream, peer: SocketAddr, token: Option<&str>, + render_service: Arc, ) { - handle_websocket_connection_with_permit(mux, stream, peer, token, None); + handle_websocket_connection_with_permit(mux, stream, peer, token, render_service, None); } fn handle_websocket_connection_with_permit( @@ -3405,6 +4019,7 @@ fn handle_websocket_connection_with_permit( stream: TcpStream, peer: SocketAddr, token: Option<&str>, + render_service: Arc, connection_permit: Option, ) { let stream = SynchronizedTcpStream::new(stream); @@ -3416,7 +4031,7 @@ fn handle_websocket_connection_with_permit( let auth_config = WebSocketConfig::default() .read_buffer_size(4 * 1024) .write_buffer_size(4 * 1024) - .max_write_buffer_size(WEBSOCKET_MESSAGE_MAX_BYTES) + .max_write_buffer_size(WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES) .max_message_size(Some(WEBSOCKET_AUTH_MAX_BYTES)) .max_frame_size(Some(WEBSOCKET_AUTH_MAX_BYTES)); let Ok(mut websocket) = accept_with_config(stream, Some(auth_config)) else { return }; @@ -3428,8 +4043,8 @@ fn handle_websocket_connection_with_permit( return; } websocket.set_config(|config| { - config.max_message_size = Some(WEBSOCKET_MESSAGE_MAX_BYTES); - config.max_frame_size = Some(WEBSOCKET_MESSAGE_MAX_BYTES); + config.max_message_size = Some(WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES); + config.max_frame_size = Some(WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES); }); let _ = websocket.get_mut().set_read_timeout(None); let _ = websocket.get_mut().set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); @@ -3438,24 +4053,23 @@ fn handle_websocket_connection_with_permit( let Ok(control) = writer_stream.try_clone_raw() else { return }; let _ = writer_stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)); let outbound = Arc::new(BoundedOutbound::default()); - let writer = MessageWriter::new(QueuedSink { - outbound: outbound.clone(), - control: Some(SinkControl::WebSocket(control)), - }); + let writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: outbound.clone(), control: Some(SinkControl::WebSocket(control)) }, + render_service, + ); let writer_outbound = outbound; let writer_close = writer.clone(); let Ok(writer_thread) = std::thread::Builder::new().name("mux-ws-out".into()).spawn(move || { - let mut websocket = WebSocket::from_raw_socket(writer_stream, Role::Server, None); + let mut writer_stream = writer_stream; while let Some(text) = writer_outbound.recv() { - if websocket.send(Message::Text(text.into())).is_err() { + if writer_stream.write_websocket_text(&text).is_err() { writer_outbound.close(); break; } } writer_close.close(); - let _ = websocket.close(None); - let _ = websocket.flush(); + let _ = writer_stream.write_websocket_close(); let _ = writer_shutdown.shutdown(Shutdown::Both); }) else { @@ -4488,7 +5102,7 @@ fn surface_public_content_id(mux: &Mux, surface: SurfaceId) -> Option { } fn resource_client_cell_pixels_set( - mux: &Mux, + mux: &Arc, requesting_client: u64, request: &crate::resource_router::ParsedResourceRequest, ) -> Result { @@ -4896,8 +5510,13 @@ fn prepare_terminal_resource_attach( )) } -fn terminal_resource_snapshot(terminal_id: &TerminalPublicId, frame: &SurfaceRenderFrame) -> Value { - let mut render = render_state_json(0, frame); +fn terminal_resource_snapshot( + render_service: &RenderService, + terminal_id: &TerminalPublicId, + frame: &SurfaceRenderFrame, +) -> Value { + let mut render = serde_json::to_value(render_state_message(render_service, 0, frame)) + .expect("render snapshot serializes"); let render = render.as_object_mut().expect("render snapshot is an object"); render.remove("event"); render.remove("surface"); @@ -4913,7 +5532,8 @@ fn terminal_resource_patch( state: &mut RenderClientState, frame: &SurfaceRenderFrame, ) -> Value { - let mut render = state.delta_json(0, frame); + let mut render = + serde_json::to_value(state.delta_message(0, frame)).expect("render patch serializes"); let render = render.as_object_mut().expect("render patch is an object"); render.remove("event"); render.remove("surface"); @@ -4999,7 +5619,11 @@ fn start_terminal_resource_attach( &start.common.outbound, &start.common.stream_id, sequence, - terminal_resource_snapshot(&start.terminal_id, &start.attach.initial), + terminal_resource_snapshot( + &worker_writer.render_service, + &start.terminal_id, + &start.attach.initial, + ), ) { finish_resource_surface_attach( &worker_mux, @@ -5011,7 +5635,8 @@ fn start_terminal_resource_attach( return; } sequence = sequence.saturating_add(1); - let mut render_state = RenderClientState::new(&start.attach.initial); + let mut render_state = + RenderClientState::new(worker_writer.render_service.clone(), &start.attach.initial); while worker_writer.is_open() && start.common.outbound.is_open() && !start.common.canceled.load(Ordering::Acquire) @@ -5740,7 +6365,7 @@ fn run_session_event_stream( Some("request a fresh session snapshot"), None, ); - let _ = stream.outbound.update_overflow(&end); + let _ = writer.update_stream_overflow(&stream.outbound, &end); if stream.canceled.load(Ordering::Acquire) || !send_resource_stream_item( writer, @@ -5909,6 +6534,13 @@ fn handle_request_with_cancellation( cancellation: Option<&AtomicBool>, ) -> bool { let Request { id, cmd } = request; + if let Command::VtState { surface } = &cmd { + return match send_vt_state_command_response(mux, id.clone(), *surface, writer) { + Ok(()) => true, + Err(error) => send_request_error(writer, id, &error.to_string()), + }; + } + let detach_self = matches!(&cmd, Command::DetachClient { client: target } if *target == client); let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { @@ -5953,6 +6585,69 @@ fn handle_request_with_cancellation( sent } +fn send_vt_state_command_response( + mux: &Mux, + id: Option, + surface: SurfaceId, + writer: &MessageWriter, +) -> anyhow::Result<()> { + // Reserve the entire wire-frame allowance before copying a replay or + // starting its base64 encoder. The writer allocates only for actual + // output and releases unused logical quota before the response is queued. + let mut output = writer.render_service.reserved_control_writer()?; + let surface = get_surface(mux, surface)?; + require_pty(&surface)?; + let (cols, rows, replay) = surface.try_with_terminal(|terminal| { + terminal + .vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .map(|replay| (terminal.cols(), terminal.rows(), replay)) + })??; + + write_vt_state_command_json( + &mut output, + id.as_ref(), + cols, + rows, + &replay.bytes, + &replay.kitty_image_aliases, + replay.kitty_state, + )?; + writer.send_serialized_control(output.finish())?; + Ok(()) +} + +fn write_vt_state_command_json( + output: &mut BudgetedJsonWriter, + id: Option<&Value>, + cols: u16, + rows: u16, + replay: &[u8], + kitty_image_aliases: &[ghostty_vt::KittyImageAlias], + kitty_state: KittyReplayState, +) -> std::io::Result<()> { + output.write_all(b"{")?; + if let Some(id) = id { + output.write_all(b"\"id\":")?; + serde_json::to_writer(&mut *output, id).map_err(json_error_to_io)?; + output.write_all(b",")?; + } + write!(output, "\"ok\":true,\"data\":{{\"cols\":{cols},\"rows\":{rows},\"data\":\"")?; + { + let mut encoder = base64::write::EncoderWriter::new( + &mut *output, + &base64::engine::general_purpose::STANDARD, + ); + encoder.write_all(replay)?; + encoder.finish()?; + } + output.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(output, kitty_image_aliases)?; + output.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(output, kitty_state)?; + output.write_all(b"}}")?; + Ok(()) +} + fn response_error_code(error: &anyhow::Error) -> Option { error .downcast_ref::() @@ -6676,6 +7371,16 @@ fn terminal_colors_json(colors: TerminalColors) -> Value { }) } +struct VtStateMessage { + surface: SurfaceId, + cols: u16, + rows: u16, + replay: Arc<[u8]>, + kitty_image_aliases: Vec, + kitty_state: KittyReplayState, + colors: Value, +} + fn rgb_hex(color: Rgb) -> String { format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b) } @@ -6735,40 +7440,286 @@ fn render_cursor_json(frame: &SurfaceRenderFrame) -> Value { }) } -fn render_state_json(surface: SurfaceId, frame: &SurfaceRenderFrame) -> Value { +fn serialize_arc_str(value: &Arc, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(value) +} + +#[derive(Serialize)] +struct RenderGraphicImageMessage { + id: u32, + generation: u64, + width: u32, + height: u32, + format: &'static str, + #[serde(serialize_with = "serialize_arc_str")] + data: Arc, +} + +#[derive(Serialize)] +struct RenderGraphicPlacementMessage { + image_id: u32, + placement_id: u32, + ordinal: u32, + x_offset: u32, + y_offset: u32, + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, + columns: u32, + rows: u32, + grid_cols: u32, + grid_rows: u32, + pixel_width: u32, + pixel_height: u32, + viewport_col: i32, + viewport_row: i32, + viewport_visible: bool, + #[serde(skip_serializing_if = "Option::is_none")] + anchor_col: Option, + #[serde(skip_serializing_if = "Option::is_none")] + anchor_row: Option, + z: i32, +} + +impl From<&ghostty_vt::KittyPlacement> for RenderGraphicPlacementMessage { + fn from(placement: &ghostty_vt::KittyPlacement) -> Self { + Self { + image_id: placement.image_id, + placement_id: placement.placement_id, + ordinal: placement.key.ordinal, + x_offset: placement.x_offset, + y_offset: placement.y_offset, + source_x: placement.source_x, + source_y: placement.source_y, + source_width: placement.source_width, + source_height: placement.source_height, + columns: placement.columns, + rows: placement.rows, + grid_cols: placement.grid_cols, + grid_rows: placement.grid_rows, + pixel_width: placement.pixel_width, + pixel_height: placement.pixel_height, + viewport_col: placement.viewport_col, + viewport_row: placement.viewport_row, + viewport_visible: placement.viewport_visible, + anchor_col: placement.anchor.map(|anchor| anchor.col), + anchor_row: placement.anchor.map(|anchor| anchor.row), + z: placement.z, + } + } +} + +#[derive(Serialize)] +struct RenderGraphicsMessage { + generation: u64, + #[serde(skip_serializing_if = "Option::is_none")] + placements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + images: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + removed_image_ids: Option>, +} + +fn render_graphics_message( + render_service: &RenderService, + graphics: &ghostty_vt::KittyGraphicsSnapshot, + image_ids: Option<&HashSet>, + removed_image_ids: &[u32], + include_placements: bool, +) -> RenderGraphicsMessage { + let images = graphics + .images + .iter() + .filter(|image| image_ids.is_none_or(|ids| ids.contains(&image.id))) + .map(|image| { + let data = render_service.encode_graphic(&image.data); + RenderGraphicImageMessage { + id: image.id, + generation: image.generation, + width: image.width, + height: image.height, + format: match image.format { + ghostty_vt::KittyImageFormat::Rgb => "rgb", + ghostty_vt::KittyImageFormat::Rgba => "rgba", + }, + data, + } + }) + .collect::>(); + RenderGraphicsMessage { + generation: graphics.generation, + placements: include_placements + .then(|| graphics.placements.iter().map(RenderGraphicPlacementMessage::from).collect()), + images: (image_ids.is_none() || !images.is_empty()).then_some(images), + removed_image_ids: (!removed_image_ids.is_empty()).then(|| removed_image_ids.to_vec()), + } +} + +#[derive(Serialize)] +struct RenderSizeMessage { + cols: u16, + rows: u16, +} + +#[derive(Serialize)] +struct RenderStateMessage { + event: &'static str, + surface: SurfaceId, + size: RenderSizeMessage, + cursor: Value, + default_fg: String, + default_bg: String, + scrollback_rows: u32, + history_epoch: u64, + rows: Vec, + graphics: RenderGraphicsMessage, +} + +fn render_state_message( + render_service: &RenderService, + surface: SurfaceId, + frame: &SurfaceRenderFrame, +) -> RenderStateMessage { let (cols, rows) = frame.frame.size; - json!({ - "event": "render-state", - "surface": surface, - "size": { "cols": cols, "rows": rows }, - "cursor": render_cursor_json(frame), - "default_fg": rgb_hex(frame.frame.default_colors.1), - "default_bg": rgb_hex(frame.frame.default_colors.0), - "scrollback_rows": frame.scrollback_rows, - "rows": render_rows_json(frame, 0..rows), - }) + RenderStateMessage { + event: "render-state", + surface, + size: RenderSizeMessage { cols, rows }, + cursor: render_cursor_json(frame), + default_fg: rgb_hex(frame.frame.default_colors.1), + default_bg: rgb_hex(frame.frame.default_colors.0), + scrollback_rows: frame.scrollback_rows, + history_epoch: frame.history_epoch, + rows: render_rows_json(frame, 0..rows), + graphics: render_graphics_message( + render_service, + &frame.frame.kitty_graphics, + None, + &[], + true, + ), + } +} + +#[derive(Serialize)] +struct RenderDeltaMessage { + event: &'static str, + surface: SurfaceId, + cursor: Value, + full: bool, + rows: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + default_fg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + default_bg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scrollback_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + history_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + graphics: Option, } struct RenderClientState { + render_service: Arc, size: (u16, u16), default_colors: (Rgb, Rgb), scrollback_rows: u32, + history_epoch: u64, + graphics_snapshot_id: u64, + graphics_image_revision: u64, + graphics_placement_revision: u64, + graphics_image_generations: Arc<[(u32, u64)]>, + graphics_image_generations_match_snapshot: bool, +} + +#[cfg(test)] +static RENDER_CLIENT_IMAGE_SCAN_COUNT: AtomicUsize = AtomicUsize::new(0); + +fn render_client_image_delta( + previous: &[(u32, u64)], + next: &[(u32, u64)], +) -> (HashSet, Vec) { + #[cfg(test)] + RENDER_CLIENT_IMAGE_SCAN_COUNT.fetch_add(previous.len().max(next.len()), Ordering::Relaxed); + let mut changed = HashSet::new(); + let mut removed = Vec::new(); + let (mut previous_index, mut next_index) = (0, 0); + while previous_index < previous.len() || next_index < next.len() { + match (previous.get(previous_index), next.get(next_index)) { + (Some(&(previous_id, previous_generation)), Some(&(next_id, next_generation))) => { + if previous_id < next_id { + removed.push(previous_id); + previous_index += 1; + } else if next_id < previous_id { + changed.insert(next_id); + next_index += 1; + } else { + if previous_generation != next_generation { + changed.insert(next_id); + } + previous_index += 1; + next_index += 1; + } + } + (Some(&(previous_id, _)), None) => { + removed.push(previous_id); + previous_index += 1; + } + (None, Some(&(next_id, _))) => { + changed.insert(next_id); + next_index += 1; + } + (None, None) => break, + } + } + (changed, removed) } impl RenderClientState { - fn new(frame: &SurfaceRenderFrame) -> Self { + fn new(render_service: Arc, frame: &SurfaceRenderFrame) -> Self { + let graphics_delta = &frame.frame.kitty_graphics_delta; + let mut graphics_image_generations = frame + .frame + .kitty_graphics + .images + .iter() + .map(|image| (image.id, image.generation)) + .collect::>(); + graphics_image_generations.sort_unstable_by_key(|(id, _)| *id); + let graphics_image_generations: Arc<[(u32, u64)]> = graphics_image_generations.into(); + let graphics_image_generations_match_snapshot = + graphics_image_generations.as_ref() == graphics_delta.image_generations.as_ref(); Self { + render_service, size: frame.frame.size, default_colors: frame.frame.default_colors, scrollback_rows: frame.scrollback_rows, + history_epoch: frame.history_epoch, + graphics_snapshot_id: graphics_delta.snapshot_id, + graphics_image_revision: graphics_delta.image_revision, + graphics_placement_revision: graphics_delta.placement_revision, + graphics_image_generations, + graphics_image_generations_match_snapshot, } } - fn delta_json(&mut self, surface: SurfaceId, frame: &SurfaceRenderFrame) -> Value { + fn delta_message( + &mut self, + surface: SurfaceId, + frame: &SurfaceRenderFrame, + ) -> RenderDeltaMessage { let size_changed = self.size != frame.frame.size; let foreground_changed = self.default_colors.1 != frame.frame.default_colors.1; let background_changed = self.default_colors.0 != frame.frame.default_colors.0; let scrollback_changed = self.scrollback_rows != frame.scrollback_rows; + let history_epoch_changed = self.history_epoch != frame.history_epoch; let full = size_changed || foreground_changed || background_changed @@ -6778,57 +7729,120 @@ impl RenderClientState { } else { render_rows_json(frame, frame.frame.dirty_rows.iter().copied()) }; - let mut value = json!({ - "event": "render-delta", - "surface": surface, - "cursor": render_cursor_json(frame), - "full": full, - "rows": rows, - }); - if size_changed { - value["size"] = json!({ "cols": frame.frame.size.0, "rows": frame.frame.size.1 }); - } - if foreground_changed { - value["default_fg"] = json!(rgb_hex(frame.frame.default_colors.1)); - } - if background_changed { - value["default_bg"] = json!(rgb_hex(frame.frame.default_colors.0)); - } - if scrollback_changed { - value["scrollback_rows"] = json!(frame.scrollback_rows); + let mut message = RenderDeltaMessage { + event: "render-delta", + surface, + cursor: render_cursor_json(frame), + full, + rows, + size: size_changed.then_some(RenderSizeMessage { + cols: frame.frame.size.0, + rows: frame.frame.size.1, + }), + default_fg: foreground_changed.then(|| rgb_hex(frame.frame.default_colors.1)), + default_bg: background_changed.then(|| rgb_hex(frame.frame.default_colors.0)), + scrollback_rows: scrollback_changed.then_some(frame.scrollback_rows), + history_epoch: history_epoch_changed.then_some(frame.history_epoch), + graphics: None, + }; + let graphics_delta = &frame.frame.kitty_graphics_delta; + if self.graphics_snapshot_id != graphics_delta.snapshot_id { + let graphics = &frame.frame.kitty_graphics; + let image_revision_changed = + self.graphics_image_revision != graphics_delta.image_revision; + let (upsert_image_ids, removed_image_ids) = if self + .graphics_image_generations_match_snapshot + && graphics_delta.previous_snapshot_id == Some(self.graphics_snapshot_id) + { + if image_revision_changed { + ( + graphics_delta.changed_image_ids.iter().copied().collect::>(), + graphics_delta.removed_image_ids.to_vec(), + ) + } else { + (HashSet::new(), Vec::new()) + } + } else { + render_client_image_delta( + &self.graphics_image_generations, + &graphics_delta.image_generations, + ) + }; + let images_changed = !upsert_image_ids.is_empty() || !removed_image_ids.is_empty(); + let placements_changed = + self.graphics_placement_revision != graphics_delta.placement_revision; + if images_changed || placements_changed { + message.graphics = Some(render_graphics_message( + &self.render_service, + graphics, + Some(&upsert_image_ids), + &removed_image_ids, + placements_changed, + )); + } + self.graphics_snapshot_id = graphics_delta.snapshot_id; + self.graphics_image_revision = graphics_delta.image_revision; + self.graphics_placement_revision = graphics_delta.placement_revision; + self.graphics_image_generations = graphics_delta.image_generations.clone(); + self.graphics_image_generations_match_snapshot = true; } self.size = frame.frame.size; self.default_colors = frame.frame.default_colors; self.scrollback_rows = frame.scrollback_rows; - value + self.history_epoch = frame.history_epoch; + message } } -fn browser_state_json( +#[derive(Serialize)] +struct BrowserStateMessage<'a> { + event: &'static str, surface: SurfaceId, - state: &BrowserAttachState, + cols: u16, + rows: u16, + url: &'a str, + title: &'a str, + status: &'static str, + error: Option<&'a str>, + pointer_frame_floor_seq: Option, + pointer_frame_seq: Option, + frames_stalled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + frame: Option>>, +} + +#[derive(Serialize)] +struct BrowserFramePayload<'a> { + seq: u64, + width: u32, + height: u32, + image_width: u32, + image_height: u32, + data: &'a str, +} + +fn browser_state_message<'a>( + surface: SurfaceId, + state: &'a BrowserAttachState, include_frame: bool, -) -> Value { - let mut value = json!({ - "event": "browser-state", - "surface": surface, - "cols": state.cols, - "rows": state.rows, - "url": state.url, - "title": state.title, - "status": state.status.as_str(), - "error": state.status.error(), - "pointer_frame_floor_seq": state.pointer_frame_floor_seq, - "pointer_frame_seq": state.pointer_frame_seq, - "frames_stalled": state.frames_stalled, - }); - if include_frame { - value["frame"] = match state.frame.as_ref() { - Some(frame) => browser_frame_payload(frame), - None => Value::Null, - }; +) -> BrowserStateMessage<'a> { + BrowserStateMessage { + event: "browser-state", + surface, + cols: state.cols, + rows: state.rows, + url: &state.url, + title: &state.title, + status: state.status.as_str(), + error: match &state.status { + crate::BrowserStatus::Failed(error) => Some(error), + crate::BrowserStatus::Starting | crate::BrowserStatus::Live => None, + }, + pointer_frame_floor_seq: state.pointer_frame_floor_seq, + pointer_frame_seq: state.pointer_frame_seq, + frames_stalled: state.frames_stalled, + frame: include_frame.then(|| state.frame.as_ref().map(browser_frame_payload)), } - value } fn browser_frame_json(surface: SurfaceId, update: &BrowserFrameUpdate) -> Value { @@ -6858,20 +7872,20 @@ fn send_browser_attach_update( writer.send_stream(&browser_frame_json(surface, &frame), outbound_stream)?; } if let Some(state) = update.state { - writer.send_stream(&browser_state_json(surface, &state, false), outbound_stream)?; + writer.send_stream(&browser_state_message(surface, &state, false), outbound_stream)?; } Ok(()) } -fn browser_frame_payload(frame: &crate::BrowserFrame) -> Value { - json!({ - "seq": frame.seq, - "width": frame.css_width, - "height": frame.css_height, - "image_width": frame.image_width, - "image_height": frame.image_height, - "data": frame.data_b64, - }) +fn browser_frame_payload(frame: &crate::BrowserFrame) -> BrowserFramePayload<'_> { + BrowserFramePayload { + seq: frame.seq, + width: frame.css_width, + height: frame.css_height, + image_width: frame.image_width, + image_height: frame.image_height, + data: &frame.data_b64, + } } fn spawn_attach_notification_stream( @@ -7405,10 +8419,11 @@ fn handle_command_with_cancellation( let surface = get_surface(mux, surface)?; require_pty(&surface)?; let count = u16::try_from(count).map_err(|_| anyhow::anyhow!("count out of range"))?; - let (start, total, rows) = surface.try_with_terminal(|term| { + let (start, total, epoch, rows) = surface.try_with_terminal(|term| { let total = term.history_rows(); let start = start.min(total); - term.styled_history_rows(start, count).map(|rows| (start, total, rows)) + let epoch = term.history_epoch(); + term.styled_history_rows(start, count).map(|rows| (start, total, epoch, rows)) })??; let runs = rows_to_runs(&rows); let rows = runs @@ -7421,7 +8436,7 @@ fn handle_command_with_cancellation( }) }) .collect::>(); - Ok(json!({ "rows": rows, "start": start, "total": total })) + Ok(json!({ "rows": rows, "start": start, "total": total, "epoch": epoch })) } Command::SidebarPlugin { cols, rows, relaunch } => { Ok(sidebar_plugin_status_json(mux.ensure_sidebar_plugin(cols, rows, relaunch))) @@ -7600,19 +8615,7 @@ fn handle_command_with_cancellation( "session": record.session, })) } - Command::VtState { surface } => { - let surface = get_surface(mux, surface)?; - require_pty(&surface)?; - let (cols, rows, replay) = surface.try_with_terminal(|t| { - t.vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) - .map(|replay| (t.cols(), t.rows(), replay)) - })??; - Ok(json!({ - "cols": cols, - "rows": rows, - "data": base64::engine::general_purpose::STANDARD.encode(replay), - })) - } + Command::VtState { .. } => unreachable!("vt-state uses its streaming response path"), Command::MintTerminalRenderer { surface, ttl_ms } => { let surface = get_surface(mux, surface)?; require_pty(&surface)?; @@ -7623,6 +8626,7 @@ fn handle_command_with_cancellation( "incarnation": grant.incarnation, "token": grant.token, "rights": grant.rights.bits(), + "protocol_version": grant.protocol_version, "ttl_ms": ttl_ms, })) } @@ -7680,6 +8684,28 @@ fn handle_command_with_cancellation( let surface = mux.new_browser_tab(url, pane, optional_surface_size(cols, rows))?; Ok(json!({ "surface": surface.id })) } + Command::GetCellPixels => { + let (width_px, height_px) = mux.cell_pixel_creation_size(); + let surfaces = mux.with_state(|state| { + state + .surfaces + .values() + .map(|surface| { + let (width_px, height_px) = surface.cell_pixel_size(); + json!({ + "surface": surface.id, + "width_px": width_px, + "height_px": height_px, + }) + }) + .collect::>() + }); + Ok(json!({ + "width_px": width_px, + "height_px": height_px, + "surfaces": surfaces, + })) + } Command::SetCellPixels { width_px, height_px } => { let update = mux.set_cell_pixel_size(width_px, height_px); let resizes = update @@ -7701,6 +8727,7 @@ fn handle_command_with_cancellation( json!({ "surface": failure.surface, "error": failure.error, + "deferred": failure.deferred, }) }) .collect::>(); @@ -8477,9 +9504,10 @@ fn handle_command_with_cancellation( return Err(error.into()); } }; - if let Err(error) = writer - .send_initial(&render_state_json(surface_id, &attach.initial), &outbound_stream) - { + if let Err(error) = writer.send_initial( + &render_state_message(&writer.render_service, surface_id, &attach.initial), + &outbound_stream, + ) { handle_attach_send_error(&lifecycle, &error); rollback_failed_attach( mux, @@ -8505,27 +9533,33 @@ fn handle_command_with_cancellation( if worker_committed.recv().is_err() { return; } - let mut state = RenderClientState::new(&attach.initial); + let mut state = + RenderClientState::new(writer.render_service.clone(), &attach.initial); while writer.is_open() && outbound_stream.is_open() && !lifecycle.is_canceled() { - let value = match attach.stream.recv_timeout(STREAM_DISCONNECT_POLL) { - Ok(RenderAttachFrame::Frame(frame)) => { - state.delta_json(surface_id, &frame) - } - Ok(RenderAttachFrame::ScrollChanged { offset, at_bottom }) => { - json!({ - "event": "scroll-changed", - "surface": surface_id, - "offset": offset, - "at_bottom": at_bottom, - }) - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, - }; - if let Err(error) = writer.send_stream(&value, &outbound_stream) { + let send_result = + match attach.stream.recv_timeout(STREAM_DISCONNECT_POLL) { + Ok(RenderAttachFrame::Frame(frame)) => { + let message = state.delta_message(surface_id, &frame); + writer.send_stream(&message, &outbound_stream) + } + Ok(RenderAttachFrame::ScrollChanged { offset, at_bottom }) => { + writer.send_stream( + &json!({ + "event": "scroll-changed", + "surface": surface_id, + "offset": offset, + "at_bottom": at_bottom, + }), + &outbound_stream, + ) + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + }; + if let Err(error) = send_result { handle_attach_send_error(&lifecycle, &error); break; } @@ -8610,9 +9644,10 @@ fn handle_command_with_cancellation( return Err(error); } }; - if let Err(error) = writer - .send_initial(&browser_state_json(surface_id, &state, true), &outbound_stream) - { + if let Err(error) = writer.send_initial( + &browser_state_message(surface_id, &state, true), + &outbound_stream, + ) { handle_attach_send_error(&lifecycle, &error); rollback_failed_attach( mux, @@ -8735,17 +9770,16 @@ fn handle_command_with_cancellation( return Err(error.into()); } }; - if let Err(error) = writer.send_initial( - &json!({ - "event": "vt-state", - "surface": surface_id, - "cols": attach.cols, - "rows": attach.rows, - "data": base64::engine::general_purpose::STANDARD.encode(attach.replay), - "colors": terminal_colors_json(attach.colors), - }), - &outbound_stream, - ) { + let initial = VtStateMessage { + surface: surface_id, + cols: attach.cols, + rows: attach.rows, + replay: attach.replay.clone(), + kitty_image_aliases: attach.kitty_image_aliases.clone(), + kitty_state: attach.kitty_state, + colors: terminal_colors_json(attach.colors), + }; + if let Err(error) = writer.send_initial_vt_state(&initial, &outbound_stream) { handle_attach_send_error(&lifecycle, &error); rollback_failed_attach(mux, client, surface_id, outbound_stream.id, size_rollback); return Err(error.into()); @@ -8791,41 +9825,9 @@ fn handle_command_with_cancellation( break; } }; - let value = match frame { - AttachFrame::Output(chunk) => json!({ - "event": "output", - "surface": surface_id, - "data": base64::engine::general_purpose::STANDARD.encode(chunk), - }), - AttachFrame::OutputWithColors { output, colors } => json!({ - "event": "output", - "surface": surface_id, - "data": base64::engine::general_purpose::STANDARD.encode(output), - "colors": terminal_colors_json(*colors), - }), - AttachFrame::Resized { cols, rows, replay } => json!({ - "event": "resized", - "surface": surface_id, - "cols": cols, - "rows": rows, - "replay": base64::engine::general_purpose::STANDARD.encode(replay), - }), - AttachFrame::ResizedWithColors { cols, rows, replay, colors } => json!({ - "event": "resized", - "surface": surface_id, - "cols": cols, - "rows": rows, - "replay": base64::engine::general_purpose::STANDARD.encode(replay), - "colors": terminal_colors_json(*colors), - }), - AttachFrame::ColorsChanged(colors) => { - let mut value = terminal_colors_json(*colors); - value["event"] = json!("colors-changed"); - value["surface"] = json!(surface_id); - value - } - }; - if let Err(error) = writer.send_stream(&value, &outbound_stream) { + if let Err(error) = + writer.send_attach_frame(surface_id, &frame, &outbound_stream) + { handle_attach_send_error(&attach.lifecycle, &error); break; } @@ -8909,6 +9911,31 @@ fn subscribed_event_json(event: &MuxEvent) -> Value { "level": notification.level.as_str(), "surface": notification.surface, }), + MuxEvent::GraphicsStatus(status) => match status { + GraphicsStatus::KittyImageBudgetWorkerStartFailed { error } => json!({ + "event": "graphics-status", + "kind": "kitty-image-budget-worker-start-failed", + "error": error.as_ref(), + }), + GraphicsStatus::KittyImageBudgetUpdateFailed { retry_exhausted, summary } => json!({ + "event": "graphics-status", + "kind": "kitty-image-budget-update-failed", + "retry_exhausted": retry_exhausted, + "summary": summary.as_ref(), + }), + GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts, + remaining, + cell_pixels, + } => json!({ + "event": "graphics-status", + "kind": "cell-pixel-update-retries-exhausted", + "attempts": attempts, + "remaining": remaining, + "cell_width": cell_pixels.0, + "cell_height": cell_pixels.1, + }), + }, MuxEvent::Status(message) => json!({"event": "status", "message": message}), MuxEvent::ConfigReloadRequested => json!({"event": "config-reload-requested"}), MuxEvent::WindowTitleRequested(title) => { @@ -9002,7 +10029,11 @@ pub fn cleanup(path: &Path) { #[cfg(test)] mod tests { use super::*; - use crate::{ProviderWorkspaceAuthority, SidebarPluginOptions, SurfaceOptions}; + use crate::{ + BrowserFrame, BrowserStatus, ProviderWorkspaceAuthority, SidebarPluginOptions, + SurfaceOptions, + }; + use ghostty_vt::{Callbacks, RenderState, Terminal}; use std::sync::mpsc::TryRecvError; use std::time::Duration; @@ -9063,6 +10094,488 @@ mod tests { }) } + fn render_protocol_frame( + terminal: &mut Terminal, + render_state: &mut RenderState, + ) -> SurfaceRenderFrame { + render_state.update(terminal).unwrap(); + SurfaceRenderFrame { + frame: render_state.build_frame().unwrap(), + content_generation: 1, + scrollback_rows: 0, + history_epoch: terminal.history_epoch(), + pointer_semantics: terminal.pointer_semantic_snapshot(), + palette_colors: [Rgb::default(); 256], + palette_overridden: [false; 256], + } + } + + fn render_protocol_client( + terminal: &mut Terminal, + render_state: &mut RenderState, + ) -> RenderClientState { + RenderClientState::new( + Arc::new(RenderService::new()), + &render_protocol_frame(terminal, render_state), + ) + } + + fn replace_render_image( + frame: &mut SurfaceRenderFrame, + image_id: u32, + pixels: impl Into>, + ) { + let graphics = Arc::make_mut(&mut frame.frame.kitty_graphics); + graphics.generation += 1; + let image = graphics.images.iter_mut().find(|image| image.id == image_id).unwrap(); + image.generation += 1; + image.data = pixels.into(); + let delta = Arc::make_mut(&mut frame.frame.kitty_graphics_delta); + delta.previous_snapshot_id = Some(delta.snapshot_id); + delta.snapshot_id = delta.snapshot_id.wrapping_add(1); + delta.image_revision = delta.image_revision.wrapping_add(1); + delta.image_generations = graphics + .images + .iter() + .map(|image| (image.id, image.generation)) + .collect::>() + .into(); + delta.changed_image_ids = Arc::from([image_id]); + delta.removed_image_ids = Arc::from([]); + } + + const RED_IMAGE_41: &[u8] = b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;/wAA\x1b\\"; + const GREEN_IMAGE_42: &[u8] = b"\x1b_Ga=T,t=d,f=24,i=42,p=8,s=1,v=1,c=1,r=1,q=2;AP8A\x1b\\"; + const LARGE_RENDER_IMAGE_WIDTH: usize = 1_024; + const LARGE_RENDER_IMAGE_HEIGHT: usize = 768; + const LARGE_RENDER_IMAGE_RAW_BYTES: usize = + LARGE_RENDER_IMAGE_WIDTH * LARGE_RENDER_IMAGE_HEIGHT * 4; + const LARGE_RENDER_IMAGE_BASE64_CHARS: usize = LARGE_RENDER_IMAGE_RAW_BYTES.div_ceil(3) * 4; + + fn large_rgba_kitty_transmission() -> Vec { + let data = base64::engine::general_purpose::STANDARD + .encode(vec![0x7f; LARGE_RENDER_IMAGE_RAW_BYTES]); + assert_eq!(data.len(), LARGE_RENDER_IMAGE_BASE64_CHARS); + format!( + "\x1b_Ga=T,t=d,f=32,i=51,p=1,s={LARGE_RENDER_IMAGE_WIDTH},v={LARGE_RENDER_IMAGE_HEIGHT},c=80,r=24,q=2;{data}\x1b\\" + ) + .into_bytes() + } + + #[test] + fn large_rgba_render_state_serializes_and_queues_within_websocket_budget() { + assert_eq!(LARGE_RENDER_IMAGE_RAW_BYTES, 3_145_728); + assert_eq!(LARGE_RENDER_IMAGE_BASE64_CHARS, 4_194_304); + + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal.vt_write(&large_rgba_kitty_transmission()); + let mut render_state = RenderState::new().unwrap(); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let value = render_state_message(&RenderService::new(), 7, &frame); + let serialized = serde_json::to_string(&value).unwrap(); + + assert_eq!( + value.graphics.images.as_ref().unwrap()[0].data.len(), + LARGE_RENDER_IMAGE_BASE64_CHARS + ); + assert!( + serialized.len() > 4 * 1024 * 1024, + "JSON overhead must put the payload beyond the old 4 MiB boundary" + ); + assert!( + serialized.len() <= OUTBOUND_BYTE_CAPACITY, + "{}-byte render state exceeds the configured {}-byte outbound boundary", + serialized.len(), + OUTBOUND_BYTE_CAPACITY + ); + + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let stream = writer.start_stream(&attach_overflow_json(7)).unwrap(); + writer.send_initial(&value, &stream).unwrap(); + assert_eq!(outbound.try_pop().unwrap(), serialized); + assert!(writer.is_open()); + assert!(stream.is_open()); + eprintln!("1024x768 RGBA render-state bytes: {}", serialized.len()); + } + + #[test] + fn render_image_base64_cache_shares_encodes_and_evicts_within_its_byte_cap() { + let first_pixels: Arc<[u8]> = Arc::from([1_u8, 2, 3, 4, 5, 6]); + let second_pixels: Arc<[u8]> = Arc::from([7_u8, 8, 9, 10, 11, 12]); + let encoded_len = base64::engine::general_purpose::STANDARD.encode(&*first_pixels).len(); + let mut cache = RenderGraphicBase64Cache::new(encoded_len, 2); + + let first = cache.encode(&first_pixels); + let shared = cache.encode(&first_pixels); + assert!(Arc::ptr_eq(&first, &shared), "same immutable pixels were encoded twice"); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.retained_bytes, encoded_len); + + let second = cache.encode(&second_pixels); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.retained_bytes, encoded_len); + assert!(!Arc::ptr_eq(&first, &second)); + assert!( + cache.entries.values().all(|entry| { + entry.source.upgrade().is_some_and(|source| Arc::ptr_eq(&source, &second_pixels)) + }), + "byte-cap eviction retained the older image" + ); + } + + #[test] + fn render_graphics_message_borrows_the_shared_base64_payload() { + let service = RenderService::new(); + let pixels: Arc<[u8]> = Arc::from([1_u8, 2, 3, 4, 5, 6]); + let encoded = service.encode_graphic(&pixels); + let graphics = ghostty_vt::KittyGraphicsSnapshot { + generation: 1, + images: vec![ghostty_vt::KittyImage { + id: 1, + number: 0, + generation: 1, + width: 2, + height: 1, + format: ghostty_vt::KittyImageFormat::Rgb, + data: pixels, + }], + placements: Vec::new(), + }; + + let message = render_graphics_message(&service, &graphics, None, &[], true); + let data = &message.images.as_ref().unwrap()[0].data; + + assert!( + Arc::ptr_eq(data, &encoded), + "render message copied the cached base64 payload before serialization" + ); + } + + #[test] + fn outbound_memory_budget_is_shared_across_connections() { + let first_overflow = attach_overflow_json(1); + let second_overflow = attach_overflow_json(2); + let message = json!({"event": "render-state", "data": "x".repeat(300)}); + let budget = serde_json::to_vec(&first_overflow).unwrap().len() + + serde_json::to_vec(&second_overflow).unwrap().len() + + serde_json::to_vec(&message).unwrap().len(); + let service = Arc::new(RenderService::new_with_outbound_budget(budget)); + let first_outbound = Arc::new(BoundedOutbound::default()); + let second_outbound = Arc::new(BoundedOutbound::default()); + let first = MessageWriter::new_with_render_service( + QueuedSink { outbound: first_outbound.clone(), control: None }, + service.clone(), + ); + let second = MessageWriter::new_with_render_service( + QueuedSink { outbound: second_outbound, control: None }, + service, + ); + let first_stream = first.start_stream(&first_overflow).unwrap(); + let second_stream = second.start_stream(&second_overflow).unwrap(); + + first.send_initial(&message, &first_stream).unwrap(); + let error = second.send_initial(&message, &second_stream).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + + drop(first_outbound.try_pop().expect("first queued message")); + second.send_initial(&message, &second_stream).unwrap(); + } + + #[test] + fn global_render_pressure_does_not_starve_control_replies() { + let overflow = attach_overflow_json(1); + let render = json!({"event": "render-state", "data": "x".repeat(300)}); + let render_bytes = { + let probe = RenderService::new_with_outbound_budget(usize::MAX); + probe.serialize(&render).unwrap().retained_bytes + }; + let service = Arc::new(RenderService::new_with_outbound_budgets(render_bytes, 1_024)); + let render_outbound = Arc::new(BoundedOutbound::default()); + let control_outbound = Arc::new(BoundedOutbound::default()); + let render_writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: render_outbound, control: None }, + service.clone(), + ); + let control_writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: control_outbound.clone(), control: None }, + service, + ); + let render_stream = render_writer.start_stream(&overflow).unwrap(); + let blocked_stream = control_writer.start_stream(&overflow).unwrap(); + + render_writer.send_initial(&render, &render_stream).unwrap(); + assert_eq!( + control_writer.send_initial(&render, &blocked_stream).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + control_writer.send_control(&json!({"id": 7, "ok": true})).unwrap(); + + let reply: Value = serde_json::from_str(&control_outbound.try_pop().unwrap()).unwrap(); + assert_eq!(reply["id"], 7); + assert!(control_writer.is_open()); + } + + #[test] + fn render_service_shares_cache_across_connections_and_releases_it_with_its_owner() { + let service = Arc::new(RenderService::new()); + let weak = Arc::downgrade(&service); + let first_writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: Arc::new(BoundedOutbound::default()), control: None }, + service.clone(), + ); + let second_writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: Arc::new(BoundedOutbound::default()), control: None }, + service.clone(), + ); + let pixels: Arc<[u8]> = Arc::from([1_u8, 2, 3, 4, 5, 6]); + + let first = first_writer.render_service.encode_graphic(&pixels); + let second = second_writer.render_service.encode_graphic(&pixels); + assert!(Arc::ptr_eq(&first, &second)); + + drop(service); + assert!(weak.upgrade().is_some(), "connection writers must retain their server service"); + drop(first_writer); + drop(second_writer); + assert!(weak.upgrade().is_none(), "the cache outlived its server and connections"); + } + + #[test] + fn render_budget_covers_max_image_and_placement_metadata() { + let placement = ghostty_vt::KittyPlacement { + key: ghostty_vt::KittyPlacementKey { + image_id: u32::MAX, + placement_id: u32::MAX, + ordinal: u32::MAX, + }, + image_id: u32::MAX, + placement_id: u32::MAX, + is_internal: false, + x_offset: u32::MAX, + y_offset: u32::MAX, + source_x: u32::MAX, + source_y: u32::MAX, + source_width: u32::MAX, + source_height: u32::MAX, + columns: u32::MAX, + rows: u32::MAX, + grid_cols: u32::MAX, + grid_rows: u32::MAX, + pixel_width: u32::MAX, + pixel_height: u32::MAX, + viewport_col: i32::MIN, + viewport_row: i32::MIN, + viewport_visible: false, + anchor: Some(ghostty_vt::KittyPlacementAnchor { col: u16::MAX, row: u32::MAX }), + z: i32::MIN, + }; + let graphics = ghostty_vt::KittyGraphicsSnapshot { + generation: u64::MAX, + images: Vec::new(), + placements: vec![placement], + }; + let message = render_graphics_message(&RenderService::new(), &graphics, None, &[], true); + let serialized = serde_json::to_value(&message).unwrap(); + let placement_bytes = serde_json::to_string(&serialized["placements"][0]).unwrap().len(); + let placement_array_bytes = 2 + + placement_bytes * RENDER_GRAPHIC_MAX_PLACEMENTS + + RENDER_GRAPHIC_MAX_PLACEMENTS.saturating_sub(1); + let image_base64_bytes = RENDER_GRAPHIC_MAX_DECODED_BYTES.div_ceil(3) * 4; + let required_without_rows = image_base64_bytes + placement_array_bytes; + + assert_eq!(placement_bytes, 485); + assert_eq!(placement_array_bytes, 7_962_625); + assert_eq!(image_base64_bytes, 13_333_336); + assert_eq!(required_without_rows, 21_295_961); + assert_eq!(placement_bytes, RENDER_GRAPHIC_MAX_PLACEMENT_JSON_BYTES); + assert_eq!(placement_array_bytes, RENDER_GRAPHIC_MAX_PLACEMENT_ARRAY_BYTES); + assert_eq!(image_base64_bytes, RENDER_GRAPHIC_MAX_ENCODED_BYTES); + assert_eq!(OUTBOUND_BYTE_CAPACITY - required_without_rows, 12_258_471); + assert!( + required_without_rows < OUTBOUND_BYTE_CAPACITY, + "{required_without_rows} image and placement bytes exceed the configured \ + {OUTBOUND_BYTE_CAPACITY}-byte outbound boundary before rows and wrapper metadata" + ); + } + + #[test] + fn render_delta_omits_graphics_for_text_only_damage() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + let mut render_state = RenderState::new().unwrap(); + let mut client = render_protocol_client(&mut terminal, &mut render_state); + + terminal.vt_write(b"text"); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + + assert!(delta.get("graphics").is_none(), "{delta:#}"); + } + + #[test] + fn render_delta_sends_placement_geometry_without_unchanged_pixels() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + let mut render_state = RenderState::new().unwrap(); + let mut client = render_protocol_client(&mut terminal, &mut render_state); + + terminal.vt_write(b"\x1b[3G\x1b_Ga=p,i=41,p=9,c=1,r=1,q=2;\x1b\\"); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + let graphics = &delta["graphics"]; + + assert!(graphics.get("images").is_none(), "{delta:#}"); + assert!(graphics.get("removed_image_ids").is_none(), "{delta:#}"); + assert_eq!(graphics["placements"].as_array().unwrap().len(), 2); + assert!(graphics["placements"].as_array().unwrap().iter().any(|placement| { + placement["placement_id"] == 9 + && placement["viewport_col"] == 2 + && placement["anchor_col"] == 2 + && placement["anchor_row"] == 0 + })); + } + + #[test] + fn placing_an_initially_unplaced_image_does_not_resend_its_pixels() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,i=43,s=1,v=1,q=2;/wAA\x1b\\"); + let mut render_state = RenderState::new().unwrap(); + let mut initial = render_protocol_frame(&mut terminal, &mut render_state); + initial.frame.kitty_graphics = + render_state.snapshot_kitty_graphics(&terminal, true).unwrap(); + assert!(initial.frame.kitty_graphics.image(43).is_some()); + assert!(initial.frame.kitty_graphics_delta.image_generations.is_empty()); + let mut client = RenderClientState::new(Arc::new(RenderService::new()), &initial); + + terminal.vt_write(b"\x1b_Ga=p,i=43,p=9,c=1,r=1,q=2;\x1b\\"); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + let graphics = &delta["graphics"]; + + assert!(graphics.get("images").is_none(), "{delta:#}"); + assert_eq!(graphics["placements"].as_array().unwrap().len(), 1); + assert_eq!(graphics["placements"][0]["image_id"], 43); + } + + #[test] + fn deleting_an_initially_unplaced_image_releases_client_pixels() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,i=43,s=1,v=1,q=2;/wAA\x1b\\"); + let mut render_state = RenderState::new().unwrap(); + let mut initial = render_protocol_frame(&mut terminal, &mut render_state); + initial.frame.kitty_graphics = + render_state.snapshot_kitty_graphics(&terminal, true).unwrap(); + let mut client = RenderClientState::new(Arc::new(RenderService::new()), &initial); + + terminal.vt_write(b"\x1b_Ga=d,d=I,i=43,q=2;\x1b\\"); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + + assert_eq!(delta["graphics"]["removed_image_ids"], json!([43]), "{delta:#}"); + assert!(delta["graphics"].get("images").is_none(), "{delta:#}"); + } + + #[test] + fn render_delta_upserts_only_images_with_changed_generations() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + terminal.vt_write(GREEN_IMAGE_42); + let mut render_state = RenderState::new().unwrap(); + let mut frame = render_protocol_frame(&mut terminal, &mut render_state); + let mut client = RenderClientState::new(Arc::new(RenderService::new()), &frame); + replace_render_image(&mut frame, 41, [0, 0, 255]); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + let images = delta["graphics"]["images"].as_array().unwrap(); + + assert_eq!(images.len(), 1, "{delta:#}"); + assert_eq!(images[0]["id"], 41); + assert_eq!(images[0]["data"], "AAD/"); + assert!(delta["graphics"].get("placements").is_none(), "{delta:#}"); + } + + #[test] + fn pixel_only_render_delta_does_not_rescan_the_full_graphics_scene() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + terminal.vt_write(GREEN_IMAGE_42); + let mut render_state = RenderState::new().unwrap(); + let mut frame = render_protocol_frame(&mut terminal, &mut render_state); + let placement_revision = frame.frame.kitty_graphics_delta.placement_revision; + let mut client = RenderClientState::new(Arc::new(RenderService::new()), &frame); + RENDER_CLIENT_IMAGE_SCAN_COUNT.store(0, Ordering::Relaxed); + + replace_render_image(&mut frame, 41, [0, 0, 255]); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + + assert_eq!( + delta["graphics"]["images"] + .as_array() + .unwrap_or_else(|| panic!("pixel update omitted graphics: {delta:#}")) + .len(), + 1 + ); + assert_eq!( + RENDER_CLIENT_IMAGE_SCAN_COUNT.load(Ordering::Relaxed), + 0, + "pixel-only animation rebuilt the complete image-generation map" + ); + assert_eq!( + frame.frame.kitty_graphics_delta.placement_revision, placement_revision, + "pixel-only animation changed the shared placement revision" + ); + } + + #[test] + fn render_client_that_skips_a_graphics_frame_falls_back_to_one_linear_diff() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + terminal.vt_write(GREEN_IMAGE_42); + let mut render_state = RenderState::new().unwrap(); + let initial = render_protocol_frame(&mut terminal, &mut render_state); + let mut client = RenderClientState::new(Arc::new(RenderService::new()), &initial); + let mut skipped = initial; + replace_render_image(&mut skipped, 41, [0, 0, 255]); + let mut latest = skipped; + replace_render_image(&mut latest, 42, [255, 255, 0]); + RENDER_CLIENT_IMAGE_SCAN_COUNT.store(0, Ordering::Relaxed); + + let delta = serde_json::to_value(client.delta_message(1, &latest)).unwrap(); + let images = delta["graphics"]["images"].as_array().unwrap(); + + assert_eq!(images.len(), 2, "{delta:#}"); + assert_eq!( + RENDER_CLIENT_IMAGE_SCAN_COUNT.load(Ordering::Relaxed), + 2, + "a skipped frame did not use one bounded linear image diff" + ); + assert!(delta["graphics"].get("placements").is_none(), "{delta:#}"); + } + + #[test] + fn render_delta_reports_deleted_image_ids_without_resending_survivors() { + let mut terminal = Terminal::new(10, 3, 0, Callbacks::default()).unwrap(); + terminal.vt_write(RED_IMAGE_41); + terminal.vt_write(GREEN_IMAGE_42); + let mut render_state = RenderState::new().unwrap(); + let mut client = render_protocol_client(&mut terminal, &mut render_state); + + terminal.vt_write(b"\x1b_Ga=d,d=I,i=41,q=2;\x1b\\"); + let frame = render_protocol_frame(&mut terminal, &mut render_state); + let delta = serde_json::to_value(client.delta_message(1, &frame)).unwrap(); + let graphics = &delta["graphics"]; + + assert_eq!(graphics["removed_image_ids"], json!([41])); + assert!(graphics.get("images").is_none(), "{delta:#}"); + assert!( + graphics["placements"] + .as_array() + .unwrap() + .iter() + .all(|placement| placement["image_id"] == 42) + ); + } + fn captured_writer() -> (MessageWriter, Arc) { let outbound = Arc::new(BoundedOutbound::default()); (MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }), outbound) @@ -9076,15 +10589,24 @@ mod tests { } impl MessageSink for BlockingControlSink { - fn send_initial(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - self.outbound.push_initial(serde_json::to_string(value)?, stream) + fn send_initial( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { + self.outbound.push_initial(text, stream) } - fn send_stream(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - self.outbound.push_regular(serde_json::to_string(value)?, stream) + fn send_stream( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { + self.outbound.push_regular(text, stream) } - fn send_control(&self, value: &Value) -> std::io::Result<()> { + fn send_control(&self, text: Arc) -> std::io::Result<()> { + let value: Value = serde_json::from_str(&text).map_err(json_error_to_io)?; if value["type"] == "response" && value["id"].as_str() == Some(self.blocked_request_id.as_str()) { @@ -9101,11 +10623,15 @@ mod tests { ) })?; } - self.outbound.push_control(serde_json::to_string(value)?) + self.outbound.push_control(text) } - fn send_terminal(&self, value: &Value, stream: &OutboundStream) -> std::io::Result<()> { - self.outbound.push_terminal(serde_json::to_string(value)?, stream) + fn send_terminal( + &self, + text: Arc, + stream: &OutboundStream, + ) -> std::io::Result<()> { + self.outbound.push_terminal(text, stream) } fn is_open(&self) -> bool { @@ -11099,8 +12625,8 @@ mod tests { title: "example".to_string(), cols: 10, rows: 5, - status: crate::BrowserStatus::Live, - frame: Some(crate::BrowserFrame { + status: BrowserStatus::Live, + frame: Some(BrowserFrame { session_id: "session-test".to_string(), data_b64: "AAAA".to_string(), css_width: 80, @@ -11114,7 +12640,7 @@ mod tests { frames_stalled: false, }; - let value = browser_state_json(1, &state, true); + let value = serde_json::to_value(browser_state_message(1, &state, true)).unwrap(); assert_eq!( value.get("pointer_frame_seq"), Some(&Value::Null), @@ -11126,7 +12652,7 @@ mod tests { #[test] fn browser_frame_json_couples_authoritative_pointer_admission() { let update = BrowserFrameUpdate { - frame: crate::BrowserFrame { + frame: BrowserFrame { session_id: "session-test".to_string(), data_b64: "AAAA".to_string(), css_width: 80, @@ -11135,7 +12661,7 @@ mod tests { image_height: 48, seq: 7, }, - status: crate::BrowserStatus::Failed("navigation failed".to_string()), + status: BrowserStatus::Failed("navigation failed".to_string()), pointer_frame_floor_seq: None, pointer_frame_seq: None, }; @@ -11150,7 +12676,7 @@ mod tests { #[test] fn browser_resource_frame_couples_exact_nullable_pointer_authority() { - let frame = crate::BrowserFrame { + let frame = BrowserFrame { session_id: "session-test".to_string(), data_b64: "AAAA".to_string(), css_width: 80, @@ -11172,7 +12698,7 @@ mod tests { let outbound = Arc::new(BoundedOutbound::default()); let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); let stream = writer.start_stream(&json!({"event": "overflow"})).unwrap(); - let frame = crate::BrowserFrame { + let frame = BrowserFrame { session_id: "session-test".to_string(), data_b64: "AAAA".to_string(), css_width: 80, @@ -11184,7 +12710,7 @@ mod tests { let update = BrowserAttachUpdate { frame: Some(BrowserFrameUpdate { frame: frame.clone(), - status: crate::BrowserStatus::Live, + status: BrowserStatus::Live, pointer_frame_floor_seq: Some(7), pointer_frame_seq: Some(7), }), @@ -11193,7 +12719,7 @@ mod tests { title: "example".to_string(), cols: 10, rows: 5, - status: crate::BrowserStatus::Live, + status: BrowserStatus::Live, frame: Some(frame), pointer_frame_floor_seq: Some(7), pointer_frame_seq: Some(7), @@ -11220,8 +12746,8 @@ mod tests { title: "Example".to_string(), cols: 80, rows: 24, - status: crate::BrowserStatus::Live, - frame: Some(crate::BrowserFrame { + status: BrowserStatus::Live, + frame: Some(BrowserFrame { session_id: "browser-session".to_string(), data_b64: "frame".to_string(), css_width: 800, @@ -11235,7 +12761,7 @@ mod tests { frames_stalled: false, }; - let value = browser_state_json(3, &state, true); + let value = serde_json::to_value(browser_state_message(3, &state, true)).unwrap(); assert_eq!(value["frame"]["width"], 800); assert_eq!(value["frame"]["height"], 600); assert_eq!(value["frame"]["image_width"], 400); @@ -11341,6 +12867,165 @@ mod tests { assert!(writer.is_open()); } + #[test] + fn vt_state_wire_prefix_identifies_attach_before_large_replay_data() { + let replay = Arc::<[u8]>::from(vec![b'x'; 1024]); + let message = VtStateMessage { + surface: 7, + cols: 80, + rows: 24, + replay: replay.clone(), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + colors: Value::Null, + }; + + let serialized = RenderService::new().serialize_vt_state(&message).unwrap(); + + assert!(serialized.starts_with(r#"{"event":"vt-state","surface":7,"#), "{}", &**serialized); + let decoded: Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(decoded["data"], base64::engine::general_purpose::STANDARD.encode(replay)); + } + + #[test] + fn maximum_vt_state_command_response_fits_the_control_reserve() { + let service = RenderService::new(); + let outbound = BoundedOutbound::default(); + let replay = vec![0_u8; crate::surface::VT_REPLAY_MAX_BYTES]; + + let mut output = service.reserved_control_writer().unwrap(); + write_vt_state_command_json( + &mut output, + Some(&json!(1)), + 80, + 24, + &replay, + &[], + KittyReplayState::disabled(), + ) + .unwrap(); + let serialized = output.finish(); + assert!(serialized.len() < OUTBOUND_CONTROL_BYTE_RESERVE); + assert_eq!(serialized.retained_bytes, OUTBOUND_CONTROL_BYTE_RESERVE); + assert!(serialized.starts_with(r#"{"id":1,"ok":true,"data":{"cols":80,"#)); + outbound.push_control(serialized).unwrap(); + assert!(outbound.try_pop().is_some()); + } + + #[test] + fn vt_state_releases_unused_control_reservation_after_encoding() { + const RESERVATION: usize = 128; + let budget = Arc::new(OutboundByteBudget::new(RESERVATION * 4)); + let mut queued = Vec::new(); + + for _ in 0..5 { + let mut reservation = + BudgetedJsonWriter::with_reservation(budget.clone(), RESERVATION).unwrap(); + assert_eq!(reservation.bytes.capacity(), 0); + reservation.write_all(b"{}").unwrap(); + queued.push(reservation.finish()); + } + + assert!(budget.retained_bytes.load(Ordering::Acquire) < RESERVATION); + drop(queued); + assert_eq!(budget.retained_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn websocket_server_headers_cover_every_outbound_payload_width() { + let (small, small_len) = websocket_server_frame_header(0x1, 125); + assert_eq!(&small[..small_len], &[0x81, 125]); + + let (medium, medium_len) = websocket_server_frame_header(0x1, 126); + assert_eq!(&medium[..medium_len], &[0x81, 126, 0, 126]); + + let (large, large_len) = websocket_server_frame_header(0x1, RENDER_ATTACH_MAX_BYTES); + assert_eq!(large_len, 10); + assert_eq!(large[0], 0x81); + assert_eq!(large[1], 127); + assert_eq!(&large[2..10], &(RENDER_ATTACH_MAX_BYTES as u64).to_be_bytes()); + } + + #[test] + fn browser_state_wire_prefix_identifies_attach_before_large_frame_data() { + let state = BrowserAttachState { + url: "https://example.com".into(), + title: "Example".into(), + cols: 80, + rows: 24, + status: BrowserStatus::Live, + frame: Some(BrowserFrame { + session_id: "session".into(), + data_b64: "eA==".repeat(256), + css_width: 800, + css_height: 600, + image_width: 800, + image_height: 600, + seq: 1, + }), + pointer_frame_floor_seq: Some(1), + pointer_frame_seq: Some(1), + frames_stalled: false, + }; + + let serialized = + RenderService::new().serialize(&browser_state_message(7, &state, true)).unwrap(); + + assert!( + serialized.starts_with(r#"{"event":"browser-state","surface":7,"#), + "{}", + &**serialized + ); + let decoded: Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(decoded["frame"]["data"], state.frame.as_ref().unwrap().data_b64); + } + + #[test] + fn vt_state_streaming_releases_partial_global_budget_on_overflow() { + let service = RenderService::new_with_outbound_budget(64); + let message = VtStateMessage { + surface: 7, + cols: 80, + rows: 24, + replay: Arc::from(vec![b'x'; 1024]), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + colors: Value::Null, + }; + + let error = service + .serialize_vt_state(&message) + .err() + .expect("oversized replay must exhaust the global budget"); + + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + assert_eq!(service.outbound_budget.retained_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn resize_stream_serialization_reserves_budget_before_queueing() { + let service = Arc::new(RenderService::new_with_outbound_budget(64)); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new_with_render_service( + QueuedSink { outbound: outbound.clone(), control: None }, + service.clone(), + ); + let stream = writer.start_stream(&attach_overflow_json(7)).unwrap(); + let frame = AttachFrame::Resized { + cols: 80, + rows: 24, + replay: Arc::from(vec![b'x'; 1024]), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + }; + + let error = writer.send_attach_frame(7, &frame, &stream).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + assert!(outbound.try_pop().is_none()); + assert_eq!(service.outbound_budget.retained_bytes.load(Ordering::Acquire), 0); + } + #[test] fn server_connection_permits_enforce_and_release_the_cap() { let active = Arc::new(AtomicU64::new(MAX_SERVER_CONNECTIONS as u64)); @@ -11903,7 +13588,13 @@ mod tests { let (server, peer) = listener.accept().unwrap(); let (done, finished) = std::sync::mpsc::channel(); let handler = std::thread::spawn(move || { - handle_websocket_connection(test_mux(), server, peer, None); + handle_websocket_connection( + test_mux(), + server, + peer, + None, + Arc::new(RenderService::new()), + ); done.send(()).unwrap(); }); @@ -11921,7 +13612,13 @@ mod tests { let (server, peer) = listener.accept().unwrap(); let (done, finished) = std::sync::mpsc::channel(); let handler = std::thread::spawn(move || { - handle_websocket_connection(test_mux(), server, peer, Some("secret")); + handle_websocket_connection( + test_mux(), + server, + peer, + Some("secret"), + Arc::new(RenderService::new()), + ); done.send(()).unwrap(); }); let (client, _) = tungstenite::client("ws://localhost/", client_stream).unwrap(); @@ -11965,13 +13662,18 @@ mod tests { #[test] fn bounded_writer_rejects_payloads_beyond_each_byte_budget() { let outbound = BoundedOutbound::default(); - let stream = OutboundStream::new(1, r#"{"event":"overflow"}"#.to_string()); + let service = RenderService::new_with_outbound_budget( + OUTBOUND_GLOBAL_BYTE_CAPACITY.saturating_mul(2), + ); + let stream = + OutboundStream::new(1, service.serialize(&json!({"event": "overflow"})).unwrap()); - let regular = - outbound.push_regular("x".repeat(OUTBOUND_BYTE_CAPACITY + 1), &stream).unwrap_err(); + let regular_text = service.serialize(&"x".repeat(OUTBOUND_BYTE_CAPACITY + 1)).unwrap(); + let regular = outbound.push_regular(regular_text, &stream).unwrap_err(); assert_eq!(regular.kind(), std::io::ErrorKind::WouldBlock); - let control = - outbound.push_control("x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1)).unwrap_err(); + let control_text = + service.serialize_control(&"x".repeat(OUTBOUND_CONTROL_BYTE_RESERVE + 1)).unwrap(); + let control = outbound.push_control(control_text).unwrap_err(); assert_eq!(control.kind(), std::io::ErrorKind::WouldBlock); let terminal: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); assert_eq!(terminal["event"], "overflow"); @@ -12097,6 +13799,24 @@ mod tests { ); } + #[test] + fn websocket_direct_writer_emits_a_tungstenite_compatible_text_frame() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + let mut writer = SynchronizedTcpStream::new(server); + let write = std::thread::spawn(move || { + writer.write_websocket_text(&"x".repeat(65_536)).unwrap(); + }); + let mut websocket = + WebSocket::from_raw_socket(client, tungstenite::protocol::Role::Client, None); + + let message = websocket.read().unwrap(); + + assert_eq!(message.into_text().unwrap().len(), 65_536); + write.join().unwrap(); + } + #[test] fn closing_bounded_writer_wakes_a_waiting_drain() { let outbound = Arc::new(BoundedOutbound::default()); @@ -12105,7 +13825,7 @@ mod tests { outbound.close(); - assert_eq!(drain.join().unwrap(), None); + assert!(drain.join().unwrap().is_none()); } #[test] @@ -14528,10 +16248,10 @@ mod tests { let encoded = serde_json::to_vec(&request).unwrap(); assert!( - encoded.len() <= WEBSOCKET_MESSAGE_MAX_BYTES, + encoded.len() <= WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES, "accepted fallback key serialized to {} bytes, above the {}-byte WebSocket limit", encoded.len(), - WEBSOCKET_MESSAGE_MAX_BYTES + WEBSOCKET_INBOUND_MESSAGE_MAX_BYTES ); } @@ -14694,6 +16414,41 @@ mod tests { ); } + #[test] + fn graphics_status_events_preserve_structured_localization_data() { + assert_eq!( + subscribed_event_json(&MuxEvent::GraphicsStatus( + GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted: true, + summary: Arc::::from("surface 7: offline"), + }, + )), + json!({ + "event": "graphics-status", + "kind": "kitty-image-budget-update-failed", + "retry_exhausted": true, + "summary": "surface 7: offline", + }) + ); + assert_eq!( + subscribed_event_json(&MuxEvent::GraphicsStatus( + GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: 5, + remaining: 2, + cell_pixels: (8, 16), + }, + )), + json!({ + "event": "graphics-status", + "kind": "cell-pixel-update-retries-exhausted", + "attempts": 5, + "remaining": 2, + "cell_width": 8, + "cell_height": 16, + }) + ); + } + #[test] fn scroll_surface_emits_one_scroll_changed_event() { let mux = test_mux(); diff --git a/cmux-tui/crates/cmux-tui-core/src/sidebar_resource.rs b/cmux-tui/crates/cmux-tui-core/src/sidebar_resource.rs index 8813898661..0f81e19d70 100644 --- a/cmux-tui/crates/cmux-tui-core/src/sidebar_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/sidebar_resource.rs @@ -12,9 +12,10 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use crate::resource::{ResourceError, Selector, SessionPublicId, SidebarViewPublicId}; +use crate::surface::RenderAttachFrameReceiver; use crate::{ - Mux, RenderAttachFrame, RenderAttachStream, ResourceSelectors, ResourceTarget, Rgb, Surface, - SurfaceKind, SurfaceRenderFrame, + Mux, RenderAttachStream, ResourceSelectors, ResourceTarget, Rgb, Surface, SurfaceKind, + SurfaceRenderFrame, }; pub(crate) const SIDEBAR_VIEW_NAME: &str = "sidebar"; @@ -85,7 +86,7 @@ pub(crate) struct SidebarRenderAttachment { pub sidebar_view_id: SidebarViewPublicId, pub sidebar_view: Value, pub initial: Arc, - pub stream: std::sync::mpsc::Receiver, + pub stream: RenderAttachFrameReceiver, } pub(crate) fn attach_sidebar_render( @@ -96,7 +97,7 @@ pub(crate) fn attach_sidebar_render( if surface.kind() != SurfaceKind::Pty || surface.is_dead() { return Err(ResourceError::not_found("sidebar_view", id.as_str())); } - let RenderAttachStream { initial, stream } = + let RenderAttachStream { initial, stream, .. } = surface.attach_render_stream().map_err(|error| { ResourceError::operation_failed( "sidebar_view.attach", diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index e03f0ba150..b89f15229f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -20,9 +20,9 @@ use std::time::{Duration, Instant}; use cmux_pty::{ChildKiller, MasterPty, PtyCommand, PtySize}; use ghostty_vt::{ - Callbacks, ClearHistoryOutcome, CursorShape, KeyEncoder, KeyInput, MouseEncoders, MouseInput, - RenderFrame, RenderState, Rgb, Screen, Scrollbar, Terminal, TerminalColorOverrides, - TerminalPointerSemanticSnapshot, + Callbacks, ClearHistoryOutcome, CursorShape, Dirty, KeyEncoder, KeyInput, KittyGraphicsLimits, + KittyReplayState, MouseEncoders, MouseInput, RenderFrame, RenderState, Rgb, Screen, Scrollbar, + Terminal, TerminalColorOverrides, TerminalPointerSemanticSnapshot, }; use crate::mux::ResourceWaitWake; @@ -39,10 +39,10 @@ use crate::browser::{ BrowserMouseDispatch, BrowserPointerOwner, BrowserResizeWaiter, BrowserSurface, PendingBrowserResize, }; +#[cfg(all(unix, test))] +use crate::terminal_host_protocol::PROTOCOL_VERSION; #[cfg(unix)] -use crate::terminal_host_protocol::{ - FLAG_COLORS_FOLLOW, Frame, MessageKind, PROTOCOL_VERSION, decode_terminal_exit, -}; +use crate::terminal_host_protocol::{FLAG_COLORS_FOLLOW, Frame, MessageKind, decode_terminal_exit}; use cmux_tui_cdp::BrowserMode; /// Result of encoding terminal mouse input against a previously observed @@ -254,7 +254,9 @@ impl TerminalColors { pub struct AttachStream { pub cols: u16, pub rows: u16, - pub replay: Vec, + pub replay: Arc<[u8]>, + pub kitty_image_aliases: Vec, + pub kitty_state: KittyReplayState, pub colors: TerminalColors, pub stream: AttachFrameReceiver, pub(crate) lifecycle: AttachLifecycle, @@ -272,14 +274,18 @@ pub enum AttachFrame { Resized { cols: u16, rows: u16, - replay: Vec, + replay: Arc<[u8]>, + kitty_image_aliases: Vec, + kitty_state: KittyReplayState, }, /// One parser transition: `replay` is theme-portable, so `colors` is part /// of the same replacement snapshot rather than a subsequent callback. ResizedWithColors { cols: u16, rows: u16, - replay: Vec, + replay: Arc<[u8]>, + kitty_image_aliases: Vec, + kitty_state: KittyReplayState, colors: Box, }, ColorsChanged(Arc), @@ -292,8 +298,19 @@ pub enum AttachFrame { #[derive(Debug)] enum HostedTransition { Output(Vec), - OutputWithColors { output: Vec, colors: TerminalColorOverrides }, - ResizedWithColors { cols: u16, rows: u16, replay: Vec, colors: TerminalColorOverrides }, + OutputWithColors { + output: Vec, + colors: TerminalColorOverrides, + }, + ResizedWithColors { + cols: u16, + rows: u16, + cell_pixels: (u16, u16), + replay: Vec, + kitty_image_aliases: Vec, + kitty_state: KittyReplayState, + colors: TerminalColorOverrides, + }, Metadata(MessageKind), Exit(TerminalExit), ResyncRequired, @@ -303,23 +320,40 @@ enum HostedTransition { #[derive(Debug)] enum PendingHostedTransition { Output(Vec), - Resized { cols: u16, rows: u16, replay: Vec }, + Resized { + cols: u16, + rows: u16, + cell_pixels: (u16, u16), + replay: Vec, + kitty_image_aliases: Vec, + kitty_state: KittyReplayState, + }, } #[cfg(unix)] struct HostedFrameStager { + protocol_version: u16, expected_sequence: u64, pending: Option, } #[cfg(unix)] impl HostedFrameStager { + #[cfg(test)] fn new(sequence_boundary: u64) -> Self { - Self { expected_sequence: sequence_boundary.wrapping_add(1), pending: None } + Self::new_for_version(sequence_boundary, PROTOCOL_VERSION) + } + + fn new_for_version(sequence_boundary: u64, protocol_version: u16) -> Self { + Self { + protocol_version, + expected_sequence: sequence_boundary.wrapping_add(1), + pending: None, + } } fn push(&mut self, frame: Frame) -> Result, &'static str> { - if frame.version != PROTOCOL_VERSION || frame.request_id != 0 { + if frame.version != self.protocol_version || frame.request_id != 0 { return Err("invalid live-frame envelope"); } if frame.sequence != self.expected_sequence { @@ -338,9 +372,22 @@ impl HostedFrameStager { PendingHostedTransition::Output(output) => { HostedTransition::OutputWithColors { output, colors } } - PendingHostedTransition::Resized { cols, rows, replay } => { - HostedTransition::ResizedWithColors { cols, rows, replay, colors } - } + PendingHostedTransition::Resized { + cols, + rows, + cell_pixels, + replay, + kitty_image_aliases, + kitty_state, + } => HostedTransition::ResizedWithColors { + cols, + rows, + cell_pixels, + replay, + kitty_image_aliases, + kitty_state, + colors, + }, })); } @@ -354,21 +401,28 @@ impl HostedFrameStager { _ => Err("unknown Output flags"), }, MessageKind::Resized => { - if frame.flags != FLAG_COLORS_FOLLOW - || frame.payload.len() < 4 - || frame.payload.len() - 4 > VT_REPLAY_MAX_BYTES - { + if frame.flags != FLAG_COLORS_FOLLOW { return Err("invalid Resized frame"); } - let (cols, rows) = crate::terminal_host_runtime::normalize_terminal_geometry( - u16::from_le_bytes([frame.payload[0], frame.payload[1]]), - u16::from_le_bytes([frame.payload[2], frame.payload[3]]), + let crate::terminal_host_runtime::DecodedHostResize { + cols, + rows, + cell_pixels, + replay, + kitty_image_aliases, + kitty_state, + } = crate::terminal_host_runtime::decode_host_resize_payload_for_version( + &frame.payload, + self.protocol_version, ) - .map_err(|_| "invalid Resized geometry")?; + .map_err(|_| "invalid Resized payload")?; self.pending = Some(PendingHostedTransition::Resized { cols, rows, - replay: frame.payload[4..].to_vec(), + cell_pixels, + replay, + kitty_image_aliases, + kitty_state, }); Ok(None) } @@ -395,11 +449,22 @@ impl HostedFrameStager { const ATTACH_STREAM_CAPACITY: usize = 256; const ATTACH_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; -pub(crate) const VT_REPLAY_MAX_BYTES: usize = 8 * 1024 * 1024; +// Preserve every valid upload prefix plus enough recent text while fitting +// both the raw attach queue and its 32 MiB base64-encoded transport. +const VT_REPLAY_TEXT_HEADROOM_BYTES: usize = 2 * 1024 * 1024; +pub(crate) const VT_REPLAY_MAX_BYTES: usize = + ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES + VT_REPLAY_TEXT_HEADROOM_BYTES; +const VT_REPLAY_FRAME_METADATA_HEADROOM_BYTES: usize = 64 * 1024; +const VT_REPLAY_ENCODED_TRANSPORT_MAX_BYTES: usize = 32 * 1024 * 1024; +const _: () = assert!( + VT_REPLAY_MAX_BYTES + VT_REPLAY_FRAME_METADATA_HEADROOM_BYTES <= ATTACH_STREAM_MAX_BYTES +); +const _: () = assert!(VT_REPLAY_MAX_BYTES.div_ceil(3) * 4 < VT_REPLAY_ENCODED_TRANSPORT_MAX_BYTES); pub struct AttachFrameReceiver { receiver: Receiver, queued_bytes: Arc, + lifecycle: AttachLifecycle, } impl AttachFrameReceiver { @@ -426,17 +491,28 @@ impl AttachFrameReceiver { } } +impl Drop for AttachFrameReceiver { + fn drop(&mut self) { + self.lifecycle.cancel(); + } +} + impl AttachFrame { fn retained_bytes(&self) -> usize { size_of::() + match self { Self::Output(bytes) => bytes.capacity(), + Self::Resized { replay, kitty_image_aliases, .. } => { + replay.len() + + kitty_image_aliases.capacity() * size_of::() + } Self::OutputWithColors { output, .. } => { output.capacity() + size_of::() } - Self::Resized { replay, .. } => replay.capacity(), - Self::ResizedWithColors { replay, .. } => { - replay.capacity() + size_of::() + Self::ResizedWithColors { replay, kitty_image_aliases, .. } => { + replay.len() + + kitty_image_aliases.capacity() * size_of::() + + size_of::() } Self::ColorsChanged(_) => size_of::(), } @@ -522,12 +598,13 @@ impl AttachTap { } } -/// One immutable terminal frame plus the scrollback count captured with it. +/// One immutable terminal frame plus retained-history metadata captured with it. #[derive(Debug, Clone)] pub struct SurfaceRenderFrame { pub frame: RenderFrame, pub content_generation: u64, pub scrollback_rows: u32, + pub history_epoch: u64, pub pointer_semantics: TerminalPointerSemanticSnapshot, pub palette_colors: [Rgb; 256], pub palette_overridden: [bool; 256], @@ -540,17 +617,242 @@ pub enum RenderAttachFrame { ScrollChanged { offset: u64, at_bottom: bool }, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PendingRenderKind { + Frame, + Scroll, +} + +struct RenderTapQueue { + pending_frame: Option, + pending_scroll: Option<(u64, bool)>, + latest_kind: Option, + sender_alive: bool, + receiver_alive: bool, +} + +struct PendingRenderFrame { + latest: Arc, + dirty: Dirty, + dirty_rows: Vec, +} + +impl PendingRenderFrame { + fn new(latest: Arc) -> Self { + Self { dirty: latest.frame.dirty, dirty_rows: latest.frame.dirty_rows.clone(), latest } + } + + /// Replace the immutable snapshot while retaining every row damaged since + /// the tap last drained. Only damage metadata is copied on this hot path. + fn coalesce(&mut self, latest: Arc) { + if self.dirty == Dirty::Full + || latest.frame.dirty == Dirty::Full + || self.latest.frame.size != latest.frame.size + { + self.dirty = Dirty::Full; + self.dirty_rows = (0..latest.frame.size.1).collect(); + } else { + self.dirty_rows.extend(latest.frame.dirty_rows.iter().copied()); + self.dirty_rows.sort_unstable(); + self.dirty_rows.dedup(); + self.dirty = + if self.dirty_rows.is_empty() { latest.frame.dirty } else { Dirty::Partial }; + } + self.latest = latest; + } + + /// Materialize one coalesced frame when the receiver drains. A tap that + /// keeps up returns the original shared frame without cloning row state. + fn into_frame(self) -> Arc { + if self.dirty == self.latest.frame.dirty && self.dirty_rows == self.latest.frame.dirty_rows + { + return self.latest; + } + let mut combined = (*self.latest).clone(); + combined.frame.dirty = self.dirty; + combined.frame.dirty_rows = self.dirty_rows; + Arc::new(combined) + } +} + +impl RenderTapQueue { + fn push(&mut self, event: RenderAttachFrame) { + match event { + RenderAttachFrame::Frame(frame) => { + match &mut self.pending_frame { + Some(pending) => pending.coalesce(frame), + None => self.pending_frame = Some(PendingRenderFrame::new(frame)), + } + self.latest_kind = Some(PendingRenderKind::Frame); + } + RenderAttachFrame::ScrollChanged { offset, at_bottom } => { + self.pending_scroll = Some((offset, at_bottom)); + self.latest_kind = Some(PendingRenderKind::Scroll); + } + } + } + + fn pop(&mut self) -> Option { + let next = + match (self.pending_frame.is_some(), self.pending_scroll.is_some(), self.latest_kind) { + (true, true, Some(PendingRenderKind::Frame)) => { + let (offset, at_bottom) = self.pending_scroll.take().unwrap(); + RenderAttachFrame::ScrollChanged { offset, at_bottom } + } + (true, true, Some(PendingRenderKind::Scroll)) => { + RenderAttachFrame::Frame(self.pending_frame.take().unwrap().into_frame()) + } + (true, true, None) => unreachable!("pending render events have an ordering"), + (true, false, _) => { + RenderAttachFrame::Frame(self.pending_frame.take().unwrap().into_frame()) + } + (false, true, _) => { + let (offset, at_bottom) = self.pending_scroll.take().unwrap(); + RenderAttachFrame::ScrollChanged { offset, at_bottom } + } + (false, false, _) => return None, + }; + if self.pending_frame.is_none() && self.pending_scroll.is_none() { + self.latest_kind = None; + } + Some(next) + } +} + +struct RenderTapState { + queue: Mutex, + ready: Condvar, +} + +struct RenderTap { + state: Arc, +} + +impl RenderTap { + fn pair(render: &Arc>) -> (Self, RenderAttachFrameReceiver) { + let state = Arc::new(RenderTapState { + queue: Mutex::new(RenderTapQueue { + pending_frame: None, + pending_scroll: None, + latest_kind: None, + sender_alive: true, + receiver_alive: true, + }), + ready: Condvar::new(), + }); + ( + Self { state: state.clone() }, + RenderAttachFrameReceiver { state, render: Arc::downgrade(render) }, + ) + } + + fn send(&self, event: RenderAttachFrame) -> bool { + let mut queue = self.state.queue.lock().unwrap(); + if !queue.receiver_alive { + return false; + } + queue.push(event); + drop(queue); + self.state.ready.notify_one(); + true + } +} + +impl Drop for RenderTap { + fn drop(&mut self) { + self.state.queue.lock().unwrap().sender_alive = false; + self.state.ready.notify_all(); + } +} + +/// Bounded receiver for one render attachment. +pub struct RenderAttachFrameReceiver { + state: Arc, + render: Weak>, +} + +impl RenderAttachFrameReceiver { + pub fn recv(&self) -> Result { + let mut queue = self.state.queue.lock().unwrap(); + loop { + if let Some(event) = queue.pop() { + return Ok(event); + } + if !queue.sender_alive { + return Err(RecvError); + } + queue = self.state.ready.wait(queue).unwrap(); + } + } + + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let started = Instant::now(); + let mut queue = self.state.queue.lock().unwrap(); + loop { + if let Some(event) = queue.pop() { + return Ok(event); + } + if !queue.sender_alive { + return Err(RecvTimeoutError::Disconnected); + } + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err(RecvTimeoutError::Timeout); + }; + let (next, result) = self.state.ready.wait_timeout(queue, remaining).unwrap(); + queue = next; + if result.timed_out() && queue.pending_frame.is_none() && queue.pending_scroll.is_none() + { + return Err(RecvTimeoutError::Timeout); + } + } + } + + pub fn try_recv(&self) -> Result { + let mut queue = self.state.queue.lock().unwrap(); + if let Some(event) = queue.pop() { + Ok(event) + } else if queue.sender_alive { + Err(TryRecvError::Empty) + } else { + Err(TryRecvError::Disconnected) + } + } +} + +impl Drop for RenderAttachFrameReceiver { + fn drop(&mut self) { + // Frame fan-out holds the hub before this queue. Release the queue + // before taking the hub so receiver teardown cannot invert that order. + { + let mut queue = self.state.queue.lock().unwrap(); + queue.receiver_alive = false; + queue.pending_frame = None; + queue.pending_scroll = None; + } + if let Some(render) = self.render.upgrade() { + render.lock().unwrap().taps.retain(|tap| !Arc::ptr_eq(&tap.state, &self.state)); + } + } +} + /// Initial render snapshot and the ordered live stream registered with it. pub struct RenderAttachStream { pub initial: Arc, - pub stream: Receiver, + pub stream: RenderAttachFrameReceiver, + _permit: crate::mux::RenderAttachmentPermit, } struct RenderHub { state: Box, built_generation: u64, latest: Option>, - taps: Vec>, + initial_graphics: Option, + taps: Vec, +} + +struct InitialGraphicsSnapshot { + source: Arc, + snapshot: Arc, } #[cfg(test)] @@ -568,6 +870,7 @@ pub enum TerminalHostConnectionState { Connected = 0, Reconnecting = 1, Exited = 2, + Failed = 3, } impl TerminalHostConnectionState { @@ -575,11 +878,60 @@ impl TerminalHostConnectionState { match value { 1 => Self::Reconnecting, 2 => Self::Exited, + 3 => Self::Failed, _ => Self::Connected, } } } +#[cfg(unix)] +const TERMINAL_HOST_RECONNECT_MAX_FAILURES: u8 = 16; +#[cfg(unix)] +const TERMINAL_HOST_RECONNECT_MAX_DELAY: Duration = Duration::from_secs(1); + +#[cfg(unix)] +#[derive(Default)] +struct TerminalHostReconnectBackoff { + failures: u8, +} + +#[cfg(unix)] +impl TerminalHostReconnectBackoff { + fn next_delay(&mut self) -> Option { + if self.failures >= TERMINAL_HOST_RECONNECT_MAX_FAILURES { + return None; + } + let multiplier = 1_u32 << self.failures.min(6); + self.failures += 1; + Some((Duration::from_millis(25) * multiplier).min(TERMINAL_HOST_RECONNECT_MAX_DELAY)) + } + + fn wait_or_fail(&mut self, pty: &PtySurface) -> bool { + let Some(delay) = self.next_delay() else { + pty.host_connection_state + .store(TerminalHostConnectionState::Failed as u8, Ordering::Release); + if let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() { + host.disconnect(); + } + return false; + }; + #[cfg(test)] + pty.run_geometry_test_hook(PtyGeometryTestStep::ReconnectBackoffStarted); + std::thread::sleep(delay); + true + } +} + +#[cfg(unix)] +fn wait_for_reconnect_after_geometry_failure( + retry: &mut TerminalHostReconnectBackoff, + pty: &PtySurface, + geometry: std::sync::MutexGuard<'_, PtyGeometry>, +) -> bool { + drop(geometry); + retry.wait_or_fail(pty) +} + impl SurfaceKind { pub fn as_str(self) -> &'static str { match self { @@ -624,11 +976,47 @@ impl Deref for Surface { /// The terminal is behind a mutex; the pty reader thread holds it only /// while feeding bytes, renderers hold it only while snapshotting into a /// [`RenderState`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PtyGeometry { + cols: u16, + rows: u16, + cell_width: u16, + cell_height: u16, +} + +impl PtyGeometry { + fn pty_size(self) -> anyhow::Result { + let pixel_width = self.cols.checked_mul(self.cell_width.max(1)).ok_or_else(|| { + anyhow::anyhow!( + "PTY pixel width exceeds {}: {} columns at {} pixels per cell", + u16::MAX, + self.cols, + self.cell_width.max(1) + ) + })?; + let pixel_height = self.rows.checked_mul(self.cell_height.max(1)).ok_or_else(|| { + anyhow::anyhow!( + "PTY pixel height exceeds {}: {} rows at {} pixels per cell", + u16::MAX, + self.rows, + self.cell_height.max(1) + ) + })?; + Ok(PtySize { rows: self.rows, cols: self.cols, pixel_width, pixel_height }) + } +} + +#[cfg(test)] +type PtyGeometryTestHook = Arc; + +#[cfg(test)] +type DeferredCellPixelAckTestHook = Arc; + pub struct PtySurface { pub(crate) meta: SurfaceMeta, - term: Mutex, + term: Mutex>, stream_progress: Box, - mouse_encoders: Mutex, + mouse_encoders: Mutex>, runtime: Mutex, supports_clear_history_key_fallback: AtomicBool, host_identity: Option, @@ -652,7 +1040,16 @@ pub struct PtySurface { dirty: AtomicBool, title: Mutex, pwd: Mutex>, - size: Mutex<(u16, u16)>, + geometry: Mutex, + kitty_graphics_limits: Box>, + #[cfg(test)] + geometry_test_hook: Mutex>, + #[cfg(test)] + deferred_cell_pixel_ack_test_hook: Mutex>, + #[cfg(test)] + test_master_control: Option>, + #[cfg(test)] + vt_replay_builds: AtomicUsize, mux: Weak, /// Live output subscribers (attach streams). Guarded by the terminal /// lock ordering: the reader thread broadcasts while holding the @@ -672,7 +1069,7 @@ pub struct PtySurface { last_attach_colors: Mutex>>, /// Single consume-once Ghostty render state shared by the local TUI and /// every protocol-v7 render attachment. - render: Mutex, + render: Arc>, render_generation: AtomicU64, frame_requests: SyncSender, #[cfg(test)] @@ -1192,60 +1589,118 @@ impl Surface { } } + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn spawn( id: SurfaceId, opts: SurfaceOptions, mux: Weak, ) -> anyhow::Result> { - Self::spawn_with_resource_identity( - id, - opts, - mux, - Some(TabResourceIdentity::terminal(None)?), - ) + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_at_cell_pixels(id, opts, mux, cell_pixels) } /// Spawn runtime-only terminal content which is not part of the public /// resource tree, such as a sidebar view process. + #[allow(dead_code)] pub(crate) fn spawn_auxiliary( id: SurfaceId, opts: SurfaceOptions, mux: Weak, ) -> anyhow::Result> { - Self::spawn_with_resource_identity(id, opts, mux, None) + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_auxiliary_at_cell_pixels(id, opts, mux, cell_pixels) } - pub(crate) fn spawn_with_terminal_id( + pub(crate) fn spawn_auxiliary_at_cell_pixels( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + cell_pixels: (u16, u16), + ) -> anyhow::Result> { + Self::spawn_with_terminal_id_and_resource_identity_at_cell_pixels( + id, + opts, + mux, + None, + None, + cell_pixels, + ) + } + + pub(crate) fn spawn_at_cell_pixels( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + cell_pixels: (u16, u16), + ) -> anyhow::Result> { + Self::spawn_with_terminal_id_and_resource_identity_at_cell_pixels( + id, + opts, + mux, + None, + Some(TabResourceIdentity::terminal(None)?), + cell_pixels, + ) + } + + pub(crate) fn spawn_with_terminal_id_at_cell_pixels( id: SurfaceId, opts: SurfaceOptions, mux: Weak, terminal_id: Option, + cell_pixels: (u16, u16), ) -> anyhow::Result> { let identity = Some(TabResourceIdentity::terminal(None)?); - Self::spawn_with_terminal_id_and_resource_identity(id, opts, mux, terminal_id, identity) + Self::spawn_with_terminal_id_and_resource_identity_at_cell_pixels( + id, + opts, + mux, + terminal_id, + identity, + cell_pixels, + ) } + #[allow(dead_code)] pub(crate) fn spawn_with_resource_identity( id: SurfaceId, opts: SurfaceOptions, mux: Weak, resource_identity: Option, ) -> anyhow::Result> { - if resource_identity.as_ref().is_some_and(|identity| { - matches!(identity.content_id, crate::resource::ContentPublicId::Browser(_)) - }) { - anyhow::bail!("terminal surface cannot use a browser resource identity"); - } - Self::spawn_with_terminal_id_and_resource_identity(id, opts, mux, None, resource_identity) + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_with_terminal_id_and_resource_identity_at_cell_pixels( + id, + opts, + mux, + None, + resource_identity, + cell_pixels, + ) } - fn spawn_with_terminal_id_and_resource_identity( + fn spawn_with_terminal_id_and_resource_identity_at_cell_pixels( id: SurfaceId, opts: SurfaceOptions, mux: Weak, terminal_id: Option, resource_identity: Option, + cell_pixels: (u16, u16), ) -> anyhow::Result> { + if resource_identity.as_ref().is_some_and(|identity| { + matches!(identity.content_id, crate::resource::ContentPublicId::Browser(_)) + }) { + anyhow::bail!("terminal surface cannot use a browser resource identity"); + } + let kitty_reservation = + mux.upgrade().map(|mux| mux.reserve_kitty_image_surface(id)).transpose()?; + let initial_kitty_limits = kitty_reservation + .as_ref() + .map(crate::mux::KittyImageBudgetReservation::initial_limits) + .unwrap_or_default(); #[cfg(unix)] if let Some(root) = opts.terminal_host_root.clone() { let default_colors = mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); @@ -1255,6 +1710,8 @@ impl Surface { &opts, &root, default_colors, + cell_pixels, + initial_kitty_limits, terminal_id, )? } @@ -1262,17 +1719,28 @@ impl Surface { &opts, &root, default_colors, + cell_pixels, + initial_kitty_limits, )?, }; - return Self::spawn_hosted(id, opts, mux, attachment, true, resource_identity); + return Self::spawn_hosted( + id, + opts, + mux, + attachment, + kitty_reservation, + true, + resource_identity, + ); } let _ = terminal_id; - let pty = cmux_pty::open(PtySize { - rows: opts.rows, + let initial_geometry = PtyGeometry { cols: opts.cols, - pixel_width: 0, - pixel_height: 0, - })?; + rows: opts.rows, + cell_width: cell_pixels.0, + cell_height: cell_pixels.1, + }; + let pty = cmux_pty::open(initial_geometry.pty_size()?)?; let argv = opts .command @@ -1330,6 +1798,8 @@ impl Surface { }; let mut term = Terminal::new(opts.cols, opts.rows, opts.scrollback, callbacks)?; + term.resize(opts.cols, opts.rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + term.set_kitty_graphics_limits(initial_kitty_limits)?; if let Some(mux) = mux.upgrade() { let colors = mux.default_colors(); term.replace_default_colors(colors.fg, colors.bg, colors.cursor); @@ -1349,9 +1819,9 @@ impl Surface { name: Mutex::new(None), selection: Mutex::new(None), }, - term: Mutex::new(term), + term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), - mouse_encoders: Mutex::new(mouse_encoders), + mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Local { writer, master, killer }), supports_clear_history_key_fallback: AtomicBool::new( supports_clear_history_key_fallback, @@ -1371,24 +1841,40 @@ impl Surface { dirty: AtomicBool::new(false), title: Mutex::new(String::new()), pwd: Mutex::new(None), - size: Mutex::new((opts.cols, opts.rows)), + geometry: Mutex::new(initial_geometry), + kitty_graphics_limits: Box::new(Mutex::new(initial_kitty_limits)), + #[cfg(test)] + geometry_test_hook: Mutex::new(None), + #[cfg(test)] + deferred_cell_pixel_ack_test_hook: Mutex::new(None), + #[cfg(test)] + test_master_control: None, + #[cfg(test)] + vt_replay_builds: AtomicUsize::new(0), mux: mux.clone(), taps: Mutex::new(Vec::new()), attach_colors_pending: AtomicBool::new(false), attach_colors_force_pending: AtomicBool::new(false), last_attach_colors: Mutex::new(None), - render: Mutex::new(RenderHub { + render: Arc::new(Mutex::new(RenderHub { state: Box::new(render_state), built_generation: 0, latest: None, + initial_graphics: None, taps: Vec::new(), - }), + })), render_generation: AtomicU64::new(1), frame_requests, #[cfg(test)] frame_producer_before_upgrade, })); + if let Some(reservation) = kitty_reservation + && let Err(error) = reservation.commit(&surface, initial_kitty_limits) + { + surface.kill(); + return Err(error); + } spawn_frame_producer(&surface, frame_rx)?; // PTY reader: pty bytes -> terminal state -> SurfaceOutput events. @@ -1498,12 +1984,132 @@ impl Surface { Ok(surface) } + #[cfg(unix)] + fn install_deferred_cell_pixel_handler( + surface: &Arc, + responses: &Arc, + ) { + let surface = Arc::downgrade(surface); + let responses = Arc::downgrade(responses); + responses + .upgrade() + .expect("control responses are live while installing their handler") + .set_deferred_cell_pixel_handler(Arc::new(move |request_id, expected, resolution| { + if matches!( + &resolution, + crate::terminal_host_runtime::DeferredCellPixelResolution::Disconnected + ) { + return; + } + let (Some(surface), Some(responses)) = (surface.upgrade(), responses.upgrade()) + else { + return; + }; + let Some(mux) = surface.as_pty().and_then(|pty| pty.mux.upgrade()) else { + return; + }; + let queued_surface = surface.clone(); + let queued_responses = responses.clone(); + let queued_resolution = resolution.clone(); + if !mux.submit_deferred_cell_pixel_ack(move || { + queued_surface.reconcile_deferred_cell_pixel_ack( + &queued_responses, + request_id, + expected, + queued_resolution, + ); + }) { + // The bounded pool is saturated or cannot create its first + // worker. Reconcile on the already-owned host reader + // instead of dropping a valid acknowledgement. + surface.reconcile_deferred_cell_pixel_ack( + &responses, request_id, expected, resolution, + ); + } + })); + } + + #[cfg(unix)] + fn reconcile_deferred_cell_pixel_ack( + &self, + responses: &Arc, + request_id: u64, + expected: (u16, u16), + resolution: crate::terminal_host_runtime::DeferredCellPixelResolution, + ) { + #[cfg(test)] + if let Some(pty) = self.as_pty() + && let Some(hook) = pty.deferred_cell_pixel_ack_test_hook.lock().unwrap().clone() + { + hook(); + } + let crate::terminal_host_runtime::DeferredCellPixelResolution::Response(frame) = resolution + else { + // The replacement host snapshot is authoritative for an + // acknowledgement whose delivery raced a broken admin stream. + return; + }; + let Some(pty) = self.as_pty() else { return }; + let owns_response = { + let runtime = pty.runtime.lock().unwrap(); + matches!( + &*runtime, + PtyRuntime::Hosted(host) + if Arc::ptr_eq(&host.control_responses(), responses) + ) + }; + if !owns_response || responses.latest_cell_pixel_ack() > request_id { + return; + } + let expected_payload = [expected.0.to_le_bytes(), expected.1.to_le_bytes()].concat(); + if frame.kind != MessageKind::CellPixelSizeAck + || frame.payload.as_slice() != expected_payload + { + if let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() { + host.disconnect(); + } + return; + } + + let mut geometry = pty.geometry.lock().unwrap(); + let still_owns_response = { + let runtime = pty.runtime.lock().unwrap(); + matches!( + &*runtime, + PtyRuntime::Hosted(host) + if Arc::ptr_eq(&host.control_responses(), responses) + ) + }; + if !still_owns_response || responses.latest_cell_pixel_ack() > request_id { + return; + } + let next = PtyGeometry { cell_width: expected.0, cell_height: expected.1, ..*geometry }; + let committed = pty.commit_geometry(&mut geometry, next, false); + drop(geometry); + match committed { + Ok(changed) => { + if let Some(mux) = pty.mux.upgrade() { + if changed { + mux.emit(MuxEvent::SurfaceOutput(self.id)); + } + mux.reconcile_deferred_cell_pixel_ack(self.id, expected); + } + } + Err(_) => { + if let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() { + host.disconnect(); + } + } + } + } + #[cfg(unix)] fn spawn_hosted( id: SurfaceId, opts: SurfaceOptions, mux: Weak, mut attachment: crate::terminal_host_runtime::HostAttachment, + kitty_reservation: Option, terminate_on_error: bool, resource_identity: Option, ) -> anyhow::Result> { @@ -1522,13 +2128,23 @@ impl Surface { let title_changed = Arc::new(AtomicBool::new(false)); let callbacks = hosted_terminal_callbacks(id, mux.clone(), title_changed.clone()); let mut term = Terminal::new(snapshot.cols, snapshot.rows, opts.scrollback, callbacks)?; + term.resize( + snapshot.cols, + snapshot.rows, + u32::from(snapshot.cell_pixels.0), + u32::from(snapshot.cell_pixels.1), + )?; if let Some(mux) = mux.upgrade() { let colors = mux.default_colors(); term.replace_default_colors(colors.fg, colors.bg, colors.cursor); term.set_default_palette(&colors.palette); replace_ghostty_cursor_defaults(&mut term, colors); } - term.vt_write(&snapshot.replay); + term.apply_vt_replay_parts( + &snapshot.replay, + &snapshot.kitty_image_aliases, + snapshot.kitty_state, + )?; let initial_color_delta = terminal_color_override_full_state(&snapshot.colors); if !initial_color_delta.is_empty() { term.vt_write(&initial_color_delta); @@ -1538,6 +2154,7 @@ impl Surface { let mut mouse_encoders = MouseEncoders::new()?; mouse_encoders.sync_from_terminal(&term); let sequence_boundary = snapshot.sequence_boundary; + let protocol_version = attachment.protocol_version(); let host_identity = attachment.identity(); let host_exit_record_path = attachment.exit_record_path(); let supports_clear_history_key_fallback = attachment.supports_clear_history(); @@ -1552,9 +2169,9 @@ impl Surface { name: Mutex::new(None), selection: Mutex::new(None), }, - term: Mutex::new(term), + term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), - mouse_encoders: Mutex::new(mouse_encoders), + mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Hosted(Box::new(attachment))), supports_clear_history_key_fallback: AtomicBool::new( supports_clear_history_key_fallback, @@ -1573,23 +2190,39 @@ impl Surface { dirty: AtomicBool::new(true), title: Mutex::new(title), pwd: Mutex::new(pwd), - size: Mutex::new((snapshot.cols, snapshot.rows)), + geometry: Mutex::new(PtyGeometry { + cols: snapshot.cols, + rows: snapshot.rows, + cell_width: snapshot.cell_pixels.0, + cell_height: snapshot.cell_pixels.1, + }), + kitty_graphics_limits: Box::new(Mutex::new(snapshot.kitty_state.limits)), + #[cfg(test)] + geometry_test_hook: Mutex::new(None), + #[cfg(test)] + deferred_cell_pixel_ack_test_hook: Mutex::new(None), + #[cfg(test)] + test_master_control: None, + #[cfg(test)] + vt_replay_builds: AtomicUsize::new(0), mux: mux.clone(), taps: Mutex::new(Vec::new()), attach_colors_pending: AtomicBool::new(false), attach_colors_force_pending: AtomicBool::new(false), last_attach_colors: Mutex::new(None), - render: Mutex::new(RenderHub { + render: Arc::new(Mutex::new(RenderHub { state: Box::new(render_state), built_generation: 0, latest: None, + initial_graphics: None, taps: Vec::new(), - }), + })), render_generation: AtomicU64::new(1), frame_requests, #[cfg(test)] frame_producer_before_upgrade, })); + Self::install_deferred_cell_pixel_handler(&surface, &control_responses); spawn_frame_producer(&surface, frame_rx)?; // Keep exact-child rollback ownership armed through the final thread @@ -1598,11 +2231,14 @@ impl Surface { // control-write failure can convert this Err into a live orphan. std::thread::Builder::new().name(format!("surface-{id}-host")).spawn({ let surface = surface.clone(); + let mux = mux.clone(); let scrollback = opts.scrollback; move || { let mut sequence_boundary = sequence_boundary; + let mut protocol_version = protocol_version; 'connection: loop { - let mut stager = HostedFrameStager::new(sequence_boundary); + let mut stager = + HostedFrameStager::new_for_version(sequence_boundary, protocol_version); let mut received_exit = None; 'host_stream: while let Ok(Some(frame)) = crate::terminal_host_protocol::read_frame( @@ -1613,10 +2249,13 @@ impl Surface { let Some(pty) = surface.as_pty() else { break }; if matches!( frame.kind, - MessageKind::Capability | MessageKind::ClearHistoryAck + MessageKind::Capability + | MessageKind::CellPixelSizeAck + | MessageKind::KittyGraphicsLimitsAck + | MessageKind::ClearHistoryAck ) && frame.request_id != 0 { - if frame.version != PROTOCOL_VERSION + if frame.version != protocol_version || frame.flags != 0 || frame.sequence != 0 || !control_responses.resolve(&frame) @@ -1719,7 +2358,22 @@ impl Surface { }); } } - HostedTransition::ResizedWithColors { cols, rows, replay, colors } => { + HostedTransition::ResizedWithColors { + cols, + rows, + cell_pixels, + replay, + kitty_image_aliases, + kitty_state, + colors, + } => { + let mut geometry = pty.geometry.lock().unwrap(); + let next_geometry = PtyGeometry { + cols, + rows, + cell_width: cell_pixels.0, + cell_height: cell_pixels.1, + }; let defaults = mux .upgrade() .map(|mux| mux.default_colors()) @@ -1734,6 +2388,17 @@ impl Surface { else { break; }; + if replacement + .resize( + cols, + rows, + u32::from(next_geometry.cell_width), + u32::from(next_geometry.cell_height), + ) + .is_err() + { + break; + } replacement.replace_default_colors( defaults.fg, defaults.bg, @@ -1741,7 +2406,16 @@ impl Surface { ); replacement.set_default_palette(&defaults.palette); replace_ghostty_cursor_defaults(&mut replacement, defaults); - replacement.vt_write(&replay); + if replacement + .apply_vt_replay_parts( + &replay, + &kitty_image_aliases, + kitty_state, + ) + .is_err() + { + break; + } let delta = terminal_color_override_full_state(&colors); if !delta.is_empty() { replacement.vt_write(&delta); @@ -1753,11 +2427,12 @@ impl Surface { let generation = { let mut term = pty.term.lock().unwrap(); let before = terminal_scroll_position(&term); - *term = replacement; + **term = replacement; pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); - *pty.size.lock().unwrap() = (cols, rows); + *geometry = next_geometry; *pty.title.lock().unwrap() = title.clone(); *pty.pwd.lock().unwrap() = pwd; + *pty.kitty_graphics_limits.lock().unwrap() = kitty_state.limits; applied_color_overrides = colors; let after = terminal_scroll_position(&term); if before != after { @@ -1770,13 +2445,16 @@ impl Surface { pty.broadcast_attach_frame(AttachFrame::ResizedWithColors { cols, rows, - replay, + replay: replay.into(), + kitty_image_aliases, + kitty_state, colors: Box::new( pty.terminal_colors_locked(&term, defaults), ), }); pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1 }; + drop(geometry); pty.stream_progress.notify(); pty.request_frame(generation); if let Some(mux) = mux.upgrade() { @@ -1810,7 +2488,7 @@ impl Surface { HostedTransition::ResyncRequired => break, } } - control_responses.cancel_all(); + control_responses.fail_all(); let Some(pty) = surface.as_pty() else { return }; if pty.owner_detaching.load(Ordering::Acquire) { return; @@ -1838,7 +2516,7 @@ impl Surface { return; } - let mut retry_delay = Duration::from_millis(25); + let mut retry = TerminalHostReconnectBackoff::default(); loop { if pty.owner_detaching.load(Ordering::Acquire) { return; @@ -1889,17 +2567,26 @@ impl Surface { | Err(_) => {} } - let replacement = match crate::terminal_host_runtime::adopt_terminal_host( + let Some(reconnect_mux) = mux.upgrade() else { return }; + let Ok(kitty_limits) = + reconnect_mux.kitty_image_limits_for_reconnect(&surface) + else { + return; + }; + let replacement = match crate::terminal_host_runtime::adopt_terminal_host_with_kitty_limits( record, record_path, + kitty_limits, ) { Ok(replacement) if replacement.identity() == identity => replacement, Ok(_) | Err(_) => { - std::thread::sleep(retry_delay); - retry_delay = (retry_delay * 2).min(Duration::from_secs(1)); + if !retry.wait_or_fail(pty) { + return; + } continue; } }; + let replacement_protocol_version = replacement.protocol_version(); let replacement_snapshot = replacement.snapshot.clone(); let replacement_control_responses = replacement.control_responses(); let installed = { @@ -1939,9 +2626,15 @@ impl Surface { } }; if !installed { - std::thread::sleep(retry_delay); + if !retry.wait_or_fail(pty) { + return; + } continue; } + Self::install_deferred_cell_pixel_handler( + &surface, + &replacement_control_responses, + ); let replacement_reader = { let mut runtime = pty.runtime.lock().unwrap(); @@ -1949,12 +2642,21 @@ impl Surface { replacement.take_reader().ok() }; let Some(replacement_reader) = replacement_reader else { - std::thread::sleep(retry_delay); + if !retry.wait_or_fail(pty) { + return; + } continue; }; let defaults = mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); + let mut geometry = pty.geometry.lock().unwrap(); + let next_geometry = PtyGeometry { + cols: replacement_snapshot.cols, + rows: replacement_snapshot.rows, + cell_width: replacement_snapshot.cell_pixels.0, + cell_height: replacement_snapshot.cell_pixels.1, + }; let callbacks = hosted_terminal_callbacks(id, mux.clone(), title_changed.clone()); let Ok(mut replacement_term) = Terminal::new( @@ -1963,9 +2665,27 @@ impl Surface { scrollback, callbacks, ) else { - std::thread::sleep(retry_delay); + if !wait_for_reconnect_after_geometry_failure(&mut retry, pty, geometry) + { + return; + } continue; }; + if replacement_term + .resize( + next_geometry.cols, + next_geometry.rows, + u32::from(next_geometry.cell_width), + u32::from(next_geometry.cell_height), + ) + .is_err() + { + if !wait_for_reconnect_after_geometry_failure(&mut retry, pty, geometry) + { + return; + } + continue; + } replacement_term.replace_default_colors( defaults.fg, defaults.bg, @@ -1973,7 +2693,20 @@ impl Surface { ); replacement_term.set_default_palette(&defaults.palette); replace_ghostty_cursor_defaults(&mut replacement_term, defaults); - replacement_term.vt_write(&replacement_snapshot.replay); + if replacement_term + .apply_vt_replay_parts( + &replacement_snapshot.replay, + &replacement_snapshot.kitty_image_aliases, + replacement_snapshot.kitty_state, + ) + .is_err() + { + if !wait_for_reconnect_after_geometry_failure(&mut retry, pty, geometry) + { + return; + } + continue; + } let color_delta = terminal_color_override_full_state(&replacement_snapshot.colors); if !color_delta.is_empty() { @@ -1984,54 +2717,115 @@ impl Surface { let pwd = replacement_term.pwd(); let generation = { let mut term = pty.term.lock().unwrap(); - *term = replacement_term; + **term = replacement_term; pty.mouse_encoders.lock().unwrap().sync_from_terminal(&term); - *pty.size.lock().unwrap() = - (replacement_snapshot.cols, replacement_snapshot.rows); + *geometry = next_geometry; *pty.title.lock().unwrap() = title.clone(); *pty.pwd.lock().unwrap() = pwd; + *pty.kitty_graphics_limits.lock().unwrap() = + replacement_snapshot.kitty_state.limits; applied_color_overrides = replacement_snapshot.colors; pty.broadcast_attach_frame(AttachFrame::ResizedWithColors { cols: replacement_snapshot.cols, rows: replacement_snapshot.rows, - replay: replacement_snapshot.replay, + replay: replacement_snapshot.replay.into(), + kitty_image_aliases: replacement_snapshot.kitty_image_aliases, + kitty_state: replacement_snapshot.kitty_state, colors: Box::new(pty.terminal_colors_locked(&term, defaults)), }); pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1 }; + drop(geometry); pty.stream_progress.notify_reconnect(); pty.request_frame(generation); + if !reconnect_mux.terminal_host_reconnected( + surface.id, + &identity, + replacement_snapshot.kitty_state.limits, + ) { + replacement_control_responses.fail_all(); + if let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() + && host.identity() == identity + { + host.disconnect(); + } + pty.host_connection_state.store( + TerminalHostConnectionState::Reconnecting as u8, + Ordering::Release, + ); + if !reconnect_mux.terminal_host_connection_lost(surface.id, &identity) { + pty.host_connection_state.store( + TerminalHostConnectionState::Failed as u8, + Ordering::Release, + ); + return; + } + if !retry.wait_or_fail(pty) { + return; + } + continue; + } + reconnect_mux.reconcile_deferred_cell_pixel_ack( + surface.id, + replacement_snapshot.cell_pixels, + ); + reconnect_mux.emit(MuxEvent::TitleChanged { + surface: surface.id, + title: title.into(), + }); + reconnect_mux.emit(MuxEvent::SurfaceResized { + surface: surface.id, + cols: replacement_snapshot.cols, + rows: replacement_snapshot.rows, + reservation_id: None, + }); reader = replacement_reader; control_responses = replacement_control_responses; sequence_boundary = replacement_snapshot.sequence_boundary; + protocol_version = replacement_protocol_version; pty.host_connection_state .store(TerminalHostConnectionState::Connected as u8, Ordering::Release); - if let Some(mux) = mux.upgrade() { - mux.emit(MuxEvent::TitleChanged { - surface: surface.id, - title: title.into(), - }); - mux.emit(MuxEvent::SurfaceResized { - surface: surface.id, - cols: replacement_snapshot.cols, - rows: replacement_snapshot.rows, - reservation_id: None, - }); - if !mux.terminal_host_reconnected(surface.id, &identity) { - return; - } - } continue 'connection; } } } })?; + let kitty_registration = kitty_reservation.map_or(Ok(()), |reservation| { + reservation.commit(&surface, snapshot.kitty_state.limits) + }); + if let Err(error) = kitty_registration { + if let Some(pty) = surface.as_pty() + && let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() + { + if terminate_on_error { + let _ = host.terminate(); + } + host.disconnect(); + } + return Err(error); + } if terminate_on_error && let Some(pty) = surface.as_pty() && let PtyRuntime::Hosted(host) = &mut *pty.runtime.lock().unwrap() { host.commit_launched_host(); } + #[cfg(debug_assertions)] + if let Some(delay) = + mux.upgrade().and_then(|mux| mux.take_test_terminal_host_disconnect_after_spawn()) + { + let test_surface = surface.clone(); + let _ = std::thread::Builder::new().name("terminal-host-test-disconnect".into()).spawn( + move || { + std::thread::sleep(delay); + if let Some(pty) = test_surface.as_pty() + && let PtyRuntime::Hosted(host) = &*pty.runtime.lock().unwrap() + { + host.disconnect(); + } + }, + ); + } Ok(surface) } @@ -2062,8 +2856,29 @@ impl Surface { record_path: PathBuf, resource_identity: TabResourceIdentity, ) -> anyhow::Result> { - let attachment = crate::terminal_host_runtime::adopt_terminal_host(record, record_path)?; - Self::spawn_hosted(id, opts, mux, attachment, false, Some(resource_identity)) + if matches!(resource_identity.content_id, crate::resource::ContentPublicId::Browser(_)) { + anyhow::bail!("terminal host cannot use a browser resource identity"); + } + let kitty_reservation = + mux.upgrade().map(|mux| mux.reserve_kitty_image_surface(id)).transpose()?; + let initial_kitty_limits = kitty_reservation + .as_ref() + .map(crate::mux::KittyImageBudgetReservation::initial_limits) + .unwrap_or_default(); + let attachment = crate::terminal_host_runtime::adopt_terminal_host_with_kitty_limits( + record, + record_path, + initial_kitty_limits, + )?; + Self::spawn_hosted( + id, + opts, + mux, + attachment, + kitty_reservation, + false, + Some(resource_identity), + ) } /// Materialize canonical Exited registry state without inventing a live @@ -2097,10 +2912,15 @@ impl Surface { if matches!(resource_identity.content_id, crate::resource::ContentPublicId::Browser(_)) { anyhow::bail!("exited terminal cannot use a browser resource identity"); } + let initial_kitty_limits = KittyGraphicsLimits::disabled(); let title_changed = Arc::new(AtomicBool::new(false)); let callbacks = hosted_terminal_callbacks(id, mux.clone(), title_changed); let (cols, rows) = (opts.cols.max(1), opts.rows.max(1)); + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); let mut term = Terminal::new(cols, rows, opts.scrollback, callbacks)?; + term.resize(cols, rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + term.set_kitty_graphics_limits(initial_kitty_limits)?; if let Some(mux) = mux.upgrade() { let colors = mux.default_colors(); term.replace_default_colors(colors.fg, colors.bg, colors.cursor); @@ -2125,9 +2945,9 @@ impl Surface { name: Mutex::new(None), selection: Mutex::new(None), }, - term: Mutex::new(term), + term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), - mouse_encoders: Mutex::new(mouse_encoders), + mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::ExitedHosted), supports_clear_history_key_fallback: AtomicBool::new(false), host_identity: Some(identity), @@ -2144,18 +2964,33 @@ impl Surface { dirty: AtomicBool::new(true), title: Mutex::new(String::new()), pwd: Mutex::new(None), - size: Mutex::new((cols, rows)), + geometry: Mutex::new(PtyGeometry { + cols, + rows, + cell_width: cell_pixels.0, + cell_height: cell_pixels.1, + }), + kitty_graphics_limits: Box::new(Mutex::new(initial_kitty_limits)), + #[cfg(test)] + geometry_test_hook: Mutex::new(None), + #[cfg(test)] + deferred_cell_pixel_ack_test_hook: Mutex::new(None), + #[cfg(test)] + test_master_control: None, + #[cfg(test)] + vt_replay_builds: AtomicUsize::new(0), mux, taps: Mutex::new(Vec::new()), attach_colors_pending: AtomicBool::new(false), attach_colors_force_pending: AtomicBool::new(false), last_attach_colors: Mutex::new(None), - render: Mutex::new(RenderHub { + render: Arc::new(Mutex::new(RenderHub { state: Box::new(render_state), built_generation: 0, latest: None, + initial_graphics: None, taps: Vec::new(), - }), + })), render_generation: AtomicU64::new(1), frame_requests, #[cfg(test)] @@ -2171,11 +3006,30 @@ impl Surface { opts: SurfaceOptions, mux: Weak, ) -> anyhow::Result> { - Self::spawn_for_test_with_resource_identity( + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_for_test_with_resource_identity_at_cell_pixels( id, opts, mux, Some(TabResourceIdentity::terminal(None)?), + cell_pixels, + ) + } + + #[cfg(test)] + pub(crate) fn spawn_for_test_at_cell_pixels( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + cell_pixels: (u16, u16), + ) -> anyhow::Result> { + Self::spawn_for_test_with_resource_identity_at_cell_pixels( + id, + opts, + mux, + Some(TabResourceIdentity::terminal(None)?), + cell_pixels, ) } @@ -2186,6 +3040,43 @@ impl Surface { mux: Weak, resource_identity: Option, ) -> anyhow::Result> { + let cell_pixels = + mux.upgrade().map(|mux| mux.cell_pixel_creation_size()).unwrap_or((8, 16)); + Self::spawn_for_test_with_resource_identity_at_cell_pixels( + id, + opts, + mux, + resource_identity, + cell_pixels, + ) + } + + #[cfg(test)] + pub(crate) fn spawn_for_test_with_resource_identity_at_cell_pixels( + id: SurfaceId, + opts: SurfaceOptions, + mux: Weak, + resource_identity: Option, + cell_pixels: (u16, u16), + ) -> anyhow::Result> { + if resource_identity.as_ref().is_some_and(|identity| { + matches!(identity.content_id, crate::resource::ContentPublicId::Browser(_)) + }) { + anyhow::bail!("terminal surface cannot use a browser resource identity"); + } + let kitty_reservation = + mux.upgrade().map(|mux| mux.reserve_kitty_image_surface(id)).transpose()?; + let initial_kitty_limits = kitty_reservation + .as_ref() + .map(crate::mux::KittyImageBudgetReservation::initial_limits) + .unwrap_or_default(); + let initial_geometry = PtyGeometry { + cols: opts.cols, + rows: opts.rows, + cell_width: cell_pixels.0, + cell_height: cell_pixels.1, + }; + let initial_pty_size = initial_geometry.pty_size()?; let callbacks = Callbacks { on_bell: Some(Box::new({ let mux = mux.clone(); @@ -2199,6 +3090,8 @@ impl Surface { }; let mut term = Terminal::new(opts.cols, opts.rows, opts.scrollback, callbacks)?; + term.resize(opts.cols, opts.rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + term.set_kitty_graphics_limits(initial_kitty_limits)?; if let Some(mux) = mux.upgrade() { let colors = mux.default_colors(); term.replace_default_colors(colors.fg, colors.bg, colors.cursor); @@ -2210,27 +3103,24 @@ impl Surface { let render_state = RenderState::new()?; let (frame_requests, _frame_rx) = sync_channel(1); + let test_master_control = Arc::new(TestMasterPtyControl::default()); let frame_producer_before_upgrade = Arc::new(Mutex::new(None)); - Ok(Arc::new(Surface::Pty(PtySurface { + let surface = Arc::new(Surface::Pty(PtySurface { meta: SurfaceMeta { id, resource_identity, name: Mutex::new(None), selection: Mutex::new(None), }, - term: Mutex::new(term), + term: Mutex::new(Box::new(term)), stream_progress: Box::new(TerminalStreamProgress::default()), - mouse_encoders: Mutex::new(mouse_encoders), + mouse_encoders: Mutex::new(Box::new(mouse_encoders)), runtime: Mutex::new(PtyRuntime::Local { writer: Box::new(std::io::sink()), master: Box::new(TestMasterPty { - size: Mutex::new(PtySize { - rows: opts.rows, - cols: opts.cols, - pixel_width: 0, - pixel_height: 0, - }), + size: Mutex::new(initial_pty_size), + control: test_master_control.clone(), }), killer: Box::new(TestChildKiller), }), @@ -2250,22 +3140,32 @@ impl Surface { dirty: AtomicBool::new(false), title: Mutex::new(String::new()), pwd: Mutex::new(None), - size: Mutex::new((opts.cols, opts.rows)), + geometry: Mutex::new(initial_geometry), + kitty_graphics_limits: Box::new(Mutex::new(initial_kitty_limits)), + geometry_test_hook: Mutex::new(None), + deferred_cell_pixel_ack_test_hook: Mutex::new(None), + test_master_control: Some(test_master_control), + vt_replay_builds: AtomicUsize::new(0), mux, taps: Mutex::new(Vec::new()), attach_colors_pending: AtomicBool::new(false), attach_colors_force_pending: AtomicBool::new(false), last_attach_colors: Mutex::new(None), - render: Mutex::new(RenderHub { + render: Arc::new(Mutex::new(RenderHub { state: Box::new(render_state), built_generation: 0, latest: None, + initial_graphics: None, taps: Vec::new(), - }), + })), render_generation: AtomicU64::new(1), frame_requests, frame_producer_before_upgrade, - }))) + })); + if let Some(reservation) = kitty_reservation { + reservation.commit(&surface, initial_kitty_limits)?; + } + Ok(surface) } fn as_pty(&self) -> Option<&PtySurface> { @@ -2369,6 +3269,87 @@ impl Surface { Some(result) } + fn configure_terminal_kitty_graphics_limits( + terminal: &mut Terminal, + limits: KittyGraphicsLimits, + ) -> anyhow::Result { + terminal.set_kitty_graphics_limits(limits).map_err(Into::into) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn set_kitty_graphics_limits( + &self, + bytes: u64, + inflight_bytes: u64, + images: u64, + placements: u64, + ) -> anyhow::Result<()> { + let requested = + KittyGraphicsLimits { image_bytes: bytes, inflight_bytes, images, placements }; + self.set_kitty_graphics_limits_until( + requested, + Instant::now() + crate::terminal_host_runtime::CONTROL_RESPONSE_TIMEOUT, + ) + } + + pub(crate) fn set_kitty_graphics_limits_until( + &self, + requested: KittyGraphicsLimits, + deadline: Instant, + ) -> anyhow::Result<()> { + let Some(pty) = self.as_pty() else { + return Ok(()); + }; + let requested = requested + .validate() + .map_err(|_| anyhow::anyhow!("Kitty graphics limits are out of range"))?; + #[cfg(unix)] + let next = { + let runtime = pty.runtime.lock().unwrap(); + if let PtyRuntime::Hosted(host) = &*runtime { + if *pty.kitty_graphics_limits.lock().unwrap() == requested { + return Ok(()); + } + if host.send_kitty_graphics_limits_until(requested, deadline)? { + // The host publishes a complete replacement before its + // acknowledgement. The reader has therefore committed the + // authoritative parser and cache before this returns. + return Ok(()); + } + // Older hosts cannot carry Kitty sidecar state. Keep the + // disposable mirror disabled so it cannot silently diverge. + KittyGraphicsLimits::disabled() + } else { + requested + } + }; + #[cfg(not(unix))] + let next = requested; + let graphics_changed = { + let mut term = pty.term.lock().unwrap(); + let mut limits = pty.kitty_graphics_limits.lock().unwrap(); + if *limits == next { + return Ok(()); + } + let graphics_changed = Self::configure_terminal_kitty_graphics_limits(&mut term, next)?; + *limits = next; + pty.resynchronize_attach_taps_locked(&mut term); + if graphics_changed { + let mut render = pty.render.lock().unwrap(); + render.state.clear_kitty_graphics_cache(); + render.latest = None; + render.initial_graphics = None; + } + graphics_changed + }; + if !graphics_changed { + return Ok(()); + } + let generation = pty.render_generation.fetch_add(1, Ordering::AcqRel) + 1; + pty.request_frame(generation); + Ok(()) + } + /// Return the coalesced revision advanced after terminal output or another /// viewport-text transition is applied. Callers can snapshot terminal /// state after reading this value, then wait on the same revision without @@ -2911,7 +3892,7 @@ impl Surface { /// reconfiguration completes on its worker and emits the final size there. pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { match self { - Surface::Pty(pty) => Ok(pty.resize(cols, rows)), + Surface::Pty(pty) => pty.resize(cols, rows), Surface::Browser(browser) => browser.resize(cols, rows), } } @@ -2941,11 +3922,16 @@ impl Surface { report: Box) + Send>, ) -> anyhow::Result> { match self { - Surface::Pty(pty) => { - let accepted = pty.resize(cols, rows); - report(accepted.then_some(0)); - Ok(accepted.then_some(0)) - } + Surface::Pty(pty) => match pty.resize(cols, rows) { + Ok(accepted) => { + report(accepted.then_some(0)); + Ok(accepted.then_some(0)) + } + Err(error) => { + report(None); + Err(error) + } + }, Surface::Browser(browser) => browser.resize_reporting_acceptance(cols, rows, report), } } @@ -2958,14 +3944,22 @@ impl Surface { completion: Option, ) -> anyhow::Result> { match self { - Surface::Pty(pty) => { - let accepted = pty.resize(cols, rows); - report(accepted.then_some(0)); - if let Some(completion) = completion { - let _ = completion.send(Ok(())); + Surface::Pty(pty) => match pty.resize(cols, rows) { + Ok(accepted) => { + report(accepted.then_some(0)); + if let Some(completion) = completion { + let _ = completion.send(Ok(())); + } + Ok(accepted.then_some(0)) } - Ok(accepted.then_some(0)) - } + Err(error) => { + report(None); + if let Some(completion) = completion { + let _ = completion.send(Err(error.to_string().into())); + } + Err(error) + } + }, Surface::Browser(browser) => { browser.resize_reporting_completion(cols, rows, report, completion) } @@ -2975,7 +3969,10 @@ impl Surface { pub fn resize_needed(&self, cols: u16, rows: u16) -> bool { let desired = (cols.max(1), rows.max(1)); match self { - Surface::Pty(pty) => *pty.size.lock().unwrap() != desired, + Surface::Pty(pty) => { + let geometry = *pty.geometry.lock().unwrap(); + (geometry.cols, geometry.rows) != desired + } Surface::Browser(browser) => browser.resize_needed(desired.0, desired.1), } } @@ -3002,21 +3999,93 @@ impl Surface { height_px: u16, report: Box) + Send>, ) -> anyhow::Result> { - if let Some(browser) = self.as_browser() { - browser.set_cell_pixel_size_reporting(width_px, height_px, report) - } else { - report(None); - Ok(None) + match self { + Surface::Pty(pty) => match pty.set_cell_pixel_size(width_px, height_px) { + Ok(changed) => { + report(changed.then_some(0)); + Ok(changed.then_some(0)) + } + Err(error) => { + report(None); + Err(error) + } + }, + Surface::Browser(browser) => { + browser.set_cell_pixel_size_reporting(width_px, height_px, report) + } + } + } + + pub(crate) fn set_cell_pixel_size_reporting_until( + &self, + width_px: u16, + height_px: u16, + deadline: Instant, + report: Box) + Send>, + ) -> anyhow::Result> { + match self { + Surface::Pty(pty) => { + match pty.set_cell_pixel_size_until(width_px, height_px, Some(deadline)) { + Ok(changed) => { + report(changed.then_some(0)); + Ok(changed.then_some(0)) + } + Err(error) => { + report(None); + Err(error) + } + } + } + Surface::Browser(browser) => { + browser.set_cell_pixel_size_reporting(width_px, height_px, report) + } } } pub fn size(&self) -> (u16, u16) { match self { - Surface::Pty(pty) => *pty.size.lock().unwrap(), + Surface::Pty(pty) => { + let geometry = *pty.geometry.lock().unwrap(); + (geometry.cols, geometry.rows) + } Surface::Browser(browser) => browser.size(), } } + pub(crate) fn cell_pixel_size(&self) -> (u16, u16) { + match self { + Surface::Pty(pty) => { + let geometry = *pty.geometry.lock().unwrap(); + (geometry.cell_width, geometry.cell_height) + } + Surface::Browser(browser) => browser.cell_pixel_size(), + } + } + + #[cfg(test)] + pub(crate) fn fail_next_test_master_resize(&self) { + self.as_pty() + .and_then(|pty| pty.test_master_control.as_ref()) + .expect("test PTY surface") + .fail_next_resize + .store(true, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn test_master_size(&self) -> PtySize { + let runtime = self.as_pty().expect("test PTY surface").runtime.lock().unwrap(); + let PtyRuntime::Local { master, .. } = &*runtime else { + panic!("test PTY surface uses a local runtime"); + }; + master.get_size().unwrap() + } + + #[cfg(test)] + pub(crate) fn test_cell_pixel_size(&self) -> (u16, u16) { + let geometry = *self.as_pty().expect("test PTY surface").geometry.lock().unwrap(); + (geometry.cell_width, geometry.cell_height) + } + /// Stop the daemon's durable hosted-terminal mirror from constraining the /// host grid when the mux has no size-participating viewer for this /// surface. A later viewer report re-registers through `resize`. @@ -3146,6 +4215,8 @@ impl Surface { let queued_bytes = Arc::new(AtomicUsize::new(0)); // Snapshot and tap registration under the same terminal lock: // the reader thread cannot apply bytes between the two. + #[cfg(test)] + pty.vt_replay_builds.fetch_add(1, Ordering::AcqRel); let replay = term.vt_replay_bounded(VT_REPLAY_MAX_BYTES)?; let (cols, rows) = (term.cols(), term.rows()); let defaults = pty.mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); @@ -3166,9 +4237,15 @@ impl Surface { Ok(AttachStream { cols, rows, - replay, + replay: replay.bytes.into(), + kitty_image_aliases: replay.kitty_image_aliases, + kitty_state: replay.kitty_state, colors, - stream: AttachFrameReceiver { receiver: rx, queued_bytes }, + stream: AttachFrameReceiver { + receiver: rx, + queued_bytes, + lifecycle: lifecycle.clone(), + }, lifecycle, }) } @@ -3179,39 +4256,64 @@ impl Surface { let Some(pty) = self.as_pty() else { return Err(ghostty_vt::Error::InvalidValue); }; + let permit = pty + .mux + .upgrade() + .and_then(|mux| mux.claim_render_attachment()) + .ok_or(ghostty_vt::Error::OutOfSpace)?; let mut term = pty.term.lock().unwrap(); let generation = pty.render_generation.load(Ordering::Acquire); let _ = pty.build_frame_locked(&mut term, generation, false)?; - let (tx, rx) = std::sync::mpsc::channel(); + let (tap, stream) = RenderTap::pair(&pty.render); let initial = { let mut render = pty.render.lock().unwrap(); - let initial = render.latest.clone().ok_or(ghostty_vt::Error::NoValue)?; + let shared = render.latest.clone().ok_or(ghostty_vt::Error::NoValue)?; + let initial_graphics = match render.initial_graphics.as_ref() { + Some(cached) if Arc::ptr_eq(&cached.source, &shared.frame.kitty_graphics) => { + cached.snapshot.clone() + } + _ => { + let snapshot = render.state.snapshot_kitty_graphics(&term, true)?; + render.initial_graphics = Some(InitialGraphicsSnapshot { + source: shared.frame.kitty_graphics.clone(), + snapshot: snapshot.clone(), + }); + snapshot + } + }; + let mut initial = (*shared).clone(); + initial.frame.kitty_graphics = initial_graphics; if !pty.dead.load(Ordering::Acquire) { - render.taps.push(tx); + render.taps.push(tap); } - initial + Arc::new(initial) }; - Ok(RenderAttachStream { initial, stream: rx }) + Ok(RenderAttachStream { initial, stream, _permit: permit }) } pub fn kill(&self) { match self { Surface::Pty(pty) => { - let mut runtime = pty.runtime.lock().unwrap(); - match &mut *runtime { - PtyRuntime::Local { killer, .. } => { - let _ = killer.kill(); + { + let mut runtime = pty.runtime.lock().unwrap(); + match &mut *runtime { + PtyRuntime::Local { killer, .. } => { + let _ = killer.kill(); + } + #[cfg(unix)] + PtyRuntime::Hosted(host) => { + // The host owns record cleanup and removes it only + // after the PTY process has actually exited. Unlinking + // here would make a failed Terminate write turn a live + // shell into an undiscoverable orphan. + let _ = host.terminate(); + } + #[cfg(unix)] + PtyRuntime::ExitedHosted => {} } - #[cfg(unix)] - PtyRuntime::Hosted(host) => { - // The host owns record cleanup and removes it only - // after the PTY process has actually exited. Unlinking - // here would make a failed Terminate write turn a live - // shell into an undiscoverable orphan. - let _ = host.terminate(); - } - #[cfg(unix)] - PtyRuntime::ExitedHosted => {} + } + if let Some(mux) = pty.mux.upgrade() { + let _ = mux.unregister_kitty_image_surface(self); } } Surface::Browser(browser) => browser.kill(), @@ -3256,6 +4358,10 @@ impl Surface { self.as_browser().and_then(BrowserSurface::latest_frame) } + pub fn browser_frame_metadata(&self) -> Option<(u64, u32, u32, Option)> { + self.as_browser().and_then(BrowserSurface::latest_frame_metadata) + } + pub fn browser_frame_update(&self) -> Option { self.as_browser().and_then(BrowserSurface::latest_frame_update) } @@ -3592,11 +4698,21 @@ impl Surface { #[cfg(test)] struct TestMasterPty { size: Mutex, + control: Arc, +} + +#[cfg(test)] +#[derive(Default)] +struct TestMasterPtyControl { + fail_next_resize: AtomicBool, } #[cfg(test)] impl MasterPty for TestMasterPty { fn resize(&self, size: PtySize) -> anyhow::Result<()> { + if self.control.fail_next_resize.swap(false, Ordering::AcqRel) { + anyhow::bail!("injected PTY master resize failure"); + } *self.size.lock().unwrap() = size; Ok(()) } @@ -3684,6 +4800,14 @@ impl ChildKiller for TestChildKiller { } impl PtySurface { + #[cfg(test)] + fn run_geometry_test_hook(&self, step: PtyGeometryTestStep) { + let hook = self.geometry_test_hook.lock().unwrap().clone(); + if let Some(hook) = hook { + hook(step); + } + } + /// Snapshot sparse colors and the host-resolved cursor visual without /// touching the shared renderer or consuming its damage. fn terminal_colors_locked(&self, term: &Terminal, defaults: DefaultColors) -> TerminalColors { @@ -3704,6 +4828,44 @@ impl PtySurface { self.taps.lock().unwrap().retain(|tap| tap.try_send(frame.clone())); } + /// Replace every byte-stream mirror after a sidecar-only state change. + /// Limit eviction has no PTY bytes, so continuing the old stream without + /// this replay would leave mirrors on a different Kitty scene. + fn resynchronize_attach_taps_locked(&self, term: &mut Terminal) { + { + let mut taps = self.taps.lock().unwrap(); + taps.retain(|tap| !tap.lifecycle.is_canceled()); + if taps.is_empty() { + return; + } + } + let replay = match term.vt_replay_bounded(VT_REPLAY_MAX_BYTES) { + Ok(replay) => replay, + Err(_) => { + let mut taps = self.taps.lock().unwrap(); + for tap in &*taps { + tap.lifecycle.cancel(); + } + taps.clear(); + return; + } + }; + let defaults = self.mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); + let colors = Box::new(self.terminal_colors_locked(term, defaults)); + self.attach_colors_pending.store(false, Ordering::Release); + self.attach_colors_force_pending.store(false, Ordering::Release); + *self.last_attach_colors.lock().unwrap() = + Some(Box::new(TerminalColors::from_pty_output(term, defaults))); + self.broadcast_attach_frame(AttachFrame::ResizedWithColors { + cols: term.cols(), + rows: term.rows(), + replay: replay.bytes.into(), + kitty_image_aliases: replay.kitty_image_aliases, + kitty_state: replay.kitty_state, + colors, + }); + } + /// Emit at most one latest effective palette snapshot per frame cadence. /// The caller holds `term`, so attach registration cannot interleave with /// the snapshot or miss a state transition. @@ -3793,13 +4955,21 @@ impl PtySurface { frame: render.state.build_frame()?, content_generation: generation, scrollback_rows: term.history_rows(), + history_epoch: term.history_epoch(), pointer_semantics: term.pointer_semantic_snapshot(), palette_colors, palette_overridden, }); + if render + .initial_graphics + .as_ref() + .is_some_and(|cached| !Arc::ptr_eq(&cached.source, &frame.frame.kitty_graphics)) + { + render.initial_graphics = None; + } render.built_generation = generation; render.latest = Some(frame.clone()); - render.taps.retain(|tap| tap.send(RenderAttachFrame::Frame(frame.clone())).is_ok()); + render.taps.retain(|tap| tap.send(RenderAttachFrame::Frame(frame.clone()))); true } }; @@ -3812,63 +4982,239 @@ impl PtySurface { /// Resize both the PTY and the terminal state. Returns whether the /// final clamped size actually changed. - fn resize(&self, cols: u16, rows: u16) -> bool { + fn resize(&self, cols: u16, rows: u16) -> anyhow::Result { + #[cfg(test)] + self.run_geometry_test_hook(PtyGeometryTestStep::ResizeStarted); let (cols, rows) = (cols.max(1), rows.max(1)); + let mut geometry = self.geometry.lock().unwrap(); + let next = PtyGeometry { cols, rows, ..*geometry }; + next.pty_size()?; #[cfg(unix)] { let runtime = self.runtime.lock().unwrap(); if let PtyRuntime::Hosted(host) = &*runtime { - if *self.size.lock().unwrap() == (cols, rows) - && host.viewer_size() == Some((cols, rows)) - { - return false; + if *geometry == next && host.viewer_size() == Some((cols, rows)) { + return Ok(false); } // Do not speculatively reflow the mirror. The host returns a // Resized+Colors pair containing the canonical post-resize // replay, which the reader installs as one transition. - return host.send_viewer_size(cols, rows).is_ok(); + return Ok(host.send_viewer_size(cols, rows).is_ok()); + } + if matches!(&*runtime, PtyRuntime::ExitedHosted) { + return Ok(false); } } + self.commit_geometry(&mut geometry, next, true) + } + + fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { + self.set_cell_pixel_size_until(width_px, height_px, None) + } + + fn set_cell_pixel_size_until( + &self, + width_px: u16, + height_px: u16, + deadline: Option, + ) -> anyhow::Result { + #[cfg(test)] + self.run_geometry_test_hook(PtyGeometryTestStep::CellPixelStarted); + let requested = (width_px.max(1), height_px.max(1)); { - let mut size = self.size.lock().unwrap(); - if *size == (cols, rows) { - return false; + let geometry = self.geometry.lock().unwrap(); + if (geometry.cell_width, geometry.cell_height) == requested { + return Ok(false); } - *size = (cols, rows); + PtyGeometry { cell_width: requested.0, cell_height: requested.1, ..*geometry } + .pty_size()?; } - // Hold the terminal lock while resizing and while sending the - // attach marker, so attach mirrors observe bytes and resizes in - // the exact order the server terminal applied them. - let mut term = self.term.lock().unwrap(); + #[cfg(unix)] { - let mut runtime = self.runtime.lock().unwrap(); - match &mut *runtime { - PtyRuntime::Local { master, .. } => { - let _ = master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }); + let runtime = self.runtime.lock().unwrap(); + match &*runtime { + PtyRuntime::Hosted(host) => { + let accepted = match deadline { + Some(deadline) => { + host.send_cell_pixel_size_until(requested.0, requested.1, deadline)? + } + None => host.send_cell_pixel_size(requested.0, requested.1)?, + }; + if !accepted { + return Ok(false); + } + drop(runtime); + // The host publishes Resized+Colors before its targeted + // acknowledgement. The reader therefore installs the + // canonical parser and metrics before this wait returns. + let geometry = self.geometry.lock().unwrap(); + if (geometry.cell_width, geometry.cell_height) != requested { + drop(geometry); + if let PtyRuntime::Hosted(host) = &*self.runtime.lock().unwrap() { + host.disconnect(); + } + anyhow::bail!( + "terminal host acknowledged cell metrics without publishing \ + the canonical geometry transition" + ); + } + return Ok(true); } - #[cfg(unix)] - PtyRuntime::Hosted(_) => unreachable!("hosted resize returned above"), - #[cfg(unix)] - PtyRuntime::ExitedHosted => return false, + PtyRuntime::ExitedHosted => return Ok(false), + PtyRuntime::Local { .. } => {} } } - // Nominal cell metrics; only pixel size reports observe these. - let _ = term.resize(cols, rows, 8, 16); - let replay = term.vt_replay_bounded(VT_REPLAY_MAX_BYTES).unwrap_or_default(); - let defaults = self.mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); + let mut geometry = self.geometry.lock().unwrap(); + let next = PtyGeometry { cell_width: requested.0, cell_height: requested.1, ..*geometry }; + next.pty_size()?; + self.commit_geometry(&mut geometry, next, false) + } + + /// Commit the PTY ioctl or hosted mirror metrics, Ghostty geometry, and + /// the published logical tuple while holding one geometry transaction. + fn commit_geometry( + &self, + geometry: &mut PtyGeometry, + next: PtyGeometry, + refresh_attach_colors: bool, + ) -> anyhow::Result { + if *geometry == next { + return Ok(false); + } + let previous = *geometry; + let next_pty_size = next.pty_size()?; + let previous_pty_size = previous.pty_size()?; + // Hold the terminal lock while resizing and while sending the attach + // marker, so mirrors observe bytes and geometry in server order. + let mut term = self.term.lock().unwrap(); + let runtime = self.runtime.lock().unwrap(); + let master = match &*runtime { + PtyRuntime::Local { master, .. } => Some(master.as_ref()), + #[cfg(unix)] + PtyRuntime::Hosted(_) => None, + #[cfg(unix)] + PtyRuntime::ExitedHosted => return Ok(false), + }; + let has_attach_taps = { + let mut taps = self.taps.lock().unwrap(); + taps.retain(|tap| !tap.lifecycle.is_canceled()); + !taps.is_empty() + }; + // The only replay state that cannot be bounded by dropping old text + // and completed graphics is an oversized in-flight Kitty upload. + // Reject it before resize mutates Ghostty's reflow and scrollback. + if has_attach_taps { + term.preflight_vt_replay_bounded(VT_REPLAY_MAX_BYTES).map_err(|error| { + anyhow::anyhow!( + "could not preflight attach replay before resizing PTY surface to {}x{} at \ + {}x{} px per cell: {error}; geometry unchanged", + next.cols, + next.rows, + next.cell_width, + next.cell_height + ) + })?; + } + if let Some(master) = master { + master.resize(next_pty_size).map_err(|error| { + anyhow::anyhow!( + "could not resize PTY master to {}x{} at {}x{} px per cell: {error}", + next.cols, + next.rows, + next.cell_width, + next.cell_height + ) + })?; + } + if let Err(error) = term.resize( + next.cols, + next.rows, + u32::from(next.cell_width), + u32::from(next.cell_height), + ) { + let rollback = master.map_or(Ok(()), |master| master.resize(previous_pty_size)); + return match rollback { + Ok(()) => Err(anyhow::anyhow!( + "could not resize Ghostty terminal to {}x{} at {}x{} px per cell: {error}", + next.cols, + next.rows, + next.cell_width, + next.cell_height + )), + Err(rollback_error) => Err(anyhow::anyhow!( + "could not resize Ghostty terminal to {}x{} at {}x{} px per cell: {error}; \ + PTY master rollback also failed: {rollback_error}", + next.cols, + next.rows, + next.cell_width, + next.cell_height + )), + }; + } + let replay = if has_attach_taps { + #[cfg(test)] + self.vt_replay_builds.fetch_add(1, Ordering::AcqRel); + match term.vt_replay_bounded(VT_REPLAY_MAX_BYTES) { + Ok(replay) => Some(replay), + Err(_) => { + // Budget failure was already ruled out under this same + // terminal lock. A formatter/backend failure must not be + // answered with a destructive inverse resize. Disconnect + // byte mirrors so they reattach from fresh state. + let mut taps = self.taps.lock().unwrap(); + for tap in &*taps { + tap.lifecycle.cancel(); + } + taps.clear(); + None + } + } + } else { + None + }; + drop(runtime); + *geometry = next; + #[cfg(test)] + self.run_geometry_test_hook(if refresh_attach_colors { + PtyGeometryTestStep::ResizeCommitBoundary + } else { + PtyGeometryTestStep::CellPixelCommitBoundary + }); let generation = self.render_generation.fetch_add(1, Ordering::AcqRel) + 1; let _ = self.build_frame_locked(&mut term, generation, false); - let live_colors = TerminalColors::from_pty_output(&term, defaults); - let colors = Box::new(self.terminal_colors_locked(&term, defaults)); - self.attach_colors_pending.store(false, Ordering::Release); - self.attach_colors_force_pending.store(false, Ordering::Release); - *self.last_attach_colors.lock().unwrap() = Some(Box::new(live_colors)); - self.broadcast_attach_frame(AttachFrame::ResizedWithColors { cols, rows, replay, colors }); + if let Some(replay) = replay { + let defaults = self.mux.upgrade().map(|mux| mux.default_colors()).unwrap_or_default(); + let colors = Box::new(self.terminal_colors_locked(&term, defaults)); + if refresh_attach_colors { + let live_colors = TerminalColors::from_pty_output(&term, defaults); + self.attach_colors_pending.store(false, Ordering::Release); + self.attach_colors_force_pending.store(false, Ordering::Release); + *self.last_attach_colors.lock().unwrap() = Some(Box::new(live_colors)); + } + self.broadcast_attach_frame(AttachFrame::ResizedWithColors { + cols: next.cols, + rows: next.rows, + replay: replay.bytes.into(), + kitty_image_aliases: replay.kitty_image_aliases, + kitty_state: replay.kitty_state, + colors, + }); + } self.stream_progress.notify(); - true + Ok(true) } } +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PtyGeometryTestStep { + ResizeStarted, + ResizeCommitBoundary, + CellPixelStarted, + CellPixelCommitBoundary, + ReconnectBackoffStarted, +} + fn terminal_color_override_full_state(next: &TerminalColorOverrides) -> Vec { let mut output = if next.cursor_visual.is_some() { b"\x1b[0 q".to_vec() } else { Vec::new() }; output.extend_from_slice(&terminal_color_override_delta(&Default::default(), next)); @@ -3996,9 +5342,7 @@ fn spawn_frame_producer(surface: &Arc, requests: Receiver) -> anyh fn broadcast_render_scroll_locked(pty: &PtySurface, position: (u64, bool)) { let (offset, at_bottom) = position; let mut render = pty.render.lock().unwrap(); - render - .taps - .retain(|tap| tap.send(RenderAttachFrame::ScrollChanged { offset, at_bottom }).is_ok()); + render.taps.retain(|tap| tap.send(RenderAttachFrame::ScrollChanged { offset, at_bottom })); } fn terminal_scroll_position(term: &Terminal) -> (u64, bool) { @@ -4010,8 +5354,35 @@ fn terminal_scroll_position(term: &Terminal) -> (u64, bool) { #[cfg(test)] mod tests { + use base64::Engine as _; + use super::*; + fn append_disabled_kitty_replay_state(payload: &mut Vec) { + for _ in 0..4 { + payload.extend_from_slice(&0u64.to_le_bytes()); + } + payload.extend_from_slice(&0u32.to_le_bytes()); + for _ in 0..4 { + payload.extend_from_slice( + &ghostty_vt::KittyImageIdCursors::DEFAULT_NEXT_IMAGE_ID.to_le_bytes(), + ); + } + } + + #[test] + fn surface_enum_keeps_terminal_state_out_of_line() { + // Test-only geometry and PTY hooks add fields that release builds omit. + const MAX_TEST_SURFACE_BYTES: usize = 800; + assert!( + size_of::() <= MAX_TEST_SURFACE_BYTES, + "Surface grew to {} bytes (PTY {}, browser {}); keep large runtime state out of line", + size_of::(), + size_of::(), + size_of::() + ); + } + #[cfg(target_os = "macos")] #[test] fn macos_surface_spawn_returns_within_deadline() { @@ -4119,6 +5490,64 @@ mod tests { term.vt_write(b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); } + #[cfg(unix)] + #[test] + fn deferred_cell_pixel_responses_run_on_the_bounded_mux_pool() { + let mux = Mux::new_for_test("bounded-deferred-cell-pixel", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let responses = Arc::new(crate::terminal_host_runtime::ControlResponses::new_for_test()); + Surface::install_deferred_cell_pixel_handler(&surface, &responses); + let (thread_name_tx, thread_name_rx) = std::sync::mpsc::channel(); + surface.as_pty().unwrap().deferred_cell_pixel_ack_test_hook.lock().unwrap().replace( + Arc::new(move || { + let name = std::thread::current().name().unwrap_or_default().to_owned(); + let _ = thread_name_tx.send(name); + }), + ); + + let mut frame = Frame::new(MessageKind::CellPixelSizeAck, vec![8, 0, 16, 0]); + frame.request_id = 1; + responses.invoke_deferred_cell_pixel_handler_for_test( + 1, + (8, 16), + crate::terminal_host_runtime::DeferredCellPixelResolution::Response(frame), + ); + + let thread_name = thread_name_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + thread_name.starts_with("mux-deadline-"), + "deferred acknowledgement ran on unbounded worker {thread_name:?}" + ); + } + + #[cfg(unix)] + #[test] + fn disconnected_deferred_cell_pixel_resolutions_do_not_schedule_work() { + let mux = Mux::new_for_test("disconnected-deferred-cell-pixel", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let responses = Arc::new(crate::terminal_host_runtime::ControlResponses::new_for_test()); + Surface::install_deferred_cell_pixel_handler(&surface, &responses); + let (called_tx, called_rx) = std::sync::mpsc::channel(); + surface.as_pty().unwrap().deferred_cell_pixel_ack_test_hook.lock().unwrap().replace( + Arc::new(move || { + let _ = called_tx.send(()); + }), + ); + + responses.invoke_deferred_cell_pixel_handler_for_test( + 1, + (8, 16), + crate::terminal_host_runtime::DeferredCellPixelResolution::Disconnected, + ); + + assert!( + called_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "a disconnect-only resolution spawned reconciliation work" + ); + } + #[test] fn attach_colors_preserve_same_valued_authored_palette_override() { let color = Rgb { r: 0x44, g: 0x55, b: 0x66 }; @@ -4154,7 +5583,7 @@ mod tests { let _ = TerminalColors::from_terminal(&term, DefaultColors::default()); shared_render.update(&mut term).unwrap(); - assert_ne!(shared_render.dirty(), ghostty_vt::Dirty::Clean); + assert_ne!(shared_render.dirty(), Dirty::Clean); } #[test] @@ -4595,13 +6024,51 @@ mod tests { assert!(lifecycle.overflowed()); } + #[test] + fn resized_replay_payload_is_shared_across_attach_taps() { + let mux = Mux::new("shared-resize-replay", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let first = surface.attach_stream().unwrap(); + let second = surface.attach_stream().unwrap(); + let pty = surface.as_pty().unwrap(); + + pty.broadcast_attach_frame(AttachFrame::ResizedWithColors { + cols: 80, + rows: 24, + replay: vec![7; 1024].into(), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + colors: Box::new(TerminalColors::default()), + }); + + let first_replay = match first.stream.recv_timeout(Duration::from_secs(1)).unwrap() { + AttachFrame::ResizedWithColors { replay, .. } => replay, + frame => panic!("unexpected first attach frame: {frame:?}"), + }; + let second_replay = match second.stream.recv_timeout(Duration::from_secs(1)).unwrap() { + AttachFrame::ResizedWithColors { replay, .. } => replay, + frame => panic!("unexpected second attach frame: {frame:?}"), + }; + assert_eq!( + first_replay.as_ptr(), + second_replay.as_ptr(), + "resize replay bytes were deep-cloned for each attach subscriber" + ); + } + #[cfg(unix)] #[test] fn hosted_stager_exposes_coupled_state_only_after_colors() { let mut stager = HostedFrameStager::new(40); let mut resize = Frame::new(MessageKind::Resized, { let mut payload = Vec::from([101, 0, 37, 0]); + payload.extend_from_slice(&(b"authoritative replay".len() as u32).to_le_bytes()); payload.extend_from_slice(b"authoritative replay"); + payload.extend_from_slice(&0u16.to_le_bytes()); + payload.extend_from_slice(&9u16.to_le_bytes()); + payload.extend_from_slice(&18u16.to_le_bytes()); + append_disabled_kitty_replay_state(&mut payload); payload }); resize.flags = FLAG_COLORS_FOLLOW; @@ -4622,9 +6089,19 @@ mod tests { ); colors_frame.sequence = 42; match stager.push(colors_frame).unwrap().unwrap() { - HostedTransition::ResizedWithColors { cols, rows, replay, colors: received } => { + HostedTransition::ResizedWithColors { + cols, + rows, + cell_pixels, + replay, + kitty_image_aliases, + colors: received, + .. + } => { assert_eq!((cols, rows), (101, 37)); + assert_eq!(cell_pixels, (9, 18)); assert_eq!(replay, b"authoritative replay"); + assert!(kitty_image_aliases.is_empty()); assert_eq!(received, colors); } other => panic!("unexpected staged transition: {other:?}"), @@ -4645,6 +6122,93 @@ mod tests { )); } + #[cfg(unix)] + #[test] + fn hosted_stager_excludes_resize_framing_and_aliases_from_vt_replay() { + let replay = b"\x1b[2Jhost replay"; + let mut payload = Vec::from([101, 0, 37, 0]); + payload.extend_from_slice(&(replay.len() as u32).to_le_bytes()); + payload.extend_from_slice(replay); + payload.extend_from_slice(&1u16.to_le_bytes()); + payload.extend_from_slice(&41u32.to_le_bytes()); + payload.extend_from_slice(&77u32.to_le_bytes()); + payload.extend_from_slice(&9u16.to_le_bytes()); + payload.extend_from_slice(&18u16.to_le_bytes()); + append_disabled_kitty_replay_state(&mut payload); + + let mut stager = HostedFrameStager::new(8); + let mut resize = Frame::new(MessageKind::Resized, payload); + resize.flags = FLAG_COLORS_FOLLOW; + resize.sequence = 9; + assert!(stager.push(resize).unwrap().is_none()); + + let colors = TerminalColorOverrides { + cursor_visual: Some((CursorShape::Block, true)), + ..Default::default() + }; + let mut colors = Frame::new( + MessageKind::Colors, + crate::terminal_host_runtime::encode_terminal_color_overrides(&colors), + ); + colors.sequence = 10; + match stager.push(colors).unwrap().unwrap() { + HostedTransition::ResizedWithColors { + replay: received, kitty_image_aliases, .. + } => { + assert_eq!( + received, replay, + "resize length and alias metadata leaked into VT replay bytes" + ); + assert_eq!( + kitty_image_aliases, + vec![ghostty_vt::KittyImageAlias { image_id: 41, image_number: 77 }] + ); + } + other => panic!("unexpected staged transition: {other:?}"), + } + } + + #[cfg(unix)] + #[test] + fn hosted_stager_accepts_protocol_one_resize_without_alias_metadata() { + let replay = b"legacy host replay"; + let mut payload = Vec::from([81, 0, 25, 0]); + payload.extend_from_slice(&(replay.len() as u32).to_le_bytes()); + payload.extend_from_slice(replay); + + let mut stager = HostedFrameStager::new_for_version(0, 1); + let mut resize = Frame::new(MessageKind::Resized, payload); + resize.version = 1; + resize.flags = FLAG_COLORS_FOLLOW; + resize.sequence = 1; + assert!(stager.push(resize).unwrap().is_none()); + + let colors = TerminalColorOverrides { + cursor_visual: Some((CursorShape::Block, true)), + ..Default::default() + }; + let mut colors = Frame::new( + MessageKind::Colors, + crate::terminal_host_runtime::encode_terminal_color_overrides(&colors), + ); + colors.version = 1; + colors.sequence = 2; + match stager.push(colors).unwrap().unwrap() { + HostedTransition::ResizedWithColors { + cols, + rows, + replay: received, + kitty_image_aliases, + .. + } => { + assert_eq!((cols, rows), (81, 25)); + assert_eq!(received, replay); + assert!(kitty_image_aliases.is_empty()); + } + other => panic!("unexpected staged transition: {other:?}"), + } + } + #[cfg(unix)] #[test] fn hosted_stager_decodes_authoritative_exit_payload() { @@ -4690,6 +6254,17 @@ mod tests { let mut exit = Frame::new(MessageKind::Exit, vec![]); exit.sequence = 2; assert!(stager.push(exit).is_err(), "a coupled frame requires Colors exactly next"); + + let mut stager = HostedFrameStager::new(0); + let mut malformed = Frame::new(MessageKind::Resized, { + let mut payload = vec![80, 0, 24, 0, 0, 0, 0, 0]; + payload.extend_from_slice(&1u16.to_le_bytes()); + payload.extend_from_slice(&41u32.to_le_bytes()); + payload + }); + malformed.flags = FLAG_COLORS_FOLLOW; + malformed.sequence = 1; + assert!(stager.push(malformed).is_err(), "truncated aliases must fail closed"); } #[cfg(unix)] @@ -4720,6 +6295,81 @@ mod tests { ); } + #[test] + fn terminal_reconnect_failure_state_never_decodes_as_connected() { + assert_ne!(TerminalHostConnectionState::from_u8(3), TerminalHostConnectionState::Connected); + } + + #[cfg(unix)] + #[test] + fn terminal_reconnect_backoff_advances_and_reaches_a_terminal_bound() { + let mut backoff = TerminalHostReconnectBackoff::default(); + let delays = (0..TERMINAL_HOST_RECONNECT_MAX_FAILURES) + .map(|_| backoff.next_delay().expect("retry within failure bound")) + .collect::>(); + + assert_eq!( + &delays[..7], + &[ + Duration::from_millis(25), + Duration::from_millis(50), + Duration::from_millis(100), + Duration::from_millis(200), + Duration::from_millis(400), + Duration::from_millis(800), + Duration::from_secs(1), + ] + ); + assert!(delays[7..].iter().all(|delay| *delay == Duration::from_secs(1))); + assert_eq!(backoff.next_delay(), None); + } + + #[cfg(unix)] + #[test] + fn hosted_reconnect_backoff_releases_geometry_before_waiting() { + let mux = Mux::new_for_test("reconnect-geometry-release", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let pty = surface.as_pty().unwrap(); + let (backoff_started_tx, backoff_started_rx) = std::sync::mpsc::channel(); + let (release_backoff_tx, release_backoff_rx) = std::sync::mpsc::channel(); + let release_backoff_rx = Arc::new(Mutex::new(release_backoff_rx)); + *pty.geometry_test_hook.lock().unwrap() = Some(Arc::new({ + move |step| { + if step == PtyGeometryTestStep::ReconnectBackoffStarted { + backoff_started_tx.send(()).unwrap(); + release_backoff_rx.lock().unwrap().recv().unwrap(); + } + } + })); + + let reconnect_surface = surface.clone(); + let reconnect = std::thread::spawn(move || { + let pty = reconnect_surface.as_pty().unwrap(); + let geometry = pty.geometry.lock().unwrap(); + let mut retry = TerminalHostReconnectBackoff::default(); + wait_for_reconnect_after_geometry_failure(&mut retry, pty, geometry) + }); + backoff_started_rx.recv().unwrap(); + + let probing_surface = surface.clone(); + let (geometry_acquired_tx, geometry_acquired_rx) = std::sync::mpsc::channel(); + let geometry_probe = std::thread::spawn(move || { + let size = probing_surface.test_cell_pixel_size(); + geometry_acquired_tx.send(size).unwrap(); + }); + let geometry_released_before_backoff = + geometry_acquired_rx.recv_timeout(Duration::from_millis(100)).is_ok(); + + release_backoff_tx.send(()).unwrap(); + assert!(reconnect.join().unwrap()); + geometry_probe.join().unwrap(); + assert!( + geometry_released_before_backoff, + "host reconnect backoff held the geometry transaction lock" + ); + } + #[test] fn producer_without_render_taps_skips_frame_but_emits_output() { let mux = Mux::new_for_test("producer-skip", SurfaceOptions::default()); @@ -4740,6 +6390,416 @@ mod tests { assert!(matches!(events.try_recv(), Ok(MuxEvent::SurfaceOutput(1)))); } + #[test] + fn stalled_render_tap_retains_only_latest_frame_and_scroll_state_in_order() { + let mux = Mux::new_for_test("render-tap-latest", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let pty = surface.as_pty().unwrap(); + let attach = surface.attach_render_stream().unwrap(); + let mut generation = pty.render.lock().unwrap().built_generation; + + let mut expected_dirty_rows = std::collections::BTreeSet::new(); + { + let mut term = pty.term.lock().unwrap(); + term.vt_write(b"\x1b[1;1Ha"); + generation += 1; + pty.build_frame_locked(&mut term, generation, false).unwrap(); + expected_dirty_rows.extend( + pty.render + .lock() + .unwrap() + .latest + .as_ref() + .unwrap() + .frame + .dirty_rows + .iter() + .copied(), + ); + broadcast_render_scroll_locked(pty, (4, false)); + term.vt_write(b"\x1b[2;1Hb"); + generation += 1; + pty.build_frame_locked(&mut term, generation, false).unwrap(); + expected_dirty_rows.extend( + pty.render + .lock() + .unwrap() + .latest + .as_ref() + .unwrap() + .frame + .dirty_rows + .iter() + .copied(), + ); + broadcast_render_scroll_locked(pty, (9, true)); + term.vt_write(b"\x1b[3;1Hc"); + generation += 1; + pty.build_frame_locked(&mut term, generation, false).unwrap(); + expected_dirty_rows.extend( + pty.render + .lock() + .unwrap() + .latest + .as_ref() + .unwrap() + .frame + .dirty_rows + .iter() + .copied(), + ); + } + + let mut pending = Vec::new(); + while let Ok(frame) = attach.stream.try_recv() { + pending.push(frame); + } + assert_eq!( + pending.len(), + 2, + "a stalled render consumer retained more than one frame plus final scroll state" + ); + assert!(matches!( + pending[0], + RenderAttachFrame::ScrollChanged { offset: 9, at_bottom: true } + )); + let RenderAttachFrame::Frame(frame) = &pending[1] else { + panic!("final render frame must follow the final preceding scroll state"); + }; + let latest = pty.render.lock().unwrap().latest.clone().unwrap(); + assert_eq!(frame.frame.seq, latest.frame.seq); + assert_eq!( + frame.frame.dirty_rows.iter().copied().collect::>(), + expected_dirty_rows, + "coalescing the newest snapshot must preserve every undrained dirty row" + ); + for row in &expected_dirty_rows { + assert_eq!(frame.frame.styled_row(*row), latest.frame.styled_row(*row)); + } + + let latest_uncoalesced = { + let mut term = pty.term.lock().unwrap(); + term.vt_write(b"d"); + generation += 1; + pty.build_frame_locked(&mut term, generation, false).unwrap(); + let latest = pty.render.lock().unwrap().latest.clone().unwrap(); + broadcast_render_scroll_locked(pty, (11, false)); + latest + }; + let first = attach.stream.try_recv().unwrap(); + let second = attach.stream.try_recv().unwrap(); + let RenderAttachFrame::Frame(frame) = first else { + panic!("frame must precede the later scroll state"); + }; + assert!( + Arc::ptr_eq(&frame, &latest_uncoalesced), + "a tap that keeps up must reuse the shared immutable frame" + ); + assert!(matches!( + second, + RenderAttachFrame::ScrollChanged { offset: 11, at_bottom: false } + )); + assert!(matches!(attach.stream.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn render_attachments_are_bounded_across_a_mux() { + const EXPECTED_MAX_ATTACHMENTS: usize = 64; + + let mux = Mux::new_for_test("render-tap-cap", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let mut attachments = Vec::with_capacity(EXPECTED_MAX_ATTACHMENTS); + for _ in 0..EXPECTED_MAX_ATTACHMENTS { + attachments.push(surface.attach_render_stream().unwrap()); + } + + assert!(matches!(surface.attach_render_stream(), Err(ghostty_vt::Error::OutOfSpace))); + attachments.pop(); + assert!(surface.attach_render_stream().is_ok()); + } + + #[test] + fn dropped_idle_render_attachments_remove_their_taps_immediately() { + let mux = Mux::new_for_test("render-tap-drop", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let pty = surface.as_pty().unwrap(); + + for _ in 0..128 { + let attachment = surface.attach_render_stream().unwrap(); + assert_eq!(pty.render.lock().unwrap().taps.len(), 1); + drop(attachment); + assert!( + pty.render.lock().unwrap().taps.is_empty(), + "an idle closed attachment remained registered until later output" + ); + } + } + + #[test] + fn changed_kitty_limits_resynchronize_live_byte_attachments() { + let mux = Mux::new_for_test("kitty-limit-resync", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let attach = surface.attach_stream().unwrap(); + surface + .with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + }) + .unwrap(); + + surface.set_kitty_graphics_limits(0, 0, 0, 0).unwrap(); + + assert!( + matches!( + attach.stream.recv_timeout(Duration::from_secs(1)), + Ok(AttachFrame::Resized { .. } | AttachFrame::ResizedWithColors { .. }) + ), + "a byte-stream mirror was left on the pre-eviction Kitty scene" + ); + } + + #[test] + fn geometry_updates_skip_vt_replay_without_byte_attach_subscribers() { + let mux = Mux::new_for_test("resize-without-byte-attach", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let pty = surface.as_pty().unwrap(); + let render = surface.attach_render_stream().unwrap(); + + surface.resize(100, 30).unwrap(); + surface.set_cell_pixel_size(9, 18).unwrap(); + + assert_eq!( + pty.vt_replay_builds.load(Ordering::Acquire), + 0, + "render-only geometry updates must not construct byte-attach replay" + ); + assert!(matches!(render.stream.try_recv(), Ok(RenderAttachFrame::Frame(_)))); + + let byte_attach = surface.attach_stream().unwrap(); + pty.vt_replay_builds.store(0, Ordering::Release); + surface.resize(101, 31).unwrap(); + assert_eq!(pty.vt_replay_builds.load(Ordering::Acquire), 1); + + drop(byte_attach); + pty.vt_replay_builds.store(0, Ordering::Release); + surface.resize(102, 31).unwrap(); + assert_eq!( + pty.vt_replay_builds.load(Ordering::Acquire), + 0, + "dropping the final byte attach must suppress the next resize replay" + ); + } + + #[test] + fn resize_replay_preserves_a_valid_large_inflight_kitty_upload() { + const OLD_VT_REPLAY_MAX_BYTES: usize = 8 * 1024 * 1024; + const IMAGE_WIDTH: usize = 2_048; + const IMAGE_HEIGHT: usize = 1_024; + const IMAGE_ID: u32 = 196; + + let pixels = vec![0xff; IMAGE_WIDTH * IMAGE_HEIGHT * 3]; + let payload = base64::engine::general_purpose::STANDARD.encode(&pixels); + let final_payload = payload.split_at(payload.len() - 4); + let first_chunk = format!( + "\x1b_Ga=t,t=d,f=24,i={IMAGE_ID},s={IMAGE_WIDTH},v={IMAGE_HEIGHT},m=1,q=2;{}\x1b\\", + final_payload.0 + ) + .into_bytes(); + let final_chunk = format!("\x1b_Gm=0,q=2;{}\x1b\\", final_payload.1).into_bytes(); + assert!( + first_chunk.len() > OLD_VT_REPLAY_MAX_BYTES, + "fixture must exceed the old {OLD_VT_REPLAY_MAX_BYTES}-byte resize replay budget" + ); + assert!(first_chunk.len() <= ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES); + + let mux = Mux::new_for_test("large-inflight-resize", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let attach = surface.attach_stream().unwrap(); + let pty = surface.as_pty().unwrap(); + { + let mut terminal = pty.term.lock().unwrap(); + terminal.vt_write(&first_chunk); + assert!(terminal.kitty_graphics_snapshot().unwrap().image(IMAGE_ID).is_none()); + } + + surface.resize(81, 24).unwrap(); + let (cols, rows, replay, kitty_image_aliases) = + match attach.stream.recv_timeout(Duration::from_secs(2)).unwrap() { + AttachFrame::Resized { cols, rows, replay, kitty_image_aliases, .. } + | AttachFrame::ResizedWithColors { + cols, rows, replay, kitty_image_aliases, .. + } => (cols, rows, replay, kitty_image_aliases), + _ => panic!("resize must publish replacement terminal state"), + }; + assert!( + replay.len() >= first_chunk.len(), + "{}-byte resize replay omitted the {}-byte in-flight prefix", + replay.len(), + first_chunk.len() + ); + + let mut mirror = Terminal::new(cols, rows, 10_000, Callbacks::default()).unwrap(); + mirror.resize(cols, rows, 8, 16).unwrap(); + mirror.vt_write(&replay); + mirror.restore_kitty_image_aliases(&kitty_image_aliases).unwrap(); + mirror.vt_write(&final_chunk); + + assert_eq!( + mirror + .kitty_graphics_snapshot() + .unwrap() + .image(IMAGE_ID) + .expect("resize replay must let a fresh terminal accept the final upload chunk") + .data + .len(), + pixels.len() + ); + } + + #[test] + fn resize_replay_budget_covers_inflight_state_and_transport_limits() { + assert_eq!(ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES, 13_595_480); + assert_eq!(VT_REPLAY_TEXT_HEADROOM_BYTES, 2_097_152); + assert_eq!(VT_REPLAY_MAX_BYTES, 15_692_632); + assert_eq!(ATTACH_STREAM_MAX_BYTES - VT_REPLAY_MAX_BYTES, 1_084_584); + assert_eq!(VT_REPLAY_MAX_BYTES.div_ceil(3) * 4, 20_923_512); + const { + assert!( + VT_REPLAY_MAX_BYTES + VT_REPLAY_FRAME_METADATA_HEADROOM_BYTES + <= ATTACH_STREAM_MAX_BYTES + ); + } + assert!(VT_REPLAY_MAX_BYTES.div_ceil(3) * 4 < VT_REPLAY_ENCODED_TRANSPORT_MAX_BYTES); + } + + #[test] + fn resize_replay_failure_preflights_without_mutating_terminal_or_pty_geometry() { + let mux = Mux::new_for_test("failed-resize-replay", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let attach = surface.attach_stream().unwrap(); + let pty = surface.as_pty().unwrap(); + let history = (0..40).map(|line| format!("history-{line:02}\r\n")).collect::(); + let mut oversized = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + oversized.resize(ghostty_vt::KITTY_INFLIGHT_REPLAY_MAX_BYTES + 1, b'A'); + oversized.extend_from_slice(b"\x1b\\"); + let (text_before, scrollbar_before) = { + let mut terminal = pty.term.lock().unwrap(); + terminal.vt_write(history.as_bytes()); + terminal.vt_write(&oversized); + assert_eq!( + terminal.vt_replay_bounded(VT_REPLAY_MAX_BYTES), + Err(ghostty_vt::Error::OutOfSpace) + ); + (terminal.plain_text().unwrap(), terminal.scrollbar()) + }; + + let error = surface.resize(100, 30).unwrap_err(); + + assert!(error.to_string().contains("geometry unchanged"), "{error:#}"); + assert_eq!(surface.size(), (80, 24)); + { + let mut terminal = pty.term.lock().unwrap(); + assert_eq!((terminal.cols(), terminal.rows()), (80, 24)); + assert_eq!(terminal.plain_text().unwrap(), text_before); + assert_eq!(terminal.scrollbar(), scrollbar_before); + } + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (80, 24, 640, 384) + ); + assert!(matches!(attach.stream.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn concurrent_pty_resize_and_cell_pixel_update_publish_one_geometry_transaction_at_a_time() { + let mux = Mux::new_for_test("pty-geometry-transaction", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let pty = surface.as_pty().unwrap(); + let (resize_entered_tx, resize_entered_rx) = std::sync::mpsc::channel(); + let (release_resize_tx, release_resize_rx) = std::sync::mpsc::channel(); + let (cell_started_tx, cell_started_rx) = std::sync::mpsc::channel(); + let release_resize_rx = Arc::new(Mutex::new(release_resize_rx)); + *pty.geometry_test_hook.lock().unwrap() = Some(Arc::new({ + move |step| match step { + PtyGeometryTestStep::ResizeCommitBoundary => { + resize_entered_tx.send(()).unwrap(); + release_resize_rx.lock().unwrap().recv().unwrap(); + } + PtyGeometryTestStep::CellPixelStarted => { + cell_started_tx.send(()).unwrap(); + } + _ => {} + } + })); + + let resizing_surface = surface.clone(); + let resizing = std::thread::spawn(move || resizing_surface.resize(100, 30)); + resize_entered_rx.recv().unwrap(); + + let updating_surface = surface.clone(); + let (cell_done_tx, cell_done_rx) = std::sync::mpsc::channel(); + let updating = std::thread::spawn(move || { + let result = updating_surface.set_cell_pixel_size(9, 18); + cell_done_tx.send(result).unwrap(); + }); + cell_started_rx.recv().unwrap(); + let cell_completed_while_resize_was_uncommitted = + cell_done_rx.recv_timeout(Duration::from_millis(100)).is_ok(); + + release_resize_tx.send(()).unwrap(); + resizing.join().unwrap().unwrap(); + updating.join().unwrap(); + + assert!( + !cell_completed_while_resize_was_uncommitted, + "cell pixels published while the resize transaction was paused before its backend commit" + ); + assert_eq!(surface.size(), (100, 30)); + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (100, 30, 900, 540) + ); + } + + #[test] + fn failed_pty_master_resize_commits_nothing_and_the_same_request_retries() { + let mux = Mux::new_for_test("pty-resize-failure", SurfaceOptions::default()); + let surface = + Surface::spawn_for_test(1, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + surface.fail_next_test_master_resize(); + + let failed = surface.resize(100, 30); + + assert!(failed.is_err(), "PTY master resize failure must reach the caller"); + assert_eq!(surface.size(), (80, 24)); + { + let term = surface.as_pty().unwrap().term.lock().unwrap(); + assert_eq!((term.cols(), term.rows()), (80, 24)); + } + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (80, 24, 640, 384) + ); + + assert!(surface.resize(100, 30).unwrap()); + assert_eq!(surface.size(), (100, 30)); + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (100, 30, 800, 480) + ); + } + #[test] fn pty_eof_publishes_final_render_frame_before_surface_removal() { const FINAL_MARKER: &str = "CMUX_FINAL_RENDER_MARKER"; @@ -4930,6 +6990,34 @@ mod tests { ); } + #[test] + fn pty_pixel_overflow_rejects_creation_and_geometry_updates_without_mutation() { + let mux = Mux::new_for_test("pty-pixel-overflow", SurfaceOptions::default()); + let oversized = SurfaceOptions { cols: 10_000, ..SurfaceOptions::default() }; + let creation_error = + Surface::spawn_for_test(1, oversized, Arc::downgrade(&mux)).unwrap_err(); + assert!(creation_error.to_string().contains("PTY pixel width exceeds 65535")); + + let surface = + Surface::spawn_for_test(2, SurfaceOptions::default(), Arc::downgrade(&mux)).unwrap(); + let resize_error = surface.resize(10_000, 24).unwrap_err(); + assert!(resize_error.to_string().contains("PTY pixel width exceeds 65535")); + let cell_error = surface.set_cell_pixel_size(1_000, 16).unwrap_err(); + assert!(cell_error.to_string().contains("PTY pixel width exceeds 65535")); + + assert_eq!(surface.size(), (80, 24)); + assert_eq!(surface.test_cell_pixel_size(), (8, 16)); + { + let terminal = surface.as_pty().unwrap().term.lock().unwrap(); + assert_eq!((terminal.cols(), terminal.rows()), (80, 24)); + } + let master = surface.test_master_size(); + assert_eq!( + (master.cols, master.rows, master.pixel_width, master.pixel_height), + (80, 24, 640, 384) + ); + } + #[test] fn clear_history_waits_for_a_partial_vt_sequence_to_finish() { let mux = Mux::new_for_test("clear-history-partial-vt", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs index 62a04ccfb2..82dd7702e5 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs @@ -14,8 +14,11 @@ use serde::{Deserialize, Serialize}; pub const MAGIC: [u8; 4] = *b"CMTH"; pub const HEADER_LEN: usize = 32; -pub const PROTOCOL_VERSION: u16 = 1; +pub const PROTOCOL_VERSION: u16 = 3; pub const MAX_FRAME_PAYLOAD: usize = 16 * 1024 * 1024; +pub const MAX_KITTY_IMAGE_ALIASES: usize = 4_096; +pub const KITTY_IMAGE_ALIAS_COUNT_LEN: usize = size_of::(); +pub const KITTY_IMAGE_ALIAS_ENCODED_LEN: usize = 2 * size_of::(); const EXIT_PAYLOAD_VERSION: u16 = 1; const EXIT_PAYLOAD_HEADER_LEN: usize = 12; const EXIT_PAYLOAD_STATUS_LEN: usize = EXIT_PAYLOAD_HEADER_LEN + 4; @@ -25,7 +28,7 @@ pub const MAX_EXIT_REASON_BYTES: usize = 4096; /// apply both before publishing terminal state. pub const FLAG_COLORS_FOLLOW: u32 = 1 << 0; /// ClientHello opt-in and HostHello acknowledgement for targeted ViewerSize -/// control responses. This handshake-only flag lets v1 peers negotiate the +/// control responses. This handshake-only flag lets compatible peers negotiate the /// optimization without exposing an unknown ResizeAck to legacy renderers. pub const FLAG_VIEWER_SIZE_ACKS: u32 = 1 << 1; /// ResizeAck payload flag: this request changed the canonical grid and its @@ -264,6 +267,12 @@ pub enum MessageKind { ResizeAck = 16, /// Targeted response to `ClearHistory`; payload is one status byte. ClearHistoryAck = 17, + /// Targeted response to `SetCellPixelSize`; payload is the committed + /// cell width:u16 + height:u16. + CellPixelSizeAck = 18, + /// Targeted response to `SetKittyGraphicsLimits`; payload is the applied + /// four-field resource limit tuple. + KittyGraphicsLimitsAck = 19, Input = 100, Paste = 101, ViewerSize = 102, @@ -278,6 +287,12 @@ pub enum MessageKind { /// authoritative parser, or encode the optional key on the alternate /// screen. New hosts advertise support in their durable discovery record. ClearHistory = 107, + /// Protocol-v2 admin request: cell width:u16 + height:u16. The host + /// commits both its PTY and authoritative Ghostty parser before replying. + SetCellPixelSize = 108, + /// Protocol-v3 admin request: image bytes, in-flight bytes, image count, + /// and placement count as four little-endian u64 values. + SetKittyGraphicsLimits = 109, } impl TryFrom for MessageKind { @@ -302,6 +317,8 @@ impl TryFrom for MessageKind { 15 => Ok(Self::Capability), 16 => Ok(Self::ResizeAck), 17 => Ok(Self::ClearHistoryAck), + 18 => Ok(Self::CellPixelSizeAck), + 19 => Ok(Self::KittyGraphicsLimitsAck), 100 => Ok(Self::Input), 101 => Ok(Self::Paste), 102 => Ok(Self::ViewerSize), @@ -310,6 +327,8 @@ impl TryFrom for MessageKind { 105 => Ok(Self::MintCapability), 106 => Ok(Self::SetDefaults), 107 => Ok(Self::ClearHistory), + 108 => Ok(Self::SetCellPixelSize), + 109 => Ok(Self::SetKittyGraphicsLimits), other => Err(ProtocolError::UnknownMessageKind(other)), } } @@ -641,7 +660,7 @@ mod tests { encoded, vec![ b'C', b'M', b'T', b'H', // magic - 0x01, 0x00, // version + 0x03, 0x00, // version 0x06, 0x00, // output 0x44, 0x33, 0x22, 0x11, // flags 0x03, 0x00, 0x00, 0x00, // payload length @@ -683,6 +702,14 @@ mod tests { assert_eq!(MessageKind::try_from(107).unwrap(), MessageKind::ClearHistory); } + #[test] + fn kitty_graphics_limits_have_stable_additive_message_kinds() { + assert_eq!(MessageKind::KittyGraphicsLimitsAck as u16, 19); + assert_eq!(MessageKind::try_from(19).unwrap(), MessageKind::KittyGraphicsLimitsAck); + assert_eq!(MessageKind::SetKittyGraphicsLimits as u16, 109); + assert_eq!(MessageKind::try_from(109).unwrap(), MessageKind::SetKittyGraphicsLimits); + } + #[test] fn clear_history_ack_statuses_are_stable() { assert_eq!(CLEAR_HISTORY_ACK_OK, 0); diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs index 8121844aec..41cf1f41ed 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs @@ -7,7 +7,10 @@ use std::path::{Path, PathBuf}; -use ghostty_vt::{KeyInput, Rgb, TerminalColorOverrides}; +use ghostty_vt::{ + KeyInput, KittyGraphicsLimits, KittyImageAlias, KittyImageIdCursors, KittyReplayState, Rgb, + TerminalColorOverrides, +}; use serde::{Deserialize, Serialize}; use crate::surface::{ @@ -25,28 +28,54 @@ use crate::terminal_host_protocol::{ CLEAR_HISTORY_ACK_AMBIGUOUS, CLEAR_HISTORY_ACK_FALLBACK_UNREPRESENTABLE, CLEAR_HISTORY_ACK_FALLBACK_WRITE_TIMEOUT, CLEAR_HISTORY_ACK_KNOWN_NOT_DELIVERED, CLEAR_HISTORY_ACK_OK, CLEAR_HISTORY_ACK_PRESERVATION_FAILED, CLEAR_HISTORY_ACK_STREAM_TIMEOUT, - FLAG_COLORS_FOLLOW, FLAG_VIEWER_SIZE_ACKS, Frame, MAX_FRAME_PAYLOAD, MessageKind, + FLAG_COLORS_FOLLOW, FLAG_VIEWER_SIZE_ACKS, Frame, KITTY_IMAGE_ALIAS_COUNT_LEN, + KITTY_IMAGE_ALIAS_ENCODED_LEN, MAX_FRAME_PAYLOAD, MAX_KITTY_IMAGE_ALIASES, MessageKind, PROTOCOL_VERSION, RESIZE_ACK_CANONICAL_CHANGED, TerminalExit, encode_terminal_exit, read_frame, wait_for_native_child_status, write_frame, }; const HOST_RECORD_VERSION: u32 = 2; +const LEGACY_PROTOCOL_VERSION: u16 = 1; const HOST_EXIT_RECORD_VERSION: u32 = 1; const MAX_LAUNCH_PAYLOAD: usize = 1024 * 1024; const MAX_STRING: usize = 256 * 1024; -const MAX_BLOB: usize = 8 * 1024 * 1024; +const MAX_BLOB: usize = crate::surface::VT_REPLAY_MAX_BYTES; const MAX_ARGV: usize = 256; const MAX_ENV: usize = 1024; const MAX_RENDERER_CAPABILITY_TTL: std::time::Duration = std::time::Duration::from_secs(60); -const CONTROL_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +pub(crate) const CONTROL_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); const HOST_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -const MAX_HOST_CLIENT_QUEUED_BYTES: usize = 8 * 1024 * 1024; +// Keep live PTY backpressure independent from the extra headroom needed by +// one maximum Resized + Colors + targeted acknowledgement transition. +const MAX_HOST_CLIENT_OUTPUT_QUEUED_BYTES: usize = 8 * 1024 * 1024; +const MAX_HOST_CLIENT_STATE_QUEUED_BYTES: usize = MAX_FRAME_PAYLOAD + + MAX_TERMINAL_COLORS_PAYLOAD + + CELL_PIXEL_SIZE_ENCODED_LEN + + KITTY_REPLAY_STATE_ENCODED_LEN + + 3 * crate::terminal_host_protocol::HEADER_LEN; +const MAX_HOST_CLIENT_QUEUED_BYTES: usize = + MAX_HOST_CLIENT_OUTPUT_QUEUED_BYTES + MAX_HOST_CLIENT_STATE_QUEUED_BYTES; const HOST_START_NONCE_LEN: usize = 32; const TERMINAL_DIMENSION_MAX: u16 = 10_000; const TERMINAL_CELL_AREA_MAX: u64 = 4_000_000; +const DEFAULT_CELL_PIXELS: (u16, u16) = (8, 16); +const CELL_PIXEL_SIZE_ENCODED_LEN: usize = 2 * size_of::(); +const KITTY_GRAPHICS_LIMITS_ENCODED_LEN: usize = 4 * size_of::(); +const KITTY_REPLAY_STATE_ENCODED_LEN: usize = + KITTY_GRAPHICS_LIMITS_ENCODED_LEN + 5 * size_of::(); const TERMINAL_COLORS_WIRE_VERSION_V1: u16 = 1; pub const TERMINAL_COLORS_WIRE_VERSION: u16 = 2; pub const MAX_TERMINAL_COLORS_PAYLOAD: usize = 8 + 3 * 3 + 2 + 256 * 4; +const _: () = assert!( + 2 * size_of::() + + size_of::() + + crate::surface::VT_REPLAY_MAX_BYTES + + KITTY_IMAGE_ALIAS_COUNT_LEN + + MAX_KITTY_IMAGE_ALIASES * KITTY_IMAGE_ALIAS_ENCODED_LEN + + CELL_PIXEL_SIZE_ENCODED_LEN + + KITTY_REPLAY_STATE_ENCODED_LEN + <= MAX_FRAME_PAYLOAD +); pub(crate) fn normalize_terminal_geometry(cols: u16, rows: u16) -> anyhow::Result<(u16, u16)> { let cols = cols.clamp(1, TERMINAL_DIMENSION_MAX); @@ -59,6 +88,24 @@ pub(crate) fn normalize_terminal_geometry(cols: u16, rows: u16) -> anyhow::Resul Ok((cols, rows)) } +pub fn validate_kitty_image_aliases(aliases: &[KittyImageAlias]) -> anyhow::Result<()> { + if aliases.len() > MAX_KITTY_IMAGE_ALIASES { + anyhow::bail!("terminal-host Kitty image alias count is too large"); + } + // Repeated image numbers preserve Kitty's assignment history. Image IDs + // remain unique identities within a snapshot. + let mut image_ids = std::collections::HashSet::with_capacity(aliases.len()); + for alias in aliases { + if alias.image_id == 0 || alias.image_number == 0 { + anyhow::bail!("terminal-host Kitty image aliases must be nonzero"); + } + if !image_ids.insert(alias.image_id) { + anyhow::bail!("duplicate terminal-host Kitty image alias ID"); + } + } + Ok(()) +} + #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TerminalHostRecord { pub record_version: u32, @@ -147,7 +194,11 @@ impl TerminalHostExitRecord { pub struct HostSnapshot { pub cols: u16, pub rows: u16, + /// Authoritative PTY and parser cell metrics at the snapshot boundary. + pub cell_pixels: (u16, u16), pub replay: Vec, + pub kitty_image_aliases: Vec, + pub kitty_state: KittyReplayState, /// Global live-stream sequence at the atomic Snapshot/Colors boundary. pub sequence_boundary: u64, /// Complete application-authored color state at `sequence_boundary`. @@ -184,6 +235,7 @@ pub struct RendererGrant { pub incarnation: String, pub token: String, pub rights: CapabilityRights, + pub protocol_version: u16, } impl std::fmt::Debug for RendererGrant { @@ -310,6 +362,31 @@ pub fn decode_terminal_color_overrides(payload: &[u8]) -> anyhow::Result) -> std::fmt::Result { + formatter.write_str( + "terminal host cell pixel acknowledgement is pending; \ + the late response will reconcile the mirror", + ) + } +} + +impl std::error::Error for DeferredCellPixelAck {} + +#[derive(Debug)] +pub(crate) struct CellPixelRequestDeadlineElapsed; + +impl std::fmt::Display for CellPixelRequestDeadlineElapsed { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("terminal host cell pixel size deadline elapsed before request") + } +} + +impl std::error::Error for CellPixelRequestDeadlineElapsed {} + #[cfg(unix)] mod unix { use std::collections::{HashMap, HashSet}; @@ -324,7 +401,7 @@ mod unix { use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; - use std::sync::mpsc::{SyncSender, TrySendError, sync_channel}; + use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -344,6 +421,34 @@ mod unix { const HOST_LAUNCH_OWNER_TIMEOUT: Duration = Duration::from_secs(5); const HOST_EXIT_STREAM_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); + fn pty_size(cols: u16, rows: u16, cell_pixels: (u16, u16)) -> anyhow::Result { + let pixel_width = cols.checked_mul(cell_pixels.0).ok_or_else(|| { + anyhow::anyhow!( + "terminal pixel width exceeds {}: {cols} columns at {} pixels per cell", + u16::MAX, + cell_pixels.0 + ) + })?; + let pixel_height = rows.checked_mul(cell_pixels.1).ok_or_else(|| { + anyhow::anyhow!( + "terminal pixel height exceeds {}: {rows} rows at {} pixels per cell", + u16::MAX, + cell_pixels.1 + ) + })?; + Ok(PtySize { rows, cols, pixel_width, pixel_height }) + } + + fn kitty_graphics_limits_within( + candidate: KittyGraphicsLimits, + ceiling: KittyGraphicsLimits, + ) -> bool { + candidate.image_bytes <= ceiling.image_bytes + && candidate.inflight_bytes <= ceiling.inflight_bytes + && candidate.images <= ceiling.images + && candidate.placements <= ceiling.placements + } + struct SpawnedHostProcess { child: Option, } @@ -391,11 +496,13 @@ mod unix { term: String, cols: u16, rows: u16, + cell_pixels: (u16, u16), scrollback: usize, cwd: Option, command: Vec, extra_env: Vec<(String, String)>, default_colors: DefaultColors, + kitty_graphics_limits: KittyGraphicsLimits, } impl HostLaunch { @@ -407,6 +514,8 @@ mod unix { anyhow::bail!("terminal-host environment count is out of range"); } let (cols, rows) = normalize_terminal_geometry(self.cols, self.rows)?; + let cell_pixels = (self.cell_pixels.0.max(1), self.cell_pixels.1.max(1)); + pty_size(cols, rows, cell_pixels)?; let mut output = Vec::new(); put_string(&mut output, &self.endpoint)?; put_string(&mut output, &self.record_path)?; @@ -429,6 +538,9 @@ mod unix { put_string(&mut output, value)?; } encode_default_colors(&mut output, self.default_colors); + output.extend_from_slice(&cell_pixels.0.to_le_bytes()); + output.extend_from_slice(&cell_pixels.1.to_le_bytes()); + encode_kitty_graphics_limits(&mut output, self.kitty_graphics_limits)?; if output.len() > MAX_LAUNCH_PAYLOAD { anyhow::bail!("terminal-host launch payload is too large"); } @@ -460,6 +572,9 @@ mod unix { extra_env.push((decoder.string()?, decoder.string()?)); } let default_colors = decode_default_colors(&mut decoder)?; + let cell_pixels = (decoder.u16()?.max(1), decoder.u16()?.max(1)); + pty_size(cols, rows, cell_pixels)?; + let kitty_graphics_limits = decode_kitty_graphics_limits(&mut decoder)?; decoder.finish()?; Ok(Self { endpoint, @@ -467,11 +582,13 @@ mod unix { term, cols, rows, + cell_pixels, scrollback, cwd, command, extra_env, default_colors, + kitty_graphics_limits, }) } } @@ -579,30 +696,124 @@ mod unix { Ok(colors) } - struct ControlResponseWaiter { - kind: MessageKind, - sender: SyncSender>, + enum ControlResponseWaiter { + Blocking { kind: MessageKind, sender: SyncSender }, + DeferredCellPixel { expected: (u16, u16) }, } + #[derive(Debug, Clone)] + pub(crate) enum DeferredCellPixelResolution { + Response(Frame), + Disconnected, + } + + pub(crate) type DeferredCellPixelHandler = + Arc; + pub(crate) struct ControlResponses { waiters: Mutex>, + deferred_cell_pixel_handler: Mutex>, + latest_cell_pixel_ack: AtomicU64, } impl ControlResponses { + fn new() -> Self { + Self { + waiters: Mutex::new(HashMap::new()), + deferred_cell_pixel_handler: Mutex::new(None), + latest_cell_pixel_ack: AtomicU64::new(0), + } + } + + #[cfg(test)] + pub(crate) fn new_for_test() -> Self { + Self::new() + } + + #[cfg(test)] + pub(crate) fn invoke_deferred_cell_pixel_handler_for_test( + &self, + request_id: u64, + expected: (u16, u16), + resolution: DeferredCellPixelResolution, + ) { + if let Some(handler) = self.deferred_cell_pixel_handler.lock().unwrap().clone() { + handler(request_id, expected, resolution); + } + } + pub(crate) fn resolve(&self, frame: &Frame) -> bool { + let waiter = self.waiters.lock().unwrap().remove(&frame.request_id); + match waiter { + Some(ControlResponseWaiter::Blocking { kind, sender }) => { + if kind != frame.kind { + return false; + } + if frame.kind == MessageKind::CellPixelSizeAck { + self.latest_cell_pixel_ack.fetch_max(frame.request_id, Ordering::AcqRel); + } + let _ = sender.try_send(frame.clone()); + true + } + Some(ControlResponseWaiter::DeferredCellPixel { expected }) => { + if frame.kind != MessageKind::CellPixelSizeAck { + return false; + } + self.latest_cell_pixel_ack.fetch_max(frame.request_id, Ordering::AcqRel); + let handler = self.deferred_cell_pixel_handler.lock().unwrap().clone(); + if let Some(handler) = handler { + handler( + frame.request_id, + expected, + DeferredCellPixelResolution::Response(frame.clone()), + ); + } + true + } + None => false, + } + } + + fn defer_cell_pixel(&self, request_id: u64, expected: (u16, u16)) -> bool { let mut waiters = self.waiters.lock().unwrap(); - let Some(waiter) = waiters.remove(&frame.request_id) else { - return false; - }; - if waiter.kind != frame.kind { + let Some(waiter) = waiters.get_mut(&request_id) else { return false }; + if !matches!( + waiter, + ControlResponseWaiter::Blocking { kind: MessageKind::CellPixelSizeAck, .. } + ) { return false; } - let _ = waiter.sender.try_send(frame.payload.clone()); + *waiter = ControlResponseWaiter::DeferredCellPixel { expected }; true } - pub(crate) fn cancel_all(&self) { - self.waiters.lock().unwrap().clear(); + pub(crate) fn fail_all(&self) { + let deferred = { + let mut waiters = self.waiters.lock().unwrap(); + waiters + .drain() + .filter_map(|(request_id, waiter)| match waiter { + ControlResponseWaiter::DeferredCellPixel { expected } => { + Some((request_id, expected)) + } + ControlResponseWaiter::Blocking { .. } => None, + }) + .collect::>() + }; + let handler = self.deferred_cell_pixel_handler.lock().unwrap().clone(); + if let Some(handler) = handler { + for (request_id, expected) in deferred { + handler(request_id, expected, DeferredCellPixelResolution::Disconnected); + } + } + } + + pub(crate) fn set_deferred_cell_pixel_handler(&self, handler: DeferredCellPixelHandler) { + *self.deferred_cell_pixel_handler.lock().unwrap() = Some(handler); + } + + pub(crate) fn latest_cell_pixel_ack(&self) -> u64 { + self.latest_cell_pixel_ack.load(Ordering::Acquire) } } @@ -610,6 +821,7 @@ mod unix { pub record: TerminalHostRecord, pub record_path: PathBuf, pub snapshot: HostSnapshot, + protocol_version: u16, reader: Option, writer: Arc>, control_responses: Arc, @@ -693,7 +905,8 @@ mod unix { pub fn send(&self, kind: MessageKind, payload: &[u8]) -> std::io::Result<()> { let mut writer = self.writer.lock().unwrap(); - let frame = Frame::new(kind, payload.to_vec()); + let mut frame = Frame::new(kind, payload.to_vec()); + frame.version = self.protocol_version; let result = write_frame(&mut *writer, &frame).map_err(protocol_io_error); if result.is_err() { // A timed-out write may have emitted only part of a frame. @@ -774,6 +987,242 @@ mod unix { Ok(()) } + /// Commit frontend cell metrics in the durable host before updating + /// this daemon's disposable mirror. Protocol-v1 hosts do not expose + /// this transaction, so callers leave their mirror unchanged. + pub fn send_cell_pixel_size(&self, width_px: u16, height_px: u16) -> anyhow::Result { + self.send_cell_pixel_size_until( + width_px, + height_px, + Instant::now() + CONTROL_RESPONSE_TIMEOUT, + ) + } + + pub(crate) fn send_cell_pixel_size_until( + &self, + width_px: u16, + height_px: u16, + deadline: Instant, + ) -> anyhow::Result { + if self.protocol_version < 2 { + return Ok(false); + } + if Instant::now() >= deadline { + return Err(CellPixelRequestDeadlineElapsed.into()); + } + let width_px = width_px.max(1); + let height_px = height_px.max(1); + let request_id = self.next_request.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = sync_channel(1); + self.control_responses.waiters.lock().unwrap().insert( + request_id, + ControlResponseWaiter::Blocking { kind: MessageKind::CellPixelSizeAck, sender }, + ); + let mut payload = Vec::with_capacity(4); + payload.extend_from_slice(&width_px.to_le_bytes()); + payload.extend_from_slice(&height_px.to_le_bytes()); + let mut frame = Frame::new(MessageKind::SetCellPixelSize, payload); + frame.version = self.protocol_version; + frame.request_id = request_id; + let write_result = { + let mut writer = self.writer.lock().unwrap(); + write_frame(&mut *writer, &frame).map_err(protocol_io_error) + }; + if let Err(error) = write_result { + let _ = self.writer.lock().unwrap().shutdown(std::net::Shutdown::Both); + self.control_responses.waiters.lock().unwrap().remove(&request_id); + return Err(error.into()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + let response = match (!remaining.is_zero()) + .then(|| receiver.recv_timeout(remaining)) + .transpose() + { + Ok(Some(response)) => response, + Ok(None) | Err(RecvTimeoutError::Timeout) => { + match self.defer_or_receive_raced_cell_pixel_ack( + request_id, + (width_px, height_px), + &receiver, + )? { + Some(response) => response, + None => return Err(DeferredCellPixelAck.into()), + } + } + Err(RecvTimeoutError::Disconnected) => { + self.control_responses.waiters.lock().unwrap().remove(&request_id); + anyhow::bail!( + "terminal host connection closed before acknowledging cell pixel size" + ); + } + }; + if response.kind != MessageKind::CellPixelSizeAck + || response.payload.as_slice() + != [width_px.to_le_bytes(), height_px.to_le_bytes()].concat() + { + let _ = self.writer.lock().unwrap().shutdown(std::net::Shutdown::Both); + anyhow::bail!("terminal host returned a malformed cell pixel size acknowledgement"); + } + Ok(true) + } + + /// Commit Kitty resource limits in the authoritative host before + /// returning control to the disposable mirror. Protocol-v1/v2 hosts + /// cannot synchronize this sidecar state and therefore keep graphics + /// disabled in new mirrors. + pub fn send_kitty_graphics_limits( + &self, + limits: KittyGraphicsLimits, + ) -> anyhow::Result { + self.send_kitty_graphics_limits_until(limits, Instant::now() + CONTROL_RESPONSE_TIMEOUT) + } + + pub fn send_kitty_graphics_limits_until( + &self, + limits: KittyGraphicsLimits, + deadline: Instant, + ) -> anyhow::Result { + if self.protocol_version < 3 { + return Ok(false); + } + let limits = limits + .validate() + .map_err(|_| anyhow::anyhow!("Kitty graphics limits are out of range"))?; + let mut payload = Vec::with_capacity(KITTY_GRAPHICS_LIMITS_ENCODED_LEN); + encode_kitty_graphics_limits(&mut payload, limits)?; + let response = self + .send_control_request_until( + MessageKind::SetKittyGraphicsLimits, + MessageKind::KittyGraphicsLimitsAck, + payload, + deadline, + ) + .map_err(ClearHistoryFailure::into_error) + .context("terminal host did not acknowledge Kitty graphics limits")?; + let mut decoder = PayloadDecoder::new(&response); + let acknowledged = decode_kitty_graphics_limits(&mut decoder)?; + decoder.finish()?; + if acknowledged != limits { + self.disconnect(); + anyhow::bail!("terminal host acknowledged different Kitty graphics limits"); + } + Ok(true) + } + + fn reconfigure_kitty_graphics_for_adoption( + &mut self, + limits: KittyGraphicsLimits, + ) -> anyhow::Result<()> { + anyhow::ensure!( + self.protocol_version >= 3, + "terminal host cannot synchronize Kitty graphics limits" + ); + let limits = limits + .validate() + .map_err(|_| anyhow::anyhow!("Kitty graphics limits are out of range"))?; + let mut payload = Vec::with_capacity(KITTY_GRAPHICS_LIMITS_ENCODED_LEN); + encode_kitty_graphics_limits(&mut payload, limits)?; + let request_id = self.next_request.fetch_add(1, Ordering::Relaxed); + if request_id == 0 { + self.disconnect(); + anyhow::bail!("terminal host control request id exhausted"); + } + let mut request = Frame::new(MessageKind::SetKittyGraphicsLimits, payload); + request.version = self.protocol_version; + request.request_id = request_id; + let write_result = { + let mut writer = self.writer.lock().unwrap(); + write_frame(&mut *writer, &request).map_err(protocol_io_error) + }; + if let Err(error) = write_result { + self.disconnect(); + return Err(error.into()); + } + + // No Surface reader exists yet. Drain the old live stream through + // the targeted acknowledgement, then reconnect for the fresh + // authoritative Snapshot produced before that acknowledgement. + let protocol_version = self.protocol_version; + let deadline = Instant::now() + CONTROL_RESPONSE_TIMEOUT; + let result = (|| -> anyhow::Result<()> { + let reader = self + .reader + .as_mut() + .ok_or_else(|| anyhow::anyhow!("terminal-host reader already taken"))?; + let previous_timeout = reader + .read_timeout() + .context("read terminal-host timeout before Kitty quota adoption")?; + let response = (|| -> anyhow::Result<()> { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + anyhow::ensure!( + !remaining.is_zero(), + "terminal host did not apply Kitty graphics limits before adoption" + ); + reader + .set_read_timeout(Some(remaining.max(Duration::from_millis(1)))) + .context("set terminal-host Kitty quota adoption timeout")?; + let frame = read_frame(reader, MAX_FRAME_PAYLOAD) + .map_err(protocol_io_error)? + .ok_or_else(|| { + anyhow::anyhow!( + "terminal host disconnected while applying Kitty graphics limits" + ) + })?; + anyhow::ensure!( + frame.version == protocol_version, + "terminal host changed protocol during Kitty quota adoption" + ); + if frame.request_id == 0 { + continue; + } + anyhow::ensure!( + frame.request_id == request_id + && frame.kind == MessageKind::KittyGraphicsLimitsAck + && frame.flags == 0 + && frame.sequence == 0, + "terminal host returned an invalid Kitty quota adoption response" + ); + let mut decoder = PayloadDecoder::new(&frame.payload); + let acknowledged = decode_kitty_graphics_limits(&mut decoder)?; + decoder.finish()?; + anyhow::ensure!( + acknowledged == limits, + "terminal host acknowledged different Kitty graphics limits" + ); + return Ok(()); + } + })(); + let restored = reader + .set_read_timeout(previous_timeout) + .context("restore terminal-host timeout after Kitty quota adoption"); + response.and(restored) + })(); + if result.is_err() { + self.disconnect(); + } + result + } + + fn defer_or_receive_raced_cell_pixel_ack( + &self, + request_id: u64, + expected: (u16, u16), + receiver: &Receiver, + ) -> anyhow::Result> { + if self.control_responses.defer_cell_pixel(request_id, expected) { + return Ok(None); + } + // resolve() removes the waiter while holding the same mutex + // before delivering the response. An absent entry therefore + // means a response won the timeout race or the connection failed. + receiver.recv().map(Some).map_err(|_| { + anyhow::anyhow!( + "terminal host connection closed while acknowledging cell pixel size" + ) + }) + } + pub fn release_viewer_size(&self) -> std::io::Result { let mut viewer_size = self.viewer_size.lock().unwrap(); if viewer_size.is_none() { @@ -791,6 +1240,10 @@ mod unix { *self.viewer_size.lock().unwrap() } + pub fn protocol_version(&self) -> u16 { + self.protocol_version + } + pub fn terminate(&self) -> std::io::Result<()> { self.send(MessageKind::Terminate, &[]) } @@ -837,6 +1290,21 @@ mod unix { request_kind: MessageKind, response_kind: MessageKind, payload: Vec, + ) -> Result, ClearHistoryFailure> { + self.send_control_request_until( + request_kind, + response_kind, + payload, + Instant::now() + CONTROL_RESPONSE_TIMEOUT, + ) + } + + fn send_control_request_until( + &self, + request_kind: MessageKind, + response_kind: MessageKind, + payload: Vec, + deadline: Instant, ) -> Result, ClearHistoryFailure> { let request_id = self.next_request.fetch_add(1, Ordering::Relaxed); if request_id == 0 { @@ -852,9 +1320,13 @@ mod unix { "terminal host control request id collision" ))); } - waiters.insert(request_id, ControlResponseWaiter { kind: response_kind, sender }); + waiters.insert( + request_id, + ControlResponseWaiter::Blocking { kind: response_kind, sender }, + ); } let mut frame = Frame::new(request_kind, payload); + frame.version = self.protocol_version; frame.request_id = request_id; let write_result = { let mut writer = self.writer.lock().unwrap(); @@ -868,8 +1340,14 @@ mod unix { self.control_responses.waiters.lock().unwrap().remove(&request_id); return Err(ClearHistoryFailure::ambiguous(error.into())); } - match receiver.recv_timeout(CONTROL_RESPONSE_TIMEOUT) { - Ok(payload) => Ok(payload), + let remaining = deadline.saturating_duration_since(Instant::now()); + let response = if remaining.is_zero() { + Err(RecvTimeoutError::Timeout) + } else { + receiver.recv_timeout(remaining) + }; + match response { + Ok(frame) => Ok(frame.payload), Err(error) => { self.control_responses.waiters.lock().unwrap().remove(&request_id); self.disconnect(); @@ -903,6 +1381,7 @@ mod unix { incarnation: self.record.incarnation.clone(), token: encode_hex(&payload), rights: CapabilityRights::RENDERER, + protocol_version: self.protocol_version, }) } @@ -989,9 +1468,18 @@ mod unix { options: &SurfaceOptions, root: &Path, default_colors: DefaultColors, + cell_pixels: (u16, u16), + kitty_graphics_limits: KittyGraphicsLimits, ) -> anyhow::Result { let terminal_id = TerminalId::random()?; - launch_terminal_host_with_identity(options, root, default_colors, terminal_id) + launch_terminal_host_with_identity( + options, + root, + default_colors, + cell_pixels, + kitty_graphics_limits, + terminal_id, + ) } /// Launch using a registry-reserved stable UUID. The workspace registry @@ -1001,6 +1489,8 @@ mod unix { options: &SurfaceOptions, root: &Path, default_colors: DefaultColors, + cell_pixels: (u16, u16), + kitty_graphics_limits: KittyGraphicsLimits, terminal_id: TerminalId, ) -> anyhow::Result { prepare_private_dir(root)?; @@ -1029,6 +1519,7 @@ mod unix { term: options.term.clone(), cols: options.cols, rows: options.rows, + cell_pixels, scrollback: options.scrollback, cwd: options.cwd.clone().or_else(|| { crate::platform::home_dir().map(|path| path.to_string_lossy().into_owned()) @@ -1036,6 +1527,7 @@ mod unix { command, extra_env: options.extra_env.clone(), default_colors, + kitty_graphics_limits, }; let binary = std::env::current_exe().context("resolve cmux-tui terminal-host binary")?; @@ -1124,6 +1616,31 @@ mod unix { connect_record(record, record_path) } + pub(crate) fn adopt_terminal_host_with_kitty_limits( + record: TerminalHostRecord, + record_path: PathBuf, + ceiling: KittyGraphicsLimits, + ) -> anyhow::Result { + let ceiling = ceiling + .validate() + .map_err(|_| anyhow::anyhow!("Kitty graphics limits are out of range"))?; + let mut attachment = adopt_terminal_host(record.clone(), record_path.clone())?; + if kitty_graphics_limits_within(attachment.snapshot.kitty_state.limits, ceiling) { + return Ok(attachment); + } + + attachment.reconfigure_kitty_graphics_for_adoption(ceiling)?; + attachment.disconnect(); + drop(attachment); + + let attachment = adopt_terminal_host(record, record_path)?; + anyhow::ensure!( + kitty_graphics_limits_within(attachment.snapshot.kitty_state.limits, ceiling), + "terminal host retained Kitty graphics state above its adoption quota" + ); + Ok(attachment) + } + /// Validate a discovery record without trusting paths or alternate /// identity spellings supplied by its JSON payload. pub fn validate_terminal_host_record( @@ -1461,6 +1978,32 @@ mod unix { record_path: PathBuf, handshake_timeout: Duration, ) -> anyhow::Result { + let mut failures = Vec::new(); + for protocol_version in (LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).rev() { + match connect_record_at_version( + record.clone(), + record_path.clone(), + handshake_timeout, + protocol_version, + ) { + Ok(attachment) => return Ok(attachment), + Err(error) => { + failures.push(format!("protocol {protocol_version}: {error:#}")); + } + } + } + anyhow::bail!("terminal-host adoption failed: {}", failures.join("; ")) + } + + fn connect_record_at_version( + record: TerminalHostRecord, + record_path: PathBuf, + handshake_timeout: Duration, + protocol_version: u16, + ) -> anyhow::Result { + if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { + anyhow::bail!("unsupported terminal-host adoption protocol {protocol_version}"); + } let terminal_id = TerminalId::from_bytes(decode_hex_array(&record.terminal_id)?); let incarnation = HostIncarnation::from_bytes(decode_hex_array(&record.incarnation)?); let owner_token = CapabilityToken::from_bytes(decode_hex_array(&record.owner_token)?); @@ -1468,32 +2011,44 @@ mod unix { stream.set_read_timeout(Some(handshake_timeout))?; stream.set_write_timeout(Some(handshake_timeout))?; let hello = ClientHello { - min_version: PROTOCOL_VERSION, - max_version: PROTOCOL_VERSION, + min_version: protocol_version, + max_version: protocol_version, role: ClientRole::Admin, requested_rights: CapabilityRights::ADMIN, terminal_id, token: owner_token, }; - write_frame(&mut stream, &hello.into_frame(1))?; + let mut hello_frame = hello.into_frame(1); + hello_frame.version = protocol_version; + write_frame(&mut stream, &hello_frame)?; let hello_frame = read_required_frame(&mut stream, "host hello")?; - if hello_frame.kind != MessageKind::HostHello { + if hello_frame.kind != MessageKind::HostHello + || hello_frame.version != protocol_version + || hello_frame.request_id != 1 + || hello_frame.sequence != 0 + { anyhow::bail!("terminal host rejected owner handshake"); } let host_hello = HostHello::decode(&hello_frame.payload)?; - if host_hello.terminal_id != terminal_id || host_hello.incarnation != incarnation { + if host_hello.selected_version != protocol_version + || host_hello.terminal_id != terminal_id + || host_hello.incarnation != incarnation + || host_hello.granted_rights != CapabilityRights::ADMIN + { anyhow::bail!("terminal-host record identity does not match live host"); } let snapshot_frame = read_required_frame(&mut stream, "terminal snapshot")?; if snapshot_frame.kind != MessageKind::Snapshot + || snapshot_frame.version != protocol_version || snapshot_frame.flags != 0 || snapshot_frame.request_id != 0 { anyhow::bail!("terminal host did not send an initial snapshot"); } - let mut snapshot = decode_snapshot(&snapshot_frame.payload)?; + let mut snapshot = decode_snapshot_for_version(&snapshot_frame.payload, protocol_version)?; let colors_frame = read_required_frame(&mut stream, "terminal color state")?; if colors_frame.kind != MessageKind::Colors + || colors_frame.version != protocol_version || colors_frame.flags != 0 || colors_frame.sequence != snapshot_frame.sequence || colors_frame.request_id != 0 @@ -1514,9 +2069,10 @@ mod unix { record, record_path, snapshot, + protocol_version, reader: Some(reader), writer: Arc::new(Mutex::new(stream)), - control_responses: Arc::new(ControlResponses { waiters: Mutex::new(HashMap::new()) }), + control_responses: Arc::new(ControlResponses::new()), next_request: AtomicU64::new(2), // New hosts do not register Admin as a viewer. Initialize this as // if they did so the unconditional release below also upgrades @@ -1679,38 +2235,73 @@ mod unix { struct HostTap { sender: SyncSender, queued_bytes: Arc, + queued_output_bytes: Arc, shutdown: Arc, max_queued_bytes: usize, } impl HostTap { - fn try_send(&self, frame: Frame) -> bool { - let retained = - crate::terminal_host_protocol::HEADER_LEN.saturating_add(frame.payload.len()); - let mut queued = self.queued_bytes.load(Ordering::Acquire); + fn new( + sender: SyncSender, + shutdown: Arc, + max_queued_bytes: usize, + ) -> Self { + Self { + sender, + queued_bytes: Arc::new(AtomicUsize::new(0)), + queued_output_bytes: Arc::new(AtomicUsize::new(0)), + shutdown, + max_queued_bytes, + } + } + + fn try_reserve(counter: &AtomicUsize, retained: usize, limit: usize) -> bool { + let mut queued = counter.load(Ordering::Acquire); loop { let Some(next) = queued.checked_add(retained) else { - self.close(); return false; }; - if next > self.max_queued_bytes { - self.close(); + if next > limit { return false; } - match self.queued_bytes.compare_exchange_weak( + match counter.compare_exchange_weak( queued, next, Ordering::AcqRel, Ordering::Acquire, ) { - Ok(_) => break, + Ok(_) => return true, Err(actual) => queued = actual, } } + } + + fn try_send(&self, frame: Frame) -> bool { + let retained = + crate::terminal_host_protocol::HEADER_LEN.saturating_add(frame.payload.len()); + if !Self::try_reserve(&self.queued_bytes, retained, self.max_queued_bytes) { + self.close(); + return false; + } + let is_output = frame.kind == MessageKind::Output; + if is_output + && !Self::try_reserve( + &self.queued_output_bytes, + retained, + MAX_HOST_CLIENT_OUTPUT_QUEUED_BYTES, + ) + { + self.queued_bytes.fetch_sub(retained, Ordering::AcqRel); + self.close(); + return false; + } match self.sender.try_send(frame) { Ok(()) => true, Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => { self.queued_bytes.fetch_sub(retained, Ordering::AcqRel); + if is_output { + self.queued_output_bytes.fetch_sub(retained, Ordering::AcqRel); + } self.close(); false } @@ -1721,6 +2312,9 @@ mod unix { let retained = crate::terminal_host_protocol::HEADER_LEN.saturating_add(frame.payload.len()); self.queued_bytes.fetch_sub(retained, Ordering::AcqRel); + if frame.kind == MessageKind::Output { + self.queued_output_bytes.fetch_sub(retained, Ordering::AcqRel); + } } fn close(&self) { @@ -1806,6 +2400,7 @@ mod unix { command: Vec, cwd: Option, size: Mutex<(u16, u16)>, + cell_pixels: Mutex<(u16, u16)>, viewer_sizes: Mutex>, taps: Mutex>, broadcast_lock: Mutex<()>, @@ -2063,6 +2658,177 @@ mod unix { ); } + fn set_cell_pixel_size( + &self, + width_px: u16, + height_px: u16, + request_id: u64, + target: &HostTap, + ) -> anyhow::Result { + let next = (width_px.max(1), height_px.max(1)); + let size = self.size.lock().unwrap(); + let mut cell_pixels = self.cell_pixels.lock().unwrap(); + let previous = *cell_pixels; + let changed = previous != next; + let resize_sizes = if changed { + Some((pty_size(size.0, size.1, previous)?, pty_size(size.0, size.1, next)?)) + } else { + None + }; + let mut term = self.term.lock().unwrap(); + if let Some((previous_size, next_size)) = resize_sizes { + term.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES).context( + "could not preflight terminal-host cell-metric replay; geometry unchanged", + )?; + let master = self.master.lock().unwrap(); + master.resize(next_size)?; + if let Err(error) = + term.resize(size.0, size.1, u32::from(next.0), u32::from(next.1)) + { + let rollback = master.resize(previous_size); + return match rollback { + Ok(()) => Err(error.into()), + Err(rollback_error) => Err(anyhow::anyhow!( + "could not update authoritative cell metrics: {error}; \ + PTY rollback also failed: {rollback_error}" + )), + }; + } + *cell_pixels = next; + } + let transition = if changed { + let replay = match term.vt_replay_bounded_theme_portable_with_aliases( + crate::surface::VT_REPLAY_MAX_BYTES, + ) { + Ok(replay) => replay, + Err(_) => { + // Preflight ruled out persistent budget failure. Keep + // the canonical commit and force every client to take + // a fresh snapshot instead of broadcasting partial + // geometry state or destructively resizing backward. + let mut taps = self.taps.lock().unwrap(); + for tap in taps.values() { + tap.close(); + } + taps.clear(); + target.close(); + return Ok(false); + } + }; + let mut resized = Frame::new( + MessageKind::Resized, + encode_resize( + size.0, + size.1, + &replay.bytes, + &replay.kitty_image_aliases, + next, + replay.kitty_state, + )?, + ); + resized.flags = FLAG_COLORS_FOLLOW; + Some([ + resized, + Frame::new( + MessageKind::Colors, + encode_terminal_color_overrides(&term.color_overrides()), + ), + ]) + } else { + None + }; + let mut ack = Frame::new(MessageKind::CellPixelSizeAck, { + let mut payload = Vec::with_capacity(4); + payload.extend_from_slice(&next.0.to_le_bytes()); + payload.extend_from_slice(&next.1.to_le_bytes()); + payload + }); + ack.request_id = request_id; + // Keep the parser locked through canonical publication and the + // targeted acknowledgement. Output parsed at the new metrics + // cannot overtake the complete Resized+Colors transition. + Ok(publish_host_frames_and_targeted( + &self.broadcast_lock, + &self.sequence, + &self.taps, + transition.into_iter().flatten(), + Some((target, ack)), + )) + } + + fn set_kitty_graphics_limits( + &self, + limits: KittyGraphicsLimits, + request_id: u64, + target: &HostTap, + ) -> anyhow::Result { + let limits = limits + .validate() + .map_err(|_| anyhow::anyhow!("Kitty graphics limits are out of range"))?; + let size = *self.size.lock().unwrap(); + let cell_pixels = *self.cell_pixels.lock().unwrap(); + let mut term = self.term.lock().unwrap(); + term.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .context("could not preflight terminal-host Kitty limit replay")?; + if let Err(error) = term.set_kitty_graphics_limits(limits) { + let mut taps = self.taps.lock().unwrap(); + for tap in taps.values() { + tap.close(); + } + taps.clear(); + target.close(); + return Err(error.into()); + } + let replay = match term + .vt_replay_bounded_theme_portable_with_aliases(crate::surface::VT_REPLAY_MAX_BYTES) + { + Ok(replay) => replay, + Err(error) => { + // The authoritative limit change may already have evicted + // state. Disconnect every mirror so none can continue from + // the pre-eviction scene. + let mut taps = self.taps.lock().unwrap(); + for tap in taps.values() { + tap.close(); + } + taps.clear(); + target.close(); + return Err(error.into()); + } + }; + let mut resized = Frame::new( + MessageKind::Resized, + encode_resize( + size.0, + size.1, + &replay.bytes, + &replay.kitty_image_aliases, + cell_pixels, + replay.kitty_state, + )?, + ); + resized.flags = FLAG_COLORS_FOLLOW; + let mut ack_payload = Vec::with_capacity(KITTY_GRAPHICS_LIMITS_ENCODED_LEN); + encode_kitty_graphics_limits(&mut ack_payload, limits)?; + let mut ack = Frame::new(MessageKind::KittyGraphicsLimitsAck, ack_payload); + ack.request_id = request_id; + // The parser stays locked until all mirrors receive one complete + // replacement and the requester receives its acknowledgement. + Ok(publish_host_frames_and_targeted( + &self.broadcast_lock, + &self.sequence, + &self.taps, + [ + resized, + Frame::new( + MessageKind::Colors, + encode_terminal_color_overrides(&term.color_overrides()), + ), + ], + Some((target, ack)), + )) + } + fn apply_viewer_minimum( &self, desired: Option<(u16, u16)>, @@ -2072,6 +2838,7 @@ mod unix { let Some((cols, rows)) = desired else { return Ok(true) }; let (cols, rows) = normalize_terminal_geometry(cols, rows)?; let mut size = self.size.lock().unwrap(); + let cell_pixels = self.cell_pixels.lock().unwrap(); let changed = *size != (cols, rows); if !changed && !acknowledge_with_replay { let targeted = targeted_ack.map(|(request_id, tap)| { @@ -2089,43 +2856,64 @@ mod unix { )); } let previous = *size; + let resize_sizes = if changed { + Some(( + pty_size(previous.0, previous.1, *cell_pixels)?, + pty_size(cols, rows, *cell_pixels)?, + )) + } else { + None + }; let mut term = self.term.lock().unwrap(); let master = self.master.lock().unwrap(); - if changed { - master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?; - if let Err(error) = term.resize(cols, rows, 8, 16) { - let _ = master.resize(PtySize { - rows: previous.1, - cols: previous.0, - pixel_width: 0, - pixel_height: 0, - }); + if let Some((previous_size, next_size)) = resize_sizes { + term.preflight_vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES).context( + "could not preflight terminal-host resize replay; geometry unchanged", + )?; + master.resize(next_size)?; + if let Err(error) = + term.resize(cols, rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1)) + { + let _ = master.resize(previous_size); return Err(error.into()); } } - let replay = - match term.vt_replay_bounded_theme_portable(crate::surface::VT_REPLAY_MAX_BYTES) { - Ok(replay) => replay, - Err(error) => { - if changed { - let _ = term.resize(previous.0, previous.1, 8, 16); - let _ = master.resize(PtySize { - rows: previous.1, - cols: previous.0, - pixel_width: 0, - pixel_height: 0, - }); - } - return Err(error.into()); + let replay = match term + .vt_replay_bounded_theme_portable_with_aliases(crate::surface::VT_REPLAY_MAX_BYTES) + { + Ok(replay) => replay, + Err(_) if changed => { + // The bounded preflight ruled out the only persistent + // budget failure. Preserve the committed terminal state + // and force mirrors to reconnect instead of attempting an + // inverse, destructive Ghostty resize. + *size = (cols, rows); + let mut taps = self.taps.lock().unwrap(); + for tap in taps.values() { + tap.close(); } - }; + taps.clear(); + return Ok(false); + } + Err(error) => return Err(error.into()), + }; let colors = term.color_overrides(); if changed { *size = (cols, rows); } // Keep the parser lock through sequence publication so output // parsed at the new size cannot overtake the Resized marker. - let mut resized = Frame::new(MessageKind::Resized, encode_resize(cols, rows, &replay)); + let mut resized = Frame::new( + MessageKind::Resized, + encode_resize( + cols, + rows, + &replay.bytes, + &replay.kitty_image_aliases, + *cell_pixels, + replay.kitty_state, + )?, + ); resized.flags = FLAG_COLORS_FOLLOW; let targeted = targeted_ack.map(|(request_id, tap)| { let mut frame = @@ -2680,12 +3468,9 @@ mod unix { launch: &HostLaunch, bootstrapped: &crate::terminal_host::BootstrappedHost, ) -> anyhow::Result> { - let pty = cmux_pty::open(PtySize { - rows: launch.rows, - cols: launch.cols, - pixel_width: 0, - pixel_height: 0, - })?; + let cell_pixels = (launch.cell_pixels.0.max(1), launch.cell_pixels.1.max(1)); + let initial_pty_size = pty_size(launch.cols, launch.rows, cell_pixels)?; + let pty = cmux_pty::open(initial_pty_size)?; let mut command = PtyCommand::new(&launch.command[0]); command.args(launch.command[1..].iter().cloned()); command.env("TERM", &launch.term); @@ -2721,6 +3506,8 @@ mod unix { })), }; let mut term = Terminal::new(launch.cols, launch.rows, launch.scrollback, callbacks)?; + term.resize(launch.cols, launch.rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + term.set_kitty_graphics_limits(launch.kitty_graphics_limits)?; term.replace_default_colors( launch.default_colors.fg, launch.default_colors.bg, @@ -2743,6 +3530,7 @@ mod unix { command: launch.command.clone(), cwd: launch.cwd.clone(), size: Mutex::new((launch.cols, launch.rows)), + cell_pixels: Mutex::new(cell_pixels), viewer_sizes: Mutex::new(HashMap::new()), taps: Mutex::new(HashMap::new()), broadcast_lock: Mutex::new(()), @@ -2908,6 +3696,7 @@ mod unix { { anyhow::bail!("terminal-host capability denied"); } + let selected_version = response.selected_version; let granted_rights = response.granted_rights; let launch_owner = LaunchOwnerConnection::claim(host.clone(), granted_rights); let viewer_size_acks = hello_frame.flags & FLAG_VIEWER_SIZE_ACKS != 0 @@ -2921,23 +3710,21 @@ mod unix { let client = host.next_client.fetch_add(1, Ordering::Relaxed); let (sender, receiver) = sync_channel(256); - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(stream.try_clone()?), - max_queued_bytes: MAX_HOST_CLIENT_QUEUED_BYTES, - }; + let tap = HostTap::new(sender, Arc::new(stream.try_clone()?), MAX_HOST_CLIENT_QUEUED_BYTES); let command_sender = tap.clone(); let (snapshot, colors, snapshot_sequence) = { - // Viewer registration follows the same viewer -> parser -> - // broadcast lock order as resize application. This makes the - // initial snapshot an atomic member of size arbitration too. + // Match resize's viewer -> size -> cell metrics -> parser -> + // broadcast lock order so a snapshot contains one atomic + // logical and pixel geometry. let mut viewer_sizes = host.viewer_sizes.lock().unwrap(); + let size = host.size.lock().unwrap(); + let cell_pixels = *host.cell_pixels.lock().unwrap(); let mut term = host.term.lock().unwrap(); - let replay = - term.vt_replay_bounded_theme_portable(crate::surface::VT_REPLAY_MAX_BYTES)?; + let replay = term.vt_replay_bounded_theme_portable_with_aliases( + crate::surface::VT_REPLAY_MAX_BYTES, + )?; let colors = term.color_overrides(); - let (cols, rows) = (term.cols(), term.rows()); + let (cols, rows) = *size; let _broadcast = host.broadcast_lock.lock().unwrap(); if host.dead.load(Ordering::Acquire) { anyhow::bail!("terminal host exited before snapshot"); @@ -2956,7 +3743,10 @@ mod unix { HostSnapshot { cols, rows, - replay, + cell_pixels, + replay: replay.bytes, + kitty_image_aliases: replay.kitty_image_aliases, + kitty_state: replay.kitty_state, sequence_boundary: 0, colors: colors.clone(), pid: host.pid, @@ -2991,7 +3781,7 @@ mod unix { while let Ok(Some(frame)) = read_frame(&mut command_stream, MAX_FRAME_PAYLOAD) { // Client-to-host messages currently define no flags and never // participate in the host live-stream sequence. - if frame.flags != 0 || frame.sequence != 0 { + if frame.version != selected_version || frame.flags != 0 || frame.sequence != 0 { break; } match frame.kind { @@ -3066,6 +3856,50 @@ mod unix { }; command_host.set_default_colors(colors); } + MessageKind::SetCellPixelSize + if frame.request_id != 0 && frame.payload.len() == 4 => + { + if !granted_rights.contains(CapabilityRights::RESIZE) { + break; + } + let width_px = u16::from_le_bytes([frame.payload[0], frame.payload[1]]); + let height_px = u16::from_le_bytes([frame.payload[2], frame.payload[3]]); + if !matches!( + command_host.set_cell_pixel_size( + width_px, + height_px, + frame.request_id, + &command_sender, + ), + Ok(true) + ) { + break; + } + } + MessageKind::SetKittyGraphicsLimits + if frame.request_id != 0 + && frame.payload.len() == KITTY_GRAPHICS_LIMITS_ENCODED_LEN => + { + if !granted_rights.contains(CapabilityRights::MINT_CAPABILITY) { + break; + } + let mut decoder = PayloadDecoder::new(&frame.payload); + let Ok(limits) = decode_kitty_graphics_limits(&mut decoder) else { + break; + }; + if decoder.finish().is_err() + || !matches!( + command_host.set_kitty_graphics_limits( + limits, + frame.request_id, + &command_sender, + ), + Ok(true) + ) + { + break; + } + } MessageKind::ClearHistory => { if !granted_rights.contains(CapabilityRights::INPUT) || frame.request_id == 0 @@ -3186,6 +4020,10 @@ mod unix { fn encode_snapshot(snapshot: &HostSnapshot) -> anyhow::Result> { let (cols, rows) = normalize_terminal_geometry(snapshot.cols, snapshot.rows)?; + snapshot + .kitty_state + .validate_for_replay(snapshot.replay.len()) + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay offset is invalid"))?; let mut output = Vec::new(); output.extend_from_slice(&cols.to_le_bytes()); output.extend_from_slice(&rows.to_le_bytes()); @@ -3199,10 +4037,28 @@ mod unix { for argument in &snapshot.command { put_string(&mut output, argument)?; } + encode_kitty_image_aliases(&mut output, &snapshot.kitty_image_aliases)?; + output.extend_from_slice(&snapshot.cell_pixels.0.max(1).to_le_bytes()); + output.extend_from_slice(&snapshot.cell_pixels.1.max(1).to_le_bytes()); + encode_kitty_replay_state(&mut output, snapshot.kitty_state)?; + if output.len() > MAX_FRAME_PAYLOAD { + anyhow::bail!("terminal-host snapshot payload is too large"); + } Ok(output) } + #[cfg(test)] fn decode_snapshot(payload: &[u8]) -> anyhow::Result { + decode_snapshot_for_version(payload, PROTOCOL_VERSION) + } + + fn decode_snapshot_for_version( + payload: &[u8], + protocol_version: u16, + ) -> anyhow::Result { + if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { + anyhow::bail!("unsupported terminal-host snapshot protocol {protocol_version}"); + } let mut decoder = PayloadDecoder::new(payload); let (cols, rows) = normalize_terminal_geometry(decoder.u16()?, decoder.u16()?)?; let pid = match decoder.u32()? { @@ -3219,11 +4075,32 @@ mod unix { for _ in 0..argc { command.push(decoder.string()?); } + let kitty_image_aliases = if protocol_version >= 2 { + decode_kitty_image_aliases(&mut decoder)? + } else { + Vec::new() + }; + let cell_pixels = if protocol_version >= 2 { + (decoder.u16()?.max(1), decoder.u16()?.max(1)) + } else { + DEFAULT_CELL_PIXELS + }; + let kitty_state = if protocol_version >= 3 { + decode_kitty_replay_state(&mut decoder)? + .validate_for_replay(replay.len()) + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay offset is invalid"))? + } else { + KittyReplayState::disabled() + }; + pty_size(cols, rows, cell_pixels)?; decoder.finish()?; Ok(HostSnapshot { cols, rows, + cell_pixels, replay, + kitty_image_aliases, + kitty_state, sequence_boundary: 0, colors: TerminalColorOverrides::default(), pid, @@ -3232,15 +4109,188 @@ mod unix { }) } - fn encode_resize(cols: u16, rows: u16, replay: &[u8]) -> Vec { - let replay_len = - u32::try_from(replay.len()).expect("terminal-host resize replay exceeds u32"); - let mut output = Vec::with_capacity(8 + replay.len()); + fn encode_kitty_image_aliases( + output: &mut Vec, + aliases: &[KittyImageAlias], + ) -> anyhow::Result<()> { + validate_kitty_image_aliases(aliases)?; + output.extend_from_slice(&(aliases.len() as u16).to_le_bytes()); + for alias in aliases { + output.extend_from_slice(&alias.image_id.to_le_bytes()); + output.extend_from_slice(&alias.image_number.to_le_bytes()); + } + Ok(()) + } + + fn decode_kitty_image_aliases( + decoder: &mut PayloadDecoder<'_>, + ) -> anyhow::Result> { + let count = decoder.u16()? as usize; + if count > MAX_KITTY_IMAGE_ALIASES { + anyhow::bail!("terminal-host Kitty image alias count is too large"); + } + let mut aliases = Vec::with_capacity(count); + for _ in 0..count { + aliases + .push(KittyImageAlias { image_id: decoder.u32()?, image_number: decoder.u32()? }); + } + validate_kitty_image_aliases(&aliases)?; + Ok(aliases) + } + + fn encode_kitty_graphics_limits( + output: &mut Vec, + limits: KittyGraphicsLimits, + ) -> anyhow::Result<()> { + let limits = limits + .validate() + .map_err(|_| anyhow::anyhow!("terminal-host Kitty graphics limits are out of range"))?; + output.extend_from_slice(&limits.image_bytes.to_le_bytes()); + output.extend_from_slice(&limits.inflight_bytes.to_le_bytes()); + output.extend_from_slice(&limits.images.to_le_bytes()); + output.extend_from_slice(&limits.placements.to_le_bytes()); + Ok(()) + } + + fn decode_kitty_graphics_limits( + decoder: &mut PayloadDecoder<'_>, + ) -> anyhow::Result { + KittyGraphicsLimits { + image_bytes: decoder.u64()?, + inflight_bytes: decoder.u64()?, + images: decoder.u64()?, + placements: decoder.u64()?, + } + .validate() + .map_err(|_| anyhow::anyhow!("terminal-host Kitty graphics limits are out of range")) + } + + fn encode_kitty_replay_state( + output: &mut Vec, + state: KittyReplayState, + ) -> anyhow::Result<()> { + let state = state + .validate() + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay state is invalid"))?; + encode_kitty_graphics_limits(output, state.limits)?; + output.extend_from_slice(&state.replay_cursor_offset.to_le_bytes()); + output.extend_from_slice(&state.replay_next_image_ids.primary.to_le_bytes()); + output.extend_from_slice(&state.next_image_ids.primary.to_le_bytes()); + output.extend_from_slice(&state.replay_next_image_ids.alternate.to_le_bytes()); + output.extend_from_slice(&state.next_image_ids.alternate.to_le_bytes()); + Ok(()) + } + + fn decode_kitty_replay_state( + decoder: &mut PayloadDecoder<'_>, + ) -> anyhow::Result { + let limits = decode_kitty_graphics_limits(decoder)?; + let replay_cursor_offset = decoder.u32()?; + let primary_replay_next_image_id = decoder.u32()?; + let primary_next_image_id = decoder.u32()?; + let alternate_replay_next_image_id = decoder.u32()?; + let alternate_next_image_id = decoder.u32()?; + KittyReplayState { + limits, + replay_cursor_offset, + replay_next_image_ids: KittyImageIdCursors { + primary: primary_replay_next_image_id, + alternate: alternate_replay_next_image_id, + }, + next_image_ids: KittyImageIdCursors { + primary: primary_next_image_id, + alternate: alternate_next_image_id, + }, + } + .validate() + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay state is invalid")) + } + + fn encode_resize( + cols: u16, + rows: u16, + replay: &[u8], + kitty_image_aliases: &[KittyImageAlias], + cell_pixels: (u16, u16), + kitty_state: KittyReplayState, + ) -> anyhow::Result> { + let (cols, rows) = normalize_terminal_geometry(cols, rows)?; + kitty_state + .validate_for_replay(replay.len()) + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay offset is invalid"))?; + let cell_pixels = (cell_pixels.0.max(1), cell_pixels.1.max(1)); + pty_size(cols, rows, cell_pixels)?; + if replay.len() > crate::surface::VT_REPLAY_MAX_BYTES { + anyhow::bail!("terminal-host resize replay is too large"); + } + let replay_len = u32::try_from(replay.len()) + .map_err(|_| anyhow::anyhow!("terminal-host resize replay exceeds u32"))?; + let mut output = Vec::with_capacity( + 8 + replay.len() + + KITTY_IMAGE_ALIAS_COUNT_LEN + + kitty_image_aliases.len() * KITTY_IMAGE_ALIAS_ENCODED_LEN + + CELL_PIXEL_SIZE_ENCODED_LEN + + KITTY_REPLAY_STATE_ENCODED_LEN, + ); output.extend_from_slice(&cols.to_le_bytes()); output.extend_from_slice(&rows.to_le_bytes()); output.extend_from_slice(&replay_len.to_le_bytes()); output.extend_from_slice(replay); - output + encode_kitty_image_aliases(&mut output, kitty_image_aliases)?; + output.extend_from_slice(&cell_pixels.0.to_le_bytes()); + output.extend_from_slice(&cell_pixels.1.to_le_bytes()); + encode_kitty_replay_state(&mut output, kitty_state)?; + if output.len() > MAX_FRAME_PAYLOAD { + anyhow::bail!("terminal-host resize payload is too large"); + } + Ok(output) + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct DecodedHostResize { + pub cols: u16, + pub rows: u16, + pub cell_pixels: (u16, u16), + pub replay: Vec, + pub kitty_image_aliases: Vec, + pub kitty_state: KittyReplayState, + } + + #[cfg(test)] + pub(crate) fn decode_host_resize_payload(payload: &[u8]) -> anyhow::Result { + decode_host_resize_payload_for_version(payload, PROTOCOL_VERSION) + } + + pub(crate) fn decode_host_resize_payload_for_version( + payload: &[u8], + protocol_version: u16, + ) -> anyhow::Result { + if !(LEGACY_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&protocol_version) { + anyhow::bail!("unsupported terminal-host resize protocol {protocol_version}"); + } + let mut decoder = PayloadDecoder::new(payload); + let (cols, rows) = normalize_terminal_geometry(decoder.u16()?, decoder.u16()?)?; + let replay = decoder.bytes_with_limit(crate::surface::VT_REPLAY_MAX_BYTES)?.to_vec(); + let kitty_image_aliases = if protocol_version >= 2 { + decode_kitty_image_aliases(&mut decoder)? + } else { + Vec::new() + }; + let cell_pixels = if protocol_version >= 2 { + (decoder.u16()?.max(1), decoder.u16()?.max(1)) + } else { + DEFAULT_CELL_PIXELS + }; + let kitty_state = if protocol_version >= 3 { + decode_kitty_replay_state(&mut decoder)? + .validate_for_replay(replay.len()) + .map_err(|_| anyhow::anyhow!("terminal-host Kitty replay offset is invalid"))? + } else { + KittyReplayState::disabled() + }; + pty_size(cols, rows, cell_pixels)?; + decoder.finish()?; + Ok(DecodedHostResize { cols, rows, cell_pixels, replay, kitty_image_aliases, kitty_state }) } fn encode_resize_ack(cols: u16, rows: u16, canonical_changed: bool) -> Vec { @@ -3358,6 +4408,10 @@ mod unix { Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) } + fn u64(&mut self) -> anyhow::Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + fn bytes_with_limit(&mut self, limit: usize) -> anyhow::Result<&'a [u8]> { let length = self.u32()? as usize; if length > limit { @@ -3429,6 +4483,117 @@ mod unix { mod tests { use super::*; + fn test_kitty_state() -> KittyReplayState { + KittyReplayState { + limits: KittyGraphicsLimits { + image_bytes: 1, + inflight_bytes: 2, + images: 3, + placements: 4, + }, + replay_cursor_offset: 0, + replay_next_image_ids: KittyImageIdCursors { primary: 5, alternate: 7 }, + next_image_ids: KittyImageIdCursors { primary: 6, alternate: 8 }, + } + } + + struct TestHostMaster { + size: Mutex, + } + + impl MasterPty for TestHostMaster { + fn resize(&self, size: PtySize) -> anyhow::Result<()> { + *self.size.lock().unwrap() = size; + Ok(()) + } + + fn get_size(&self) -> anyhow::Result { + Ok(*self.size.lock().unwrap()) + } + + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(std::io::empty())) + } + + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new(std::io::sink())) + } + + fn process_group_leader(&self) -> Option { + None + } + + fn as_raw_fd(&self) -> Option { + None + } + + fn tty_name(&self) -> Option { + None + } + } + + #[derive(Debug)] + struct TestHostKiller; + + impl ChildKiller for TestHostKiller { + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(Self) + } + } + + fn test_host_shared() -> HostShared { + let mut term = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + term.resize(80, 24, 8, 16).unwrap(); + let (pty_drain_waker, _pty_drain_waiter) = UnixStream::pair().unwrap(); + HostShared { + terminal_id: TerminalId::random().unwrap(), + incarnation: HostIncarnation::random().unwrap(), + owner_token: CapabilityToken::random().unwrap(), + capabilities: CapabilityStore::new(64), + term: Mutex::new(term), + stream_progress: TerminalStreamProgress::default(), + writer: Mutex::new(Box::new(std::io::sink())), + master: Mutex::new(Box::new(TestHostMaster { + size: Mutex::new(pty_size(80, 24, DEFAULT_CELL_PIXELS).unwrap()), + })), + killer: Mutex::new(Box::new(TestHostKiller)), + pid: None, + command: vec!["/bin/cat".into()], + cwd: None, + size: Mutex::new((80, 24)), + cell_pixels: Mutex::new(DEFAULT_CELL_PIXELS), + viewer_sizes: Mutex::new(HashMap::new()), + taps: Mutex::new(HashMap::new()), + broadcast_lock: Mutex::new(()), + sequence: AtomicU64::new(0), + next_client: AtomicU64::new(1), + dead: AtomicBool::new(false), + launch_owner_claimed: AtomicBool::new(false), + launch_owner_stream_ready: AtomicBool::new(false), + launch_owner_completed: AtomicBool::new(false), + child_exit: (Mutex::new(None), Condvar::new()), + child_waitable: AtomicBool::new(false), + pty_drained: AtomicBool::new(false), + exit_published: AtomicBool::new(false), + exit_record_path: std::env::temp_dir().join(format!( + "cmux-host-test-exit-{}-{}", + std::process::id(), + RECORD_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )), + exit_publish_lock: Mutex::new(()), + force_pty_drain: AtomicBool::new(false), + pty_drain_waker: Mutex::new(pty_drain_waker), + termination_started: AtomicBool::new(false), + child_signal_lock: Mutex::new(()), + child_reaped: AtomicBool::new(false), + group_escalation_complete: AtomicBool::new(false), + } + } + fn record_fixture(name: &str) -> (PathBuf, TerminalHostRecord, HostLivenessLease) { let root = std::env::temp_dir().join(format!( "cmux-host-record-{name}-{}-{}", @@ -3460,6 +4625,40 @@ mod unix { (record_path, record, lease) } + #[test] + fn default_host_cell_metrics_initialize_both_terminal_backends() { + let size = pty_size(80, 24, DEFAULT_CELL_PIXELS).unwrap(); + assert_eq!( + (size.cols, size.rows, size.pixel_width, size.pixel_height), + (80, 24, 640, 384) + ); + + let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + terminal + .resize(80, 24, u32::from(DEFAULT_CELL_PIXELS.0), u32::from(DEFAULT_CELL_PIXELS.1)) + .unwrap(); + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=1,c=1,r=1,q=2;/wAA\x1b\\"); + let graphics = terminal.kitty_graphics_snapshot().unwrap(); + assert_eq!( + (graphics.placements[0].pixel_width, graphics.placements[0].pixel_height), + (8, 16) + ); + } + + #[test] + fn pty_size_rejects_pixel_dimension_overflow() { + let maximum_cols = u16::MAX / DEFAULT_CELL_PIXELS.0; + let boundary = pty_size(maximum_cols, 24, DEFAULT_CELL_PIXELS).unwrap(); + assert_eq!(boundary.pixel_width, maximum_cols * DEFAULT_CELL_PIXELS.0); + + let width_error = pty_size(maximum_cols + 1, 24, DEFAULT_CELL_PIXELS).unwrap_err(); + assert!(width_error.to_string().contains("pixel width")); + + let maximum_rows = u16::MAX / DEFAULT_CELL_PIXELS.1; + let height_error = pty_size(80, maximum_rows + 1, DEFAULT_CELL_PIXELS).unwrap_err(); + assert!(height_error.to_string().contains("pixel height")); + } + #[test] fn launch_round_trip_preserves_ghostty_defaults() { let mut default_colors = DefaultColors { @@ -3480,15 +4679,24 @@ mod unix { term: "xterm-256color".into(), cols: 80, rows: 24, + cell_pixels: (9, 18), scrollback: 10_000, cwd: Some("/tmp".into()), command: vec!["/bin/cat".into()], extra_env: vec![("KEY".into(), "value".into())], default_colors, + kitty_graphics_limits: KittyGraphicsLimits { + image_bytes: 1_000, + inflight_bytes: 500, + images: 10, + placements: 20, + }, }; let decoded = HostLaunch::decode(&launch.encode().unwrap()).unwrap(); assert_eq!(decoded.default_colors, default_colors); + assert_eq!(decoded.cell_pixels, (9, 18)); + assert_eq!(decoded.kitty_graphics_limits, launch.kitty_graphics_limits); assert_eq!(decoded.command, launch.command); assert_eq!(decoded.extra_env, launch.extra_env); assert_eq!( @@ -3511,31 +4719,256 @@ mod unix { #[test] fn resized_payload_is_length_prefixed_for_cross_language_clients() { assert_eq!( - encode_resize(0x0123, 0x4567, &[0xaa, 0xbb, 0xcc]), - vec![0x23, 0x01, 0x67, 0x45, 3, 0, 0, 0, 0xaa, 0xbb, 0xcc] + encode_resize( + 0x0123, + 0x0456, + &[0xaa, 0xbb, 0xcc], + &[], + (9, 18), + test_kitty_state(), + ) + .unwrap(), + vec![ + 0x23, 0x01, 0x56, 0x04, 3, 0, 0, 0, 0xaa, 0xbb, 0xcc, 0, 0, 9, 0, 18, 0, 1, 0, + 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 8, 0, 0, 0, + ] ); } + #[test] + fn snapshot_payload_round_trip_preserves_kitty_image_alias_section() { + let snapshot = HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: (9, 18), + replay: b"theme-portable replay".to_vec(), + kitty_image_aliases: vec![ + KittyImageAlias { image_id: 41, image_number: 77 }, + KittyImageAlias { image_id: 42, image_number: 77 }, + ], + kitty_state: test_kitty_state(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: Some(42), + command: vec!["/bin/cat".into()], + cwd: Some("/tmp".into()), + }; + let payload = encode_snapshot(&snapshot).unwrap(); + + let decoded = + decode_snapshot(&payload).expect("snapshot decoder must retain Kitty aliases"); + assert_eq!(decoded.kitty_image_aliases, snapshot.kitty_image_aliases); + assert_eq!(decoded.kitty_state, snapshot.kitty_state); + assert_eq!(decoded.cell_pixels, snapshot.cell_pixels); + assert_eq!( + encode_snapshot(&decoded).unwrap(), + payload, + "snapshot encode/decode dropped Kitty image-number aliases" + ); + } + + #[test] + fn snapshot_payload_matches_the_cross_language_v3_golden_bytes() { + let snapshot = HostSnapshot { + cols: 1, + rows: 2, + cell_pixels: (9, 18), + replay: Vec::new(), + kitty_image_aliases: Vec::new(), + kitty_state: test_kitty_state(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: None, + command: Vec::new(), + cwd: None, + }; + + assert_eq!( + encode_snapshot(&snapshot).unwrap(), + vec![ + 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 18, 0, 1, 0, 0, 0, 0, + 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0, 7, 0, 0, 0, 8, 0, 0, 0, + ] + ); + } + + #[test] + fn legacy_snapshots_and_resizes_decode_without_newer_tails() { + let snapshot = HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: (9, 18), + replay: b"legacy replay".to_vec(), + kitty_image_aliases: vec![KittyImageAlias { image_id: 41, image_number: 77 }], + kitty_state: test_kitty_state(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: Some(42), + command: vec!["/bin/cat".into()], + cwd: Some("/tmp".into()), + }; + let snapshot_payload = encode_snapshot(&snapshot).unwrap(); + let v2_snapshot_len = snapshot_payload.len() - KITTY_REPLAY_STATE_ENCODED_LEN; + let decoded = decode_snapshot_for_version(&snapshot_payload[..v2_snapshot_len], 2) + .expect("protocol-v2 snapshots end after cell metrics"); + assert_eq!(decoded.replay, snapshot.replay); + assert_eq!(decoded.kitty_image_aliases, snapshot.kitty_image_aliases); + assert_eq!(decoded.cell_pixels, snapshot.cell_pixels); + assert_eq!(decoded.kitty_state, KittyReplayState::disabled()); + + let v1_snapshot_len = snapshot_payload.len() + - KITTY_IMAGE_ALIAS_COUNT_LEN + - snapshot.kitty_image_aliases.len() * KITTY_IMAGE_ALIAS_ENCODED_LEN + - CELL_PIXEL_SIZE_ENCODED_LEN + - KITTY_REPLAY_STATE_ENCODED_LEN; + let decoded = decode_snapshot_for_version( + &snapshot_payload[..v1_snapshot_len], + LEGACY_PROTOCOL_VERSION, + ) + .expect("protocol-v1 snapshots end before Kitty aliases"); + assert_eq!(decoded.replay, snapshot.replay); + assert!(decoded.kitty_image_aliases.is_empty()); + assert_eq!(decoded.cell_pixels, DEFAULT_CELL_PIXELS); + assert_eq!(decoded.kitty_state, KittyReplayState::disabled()); + + let resize_payload = encode_resize( + 81, + 25, + b"legacy resize", + &snapshot.kitty_image_aliases, + snapshot.cell_pixels, + test_kitty_state(), + ) + .unwrap(); + let v2_resize_len = resize_payload.len() - KITTY_REPLAY_STATE_ENCODED_LEN; + assert_eq!( + decode_host_resize_payload_for_version(&resize_payload[..v2_resize_len], 2) + .unwrap(), + DecodedHostResize { + cols: 81, + rows: 25, + cell_pixels: snapshot.cell_pixels, + replay: b"legacy resize".to_vec(), + kitty_image_aliases: snapshot.kitty_image_aliases.clone(), + kitty_state: KittyReplayState::disabled(), + } + ); + + let v1_resize_len = resize_payload.len() + - KITTY_IMAGE_ALIAS_COUNT_LEN + - snapshot.kitty_image_aliases.len() * KITTY_IMAGE_ALIAS_ENCODED_LEN + - CELL_PIXEL_SIZE_ENCODED_LEN + - KITTY_REPLAY_STATE_ENCODED_LEN; + assert_eq!( + decode_host_resize_payload_for_version( + &resize_payload[..v1_resize_len], + LEGACY_PROTOCOL_VERSION, + ) + .unwrap(), + DecodedHostResize { + cols: 81, + rows: 25, + cell_pixels: DEFAULT_CELL_PIXELS, + replay: b"legacy resize".to_vec(), + kitty_image_aliases: Vec::new(), + kitty_state: KittyReplayState::disabled(), + } + ); + } + + #[test] + fn resize_alias_section_preserves_number_history_and_rejects_malformed_data() { + let alias = KittyImageAlias { image_id: 41, image_number: 77 }; + let valid = + encode_resize(80, 24, b"replay", &[alias], (9, 18), test_kitty_state()).unwrap(); + assert_eq!( + decode_host_resize_payload(&valid).unwrap(), + DecodedHostResize { + cols: 80, + rows: 24, + cell_pixels: (9, 18), + replay: b"replay".to_vec(), + kitty_image_aliases: vec![alias], + kitty_state: test_kitty_state(), + } + ); + + let alias_offset = 8 + b"replay".len(); + let mut zero_id = valid.clone(); + zero_id[alias_offset + 2..alias_offset + 6].fill(0); + assert!(decode_host_resize_payload(&zero_id).is_err()); + + let duplicate_aliases = [ + KittyImageAlias { image_id: 41, image_number: 77 }, + KittyImageAlias { image_id: 42, image_number: 77 }, + ]; + let duplicate_numbers = + encode_resize(80, 24, b"replay", &duplicate_aliases, (9, 18), test_kitty_state()) + .unwrap(); + assert_eq!( + decode_host_resize_payload(&duplicate_numbers).unwrap(), + DecodedHostResize { + cols: 80, + rows: 24, + cell_pixels: (9, 18), + replay: b"replay".to_vec(), + kitty_image_aliases: duplicate_aliases.to_vec(), + kitty_state: test_kitty_state(), + } + ); + + let mut truncated = valid.clone(); + truncated.pop(); + assert!(decode_host_resize_payload(&truncated).is_err()); + + let mut invalid_offset = valid.clone(); + let state_offset = alias_offset + + KITTY_IMAGE_ALIAS_COUNT_LEN + + KITTY_IMAGE_ALIAS_ENCODED_LEN + + CELL_PIXEL_SIZE_ENCODED_LEN; + invalid_offset[state_offset + KITTY_GRAPHICS_LIMITS_ENCODED_LEN + ..state_offset + KITTY_GRAPHICS_LIMITS_ENCODED_LEN + size_of::()] + .copy_from_slice(&7u32.to_le_bytes()); + assert!(decode_host_resize_payload(&invalid_offset).is_err()); + + let mut invalid_state = test_kitty_state(); + invalid_state.replay_cursor_offset = 7; + assert!(encode_resize(80, 24, b"replay", &[alias], (9, 18), invalid_state).is_err()); + + let mut trailing = valid; + trailing.push(0); + assert!(decode_host_resize_payload(&trailing).is_err()); + + let mut excessive = vec![80, 0, 24, 0, 0, 0, 0, 0]; + excessive.extend_from_slice(&((MAX_KITTY_IMAGE_ALIASES + 1) as u16).to_le_bytes()); + assert!(decode_host_resize_payload(&excessive).is_err()); + } + #[test] fn clear_history_ack_preserves_known_not_delivered_failure() { let (record_path, record, lease) = record_fixture("clear-history-ack"); let root = record_path.parent().unwrap().to_path_buf(); let (client, mut host) = UnixStream::pair().unwrap(); - let control_responses = - Arc::new(ControlResponses { waiters: Mutex::new(HashMap::new()) }); + let control_responses = Arc::new(ControlResponses::new()); let attachment = HostAttachment { record, record_path, snapshot: HostSnapshot { cols: 80, rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, replay: Vec::new(), + kitty_image_aliases: Vec::new(), + kitty_state: test_kitty_state(), sequence_boundary: 0, colors: TerminalColorOverrides::default(), pid: None, command: Vec::new(), cwd: None, }, + protocol_version: PROTOCOL_VERSION, reader: None, writer: Arc::new(Mutex::new(client)), control_responses: control_responses.clone(), @@ -3576,18 +5009,20 @@ mod unix { snapshot: HostSnapshot { cols: 80, rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, replay: Vec::new(), + kitty_image_aliases: Vec::new(), + kitty_state: test_kitty_state(), sequence_boundary: 0, colors: TerminalColorOverrides::default(), pid: None, command: Vec::new(), cwd: None, }, + protocol_version: PROTOCOL_VERSION, reader: None, writer: Arc::new(Mutex::new(client)), - control_responses: Arc::new(ControlResponses { - waiters: Mutex::new(HashMap::new()), - }), + control_responses: Arc::new(ControlResponses::new()), next_request: AtomicU64::new(2), viewer_size: Mutex::new(None), launch_process: None, @@ -3876,18 +5311,366 @@ mod unix { let _ = fs::remove_dir_all(record_path.parent().unwrap()); } + #[test] + fn timed_out_cell_pixel_ack_reconciles_when_the_response_arrives_late() { + let (record_path, record, lease) = record_fixture("late-cell-pixel-ack"); + let (client, mut host) = UnixStream::pair().unwrap(); + let control_responses = Arc::new(ControlResponses::new()); + let (reconciled_tx, reconciled_rx) = std::sync::mpsc::channel(); + control_responses.set_deferred_cell_pixel_handler(Arc::new( + move |request_id, expected, frame| { + reconciled_tx.send((request_id, expected, frame)).unwrap(); + }, + )); + let attachment = HostAttachment { + record: record.clone(), + record_path: record_path.clone(), + snapshot: HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + replay: Vec::new(), + kitty_image_aliases: Vec::new(), + kitty_state: test_kitty_state(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: None, + command: vec!["/bin/cat".into()], + cwd: None, + }, + protocol_version: PROTOCOL_VERSION, + reader: None, + writer: Arc::new(Mutex::new(client)), + control_responses: control_responses.clone(), + next_request: AtomicU64::new(2), + viewer_size: Mutex::new(None), + launch_process: None, + }; + let resolver = { + let control_responses = control_responses.clone(); + thread::spawn(move || { + let request = + read_required_frame(&mut host, "cell pixel size request").unwrap(); + assert_eq!(request.kind, MessageKind::SetCellPixelSize); + thread::sleep(Duration::from_millis(50)); + let mut ack = + Frame::new(MessageKind::CellPixelSizeAck, request.payload.clone()); + ack.request_id = request.request_id; + control_responses.resolve(&ack); + }) + }; + + let error = attachment + .send_cell_pixel_size_until(9, 18, Instant::now() + Duration::from_millis(10)) + .unwrap_err(); + assert!(error.is::()); + assert!( + error.to_string().contains("late response will reconcile the mirror"), + "{error:#}" + ); + let (request_id, expected, resolution) = + reconciled_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(request_id, 2); + assert_eq!(expected, (9, 18)); + let DeferredCellPixelResolution::Response(ack) = resolution else { + panic!("late acknowledgement was reported as a disconnect"); + }; + assert_eq!(ack.payload, vec![9, 0, 18, 0]); + assert_eq!(control_responses.latest_cell_pixel_ack(), 2); + + resolver.join().unwrap(); + drop(attachment); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + } + + #[test] + fn disconnect_settles_deferred_cell_pixel_waiters() { + let control_responses = ControlResponses::new(); + let (sender, _receiver) = sync_channel(1); + control_responses.waiters.lock().unwrap().insert( + 7, + ControlResponseWaiter::Blocking { kind: MessageKind::CellPixelSizeAck, sender }, + ); + assert!(control_responses.defer_cell_pixel(7, (9, 18))); + let (settled_tx, settled_rx) = std::sync::mpsc::channel(); + control_responses.set_deferred_cell_pixel_handler(Arc::new( + move |request_id, expected, _frame| { + settled_tx.send((request_id, expected)).unwrap(); + }, + )); + + control_responses.fail_all(); + + assert_eq!(settled_rx.recv_timeout(Duration::from_secs(1)).unwrap(), (7, (9, 18))); + } + + #[test] + fn cell_pixel_commit_is_broadcast_to_live_renderer_taps_before_ack() { + let host = test_host_shared(); + let (renderer_socket, _renderer_peer) = UnixStream::pair().unwrap(); + let (renderer_tx, renderer_rx) = sync_channel(4); + host.taps + .lock() + .unwrap() + .insert(1, HostTap::new(renderer_tx, Arc::new(renderer_socket), usize::MAX)); + let (target_socket, _target_peer) = UnixStream::pair().unwrap(); + let (target_tx, target_rx) = sync_channel(1); + let target = HostTap::new(target_tx, Arc::new(target_socket), usize::MAX); + + assert!(host.set_cell_pixel_size(9, 18, 42, &target).unwrap()); + + let resized = renderer_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(resized.kind, MessageKind::Resized); + assert_eq!(resized.flags, FLAG_COLORS_FOLLOW); + assert_eq!(decode_host_resize_payload(&resized.payload).unwrap().cell_pixels, (9, 18)); + let colors = renderer_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(colors.kind, MessageKind::Colors); + assert!(colors.sequence > resized.sequence); + let ack = target_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(ack.kind, MessageKind::CellPixelSizeAck); + assert_eq!(ack.request_id, 42); + } + + #[test] + fn kitty_limit_commit_replaces_live_mirrors_before_ack() { + let host = test_host_shared(); + host.term + .lock() + .unwrap() + .vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + let (target_socket, _target_peer) = UnixStream::pair().unwrap(); + let (target_tx, target_rx) = sync_channel(3); + let target = HostTap::new(target_tx, Arc::new(target_socket), usize::MAX); + host.taps.lock().unwrap().insert(1, target.clone()); + let limits = KittyGraphicsLimits::disabled(); + + assert!(host.set_kitty_graphics_limits(limits, 43, &target).unwrap()); + + let resized = target_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(resized.kind, MessageKind::Resized); + assert_eq!(resized.flags, FLAG_COLORS_FOLLOW); + let decoded = decode_host_resize_payload(&resized.payload).unwrap(); + assert_eq!(decoded.kitty_state.limits, limits); + let colors = target_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(colors.kind, MessageKind::Colors); + assert!(colors.sequence > resized.sequence); + let ack = target_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(ack.kind, MessageKind::KittyGraphicsLimitsAck); + assert_eq!(ack.request_id, 43); + let mut decoder = PayloadDecoder::new(&ack.payload); + assert_eq!(decode_kitty_graphics_limits(&mut decoder).unwrap(), limits); + decoder.finish().unwrap(); + + let mut mirror = + Terminal::new(decoded.cols, decoded.rows, 0, Callbacks::default()).unwrap(); + mirror + .apply_vt_replay(&ghostty_vt::VtReplay { + bytes: decoded.replay, + kitty_image_aliases: decoded.kitty_image_aliases, + kitty_state: decoded.kitty_state, + }) + .unwrap(); + assert!(mirror.kitty_graphics_snapshot().unwrap().images.is_empty()); + assert_eq!(mirror.kitty_graphics_limits().unwrap(), limits); + } + + #[test] + fn adoption_quota_reconfiguration_finishes_before_snapshot_use() { + let (record_path, record, lease) = record_fixture("adoption-kitty-quota"); + let root = record_path.parent().unwrap().to_path_buf(); + let (client, mut host) = UnixStream::pair().unwrap(); + let reader = client.try_clone().unwrap(); + let mut stale_state = test_kitty_state(); + stale_state.limits = KittyGraphicsLimits { + image_bytes: 8_000, + inflight_bytes: 8_000, + images: 80, + placements: 160, + }; + let ceiling = KittyGraphicsLimits { + image_bytes: 4_000, + inflight_bytes: 4_000, + images: 40, + placements: 80, + }; + let mut attachment = HostAttachment { + record, + record_path, + snapshot: HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + replay: Vec::new(), + kitty_image_aliases: Vec::new(), + kitty_state: stale_state, + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: None, + command: Vec::new(), + cwd: None, + }, + protocol_version: PROTOCOL_VERSION, + reader: Some(reader), + writer: Arc::new(Mutex::new(client)), + control_responses: Arc::new(ControlResponses::new()), + next_request: AtomicU64::new(2), + viewer_size: Mutex::new(None), + launch_process: None, + }; + let responder = thread::spawn(move || { + let request = read_frame(&mut host, MAX_FRAME_PAYLOAD).unwrap().unwrap(); + assert_eq!(request.kind, MessageKind::SetKittyGraphicsLimits); + let mut decoder = PayloadDecoder::new(&request.payload); + assert_eq!(decode_kitty_graphics_limits(&mut decoder).unwrap(), ceiling); + decoder.finish().unwrap(); + + let mut fresh_state = test_kitty_state(); + fresh_state.limits = ceiling; + let mut resized = Frame::new( + MessageKind::Resized, + encode_resize(80, 24, &[], &[], DEFAULT_CELL_PIXELS, fresh_state).unwrap(), + ); + resized.version = PROTOCOL_VERSION; + resized.flags = FLAG_COLORS_FOLLOW; + resized.sequence = 1; + write_frame(&mut host, &resized).unwrap(); + let mut colors = Frame::new( + MessageKind::Colors, + encode_terminal_color_overrides(&TerminalColorOverrides { + cursor_visual: Some((CursorShape::Block, false)), + ..TerminalColorOverrides::default() + }), + ); + colors.version = PROTOCOL_VERSION; + colors.sequence = 2; + write_frame(&mut host, &colors).unwrap(); + + let mut payload = Vec::new(); + encode_kitty_graphics_limits(&mut payload, ceiling).unwrap(); + let mut ack = Frame::new(MessageKind::KittyGraphicsLimitsAck, payload); + ack.version = PROTOCOL_VERSION; + ack.request_id = request.request_id; + write_frame(&mut host, &ack).unwrap(); + assert!(read_frame(&mut host, MAX_FRAME_PAYLOAD).unwrap().is_none()); + }); + + attachment.reconfigure_kitty_graphics_for_adoption(ceiling).unwrap(); + attachment.disconnect(); + responder.join().unwrap(); + + drop(attachment); + drop(lease); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn upgraded_daemon_falls_back_to_a_live_protocol_one_host() { + let (record_path, record, lease) = record_fixture("protocol-one-adoption"); + let endpoint = PathBuf::from(&record.endpoint); + prepare_private_dir(endpoint.parent().unwrap()).unwrap(); + let _ = fs::remove_file(&endpoint); + let listener = UnixListener::bind(&endpoint).unwrap(); + let terminal_id = + TerminalId::from_bytes(decode_hex_array(&record.terminal_id).unwrap()); + let incarnation = + HostIncarnation::from_bytes(decode_hex_array(&record.incarnation).unwrap()); + let expected_replay = b"protocol-one-live-state".to_vec(); + let host_replay = expected_replay.clone(); + let fake_host = thread::spawn(move || { + for rejected_version in ((LEGACY_PROTOCOL_VERSION + 1)..=PROTOCOL_VERSION).rev() { + let (mut rejected, _) = listener.accept().unwrap(); + let hello = read_required_frame(&mut rejected, "newer-version hello").unwrap(); + assert_eq!(hello.kind, MessageKind::ClientHello); + assert_eq!(hello.version, rejected_version); + } + + let (mut legacy, _) = listener.accept().unwrap(); + let legacy_hello = read_required_frame(&mut legacy, "legacy hello").unwrap(); + assert_eq!(legacy_hello.kind, MessageKind::ClientHello); + assert_eq!(legacy_hello.version, LEGACY_PROTOCOL_VERSION); + let decoded = ClientHello::decode(&legacy_hello.payload).unwrap(); + assert_eq!( + (decoded.min_version, decoded.max_version), + (LEGACY_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION) + ); + + let response = HostHello { + selected_version: LEGACY_PROTOCOL_VERSION, + granted_rights: CapabilityRights::ADMIN, + terminal_id, + incarnation, + }; + let mut hello = Frame::new(MessageKind::HostHello, response.encode()); + hello.version = LEGACY_PROTOCOL_VERSION; + hello.request_id = legacy_hello.request_id; + write_frame(&mut legacy, &hello).unwrap(); + + let snapshot = HostSnapshot { + cols: 80, + rows: 24, + cell_pixels: DEFAULT_CELL_PIXELS, + replay: host_replay, + kitty_image_aliases: Vec::new(), + kitty_state: test_kitty_state(), + sequence_boundary: 0, + colors: TerminalColorOverrides::default(), + pid: Some(42), + command: vec!["/bin/cat".into()], + cwd: Some("/tmp".into()), + }; + let mut payload = encode_snapshot(&snapshot).unwrap(); + payload.truncate( + payload.len() + - KITTY_IMAGE_ALIAS_COUNT_LEN + - CELL_PIXEL_SIZE_ENCODED_LEN + - KITTY_REPLAY_STATE_ENCODED_LEN, + ); + let mut frame = Frame::new(MessageKind::Snapshot, payload); + frame.version = LEGACY_PROTOCOL_VERSION; + write_frame(&mut legacy, &frame).unwrap(); + + let colors = TerminalColorOverrides { + cursor_visual: Some((CursorShape::Block, true)), + ..TerminalColorOverrides::default() + }; + let mut frame = + Frame::new(MessageKind::Colors, encode_terminal_color_overrides(&colors)); + frame.version = LEGACY_PROTOCOL_VERSION; + write_frame(&mut legacy, &frame).unwrap(); + + let release = read_required_frame(&mut legacy, "legacy viewer release").unwrap(); + assert_eq!(release.kind, MessageKind::ReleaseViewer); + assert_eq!(release.version, LEGACY_PROTOCOL_VERSION); + }); + + let attachment = connect_record_with_timeout( + record.clone(), + record_path.clone(), + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(attachment.protocol_version(), LEGACY_PROTOCOL_VERSION); + assert_eq!(attachment.snapshot.replay, expected_replay); + assert!(attachment.snapshot.kitty_image_aliases.is_empty()); + assert!(!attachment.send_cell_pixel_size(9, 18).unwrap()); + drop(attachment); + fake_host.join().unwrap(); + + let _ = fs::remove_file(endpoint); + drop(lease); + assert!(remove_stale_terminal_host_record(&record_path, &record).unwrap()); + let _ = fs::remove_dir_all(record_path.parent().unwrap()); + } + #[test] fn host_tap_byte_overflow_closes_the_client_socket() { let (host_socket, mut client_socket) = UnixStream::pair().unwrap(); client_socket.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); let (sender, _receiver) = sync_channel(8); let one_frame = crate::terminal_host_protocol::HEADER_LEN + 4; - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(host_socket), - max_queued_bytes: one_frame, - }; + let tap = HostTap::new(sender, Arc::new(host_socket), one_frame); assert!(tap.try_send(Frame::new(MessageKind::Output, vec![1; 4]))); assert!(!tap.try_send(Frame::new(MessageKind::Output, vec![2]))); @@ -3895,17 +5678,26 @@ mod unix { assert_eq!(client_socket.read(&mut byte).unwrap(), 0); } + #[test] + fn host_tap_snapshot_headroom_does_not_expand_live_output_budget() { + let (host_socket, mut client_socket) = UnixStream::pair().unwrap(); + client_socket.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let (sender, _receiver) = sync_channel(8); + let tap = HostTap::new(sender, Arc::new(host_socket), MAX_HOST_CLIENT_QUEUED_BYTES); + let half_output_budget = 4 * 1024 * 1024; + + assert!(tap.try_send(Frame::new(MessageKind::Output, vec![1; half_output_budget],))); + assert!(!tap.try_send(Frame::new(MessageKind::Output, vec![2; half_output_budget],))); + let mut byte = [0u8; 1]; + assert_eq!(client_socket.read(&mut byte).unwrap(), 0); + } + #[test] fn host_tap_channel_overflow_closes_the_client_socket() { let (host_socket, mut client_socket) = UnixStream::pair().unwrap(); client_socket.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); let (sender, _receiver) = sync_channel(1); - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(host_socket), - max_queued_bytes: usize::MAX, - }; + let tap = HostTap::new(sender, Arc::new(host_socket), usize::MAX); assert!(tap.try_send(Frame::new(MessageKind::Output, vec![1]))); assert!(!tap.try_send(Frame::new(MessageKind::Output, vec![2]))); @@ -3985,12 +5777,7 @@ mod unix { for child_first in [false, true] { let (host_socket, _client_socket) = UnixStream::pair().unwrap(); let (sender, receiver) = sync_channel(8); - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(host_socket), - max_queued_bytes: usize::MAX, - }; + let tap = HostTap::new(sender, Arc::new(host_socket), usize::MAX); let broadcast_lock = Mutex::new(()); let sequence = AtomicU64::new(0); let taps = Mutex::new(HashMap::from([(1, tap)])); @@ -4173,12 +5960,7 @@ mod unix { fn coupled_color_frames_stay_adjacent_under_concurrent_exit_and_resize() { let (host_socket, _client_socket) = UnixStream::pair().unwrap(); let (sender, receiver) = sync_channel(8); - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(host_socket), - max_queued_bytes: usize::MAX, - }; + let tap = HostTap::new(sender, Arc::new(host_socket), usize::MAX); let broadcast_lock = Mutex::new(()); let sequence = AtomicU64::new(0); let taps = Mutex::new(HashMap::from([(1, tap)])); @@ -4275,12 +6057,7 @@ mod unix { fn pwd_change_stays_contiguous_with_its_output_boundary() { let (host_socket, _client_socket) = UnixStream::pair().unwrap(); let (sender, receiver) = sync_channel(8); - let tap = HostTap { - sender, - queued_bytes: Arc::new(AtomicUsize::new(0)), - shutdown: Arc::new(host_socket), - max_queued_bytes: usize::MAX, - }; + let tap = HostTap::new(sender, Arc::new(host_socket), usize::MAX); let broadcast_lock = Mutex::new(()); let sequence = AtomicU64::new(0); let taps = Mutex::new(HashMap::from([(1, tap)])); @@ -4326,6 +6103,11 @@ mod unix { } } +#[cfg(unix)] +pub(crate) use unix::{ + ControlResponses, DecodedHostResize, DeferredCellPixelResolution, + adopt_terminal_host_with_kitty_limits, decode_host_resize_payload_for_version, +}; #[cfg(unix)] pub use unix::{ HostAttachment, acknowledge_terminal_host_exit_record, adopt_terminal_host, diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs index 0a93d9300f..b020b0258a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs @@ -2887,18 +2887,18 @@ fn validate_touched_resource_invariants( |row| row.get(0), )?; match meta_value(transaction, "active_workspace_id")? { - Some(active_workspace) => { + Some(active_workspace) if live_resource_field( transaction, "resource_workspaces", "public_id", &active_workspace, )? - .is_none() - { - anyhow::bail!("active workspace {active_workspace} is not live"); - } + .is_none() => + { + anyhow::bail!("active workspace {active_workspace} is not live"); } + Some(_) => {} None if live_workspace_count != 0 => { anyhow::bail!("live session has workspaces but no active workspace"); } diff --git a/cmux-tui/crates/cmux-tui-core/tests/pty.rs b/cmux-tui/crates/cmux-tui-core/tests/pty.rs index f4c2bcae05..c557fa713e 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/pty.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/pty.rs @@ -1,5 +1,6 @@ use std::io::{BufRead, BufReader, Write}; use std::path::Path; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::TryRecvError; use std::time::{Duration, Instant}; @@ -659,6 +660,77 @@ fn control_socket_attach_vt_state_includes_effective_colors() { cmux_tui_core::server::cleanup(&sock_path); } +#[test] +fn control_socket_attach_serializes_kitty_aliases_on_initial_and_resize_replay() { + let mux = Mux::new(unique_session("test-attach-kitty-aliases"), shell_opts("cat")); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,I=77,s=1,v=1,q=2;/wAA\x1b\\"); + }) + .unwrap(); + let image_id = surface + .try_with_terminal(|terminal| terminal.kitty_graphics_snapshot().unwrap().images[0].id) + .unwrap(); + let expected_aliases = serde_json::json!([{"image_id": image_id, "image_number": 77}]); + + let sock_path = cmux_tui_core::server::serve(mux.clone(), None).unwrap(); + let stream = connect(&sock_path); + stream.set_read_timeout(Some(Duration::from_millis(100))).unwrap(); + let mut writer = stream.try_clone_box().unwrap(); + let mut reader = BufReader::new(stream); + + writeln!(writer, r#"{{"id":1,"cmd":"attach-surface","surface":{}}}"#, surface.id).unwrap(); + let initial = + wait_for(|| read_json_line(&mut reader), Duration::from_secs(5)).expect("initial vt-state"); + assert_eq!(initial["event"], "vt-state"); + assert_eq!(initial["kitty_image_aliases"], expected_aliases); + let response = + wait_for(|| read_json_line(&mut reader), Duration::from_secs(5)).expect("attach response"); + assert_eq!(response["ok"], true, "attach failed: {response}"); + + let restore_and_place = |event: &serde_json::Value, data_field: &str, cols: u16, rows: u16| { + let bytes = base64::engine::general_purpose::STANDARD + .decode(event[data_field].as_str().unwrap()) + .unwrap(); + let aliases = event["kitty_image_aliases"] + .as_array() + .unwrap() + .iter() + .map(|alias| ghostty_vt::KittyImageAlias { + image_id: alias["image_id"].as_u64().unwrap() as u32, + image_number: alias["image_number"].as_u64().unwrap() as u32, + }) + .collect::>(); + let mut mirror = + ghostty_vt::Terminal::new(cols, rows, 100, ghostty_vt::Callbacks::default()).unwrap(); + mirror.vt_write(&bytes); + mirror.restore_kitty_image_aliases(&aliases).unwrap(); + mirror.vt_write(b"\x1b_Ga=p,I=77,p=3,c=1,r=1,q=2;\x1b\\"); + assert_eq!(mirror.kitty_graphics_snapshot().unwrap().placements[0].image_id, image_id); + }; + restore_and_place(&initial, "data", 20, 4); + + mux.resize_surface(surface.id, 21, 4).unwrap(); + let resized = wait_for( + || { + while let Some(value) = read_json_line(&mut reader) { + if value["event"] == "resized" { + return Some(value); + } + } + None + }, + Duration::from_secs(5), + ) + .expect("resize replay"); + assert_eq!(resized["kitty_image_aliases"], expected_aliases); + restore_and_place(&resized, "replay", 21, 4); + + let _ = mux.close_surface(surface.id); + cmux_tui_core::server::cleanup(&sock_path); +} + #[test] fn control_socket_attach_vt_state_reports_builtin_cursor_without_config() { let mux = Mux::new(unique_session("test-attach-cursor-null"), shell_opts("cat")); @@ -989,7 +1061,7 @@ fn attach_stream_replays_then_streams_without_duplication() { break; } } - Ok(AttachFrame::Resized { cols, rows, replay }) + Ok(AttachFrame::Resized { cols, rows, replay, .. }) | Ok(AttachFrame::ResizedWithColors { cols, rows, replay, .. }) => { assert!(!replay.is_empty()); mirror = @@ -1007,6 +1079,167 @@ fn attach_stream_replays_then_streams_without_duplication() { mux.close_surface(surface.id).unwrap(); } +#[test] +fn default_byte_attach_replays_preexisting_kitty_image() { + let mux = Mux::new(unique_session("test-attach-kitty"), shell_opts("cat")); + mux.set_cell_pixel_size(9, 18); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=T,t=d,f=32,i=73,p=2,s=1,v=1,c=2,r=2,q=2;/wAAfw==\x1b\\"); + }) + .unwrap(); + + // Current remote clients omit `mode`, which selects the byte attach path. + let attach = surface.attach_stream().unwrap(); + let mut mirror = + ghostty_vt::Terminal::new(attach.cols, attach.rows, 1000, ghostty_vt::Callbacks::default()) + .unwrap(); + mirror.resize(attach.cols, attach.rows, 9, 18).unwrap(); + mirror.vt_write(&attach.replay); + + let graphics = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!(&*graphics.image(73).expect("pre-attach image").data, &[255, 0, 0, 127]); + assert_eq!((graphics.placements[0].pixel_width, graphics.placements[0].pixel_height), (18, 36)); + let _ = mux.close_surface(surface.id); +} + +#[test] +fn byte_attach_between_transmit_and_place_keeps_the_unplaced_image() { + let mux = Mux::new(unique_session("test-attach-unplaced-kitty"), shell_opts("cat")); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,i=76,s=1,v=1,q=2;/wAA\x1b\\"); + }) + .unwrap(); + + let attach = surface.attach_stream().unwrap(); + let mut mirror = + ghostty_vt::Terminal::new(attach.cols, attach.rows, 1000, ghostty_vt::Callbacks::default()) + .unwrap(); + mirror.vt_write(&attach.replay); + let before_place = mirror.kitty_graphics_snapshot().unwrap(); + assert!(before_place.image(76).is_some()); + assert!(before_place.placements.is_empty()); + + mirror.vt_write(b"\x1b_Ga=p,i=76,p=5,c=1,r=1,q=2;\x1b\\"); + assert_eq!(mirror.kitty_graphics_snapshot().unwrap().placements.len(), 1); + let _ = mux.close_surface(surface.id); +} + +#[test] +fn attach_resize_replay_preserves_an_inflight_kitty_transmission() { + let mux = Mux::new(unique_session("test-attach-inflight-kitty"), shell_opts("cat")); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,i=79,s=1,v=2,m=1,q=2;////\x1b\\"); + }) + .unwrap(); + + let attach = surface.attach_stream().unwrap(); + let mut initial = + ghostty_vt::Terminal::new(attach.cols, attach.rows, 1000, ghostty_vt::Callbacks::default()) + .unwrap(); + initial + .apply_vt_replay(&ghostty_vt::VtReplay { + bytes: attach.replay.to_vec(), + kitty_image_aliases: attach.kitty_image_aliases.clone(), + kitty_state: attach.kitty_state, + }) + .unwrap(); + + mux.resize_surface(surface.id, 21, 4).unwrap(); + let (cols, rows, replay, aliases, kitty_state) = + match attach.stream.recv_timeout(Duration::from_secs(2)) { + Ok(AttachFrame::Resized { cols, rows, replay, kitty_image_aliases, kitty_state }) + | Ok(AttachFrame::ResizedWithColors { + cols, + rows, + replay, + kitty_image_aliases, + kitty_state, + .. + }) => (cols, rows, replay, kitty_image_aliases, kitty_state), + other => panic!("missing ordered resize replay: {other:?}"), + }; + let mut resized = + ghostty_vt::Terminal::new(cols, rows, 1000, ghostty_vt::Callbacks::default()).unwrap(); + resized + .apply_vt_replay(&ghostty_vt::VtReplay { + bytes: replay.to_vec(), + kitty_image_aliases: aliases, + kitty_state, + }) + .unwrap(); + + let final_chunk = b"\x1b_Gm=0,q=2;////\x1b\\"; + surface.try_with_terminal(|terminal| terminal.vt_write(final_chunk)).unwrap(); + initial.vt_write(final_chunk); + resized.vt_write(final_chunk); + assert_eq!( + &*surface + .try_with_terminal(|terminal| { + terminal.kitty_graphics_snapshot().unwrap().image(79).unwrap().data.clone() + }) + .unwrap(), + &[255; 6] + ); + assert_eq!(&*initial.kitty_graphics_snapshot().unwrap().image(79).unwrap().data, &[255; 6]); + assert_eq!(&*resized.kitty_graphics_snapshot().unwrap().image(79).unwrap().data, &[255; 6]); + let _ = mux.close_surface(surface.id); +} + +#[test] +fn render_attach_snapshot_contains_preexisting_kitty_image() { + let mux = Mux::new(unique_session("test-render-attach-kitty"), shell_opts("cat")); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=74,p=0,s=1,v=1,c=1,r=1,q=2;/wAA\x1b\\"); + }) + .unwrap(); + + let attach = surface.attach_render_stream().unwrap(); + let second_attach = surface.attach_render_stream().unwrap(); + let graphics = &attach.initial.frame.kitty_graphics; + let second_graphics = &second_attach.initial.frame.kitty_graphics; + assert_eq!(&*graphics.image(74).expect("render-attach image").data, &[255, 0, 0]); + assert_eq!(graphics.placements.len(), 1); + assert!( + Arc::ptr_eq(graphics, second_graphics), + "unchanged render attachments must share one graphics snapshot" + ); + assert!( + Arc::ptr_eq( + &graphics.image(74).expect("first render-attach image").data, + &second_graphics.image(74).expect("second render-attach image").data, + ), + "unchanged render attachments must share decoded Kitty pixels" + ); + let _ = mux.close_surface(surface.id); +} + +#[test] +fn render_attach_initial_snapshot_includes_unplaced_image_for_later_placement() { + let mux = Mux::new(unique_session("test-render-attach-unplaced-kitty"), shell_opts("cat")); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,i=77,s=1,v=1,q=2;/wAA\x1b\\"); + }) + .unwrap(); + + let attach = surface.attach_render_stream().unwrap(); + assert_eq!( + &*attach.initial.frame.kitty_graphics.image(77).expect("unplaced image").data, + &[255, 0, 0] + ); + assert!(attach.initial.frame.kitty_graphics.placements.is_empty()); + let _ = mux.close_surface(surface.id); +} + #[test] fn byte_attach_cursor_snapshot_does_not_fan_out_a_render_frame() { let mux = Mux::new(unique_session("test-attach-no-render-fanout"), shell_opts("cat")); @@ -1049,7 +1282,7 @@ fn attach_stream_orders_resize_between_output_frames() { mux.resize_surface(surface.id, 100, 40).unwrap(); let resized = wait_for( || match attach.stream.recv_timeout(Duration::from_millis(200)) { - Ok(AttachFrame::ResizedWithColors { cols, rows, replay, colors }) => { + Ok(AttachFrame::ResizedWithColors { cols, rows, replay, colors, .. }) => { assert!(!replay.is_empty()); assert_eq!(colors.palette[4], Some(Rgb { r: 0x11, g: 0x22, b: 0x33 })); Some((cols, rows)) @@ -1120,6 +1353,7 @@ fn render_attach_headless_fans_one_frame_to_render_and_byte_consumers() { assert_eq!(initial["size"], serde_json::json!({"cols": 20, "rows": 4})); assert_eq!(initial["rows"].as_array().unwrap().len(), 4); assert!(initial["cursor"].get("visible").is_some()); + assert!(initial["history_epoch"].as_u64().is_some(), "render state history epoch"); let response = wait_for(|| read_json_line(&mut render_reader), Duration::from_secs(5)) .expect("render attach response"); assert_eq!(response["id"], 1); @@ -1157,6 +1391,10 @@ fn render_attach_headless_fans_one_frame_to_render_and_byte_consumers() { assert_eq!(delta["full"], false); assert_eq!(delta["rows"].as_array().unwrap().len(), 1); assert!(delta.get("cursor").is_some()); + assert!( + delta.get("history_epoch").is_none(), + "active-screen output must not invalidate retained history" + ); let output = wait_for( || { @@ -1313,6 +1551,7 @@ fn read_scrollback_pages_oldest_rows_and_clamps_bounds() { assert_eq!(page["start"], 0); assert_eq!(page["rows"].as_array().unwrap().len(), 3); assert!(page["total"].as_u64().unwrap() >= 20); + assert!(page["epoch"].as_u64().is_some()); assert!(rendered_rows_text(&page).contains("history-00")); let total = page["total"].as_u64().unwrap(); diff --git a/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs b/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs index a8f8b5eafd..5eead70132 100644 --- a/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs +++ b/cmux-tui/crates/cmux-tui-core/tests/websocket_transport.rs @@ -11,6 +11,21 @@ use serde_json::{Value, json}; use tungstenite::{Message, WebSocket, client}; const TEST_TOKEN: &str = "test-token"; +const LARGE_RENDER_IMAGE_WIDTH: usize = 1_024; +const LARGE_RENDER_IMAGE_HEIGHT: usize = 768; +const LARGE_RENDER_IMAGE_RAW_BYTES: usize = + LARGE_RENDER_IMAGE_WIDTH * LARGE_RENDER_IMAGE_HEIGHT * 4; +const LARGE_RENDER_IMAGE_BASE64_CHARS: usize = LARGE_RENDER_IMAGE_RAW_BYTES.div_ceil(3) * 4; + +fn large_rgba_kitty_transmission() -> Vec { + let data = + base64::engine::general_purpose::STANDARD.encode(vec![0x7f; LARGE_RENDER_IMAGE_RAW_BYTES]); + assert_eq!(data.len(), LARGE_RENDER_IMAGE_BASE64_CHARS); + format!( + "\x1b_Ga=T,t=d,f=32,i=51,p=1,s={LARGE_RENDER_IMAGE_WIDTH},v={LARGE_RENDER_IMAGE_HEIGHT},c=80,r=24,q=2;{data}\x1b\\" + ) + .into_bytes() +} fn connect(addr: SocketAddr) -> WebSocket { connect_raw(addr) @@ -33,10 +48,17 @@ fn send_json(websocket: &mut WebSocket, value: Value) { } fn read_json(websocket: &mut WebSocket) -> Value { + read_json_with_size(websocket).0 +} + +fn read_json_with_size(websocket: &mut WebSocket) -> (Value, usize) { loop { // valgrind's signal delivery interrupts blocking reads with EINTR. match websocket.read() { - Ok(Message::Text(text)) => return serde_json::from_str(&text).unwrap(), + Ok(Message::Text(text)) => { + let bytes = text.len(); + return (serde_json::from_str(&text).unwrap(), bytes); + } Ok(Message::Ping(data)) => websocket.send(Message::Pong(data)).unwrap(), Ok(message) => panic!("expected a JSON text frame, got {message:?}"), Err(tungstenite::Error::Io(error)) @@ -128,6 +150,30 @@ fn websocket_rejects_oversized_authentication_frames() { mux.shutdown(); } +#[test] +fn websocket_keeps_authenticated_inbound_requests_at_four_mib() { + let mux = Mux::new("ws-inbound-frame-limit", SurfaceOptions::default()); + let server = server::serve_websocket( + mux.clone(), + "127.0.0.1:0".parse().unwrap(), + Some(TEST_TOKEN.to_string()), + false, + ) + .unwrap(); + + let mut websocket = authenticated_connect(server.local_addr()); + let rejected = match websocket.send(Message::Text("x".repeat(4 * 1024 * 1024 + 1).into())) { + Ok(()) => matches!(websocket.read(), Ok(Message::Close(_)) | Err(_)), + Err(_) => true, + }; + assert!(rejected, "oversized authenticated request remained accepted"); + + let mut next = authenticated_connect(server.local_addr()); + send_json(&mut next, json!({"id": 1, "cmd": "ping"})); + assert_eq!(read_until(&mut next, |value| value["id"] == 1)["ok"], true); + mux.shutdown(); +} + #[test] fn websocket_auth_accepts_exact_preamble_and_rejects_missing_or_wrong_tokens() { let mux = Mux::new("ws-auth", SurfaceOptions::default()); @@ -268,6 +314,52 @@ fn websocket_streams_subscribe_and_attach_and_survives_unclean_disconnect() { mux.shutdown(); } +#[test] +fn websocket_render_attach_carries_large_rgba_state_and_stays_connected() { + let mux = Mux::new("ws-large-render", SurfaceOptions::default()); + let surface = mux + .run_command_surface(vec!["/bin/cat".to_string()], None, true, None, None, Some((80, 24))) + .unwrap() + .surface; + mux.surface(surface) + .unwrap() + .try_with_terminal(|terminal| terminal.vt_write(&large_rgba_kitty_transmission())) + .unwrap(); + let server = server::serve_websocket( + mux.clone(), + "127.0.0.1:0".parse().unwrap(), + Some(TEST_TOKEN.to_string()), + false, + ) + .unwrap(); + + let mut websocket = authenticated_connect(server.local_addr()); + send_json( + &mut websocket, + json!({"id": 1, "cmd": "attach-surface", "surface": surface, "mode": "render"}), + ); + let (render_state, render_state_bytes) = read_json_with_size(&mut websocket); + assert_eq!(render_state["event"], "render-state"); + assert_eq!(render_state["surface"], surface); + assert_eq!( + render_state["graphics"]["images"][0]["data"].as_str().unwrap().len(), + LARGE_RENDER_IMAGE_BASE64_CHARS + ); + assert!( + render_state_bytes > 4 * 1024 * 1024, + "{render_state_bytes}-byte render state did not cross the old 4 MiB boundary" + ); + assert_eq!(read_until(&mut websocket, |value| value["id"] == 1)["ok"], true); + + send_json(&mut websocket, json!({"id": 2, "cmd": "ping"})); + let ping = read_until(&mut websocket, |value| value["id"] == 2); + assert_eq!(ping["ok"], true); + assert_eq!(ping["data"]["ok"], true); + eprintln!("WebSocket render-state and follow-up request bytes: {render_state_bytes}"); + + mux.shutdown(); +} + #[test] fn websocket_subscriber_receives_cross_connection_event_without_poll_delay() { let mux = Mux::new("ws-outbound-latency", SurfaceOptions::default()); diff --git a/cmux-tui/crates/cmux-tui/Cargo.toml b/cmux-tui/crates/cmux-tui/Cargo.toml index 99f317c8d7..f35da6250b 100644 --- a/cmux-tui/crates/cmux-tui/Cargo.toml +++ b/cmux-tui/crates/cmux-tui/Cargo.toml @@ -32,6 +32,8 @@ getrandom.workspace = true libc.workspace = true smallvec.workspace = true zeroize.workspace = true +terminput.workspace = true +terminput-crossterm.workspace = true parking_lot.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b470d5ba3b..5692546d67 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -12,18 +12,18 @@ use std::panic::AssertUnwindSafe; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ BrowserFrame, BrowserSource, BrowserStatus, ClearHistoryDelivery, ClearHistoryFailure, - DEFAULT_VIEWPORT_PANE_WIDTH, Direction, GuardedMouseEncode, LayoutUndoError, LayoutUndoResult, - MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, MuxEvent, Node, PairingChallenge, PaneId, - PointerSemanticProbe, PointerSnapshotProbe, Rect, ScreenId, SplitDir, SplitEdge, SplitId, - SurfaceId, SurfaceKind, TerminalPointerSnapshot, ViewportColumn, ViewportLayoutResult, - VirtualRect, WorkspaceId, ZoomMode, exact_split_for_pane_edge, + DEFAULT_VIEWPORT_PANE_WIDTH, Direction, GraphicsStatus, GuardedMouseEncode, LayoutUndoError, + LayoutUndoResult, MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, MuxEvent, Node, + PairingChallenge, PaneId, PointerSemanticProbe, PointerSnapshotProbe, Rect, ScreenId, SplitDir, + SplitEdge, SplitId, SurfaceId, SurfaceKind, TerminalPointerSnapshot, ViewportColumn, + ViewportLayoutResult, VirtualRect, WorkspaceId, ZoomMode, exact_split_for_pane_edge, exact_split_for_pane_edge_with_viewport, layout_screen, layout_screen_with_viewport, split_sides, zellij_default_pane_layout, }; @@ -39,8 +39,9 @@ use crossterm::terminal::{ query_keyboard_enhancement_flags_with_timeout, }; use ghostty_vt::{ - CursorShape, KeyEncoder, KeyInput, Mods, MouseAction, MouseButton as GhosttyMouseButton, - MouseInput, RenderState, Rgb, Screen, Scrollbar, TerminalPointerSemanticSnapshot, + CursorShape, KeyEncoder, KeyInput, KittyGraphicsSnapshot, Mods, MouseAction, + MouseButton as GhosttyMouseButton, MouseInput, RenderState, Rgb, Screen, Scrollbar, + TerminalPointerSemanticSnapshot, }; use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::{Backend, CrosstermBackend}; @@ -72,10 +73,12 @@ use crate::session::{ TreeView, is_remote_surface_unavailable, is_remote_timeout, is_remote_transport_failure, }; use crate::sidebar_files::{FileBrowser, FileCommand, file_url, shell_single_quote}; -use crate::ui::graphics::GraphicPlacement; +use crate::ui::graphics::{ + GraphicPlacement, GraphicSourceRect, kitty_graphic_image, kitty_graphic_placement, +}; use crate::ui::graphics_writer::{ - GraphicsCompletion, GraphicsProcessing, GraphicsResponseFilter, GraphicsWriter, StdoutLock, - graphics_fence_channel, + GraphicsCompletion, GraphicsProcessing, GraphicsResponseFilter, GraphicsWriter, + GraphicsWriterShutdown, StdoutLock, graphics_fence_channel, }; use crate::ui::input::{InputEvent, TextInput}; use crate::ui::{ @@ -177,6 +180,9 @@ enum AppEvent { outcome: SessionMutationOutcome, impact: MutationImpact, }, + SurfaceAttachSettled { + outcome: SessionMutationOutcome, + }, RemoteTreeUpdated { refresh_sequence: u64, destination_generation: u64, @@ -852,17 +858,38 @@ struct SurfaceResizeClaim { struct SurfaceAttachClaim { claims: Arc>>, surface: SurfaceId, + active: bool, +} + +impl SurfaceAttachClaim { + fn snapshot(&self) -> Option { + self.claims.lock().unwrap().get(&self.surface).copied() + } + + fn complete_if_revision(&mut self, revision: u64) -> bool { + let mut claims = self.claims.lock().unwrap(); + if claims.get(&self.surface).is_none_or(|claim| claim.revision != revision) { + return false; + } + claims.remove(&self.surface); + self.active = false; + true + } } impl Drop for SurfaceAttachClaim { fn drop(&mut self) { - self.claims.lock().unwrap().remove(&self.surface); + if self.active { + self.claims.lock().unwrap().remove(&self.surface); + } } } #[derive(Clone, Copy, Default)] struct SurfaceAttachClaimState { retired: bool, + requested_size: Option<(u16, u16)>, + revision: u64, } #[cfg(test)] @@ -915,6 +942,343 @@ fn surface_sync_failure_blocks(state: SurfaceSyncFailureState) -> bool { state.retry_after.is_none_or(|retry_after| Instant::now() < retry_after) } +struct SurfaceAttachResult { + outcome: SessionMutationOutcome, + surface: Option, + requested_size: Option<(u16, u16)>, +} + +fn perform_surface_attach( + session: &Session, + exited_surfaces: &Mutex>, + attach_claims: &Mutex>, + attach_failures: &Mutex>, + id: SurfaceId, + size: Option<(u16, u16)>, + after_obsolete_check: impl FnOnce(), +) -> SurfaceAttachResult { + let retired_before_attach = { + let exited_surfaces = exited_surfaces.lock().unwrap(); + exited_surfaces.contains(&id) + || attach_claims.lock().unwrap().get(&id).is_some_and(|claim| claim.retired) + }; + if retired_before_attach { + return SurfaceAttachResult { + outcome: SessionMutationOutcome::Success { tree: None }, + surface: None, + requested_size: size, + }; + } + after_obsolete_check(); + let result = session.try_surface_sized(id, size); + let attach_claims = attach_claims.lock().unwrap(); + let retired = attach_claims.get(&id).is_some_and(|claim| claim.retired); + match result { + Ok(Some(surface)) => { + attach_failures.lock().unwrap().remove(&id); + SurfaceAttachResult { + outcome: SessionMutationOutcome::Success { tree: None }, + surface: Some(surface), + requested_size: size, + } + } + Ok(None) if retired => { + attach_failures.lock().unwrap().remove(&id); + SurfaceAttachResult { + outcome: SessionMutationOutcome::Success { tree: None }, + surface: None, + requested_size: size, + } + } + Ok(None) => { + let mut failures = attach_failures.lock().unwrap(); + let state = next_surface_sync_failure(failures.get(&id).copied(), false, false); + failures.insert(id, state); + SurfaceAttachResult { + outcome: SessionMutationOutcome::SurfaceSyncFailed { + surface: id, + operation: "attach", + error: format!("surface {id} is unavailable"), + reconnect_required: false, + }, + surface: None, + requested_size: size, + } + } + Err(error) if retired && is_remote_surface_unavailable(&error, id) => { + attach_failures.lock().unwrap().remove(&id); + SurfaceAttachResult { + outcome: SessionMutationOutcome::Success { tree: None }, + surface: None, + requested_size: size, + } + } + Err(error) => { + let timed_out = is_remote_timeout(&error); + let transport_failed = is_remote_transport_failure(&error); + let message = error.to_string(); + let mut failures = attach_failures.lock().unwrap(); + let state = + next_surface_sync_failure(failures.get(&id).copied(), transport_failed, timed_out); + failures.insert(id, state); + SurfaceAttachResult { + outcome: SessionMutationOutcome::SurfaceSyncFailed { + surface: id, + operation: "attach", + error: message, + reconnect_required: timed_out, + }, + surface: None, + requested_size: size, + } + } + } +} + +const REMOTE_ATTACH_WORKER_LIMIT: usize = 4; +const REMOTE_ATTACH_QUEUE_CAPACITY: usize = 512; + +struct RemoteSurfaceAttachJob { + claim: SurfaceAttachClaim, + session: Session, + exited_surfaces: Arc>>, + attach_claims: Arc>>, + attach_failures: Arc>>, + resize_failures: Arc>>, + events: SessionEventSender, + id: SurfaceId, + #[cfg(test)] + after_obsolete_check: SurfaceAttachAfterObsoleteCheckHook, +} + +impl RemoteSurfaceAttachJob { + fn run(self) { + let Self { + mut claim, + session, + exited_surfaces, + attach_claims, + attach_failures, + resize_failures, + events, + id, + #[cfg(test)] + after_obsolete_check, + } = self; + let initial = claim.snapshot().unwrap_or_default(); + let mut result = perform_surface_attach( + &session, + &exited_surfaces, + &attach_claims, + &attach_failures, + id, + initial.requested_size, + || { + #[cfg(test)] + if let Some(hook) = { after_obsolete_check.lock().unwrap().clone() } { + hook(); + } + }, + ); + let mut applied_revision = initial.revision; + while let Some(latest) = claim.snapshot() { + if latest.revision != applied_revision { + if !latest.retired + && latest.requested_size != result.requested_size + && let (Some(surface), Some((cols, rows))) = + (result.surface.as_ref(), latest.requested_size) + { + match surface.resize(cols, rows) { + Ok(_) => { + resize_failures.lock().unwrap().remove(&id); + result.outcome = SessionMutationOutcome::Success { tree: None }; + result.requested_size = latest.requested_size; + } + Err(error) => { + let transient = + is_remote_timeout(&error) || is_remote_transport_failure(&error); + let mut failures = resize_failures.lock().unwrap(); + let previous = failures + .get(&id) + .filter(|failure| failure.desired == (cols, rows)) + .map(|failure| failure.state); + let state = next_surface_sync_failure(previous, transient, false); + failures + .insert(id, SurfaceResizeFailure { desired: (cols, rows), state }); + result.outcome = SessionMutationOutcome::SurfaceSyncFailed { + surface: id, + operation: "resize", + error: error.to_string(), + reconnect_required: state.sticky_until_reconnect, + }; + } + } + } + applied_revision = latest.revision; + } + if claim.complete_if_revision(applied_revision) { + break; + } + } + let _ = events.send(AppEvent::SurfaceAttachSettled { outcome: result.outcome }); + } + + fn fail(self, error: String) { + let mut failures = self.attach_failures.lock().unwrap(); + let state = next_surface_sync_failure(failures.get(&self.id).copied(), true, false); + failures.insert(self.id, state); + drop(failures); + let _ = self.events.send(AppEvent::SurfaceAttachSettled { + outcome: SessionMutationOutcome::SurfaceSyncFailed { + surface: self.id, + operation: "attach", + error, + reconnect_required: false, + }, + }); + } +} + +#[derive(Default)] +struct RemoteSurfaceAttachQueue { + visible: VecDeque, + background: VecDeque, + background_running: usize, + background_limit: usize, + stopped: bool, +} + +struct RemoteSurfaceAttachExecutor { + shared: Arc<(Mutex, Condvar)>, + worker_count: usize, +} + +fn remote_attach_background_limit(worker_count: usize) -> usize { + worker_count.saturating_sub(1) +} + +enum RemoteSurfaceAttachAdmission { + Enqueued { displaced: Option }, + Rejected(RemoteSurfaceAttachJob), +} + +impl RemoteSurfaceAttachExecutor { + fn new() -> std::io::Result { + let shared = Arc::new((Mutex::new(RemoteSurfaceAttachQueue::default()), Condvar::new())); + let mut worker_count: usize = 0; + for worker in 0..REMOTE_ATTACH_WORKER_LIMIT { + let worker_shared = shared.clone(); + let spawned = std::thread::Builder::new() + .name(format!("surface-attach-{worker}")) + .spawn(move || { + loop { + let (job, background) = { + let (queue, ready) = &*worker_shared; + let mut queue = queue.lock().unwrap(); + while !queue.stopped && queue.visible.is_empty() && { + queue.background.is_empty() + || queue.background_running >= queue.background_limit + } { + queue = ready.wait(queue).unwrap(); + } + if queue.stopped { + return; + } + if let Some(job) = queue.visible.pop_front() { + (Some(job), false) + } else { + let job = queue.background.pop_front(); + if job.is_some() { + queue.background_running += 1; + } + (job, true) + } + }; + if let Some(job) = job { + job.run(); + } + if background { + let (queue, ready) = &*worker_shared; + let mut queue = queue.lock().unwrap(); + queue.background_running = queue.background_running.saturating_sub(1); + ready.notify_all(); + } + } + }); + match spawned { + Ok(_) => worker_count += 1, + Err(error) if worker_count == 0 => return Err(error), + Err(_) => break, + } + } + { + let (queue, ready) = &*shared; + let mut queue = queue.lock().unwrap(); + queue.background_limit = remote_attach_background_limit(worker_count); + ready.notify_all(); + } + Ok(Self { shared, worker_count }) + } + + /// Returns work that was not admitted. Visible work may displace the + /// newest background prefetch. Dropping either job releases its attach + /// claim so a later layout pass can retry it. + fn enqueue(&self, job: RemoteSurfaceAttachJob, visible: bool) -> RemoteSurfaceAttachAdmission { + debug_assert!(self.worker_count > 0); + let (queue, ready) = &*self.shared; + let mut queue = queue.lock().unwrap(); + if queue.stopped { + return RemoteSurfaceAttachAdmission::Rejected(job); + } + let queued = queue.visible.len() + queue.background.len(); + let displaced = if queued >= REMOTE_ATTACH_QUEUE_CAPACITY { + if visible { + let Some(displaced) = queue.background.pop_back() else { + return RemoteSurfaceAttachAdmission::Rejected(job); + }; + Some(displaced) + } else { + return RemoteSurfaceAttachAdmission::Rejected(job); + } + } else { + None + }; + if visible { + queue.visible.push_back(job); + } else { + queue.background.push_back(job); + } + ready.notify_one(); + RemoteSurfaceAttachAdmission::Enqueued { displaced } + } + + fn promote(&self, surface: SurfaceId) { + let (queue, ready) = &*self.shared; + let mut queue = queue.lock().unwrap(); + let Some(index) = queue.background.iter().position(|job| job.id == surface) else { + return; + }; + let job = queue.background.remove(index).expect("the located attach job must exist"); + queue.visible.push_back(job); + ready.notify_one(); + } + + fn shutdown(&self) { + let (queue, ready) = &*self.shared; + let mut queue = queue.lock().unwrap(); + queue.stopped = true; + queue.visible.clear(); + queue.background.clear(); + ready.notify_all(); + } +} + +impl Drop for RemoteSurfaceAttachExecutor { + fn drop(&mut self) { + self.shutdown(); + } +} + enum SurfaceResizeDecision { Noop, AlreadyClaimed, @@ -1016,6 +1380,7 @@ pub struct OrderedSession { surface_resize_ownership: Arc>>, surface_attach_claims: Arc>>, surface_attach_failures: Arc>>, + remote_surface_attaches: Mutex>, surface_resize_failures: Arc>>, config_generation: Arc, sidebar_plugin_sync: Arc>, @@ -1072,6 +1437,7 @@ impl OrderedSession { surface_resize_ownership: Arc::new(Mutex::new(HashMap::new())), surface_attach_claims: Arc::new(Mutex::new(HashMap::new())), surface_attach_failures: Arc::new(Mutex::new(HashMap::new())), + remote_surface_attaches: Mutex::new(None), surface_resize_failures: Arc::new(Mutex::new(HashMap::new())), config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), @@ -1262,6 +1628,9 @@ impl OrderedSession { } fn begin_shutdown(&self) { + if let Some(executor) = self.remote_surface_attaches.lock().unwrap().as_ref() { + executor.shutdown(); + } self.inner.begin_shutdown(); } @@ -1270,6 +1639,27 @@ impl OrderedSession { } fn attach_surface(&self, id: SurfaceId, size: Option<(u16, u16)>) { + if self.remote + && let Some(size) = size + { + let promoted = { + let mut claims = self.surface_attach_claims.lock().unwrap(); + claims.get_mut(&id).is_some_and(|claim| { + if claim.retired { + return false; + } + claim.requested_size = Some(size); + claim.revision = claim.revision.wrapping_add(1).max(1); + true + }) + }; + if promoted { + if let Some(executor) = self.remote_surface_attaches.lock().unwrap().as_ref() { + executor.promote(id); + } + return; + } + } if !self.can_attach_surface(id) { return; } @@ -1282,16 +1672,67 @@ impl OrderedSession { if attach_claims.contains_key(&id) { return; } - attach_claims.insert(id, SurfaceAttachClaimState::default()); + attach_claims.insert( + id, + SurfaceAttachClaimState { retired: false, requested_size: size, revision: 1 }, + ); } - let claim = SurfaceAttachClaim { claims: self.surface_attach_claims.clone(), surface: id }; + let claim = SurfaceAttachClaim { + claims: self.surface_attach_claims.clone(), + surface: id, + active: true, + }; let attach_claims = self.surface_attach_claims.clone(); let session = self.inner.clone(); let exited_surfaces = self.exited_surfaces.clone(); let attach_failures = self.surface_attach_failures.clone(); - let enqueue_failures = attach_failures.clone(); #[cfg(test)] let attach_after_obsolete_check = self.surface_attach_after_obsolete_check.clone(); + + if self.remote { + // Attach is mirror synchronization, not an authoritative session + // mutation. A fixed worker pool waits on progress-aware deadlines + // independently, while size-bearing visible work stays ahead of + // background tab prefetch. + let job = RemoteSurfaceAttachJob { + claim, + session, + exited_surfaces, + attach_claims, + attach_failures, + resize_failures: self.surface_resize_failures.clone(), + events: self.events.clone(), + id, + #[cfg(test)] + after_obsolete_check: attach_after_obsolete_check, + }; + let mut executor = self.remote_surface_attaches.lock().unwrap(); + if executor.is_none() { + match RemoteSurfaceAttachExecutor::new() { + Ok(created) => *executor = Some(created), + Err(error) => { + drop(executor); + job.fail( + localization::catalog() + .attach + .remote_attach_workers_failed(&error.to_string()), + ); + return; + } + } + } + let admission = executor.as_ref().unwrap().enqueue(job, size.is_some()); + drop(executor); + match admission { + RemoteSurfaceAttachAdmission::Enqueued { displaced } => drop(displaced), + RemoteSurfaceAttachAdmission::Rejected(job) => { + job.fail(localization::catalog().attach.remote_attach_queue_full.to_string()); + } + } + return; + } + + let enqueue_failures = attach_failures.clone(); let pending = self.pending_mutation_with_impact(MutationImpact::PointerMap); let superseded = pending.clone(); let settlement = pending.clone(); @@ -2167,15 +2608,21 @@ impl OrderedSession { pub fn set_cell_pixel_size(&self, width: u16, height: u16) { let ownership = self.surface_resize_ownership.clone(); - self.enqueue_pointer_mutation("set cell pixel size", move |session| { - session.set_cell_pixel_size( - width, - height, - Arc::new(move |surface, desired, accepted| { - record_surface_resize_dispatch_result(&ownership, surface, desired, accepted); - }), - ) - }); + self.enqueue_coalescing_pointer_mutation( + "set cell pixel size", + ("cell pixel size", 0), + move |session| { + session.set_cell_pixel_size( + width, + height, + Arc::new(move |surface, desired, accepted| { + record_surface_resize_dispatch_result( + &ownership, surface, desired, accepted, + ); + }), + ) + }, + ); } pub fn new_workspace(&self, size: Option<(u16, u16)>) -> anyhow::Result<()> { @@ -3186,10 +3633,6 @@ impl ContextMenu { self.levels.iter().any(|level| level.rect.contains(x, y)) } - pub fn intersects(&self, rect: Rect) -> bool { - self.levels.iter().any(|level| rects_intersect(rect, level.rect)) - } - fn selected_action(&self) -> Option { let level = self.levels.last()?; level.items.get(level.selected).and_then(MenuItem::action) @@ -4795,6 +5238,58 @@ impl PaneFocusHistory { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct KittySceneSnapshotKey { + identity: usize, + generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum GraphicsSceneSourceKey { + Unavailable, + Browser { frame: Option<(u64, u32, u32, Option)> }, + Pty { snapshot: Option }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GraphicsPaneSceneKey { + surface: SurfaceId, + content: Rect, + content_source_x: u16, + full_content_width: u16, + terminal_bounds: Option, + source: GraphicsSceneSourceKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GraphicsSceneContextKey { + session_generation: u64, + cell_pixels: (u16, u16), + occluders: Vec, +} + +struct CachedGraphicsProjection { + key: GraphicsPaneSceneKey, + placements: Arc<[GraphicPlacement]>, +} + +#[derive(Default)] +struct GraphicsSceneCache { + context: Option, + projections: HashMap, + #[cfg(test)] + rebuilds: usize, + #[cfg(test)] + projection_rebuilds: HashMap, +} + +impl GraphicsSceneCache { + fn invalidate(&mut self) { + self.context = None; + self.projections.clear(); + } +} + pub struct App { pub session: OrderedSession, session_event_worker: Option, @@ -4843,6 +5338,9 @@ pub struct App { /// Graphics placements confirmed written to the outer terminal. last_graphics_snapshot: Vec, pub graphics_supported: bool, + graphics_host_scene_reset_pending: bool, + graphics_scene_cache: GraphicsSceneCache, + graphics_dirty_surfaces: HashSet, stdout_lock: Arc, pub pane_areas: Vec, viewport_projection: ViewportPaneAreaProjection, @@ -4855,6 +5353,10 @@ pub struct App { /// Terminal cells actually represented by the last rendered snapshot. /// Foreign-viewer padding outside these bounds is display-only. pub(crate) rendered_terminal_bounds: HashMap, + /// Kitty graphics captured from the exact immutable terminal frame drawn + /// for each visible surface. Graphics emission must not render again, + /// because doing so would consume remote damage independently of text. + pub(crate) rendered_kitty_graphics: HashMap>, /// Surfaces whose active tabs occupy the current viewport or an active /// animation sweep. Attach streams may outlive this set, but only members /// hold size leases. @@ -5816,6 +6318,51 @@ pub fn run_with_machine_updates( surface_only: Option, machine_ui: Option, machine_controller: Option>, +) -> anyhow::Result { + type PanicHook = dyn for<'a> Fn(&std::panic::PanicHookInfo<'a>) + Send + Sync + 'static; + let previous_panic_hook: Arc = Arc::from(std::panic::take_hook()); + let previous_panic_hook_for_threads = previous_panic_hook.clone(); + let run_thread = std::thread::current().id(); + let panic_diagnostic = Arc::new(Mutex::new(None)); + let panic_diagnostic_hook = panic_diagnostic.clone(); + std::panic::set_hook(Box::new(move |info| { + if std::thread::current().id() == run_thread { + *panic_diagnostic_hook.lock().unwrap() = Some(format_panic_diagnostic(info)); + } else { + previous_panic_hook_for_threads(info); + } + })); + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + run_with_machine_updates_inner( + session, + session_label, + default_colors, + surface_only, + machine_ui, + machine_controller, + ) + })); + let _ = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| previous_panic_hook(info))); + + let result = report_after_unwind(result, || { + if let Some(diagnostic) = panic_diagnostic.lock().unwrap().take() { + eprint!("{diagnostic}"); + } + }); + match result { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } +} + +fn run_with_machine_updates_inner( + session: Session, + session_label: String, + default_colors: cmux_tui_core::DefaultColors, + surface_only: Option, + machine_ui: Option, + machine_controller: Option>, ) -> anyhow::Result { let mut config = crate::config::load(); let chrome = ChromeTheme::for_defaults(config.chrome, default_colors); @@ -5881,6 +6428,26 @@ pub fn run_with_machine_updates( stdout_lock.recover_stream_locked()?; let mut stdout = std::io::stdout(); stdout.execute(EnterAlternateScreen)?; + Ok(()) + })() { + return Err(terminal_restore.restore_after_error(e)); + } + + // Probe before enabling application input modes. Any keys read alongside + // terminal replies are parsed and queued into the same app channel below. + let terminal_probe = crate::ui::graphics::probe_terminal(None); + let cell_pixels = terminal_probe.cell_pixels; + if session_available && publishes_global_cell_metrics(surface_only) { + session.set_cell_pixel_size(cell_pixels.0, cell_pixels.1); + } + let graphics_supported = + terminal_probe.graphics_supported && GraphicsWriter::platform_supported(); + let pending_input = terminal_probe.pending_input; + let (graphics_fence_waiter, graphics_fence_notifier) = graphics_fence_channel(); + + if let Err(e) = (|| -> anyhow::Result<()> { + let _guard = stdout_lock.lock(); + let mut stdout = std::io::stdout(); negotiate_host_keyboard_protocol( &mut stdout, terminal_restore.host_keyboard_protocol_mut(), @@ -5897,17 +6464,15 @@ pub fn run_with_machine_updates( return Err(terminal_restore.restore_after_error(e)); } - let cell_pixels = crate::ui::graphics::detect_cell_pixels(None, true); - if session_available && publishes_global_cell_metrics(surface_only) { - session.set_cell_pixel_size(cell_pixels.0, cell_pixels.1); - } - let graphics_supported = crate::ui::graphics::probe_kitty_graphics(); - let (graphics_fence_waiter, graphics_fence_notifier) = graphics_fence_channel(); - // Crossterm input → app channel. Start this after startup terminal - // probes so DA / window-size responses are not consumed as key input. + // probes so their replies cannot be consumed as key input. let input_tx = tx.clone(); if let Err(error) = std::thread::Builder::new().name("input".into()).spawn(move || { + for event in crate::ui::graphics::finish_startup_input(pending_input) { + if input_tx.send(AppEvent::Input(event)).is_err() { + return; + } + } let mut graphics_responses = GraphicsResponseFilter::new(graphics_fence_notifier); 'input: loop { let events = if let Some(timeout) = graphics_responses.time_until_expiry() { @@ -5944,24 +6509,6 @@ pub fn run_with_machine_updates( return Err(terminal_restore.restore_after_error(error.into())); } - // Restore the host terminal even if we panic mid-frame. - let default_hook = std::panic::take_hook(); - let restore_lock = stdout_lock.clone(); - let panic_keyboard_protocol = terminal_restore.host_keyboard_protocol().clone(); - std::panic::set_hook(Box::new(move |info| { - with_panic_stdout_lock(&restore_lock, || { - let _ = restore_terminal_unlocked(&panic_keyboard_protocol); - }); - default_hook(info); - })); - - let backend = CrosstermBackend::new(std::io::stdout()); - let mut terminal = match RatatuiTerminal::new(backend) { - Ok(terminal) => terminal, - Err(e) => { - return Err(terminal_restore.restore_after_error(e.into())); - } - }; let graphics_writer = if graphics_supported { let graphics_ready = tx.clone(); match GraphicsWriter::spawn(stdout_lock.clone(), graphics_fence_waiter, move || { @@ -5975,7 +6522,31 @@ pub fn run_with_machine_updates( } else { None }; + let graphics_shutdown = graphics_writer.as_ref().map(GraphicsWriter::shutdown_control); + terminal_restore.set_graphics_shutdown(graphics_shutdown.clone()); + // Restore the host terminal even if we panic mid-frame. + let default_hook = std::panic::take_hook(); + let restore_lock = stdout_lock.clone(); + let panic_keyboard_protocol = terminal_restore.host_keyboard_protocol().clone(); + let panic_graphics_shutdown = graphics_shutdown; + std::panic::set_hook(Box::new(move |info| { + if let Some(graphics_shutdown) = &panic_graphics_shutdown { + graphics_shutdown.cancel_for_panic_hook(); + } + with_panic_stdout_lock(&restore_lock, || { + let _ = restore_terminal_unlocked(&panic_keyboard_protocol); + }); + default_hook(info); + })); + + let backend = CrosstermBackend::new(std::io::stdout()); + let mut terminal = match RatatuiTerminal::new(backend) { + Ok(terminal) => terminal, + Err(e) => { + return Err(terminal_restore.restore_after_error(e.into())); + } + }; let sidebar_view = config.sidebar.view; let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let initial_machine_notice = machine_ui.as_ref().and_then(|machine| machine.notice.clone()); @@ -6018,6 +6589,9 @@ pub fn run_with_machine_updates( pending_graphics_affected_rect: None, last_graphics_snapshot: Vec::new(), graphics_supported, + graphics_host_scene_reset_pending: false, + graphics_scene_cache: GraphicsSceneCache::default(), + graphics_dirty_surfaces: HashSet::new(), stdout_lock, pane_areas: Vec::new(), viewport_projection: ViewportPaneAreaProjection::default(), @@ -6028,6 +6602,7 @@ pub fn run_with_machine_updates( viewport_offset: 0, pane_focus_history: PaneFocusHistory::default(), rendered_terminal_bounds: HashMap::new(), + rendered_kitty_graphics: HashMap::new(), visible_size_surfaces: HashSet::new(), pending_size_releases: HashSet::new(), prefix_armed: false, @@ -6149,9 +6724,41 @@ pub fn run_with_machine_updates( Ok(outcome) } +fn format_panic_diagnostic(info: &std::panic::PanicHookInfo<'_>) -> String { + let payload = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(String::as_str)) + .unwrap_or("Box"); + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or(""); + let location = info + .location() + .map(|location| location.to_string()) + .unwrap_or_else(|| "".to_string()); + let mut diagnostic = format!("thread '{thread_name}' panicked at {location}:\n{payload}\n"); + let backtrace = std::backtrace::Backtrace::capture(); + if backtrace.status() == std::backtrace::BacktraceStatus::Captured { + diagnostic.push_str(&format!("{backtrace}\n")); + } + diagnostic +} + +fn report_after_unwind( + result: std::thread::Result, + report: impl FnOnce(), +) -> std::thread::Result { + if result.is_err() { + report(); + } + result +} + struct TerminalRestoreGuard { stdout_lock: Arc, host_keyboard_protocol: HostKeyboardProtocolOwnership, + graphics_shutdown: Option, armed: bool, } @@ -6160,6 +6767,7 @@ impl TerminalRestoreGuard { Self { stdout_lock, host_keyboard_protocol: HostKeyboardProtocolOwnership::default(), + graphics_shutdown: None, armed: true, } } @@ -6172,11 +6780,18 @@ impl TerminalRestoreGuard { &mut self.host_keyboard_protocol } + fn set_graphics_shutdown(&mut self, graphics_shutdown: Option) { + self.graphics_shutdown = graphics_shutdown; + } + fn restore(&mut self) -> anyhow::Result<()> { if !self.armed { return Ok(()); } self.armed = false; + if let Some(graphics_shutdown) = &self.graphics_shutdown { + graphics_shutdown.cancel_and_wait(); + } restore_terminal(Some(&self.stdout_lock), &self.host_keyboard_protocol) } @@ -6200,6 +6815,9 @@ impl Drop for TerminalRestoreGuard { fn drop(&mut self) { if self.armed { self.armed = false; + if let Some(graphics_shutdown) = &self.graphics_shutdown { + graphics_shutdown.cancel_and_wait(); + } with_panic_stdout_lock(&self.stdout_lock, || { let _ = restore_terminal_unlocked(&self.host_keyboard_protocol); }); @@ -6281,7 +6899,8 @@ fn negotiate_host_keyboard_protocol_with( ownership: &mut HostKeyboardProtocolOwnership, query: impl FnOnce(Duration) -> std::io::Result>, ) -> std::io::Result<()> { - *ownership = enable_host_keyboard_protocol(stdout)?; + let enabled = enable_host_keyboard_protocol(stdout)?; + ownership.pushed.store(enabled.is_pushed(), Ordering::Release); if !ownership.is_pushed() { return Ok(()); } @@ -6294,7 +6913,6 @@ fn negotiate_host_keyboard_protocol_with( } disable_host_keyboard_protocol(stdout, ownership)?; - *ownership = HostKeyboardProtocolOwnership::default(); Ok(()) } @@ -6340,8 +6958,11 @@ fn restore_terminal_unlocked( host_keyboard_protocol: &HostKeyboardProtocolOwnership, ) -> anyhow::Result<()> { let mut stdout = std::io::stdout(); - // Close an interrupted terminal string before emitting any restoration - // sequences. A standalone ST is ignored when no string is active. + // A canceled or failed graphics write may have ended inside a Kitty APC. + // CAN returns the outer parser to ground before any restoration sequence. + let _ = stdout.write_all(&[0x18]); + // A standalone ST is harmless after CAN and closes hosts that retain a + // string despite cancellation. let _ = write!(stdout, "\x1b\\"); // Reset the mouse pointer shape in case we left it as a hand. let _ = write!(stdout, "\x1b]22;default\x07"); @@ -6635,6 +7256,7 @@ impl App { // Always drain retained failures. PtyFailuresReady only shortens // the idle wait, so a failed try_send cannot create a lost wakeup. action = action.merge(self.apply_pty_failures()); + self.ensure_graphics_writer_healthy()?; if self.session.take_cancellation_pending() { if self.session.has_pending_mutations() { self.session.defer_cancellation(); @@ -7288,6 +7910,9 @@ impl App { self.rendered_terminal_pointer_semantics.clear(); self.rendered_pane_content_generations.clear(); self.rendered_terminal_bounds.clear(); + self.rendered_kitty_graphics.clear(); + self.graphics_scene_cache.invalidate(); + self.graphics_dirty_surfaces.clear(); self.visible_size_surfaces.clear(); self.pending_size_releases.clear(); self.prefix_armed = false; @@ -7358,6 +7983,7 @@ impl App { where B::Error: Send + Sync + 'static, { + self.ensure_graphics_writer_healthy()?; self.mark_pointer_route_for_rebuild(action); match action { RenderAction::Draw => { @@ -7370,7 +7996,7 @@ impl App { self.draw_terminal(terminal)?; self.emit_graphics()?; } - RenderAction::Graphics => self.emit_graphics()?, + RenderAction::Graphics => self.emit_dirty_graphics()?, RenderAction::None => {} } if action.rebuilds_pointer_route() { @@ -8263,6 +8889,9 @@ impl App { self.drag = None; } self.render_states.remove(&surface); + self.rendered_kitty_graphics.remove(&surface); + self.graphics_scene_cache.invalidate(); + self.graphics_dirty_surfaces.remove(&surface); self.rendered_terminal_sizes.remove(&surface); self.rendered_terminal_pointer_semantics.remove(&surface); self.rendered_pane_content_generations.remove(&surface); @@ -8380,6 +9009,19 @@ impl App { } } + fn ensure_graphics_writer_healthy(&self) -> anyhow::Result<()> { + let Some(failure) = self.graphics_writer.as_ref().and_then(GraphicsWriter::failure) else { + return Ok(()); + }; + let messages = &localization::catalog().graphics; + let message = if failure.parser_reset_required { + messages.parser_recovery_failed + } else { + messages.output_failed + }; + anyhow::bail!("{message}") + } + fn draw_terminal(&mut self, terminal: &mut RatatuiTerminal) -> anyhow::Result<()> where B::Error: Send + Sync + 'static, @@ -8387,8 +9029,16 @@ impl App { let lock = self.stdout_lock.clone(); let _guard = lock.lock(); lock.recover_stream_locked()?; + self.ensure_graphics_writer_healthy()?; self.painted_durable_notice_this_frame = None; catch_renderer_panic(|| terminal.draw(|f| crate::ui::draw(self, f)))??; + if self.graphics_host_scene_reset_pending { + if let Some(writer) = &self.graphics_writer { + writer.invalidate_host_scene(); + } + self.graphics_scene_cache.invalidate(); + self.graphics_host_scene_reset_pending = false; + } self.commit_successful_durable_notice_paint(); self.submit_pending_durable_notice_ack(); if let Some(sequence) = @@ -8426,11 +9076,23 @@ impl App { return false; }; surface.kind() == SurfaceKind::Browser - && surface.has_browser_frame() + && surface.browser_frame_metadata().is_some() && area.content.width > 0 && area.content.height > 0 } + fn mark_graphics_clean(&self, dirty_surfaces: Option<&HashSet>) { + let mut visited = HashSet::new(); + for area in &self.pane_areas { + if dirty_surfaces.is_none_or(|dirty| dirty.contains(&area.surface)) + && visited.insert(area.surface) + && let Some(surface) = self.session.surface(area.surface) + { + surface.take_dirty(); + } + } + } + fn track_graphics_submission(&mut self, submission: u64, snapshot: Vec) { let previous = self.pending_graphics_snapshot.as_deref().unwrap_or(&self.last_graphics_snapshot); @@ -8444,16 +9106,149 @@ impl App { self.pending_graphics_snapshot = Some(snapshot); } + fn graphic_occlusion_rects(&self) -> Vec { + let mut rects: Vec = self + .menu + .as_ref() + .map(|menu| menu.levels.iter().map(|level| level.rect).collect()) + .unwrap_or_default(); + rects.extend(self.prompt.as_ref().map(|prompt| prompt.rect)); + rects.extend(self.pairing_dialog.as_ref().map(|dialog| dialog.rect)); + rects.extend(self.shortcut_help.as_ref().map(|help| help.rect)); + rects.extend(crate::ui::toast_rect(self)); + rects + } + + fn graphics_pane_scene_key( + &self, + area: PaneArea, + surface: Option<&SurfaceHandle>, + ) -> GraphicsPaneSceneKey { + let (terminal_bounds, source) = match surface { + None => (None, GraphicsSceneSourceKey::Unavailable), + Some(surface) => match surface.kind() { + SurfaceKind::Browser => ( + None, + GraphicsSceneSourceKey::Browser { frame: surface.browser_frame_metadata() }, + ), + SurfaceKind::Pty => { + let snapshot = + self.rendered_kitty_graphics.get(&area.surface).map(|snapshot| { + KittySceneSnapshotKey { + identity: Arc::as_ptr(snapshot) as usize, + generation: snapshot.generation, + } + }); + ( + self.rendered_terminal_bounds.get(&area.surface).copied(), + GraphicsSceneSourceKey::Pty { snapshot }, + ) + } + }, + }; + GraphicsPaneSceneKey { + surface: area.surface, + content: area.content, + content_source_x: area.content_source_x(), + full_content_width: area.content_size().0, + terminal_bounds, + source, + } + } + fn emit_graphics(&mut self) -> anyhow::Result<()> { + self.emit_graphics_with_scope(false) + } + + fn emit_dirty_graphics(&mut self) -> anyhow::Result<()> { + self.emit_graphics_with_scope(true) + } + + fn emit_graphics_with_scope(&mut self, dirty_only: bool) -> anyhow::Result<()> { if !self.graphics_supported { return Ok(()); } - let placements = self.graphic_placements(); - let snapshot = - placements.iter().map(|placement| self.graphic_identity(placement)).collect::>(); + let dirty_surfaces = std::mem::take(&mut self.graphics_dirty_surfaces); + let occluders = self.graphic_occlusion_rects(); + let context = GraphicsSceneContextKey { + session_generation: self.session_generation, + cell_pixels: self.cell_pixels, + occluders: occluders.clone(), + }; + let context_changed = self.graphics_scene_cache.context.as_ref() != Some(&context); + let full_scan = !dirty_only || context_changed; + self.mark_graphics_clean((!full_scan).then_some(&dirty_surfaces)); + let areas = self + .pane_areas + .iter() + .copied() + .filter(|area| full_scan || dirty_surfaces.contains(&area.surface)) + .collect::>(); + let mut updates = Vec::new(); + for area in areas { + let surface = self.session.surface(area.surface); + let key = self.graphics_pane_scene_key(area, surface.as_ref()); + let unchanged = !context_changed + && self + .graphics_scene_cache + .projections + .get(&area.surface) + .is_some_and(|cached| cached.key == key); + if unchanged { + continue; + } + let placements = + Arc::from(self.graphic_placements_for_area(area, surface.as_ref(), &occluders)); + updates.push((area.surface, key, placements)); + } + + let visible = full_scan.then(|| { + self.pane_areas.iter().map(|area| area.surface).collect::>() + }); + let removed = visible.as_ref().is_some_and(|visible| { + self.graphics_scene_cache.projections.keys().any(|surface| !visible.contains(surface)) + }); + let projection_changed = !updates.is_empty(); + if context_changed { + self.graphics_scene_cache.context = Some(context); + self.graphics_scene_cache.projections.clear(); + } + if let Some(visible) = &visible { + self.graphics_scene_cache.projections.retain(|surface, _| visible.contains(surface)); + } + for (surface, key, placements) in updates { + self.graphics_scene_cache + .projections + .insert(surface, CachedGraphicsProjection { key, placements }); + #[cfg(test)] + { + *self.graphics_scene_cache.projection_rebuilds.entry(surface).or_default() += 1; + } + } + let scene_changed = context_changed || removed || projection_changed; + #[cfg(test)] + if scene_changed { + self.graphics_scene_cache.rebuilds += 1; + } + let scene = self + .pane_areas + .iter() + .filter_map(|area| { + self.graphics_scene_cache + .projections + .get(&area.surface) + .map(|cached| cached.placements.clone()) + }) + .collect::>(); + let snapshot = scene + .iter() + .flat_map(|placements| placements.iter()) + .filter(|placement| placement.is_browser_frame()) + .map(|placement| self.graphic_identity(placement)) + .collect::>(); let submitted_snapshot = self.pending_graphics_snapshot.as_ref().unwrap_or(&self.last_graphics_snapshot); - if &snapshot == submitted_snapshot { + if !scene_changed && &snapshot == submitted_snapshot { if self.pointer_route_phase == PointerRoutePhase::GraphicsRenderPending { self.pointer_route_phase = if self.pending_graphics_submission.is_some() { PointerRoutePhase::GraphicsProcessingPending @@ -8468,7 +9263,7 @@ impl App { }; self.next_graphics_submission = self.next_graphics_submission.wrapping_add(1).max(1); let submission = self.next_graphics_submission; - if writer.submit(submission, self.session_generation, placements) { + if writer.submit_scene(submission, self.session_generation, scene) { self.track_graphics_submission(submission, snapshot); self.pointer_route_phase = PointerRoutePhase::GraphicsProcessingPending; } @@ -8477,52 +9272,94 @@ impl App { fn graphic_identity(&self, placement: &GraphicPlacement) -> GraphicIdentity { GraphicIdentity { - session_generation: self.session_generation, - surface: placement.surface, + session_generation: placement.key.image.namespace, + surface: placement.key.image.surface, rect: placement.rect, - seq: placement.frame.seq, + seq: placement.image.generation, pointer_frame_seq: placement.pointer_frame_seq, } } - fn graphic_placements(&self) -> Vec { + fn graphic_placements_for_area( + &self, + area: PaneArea, + surface: Option<&SurfaceHandle>, + occluders: &[Rect], + ) -> Vec { let mut placements = Vec::new(); - for area in &self.pane_areas { - let Some(surface) = self.session.surface(area.surface) else { continue }; - if surface.kind() != SurfaceKind::Browser { - continue; + let Some(surface) = surface else { return placements }; + if area.content.width == 0 || area.content.height == 0 { + return placements; + } + match surface.kind() { + SurfaceKind::Browser => { + if Self::graphic_occluded(area.content, occluders) { + return placements; + } + let Some(update) = surface.browser_frame_update() else { return placements }; + let frame = Arc::new(update.frame); + let source = area.viewport.and_then(|clip| { + browser_frame_source_crop( + &frame, + clip.content_source_x, + area.content.width, + clip.full_content_width, + ) + .map(|(x, width)| GraphicSourceRect { + x, + y: 0, + width, + height: frame.image_height, + }) + }); + placements.push(GraphicPlacement::browser_frame( + self.session_generation, + area.surface, + area.content, + frame, + update.pointer_frame_seq, + source, + )); } - if area.content.width == 0 || area.content.height == 0 { - continue; + SurfaceKind::Pty => { + let Some(snapshot) = self.rendered_kitty_graphics.get(&area.surface) else { + return placements; + }; + let pane = self + .rendered_terminal_bounds + .get(&area.surface) + .copied() + .unwrap_or(area.content); + let images = snapshot + .images + .iter() + .map(|image| { + ( + image.id, + kitty_graphic_image(self.session_generation, area.surface, image), + ) + }) + .collect::>(); + placements.extend(snapshot.placements.iter().filter_map(|placement| { + let image = images.get(&placement.image_id)?.clone(); + let placement = kitty_graphic_placement( + pane, + area.content_source_x(), + self.cell_pixels, + image, + placement, + )?; + (!Self::graphic_occluded(placement.rect, occluders)).then_some(placement) + })); } - if self.browser_graphic_occluded(area.content) { - continue; - } - // Clear before reading the frame. An update after this point sets - // dirty again and emits another SurfaceOutput instead of having - // its wakeup erased after we captured an older frame. - surface.take_dirty(); - let Some(update) = surface.browser_frame_update() else { continue }; - let frame = Arc::new(update.frame); - let source_crop_px = area.viewport.and_then(|clip| { - browser_frame_source_crop( - &frame, - clip.content_source_x, - area.content.width, - clip.full_content_width, - ) - }); - placements.push(GraphicPlacement { - surface: area.surface, - rect: area.content, - pointer_frame_seq: update.pointer_frame_seq, - source_crop_px, - frame, - }); } placements } + fn graphic_occluded(rect: Rect, occluders: &[Rect]) -> bool { + occluders.iter().any(|occluder| rects_intersect(rect, *occluder)) + } + fn apply_graphics_completion(&mut self) -> RenderAction { let Some(completion) = self.graphics_writer.as_ref().and_then(GraphicsWriter::take_completion) @@ -8546,6 +9383,7 @@ impl App { self.pending_graphics_snapshot = None; self.pending_graphics_affected_rect = None; self.last_graphics_snapshot.clear(); + self.graphics_scene_cache.invalidate(); self.rendered_pane_content_generations .retain(|_, generation| !matches!(generation, PaneContentGeneration::Browser(_))); self.commit_rendered_pane_content_generations(); @@ -8641,23 +9479,27 @@ impl App { } } + #[cfg(test)] fn browser_graphic_occluded(&self, rect: Rect) -> bool { - self.menu.as_ref().is_some_and(|menu| menu.intersects(rect)) - || self.prompt.as_ref().is_some_and(|prompt| rects_intersect(rect, prompt.rect)) - || self.shortcut_help.as_ref().is_some_and(|help| rects_intersect(rect, help.rect)) + Self::graphic_occluded(rect, &self.graphic_occlusion_rects()) } - fn refresh_cell_pixels(&mut self, query_fallback: bool) { - let next = crate::ui::graphics::detect_cell_pixels(Some(self.cell_pixels), query_fallback); - if self.cell_pixels != next { + fn refresh_cell_pixels(&mut self) { + let next = crate::ui::graphics::detect_cell_pixels(Some(self.cell_pixels)); + let changed = self.cell_pixels != next; + if changed { if !self.prepare_pty_input_before_mutation() { return; } self.cell_pixels = next; self.browser_input.clear_resize_failures(); - if publishes_global_cell_metrics(self.surface_only) { - self.session.set_cell_pixel_size(next.0, next.1); - } + } + // Repeat unchanged measurements too. The mux publishes a new global + // default only after every surface converges, so this reconciles a + // transient failure or an acknowledgement that timed out after the + // authoritative host committed. + if publishes_global_cell_metrics(self.surface_only) { + self.session.set_cell_pixel_size(next.0, next.1); } } @@ -8727,6 +9569,7 @@ impl App { let lock = self.stdout_lock.clone(); let _guard = lock.lock(); lock.recover_stream_locked()?; + self.ensure_graphics_writer_healthy()?; let mut stdout = std::io::stdout(); stdout.write_all(&cmux_tui_core::server::window_title_osc(title))?; stdout.flush()?; @@ -9631,9 +10474,11 @@ impl App { retry_after_ms, reservation_id, ) { - self.status_message = Some(format!( - "browser surface {surface} resize to {cols}x{rows} failed: {error}" - )); + self.status_message = Some( + localization::catalog() + .graphics + .browser_surface_resize_failed(surface, cols, rows, &error), + ); Ok(RenderAction::Draw) } else { Ok(RenderAction::None) @@ -9643,6 +10488,27 @@ impl App { self.status_message = Some(message); Ok(RenderAction::Draw) } + AppEvent::Mux(MuxEvent::GraphicsStatus(status)) => { + let messages = &localization::catalog().graphics; + self.status_message = Some(match status { + GraphicsStatus::KittyImageBudgetWorkerStartFailed { error } => { + messages.kitty_image_budget_worker_start_failed(&error) + } + GraphicsStatus::KittyImageBudgetUpdateFailed { retry_exhausted, summary } => { + messages.kitty_image_budget_update_failed(retry_exhausted, &summary) + } + GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts, + remaining, + cell_pixels, + } => messages.cell_pixel_update_retries_exhausted( + attempts, + remaining, + cell_pixels, + ), + }); + Ok(RenderAction::Draw) + } AppEvent::Mux(MuxEvent::ConfigReloadRequested) => { self.reload_config(); Ok(RenderAction::Draw) @@ -9652,6 +10518,7 @@ impl App { Ok(RenderAction::None) } AppEvent::Mux(MuxEvent::SurfaceOutput(id)) => { + self.graphics_dirty_surfaces.insert(id); if self.sidebar_plugin_surface == Some(id) { return Ok(RenderAction::Paint); } @@ -9697,14 +10564,53 @@ impl App { } AppEvent::Mux(_) => Ok(RenderAction::Draw), AppEvent::BrowserResizeFailed(failure) => { - self.status_message = Some(format!( - "browser surface {} resize to {}x{} failed: {}", - failure.surface_id, failure.cols, failure.rows, failure.error - )); + self.status_message = + Some(localization::catalog().graphics.browser_surface_resize_failed( + failure.surface_id, + failure.cols, + failure.rows, + &failure.error, + )); Ok(RenderAction::Draw) } AppEvent::PtyFailuresReady => Ok(self.apply_pty_failures()), AppEvent::PtyOperationFailed(failure) => Ok(self.apply_pty_operation_failure(failure)), + AppEvent::SurfaceAttachSettled { outcome } => { + match outcome { + SessionMutationOutcome::Success { .. } => {} + SessionMutationOutcome::SurfaceSyncFailed { + surface, + operation, + error, + reconnect_required, + } => { + if reconnect_required { + self.deferred_input.retain(|input| input.destination != Some(surface)); + self.status_message = Some( + localization::catalog() + .attach + .surface_sync_unknown(surface, operation, &error), + ); + } else { + self.status_message = Some( + localization::catalog() + .attach + .surface_sync_failed(surface, operation, &error), + ); + } + } + _ => { + debug_assert!(false, "surface attach worker returned a non-attach outcome"); + } + } + if !self.session.has_pending_mutations() + && !self.session.remote_tree_is_stale() + && !self.deferred_input.is_empty() + { + self.pointer_route_phase = PointerRoutePhase::DrawPending; + } + Ok(RenderAction::Draw) + } AppEvent::ClearHistorySucceeded { surface, input_revision, @@ -9815,13 +10721,17 @@ impl App { } if reconnect_required { self.deferred_input.retain(|input| input.destination != Some(surface)); - self.status_message = Some(format!( - "surface {surface} {operation} outcome is unknown; detach and reconnect before sending more input: {error}" - )); + self.status_message = Some( + localization::catalog() + .attach + .surface_sync_unknown(surface, operation, &error), + ); } else { - self.status_message = Some(format!( - "surface {surface} {operation} failed; retries are rate-limited: {error}" - )); + self.status_message = Some( + localization::catalog() + .attach + .surface_sync_failed(surface, operation, &error), + ); } } SessionMutationOutcome::SurfaceSizeReleased { surface } => { @@ -9991,7 +10901,10 @@ impl App { Ok(RenderAction::None) } TerminalInput::Resize => { - self.refresh_cell_pixels(false); + if self.graphics_supported { + self.graphics_host_scene_reset_pending = true; + } + self.refresh_cell_pixels(); self.render_states.clear(); self.sidebar_plugin_surface = None; Ok(RenderAction::Draw) @@ -14483,6 +15396,9 @@ impl App { if lock.recover_stream_locked().is_err() { return; } + if self.ensure_graphics_writer_healthy().is_err() { + return; + } self.pointer_shape = want_pointer; let mut stdout = std::io::stdout(); let _ = write!(stdout, "\x1b]22;{shape}\x07"); @@ -15237,6 +16153,9 @@ impl App { if lock.recover_stream_locked().is_err() { return; } + if self.ensure_graphics_writer_healthy().is_err() { + return; + } let mut stdout = std::io::stdout(); let _ = write!(stdout, "\x1b]52;c;{encoded}\x07"); let _ = stdout.flush(); @@ -16412,12 +17331,13 @@ fn browser_character_code(character: char) -> (&'static str, u32) { #[cfg(test)] mod tests { use super::{ - App, AppEvent, BACKGROUND_REFRESH_RETRIES, ContextMenu, DeferredInput, - DeferredReplayDisposition, Drag, FocusTarget, ForwardMuxOutcome, GraphicIdentity, - GraphicPlacement, GuardedMouseEncode, MachineActionWorker, MachineConnectRoute, MenuAction, - MenuItem, MutationImpact, MuxTitleIngress, OmnibarHit, OmnibarState, OrderedSession, - OuterCursorSpec, PaneArea, PaneAreaProjection, PaneContentGeneration, PaneEdge, - PaneFocusHistory, PaneResizeDragTarget, PaneViewportClip, PendingSessionMutation, + App, AppEvent, BACKGROUND_REFRESH_RETRIES, BrowserResizeFailure, ContextMenu, + DeferredInput, DeferredReplayDisposition, Drag, FocusTarget, ForwardMuxOutcome, + GraphicIdentity, GraphicPlacement, GraphicSourceRect, GraphicsSceneCache, + GuardedMouseEncode, MachineActionWorker, MachineConnectRoute, MenuAction, MenuItem, + MutationImpact, MuxTitleIngress, OmnibarHit, OmnibarState, OrderedSession, OuterCursorSpec, + PaneArea, PaneAreaProjection, PaneContentGeneration, PaneEdge, PaneFocusHistory, + PaneResizeDragTarget, PaneViewportClip, PendingSessionMutation, PendingSessionMutationState, PointerHitIdentity, PointerRouteIdentity, PointerRoutePhase, Prompt, PromptTarget, PtyFailureIngress, PtyMousePressResult, RailKind, RenderAction, RenderedMenuLevel, RenderedPaneRoute, RenderedPointerFrame, Selection, SessionCompletion, @@ -16435,8 +17355,8 @@ mod tests { negotiate_host_keyboard_protocol_with, outer_cursor_escape, outer_cursor_escape_if_changed, pane_area_projection_work, pane_context_menu_groups, pane_parts_for_rect, prepare_ordered_session, preserve_client_view, rail_drag_width, rebuild_pane_areas, - record_surface_resize_dispatch_result, reset_pane_area_projection_work, - should_claim_clear_history_shortcut, sidebar_layout_for, + record_surface_resize_dispatch_result, report_after_unwind, + reset_pane_area_projection_work, should_claim_clear_history_shortcut, sidebar_layout_for, sidebar_plugin_status_settles_passive_claim, start_ordered_session, swept_viewport_size_leases, thumb_geometry, with_panic_stdout_lock, }; @@ -16457,7 +17377,8 @@ mod tests { MouseButton, MouseEvent, MouseEventKind, }; use ghostty_vt::{ - CursorShape, KeyEncoder, KeyInput, Mods, MouseAction, MouseButton as GhosttyMouseButton, + CursorShape, KeyEncoder, KeyInput, KittyGraphicsSnapshot, KittyImage, KittyImageFormat, + KittyPlacement, KittyPlacementKey, Mods, MouseAction, MouseButton as GhosttyMouseButton, MouseInput, RenderState, Rgb, Screen, }; use ratatui::Terminal; @@ -16488,7 +17409,8 @@ mod tests { use crate::session::tree::{PaneView, ScreenView, TabNotificationView, TabView, WorkspaceView}; use crate::session::{ ClientInfo, ClientSizeInfo, RemoteSession, Session, SidebarPluginSurface, SurfaceHandle, - TreeView, + TreeView, test_remote_session_with_deferred_attach, + test_remote_session_with_deferred_attach_and_first_resize_failure, }; #[test] @@ -19323,7 +20245,7 @@ mod tests { let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); let attach = surface.attach_stream().unwrap(); let deadline = Instant::now() + Duration::from_secs(1); - let mut output = attach.replay.clone(); + let mut output = attach.replay.to_vec(); while !output.windows(5).any(|window| window == b"ready") { match attach.stream.recv_timeout(Duration::from_millis(20)) { Ok(cmux_tui_core::AttachFrame::Output(bytes)) => output.extend_from_slice(&bytes), @@ -19383,7 +20305,7 @@ mod tests { let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); let attach = surface.attach_stream().unwrap(); let deadline = Instant::now() + Duration::from_secs(1); - let mut output = attach.replay.clone(); + let mut output = attach.replay.to_vec(); while !output.windows(5).any(|window| window == b"ready") { match attach.stream.recv_timeout(Duration::from_millis(20)) { Ok(cmux_tui_core::AttachFrame::Output(bytes)) => output.extend_from_slice(&bytes), @@ -19477,7 +20399,7 @@ mod tests { let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); let attach = surface.attach_stream().unwrap(); let deadline = Instant::now() + Duration::from_secs(1); - let mut output = attach.replay.clone(); + let mut output = attach.replay.to_vec(); while !output.windows(5).any(|window| window == b"ready") { match attach.stream.recv_timeout(Duration::from_millis(20)) { Ok(cmux_tui_core::AttachFrame::Output(bytes)) => output.extend_from_slice(&bytes), @@ -19548,7 +20470,7 @@ mod tests { let surface = mux.new_workspace(Some("work".to_string()), Some((20, 8))).unwrap(); let attach = surface.attach_stream().unwrap(); let deadline = Instant::now() + Duration::from_secs(1); - let mut output = attach.replay.clone(); + let mut output = attach.replay.to_vec(); while !output.windows(5).any(|window| window == b"ready") { match attach.stream.recv_timeout(Duration::from_millis(20)) { Ok(cmux_tui_core::AttachFrame::Output(bytes)) => output.extend_from_slice(&bytes), @@ -22047,26 +22969,29 @@ mod tests { fn graphics_identity_is_scoped_to_the_machine_session() { let mux = Mux::new("graphics-session-identity-test", SurfaceOptions::default()); let mut app = test_app(Session::Local(mux)); - let placement = GraphicPlacement { - surface: 11, - rect: Rect { x: 1, y: 2, width: 3, height: 4 }, - pointer_frame_seq: Some(15), - source_crop_px: None, - frame: Arc::new(BrowserFrame { - session_id: "test".to_string(), - data_b64: "AAAA".to_string(), - css_width: 3, - css_height: 4, - image_width: 3, - image_height: 4, - seq: 15, - }), + let placement = |session_generation| { + GraphicPlacement::browser_frame( + session_generation, + 11, + Rect { x: 1, y: 2, width: 3, height: 4 }, + Arc::new(BrowserFrame { + session_id: "test".to_string(), + data_b64: "AAAA".to_string(), + css_width: 3, + css_height: 4, + image_width: 3, + image_height: 4, + seq: 15, + }), + Some(15), + None, + ) }; app.session_generation = 1; - let first = app.graphic_identity(&placement); + let first = app.graphic_identity(&placement(app.session_generation)); app.session_generation = 2; - let replacement = app.graphic_identity(&placement); + let replacement = app.graphic_identity(&placement(app.session_generation)); assert_ne!( first, replacement, @@ -23872,6 +24797,218 @@ mod tests { assert!(!app.session.client_refresh_queued.load(Ordering::Acquire)); } + #[test] + fn panic_report_is_emitted_after_terminal_restore() { + let events = Mutex::new(Vec::new()); + let result: std::thread::Result<()> = { + events.lock().unwrap().push("restore"); + Err(Box::new(())) + }; + let result = report_after_unwind(result, || events.lock().unwrap().push("panic")); + + assert!(result.is_err()); + assert_eq!(*events.lock().unwrap(), vec!["restore", "panic"]); + } + + #[test] + fn host_terminal_resize_marks_graphics_scene_for_retransmission() { + let mux = Mux::new("resize-graphics-invalidation-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.graphics_supported = true; + assert!(!app.graphics_host_scene_reset_pending); + + assert_eq!( + app.handle(AppEvent::Input(Event::Resize(120, 40))).unwrap(), + RenderAction::Draw + ); + + assert!(app.graphics_host_scene_reset_pending); + } + + #[test] + fn graphics_scene_cache_skips_text_only_snapshot_rebuilds() { + let mux = Mux::new("graphics-scene-cache-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.graphics_supported = true; + app.pane_areas.push(PaneArea { + pane: 1, + surface: surface.id, + rect: Rect { x: 0, y: 0, width: 82, height: 26 }, + bar: Some(Rect { x: 0, y: 0, width: 82, height: 1 }), + omnibar: None, + content: Rect { x: 1, y: 1, width: 80, height: 24 }, + track: None, + viewport: None, + }); + app.rendered_terminal_bounds.insert(surface.id, Rect { x: 1, y: 1, width: 80, height: 24 }); + let snapshot = Arc::new(KittyGraphicsSnapshot { generation: 7, ..Default::default() }); + app.rendered_kitty_graphics.insert(surface.id, snapshot.clone()); + + app.emit_graphics().unwrap(); + app.rendered_kitty_graphics.insert(surface.id, snapshot); + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 1); + + app.rendered_kitty_graphics.insert( + surface.id, + Arc::new(KittyGraphicsSnapshot { generation: 7, ..Default::default() }), + ); + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 2); + + app.cell_pixels = (9, 18); + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 3); + + app.pane_areas[0].content.x = 2; + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 4); + + app.menu = Some(ContextMenu::at(10, 5, vec![vec![MenuAction::CopyPaneId(1)]])); + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 5); + + app.graphics_scene_cache.invalidate(); + app.emit_graphics().unwrap(); + assert_eq!(app.graphics_scene_cache.rebuilds, 6); + } + + #[test] + fn graphics_scene_cache_rebuilds_only_the_dirty_surface_projection() { + let mux = Mux::new("graphics-surface-cache-test", SurfaceOptions::default()); + let first = mux.new_workspace(None, Some((40, 24))).unwrap(); + let second = mux.new_workspace(None, Some((40, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.graphics_supported = true; + app.pane_areas = vec![ + PaneArea { + pane: 1, + surface: first.id, + rect: Rect { x: 0, y: 0, width: 42, height: 26 }, + bar: Some(Rect { x: 0, y: 0, width: 42, height: 1 }), + omnibar: None, + content: Rect { x: 1, y: 1, width: 40, height: 24 }, + track: None, + viewport: None, + }, + PaneArea { + pane: 2, + surface: second.id, + rect: Rect { x: 42, y: 0, width: 42, height: 26 }, + bar: Some(Rect { x: 42, y: 0, width: 42, height: 1 }), + omnibar: None, + content: Rect { x: 43, y: 1, width: 40, height: 24 }, + track: None, + viewport: None, + }, + ]; + for area in &app.pane_areas { + app.rendered_terminal_bounds.insert(area.surface, area.content); + app.rendered_kitty_graphics.insert( + area.surface, + Arc::new(KittyGraphicsSnapshot { generation: 1, ..Default::default() }), + ); + } + app.emit_graphics().unwrap(); + + app.rendered_kitty_graphics.insert( + first.id, + Arc::new(KittyGraphicsSnapshot { generation: 2, ..Default::default() }), + ); + app.graphics_dirty_surfaces.insert(first.id); + app.emit_dirty_graphics().unwrap(); + + assert_eq!(app.graphics_scene_cache.projection_rebuilds.get(&first.id), Some(&2)); + assert_eq!(app.graphics_scene_cache.projection_rebuilds.get(&second.id), Some(&1)); + assert_eq!(app.graphics_scene_cache.rebuilds, 2); + } + + #[test] + fn horizontal_viewport_keeps_visible_kitty_placement_aligned() { + let mux = Mux::new("viewport-kitty-placement-test", SurfaceOptions::default()); + let created = mux.new_workspace(None, Some((10, 2))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.cell_pixels = (10, 20); + let area = PaneArea { + pane: 1, + surface: created.id, + rect: Rect { x: 0, y: 0, width: 7, height: 4 }, + bar: Some(Rect { x: 0, y: 0, width: 7, height: 1 }), + omnibar: None, + content: Rect { x: 1, y: 1, width: 5, height: 2 }, + track: None, + viewport: Some(PaneViewportClip { + rect_source_x: 5, + full_rect_width: 12, + omnibar_source_x: 0, + full_omnibar_width: 0, + content_source_x: 5, + full_content_width: 10, + }), + }; + app.rendered_terminal_bounds.insert(created.id, area.content); + app.rendered_kitty_graphics.insert( + created.id, + Arc::new(KittyGraphicsSnapshot { + generation: 1, + images: vec![KittyImage { + id: 41, + number: 0, + generation: 1, + width: 20, + height: 20, + format: KittyImageFormat::Rgb, + data: Arc::from(vec![0; 20 * 20 * 3]), + }], + placements: vec![KittyPlacement { + key: KittyPlacementKey { image_id: 41, placement_id: 7, ordinal: 0 }, + image_id: 41, + placement_id: 7, + is_internal: false, + x_offset: 0, + y_offset: 0, + source_x: 0, + source_y: 0, + source_width: 20, + source_height: 20, + columns: 2, + rows: 1, + grid_cols: 2, + grid_rows: 1, + pixel_width: 20, + pixel_height: 20, + viewport_col: 5, + viewport_row: 0, + viewport_visible: true, + anchor: None, + z: 0, + }], + }), + ); + let surface = app.session.surface(created.id).unwrap(); + + let placements = app.graphic_placements_for_area(area, Some(&surface), &[]); + + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].rect, Rect { x: 1, y: 1, width: 2, height: 1 }); + assert_eq!( + placements[0].source, + Some(GraphicSourceRect { x: 0, y: 0, width: 20, height: 20 }) + ); + } + + #[test] + fn host_resize_without_ioctl_pixels_preserves_last_queried_cell_measurement() { + let mux = Mux::new("resize-cell-pixel-preservation-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.cell_pixels = (11, 19); + + app.handle(AppEvent::Input(Event::Resize(120, 40))).unwrap(); + + assert_eq!(app.cell_pixels, (11, 19)); + } + #[test] fn superseded_client_refresh_results_are_ignored() { let mux = Mux::new("stale-client-refresh-test", SurfaceOptions::default()); @@ -24049,7 +25186,7 @@ mod tests { } #[test] - fn retiring_surface_during_queued_attach_is_not_a_sync_failure() { + fn retiring_surface_during_inflight_attach_is_not_a_sync_failure() { let mux = Mux::new("surface-retired-during-attach-test", SurfaceOptions::default()); let surface = 77; let (mut app, events) = test_app_with_events(Session::Local(mux)); @@ -24078,9 +25215,8 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( &settled, - AppEvent::SessionMutationSettled { - outcome: super::SessionMutationOutcome::Success { tree: None }, - .. + AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::Success { tree: None } } )); app.handle(settled).unwrap(); @@ -24110,14 +25246,13 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( &settled, - AppEvent::SessionMutationSettled { + AppEvent::SurfaceAttachSettled { outcome: super::SessionMutationOutcome::SurfaceSyncFailed { surface: 77, operation: "attach", error, .. - }, - .. + } } if error.contains("remote transport write failed") )); app.handle(settled).unwrap(); @@ -24125,37 +25260,38 @@ mod tests { } #[test] - fn unrelated_stale_tree_before_queued_attach_preserves_the_sync_failure() { + fn unrelated_stale_tree_before_attach_request_preserves_the_sync_failure() { let session = crate::session::test_remote_session_without_provider_authority(); assert!(session.take_remote_tree_stale()); let surface = 77; let (mut app, events) = test_app_with_events(session); app.replace_tree(notify_tree(surface, false)); - let (started_tx, started_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - app.session.operations.enqueue_session_mutation("block attach lane", false, move || { - started_tx.send(()).unwrap(); - release_rx.recv().unwrap(); - Ok(()) - }); - started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let reached = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let hook_reached = reached.clone(); + let hook_release = release.clone(); + *app.session.surface_attach_after_obsolete_check.lock().unwrap() = + Some(Arc::new(move || { + hook_reached.wait(); + hook_release.wait(); + })); app.session.attach_surface(surface, Some((80, 24))); + reached.wait(); app.session.invalidate_remote_tree(); app.session.begin_shutdown(); - release_tx.send(()).unwrap(); + release.wait(); let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( &settled, - AppEvent::SessionMutationSettled { + AppEvent::SurfaceAttachSettled { outcome: super::SessionMutationOutcome::SurfaceSyncFailed { surface: 77, operation: "attach", .. - }, - .. + } } )); app.handle(settled).unwrap(); @@ -24186,13 +25322,12 @@ mod tests { let settled = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( &settled, - AppEvent::SessionMutationSettled { + AppEvent::SurfaceAttachSettled { outcome: super::SessionMutationOutcome::SurfaceSyncFailed { surface: 77, operation: "attach", .. - }, - .. + } } )); app.handle(settled).unwrap(); @@ -24250,6 +25385,170 @@ mod tests { ); } + #[test] + fn surface_attach_failure_status_uses_the_selected_locale() { + const CHILD_ENV: &str = "CMUX_SURFACE_ATTACH_FAILURE_LOCALE_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("app::tests::surface_attach_failure_status_uses_the_selected_locale") + .arg("--exact") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env("LC_ALL", "ja_JP.UTF-8") + .output() + .unwrap(); + assert!( + output.status.success(), + "Japanese surface attach failure child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let mux = Mux::new("surface-attach-failure-locale-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.handle(AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + error: "offline".to_string(), + reconnect_required: false, + }, + }) + .unwrap(); + assert_eq!( + app.status_message.as_deref(), + Some("サーフェス 77 の接続に失敗しました。再試行は制限されています: offline") + ); + + app.handle(AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "attach", + error: "timeout".to_string(), + reconnect_required: true, + }, + }) + .unwrap(); + assert_eq!( + app.status_message.as_deref(), + Some( + "サーフェス 77 の接続結果は不明です。入力を続ける前に切断して再接続してください: timeout" + ) + ); + + app.session.pending_mutations.store(1, Ordering::Release); + app.handle(AppEvent::SessionMutationSettled { + outcome: super::SessionMutationOutcome::SurfaceSyncFailed { + surface: 77, + operation: "resize", + error: "offline".to_string(), + reconnect_required: false, + }, + impact: MutationImpact::Ordered, + }) + .unwrap(); + assert_eq!( + app.status_message.as_deref(), + Some("サーフェス 77 のサイズ変更に失敗しました。再試行は制限されています: offline") + ); + } + + #[test] + fn graphics_status_events_use_the_selected_locale() { + const CHILD_ENV: &str = "CMUX_GRAPHICS_STATUS_LOCALE_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("app::tests::graphics_status_events_use_the_selected_locale") + .arg("--exact") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env("LC_ALL", "ja_JP.UTF-8") + .output() + .unwrap(); + assert!( + output.status.success(), + "Japanese graphics status child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let mux = Mux::new("graphics-status-locale-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.session + .surface_resize_ownership + .lock() + .unwrap() + .insert(7, SurfaceResizeOwnership { desired: (90, 31), reservation_id: None }); + let cases = [ + ( + MuxEvent::GraphicsStatus( + cmux_tui_core::GraphicsStatus::KittyImageBudgetWorkerStartFailed { + error: Arc::::from("thread unavailable"), + }, + ), + "Kitty 画像予算ワーカーを開始できませんでした: thread unavailable", + ), + ( + MuxEvent::GraphicsStatus( + cmux_tui_core::GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted: false, + summary: Arc::::from("surface 7: offline"), + }, + ), + "Kitty 画像予算の更新に失敗しました。再試行しています: surface 7: offline", + ), + ( + MuxEvent::GraphicsStatus( + cmux_tui_core::GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted: true, + summary: Arc::::from("surface 7: offline"), + }, + ), + "Kitty 画像予算の更新に失敗し、再試行回数の上限に達したため停止しました: surface 7: offline", + ), + ( + MuxEvent::GraphicsStatus( + cmux_tui_core::GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: 5, + remaining: 2, + cell_pixels: (8, 16), + }, + ), + "セルピクセル更新は 5 回の再試行後に停止しました。8x16 で未収束のサーフェスが 2 個あります。後続のホスト確認応答で復旧できます", + ), + ( + MuxEvent::SurfaceResizeFailed { + surface: 7, + cols: 90, + rows: 31, + error: Arc::::from("device metrics rejected"), + retry_after_ms: None, + reservation_id: None, + }, + "ブラウザサーフェス 7 の 90x31 へのサイズ変更に失敗しました: device metrics rejected", + ), + ]; + for (event, expected) in cases { + app.handle(AppEvent::Mux(event)).unwrap(); + assert_eq!(app.status_message.as_deref(), Some(expected)); + } + app.handle(AppEvent::BrowserResizeFailed(BrowserResizeFailure { + surface_id: 8, + cols: 100, + rows: 40, + error: "viewport rejected".to_string(), + })) + .unwrap(); + assert_eq!( + app.status_message.as_deref(), + Some("ブラウザサーフェス 8 の 100x40 へのサイズ変更に失敗しました: viewport rejected") + ); + } + #[test] fn first_input_for_missing_mirror_is_deferred_through_attach() { let mux = Mux::new("missing-mirror-input-test", SurfaceOptions::default()); @@ -24948,6 +26247,242 @@ mod tests { assert!(events.try_recv().is_err()); } + #[test] + fn remote_attach_waits_outside_the_session_mutation_lane() { + let (session, attach_started, release_attach) = test_remote_session_with_deferred_attach(); + let (app, events) = test_app_with_events(session); + app.session.attach_surface(7, Some((80, 24))); + attach_started.recv_timeout(Duration::from_secs(1)).unwrap(); + + assert!(!app.session.has_pending_mutations()); + let (mutation_ran_tx, mutation_ran_rx) = std::sync::mpsc::channel(); + app.session.operations.enqueue_session_mutation("probe", true, move || { + mutation_ran_tx.send(()).unwrap(); + Ok(()) + }); + mutation_ran_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + release_attach.send(()).unwrap(); + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::Success { .. } + } + )); + } + + #[test] + fn remote_surface_attach_concurrency_is_bounded() { + const ATTACH_WORKER_LIMIT: usize = 4; + const ATTACH_COUNT: usize = ATTACH_WORKER_LIMIT * 3; + + let mux = Mux::new("bounded-surface-attach-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + let state = Arc::new((Mutex::new((0_usize, 0_usize, false)), std::sync::Condvar::new())); + let hook_state = state.clone(); + *app.session.surface_attach_after_obsolete_check.lock().unwrap() = + Some(Arc::new(move || { + let (state, release) = &*hook_state; + let mut state = state.lock().unwrap(); + state.0 += 1; + state.1 = state.1.max(state.0); + release.notify_all(); + while !state.2 { + state = release.wait(state).unwrap(); + } + state.0 -= 1; + })); + + for surface in 1..=ATTACH_COUNT as u64 { + app.session.attach_surface(surface, None); + } + + let (lock, release) = &*state; + let state = lock.lock().unwrap(); + let (mut state, _) = release + .wait_timeout_while(state, Duration::from_millis(300), |state| { + state.1 <= ATTACH_WORKER_LIMIT + }) + .unwrap(); + let maximum_concurrency = state.1; + state.2 = true; + release.notify_all(); + drop(state); + + for _ in 0..ATTACH_COUNT { + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::SurfaceAttachSettled { .. } + )); + } + assert!( + maximum_concurrency <= ATTACH_WORKER_LIMIT, + "remote attach fanout reached {maximum_concurrency} concurrent workers" + ); + } + + #[test] + fn single_remote_attach_worker_is_reserved_for_visible_work() { + assert_eq!(super::remote_attach_background_limit(1), 0); + assert_eq!( + super::remote_attach_background_limit(super::REMOTE_ATTACH_WORKER_LIMIT), + super::REMOTE_ATTACH_WORKER_LIMIT - 1 + ); + } + + #[test] + fn visible_remote_attach_promotes_prefetch_into_reserved_capacity() { + let mux = Mux::new("visible-attach-promotion-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + let state = Arc::new((Mutex::new((0_usize, false)), std::sync::Condvar::new())); + let hook_state = state.clone(); + *app.session.surface_attach_after_obsolete_check.lock().unwrap() = + Some(Arc::new(move || { + let (state, changed) = &*hook_state; + let mut state = state.lock().unwrap(); + state.0 += 1; + changed.notify_all(); + while !state.1 { + state = changed.wait(state).unwrap(); + } + })); + + for surface in 1..=4 { + app.session.attach_surface(surface, None); + } + + let (lock, changed) = &*state; + let state = lock.lock().unwrap(); + let (state, _) = + changed.wait_timeout_while(state, Duration::from_secs(1), |state| state.0 < 3).unwrap(); + let (mut state, _) = changed + .wait_timeout_while(state, Duration::from_millis(200), |state| state.0 == 3) + .unwrap(); + let background_running = state.0; + if background_running != 3 { + state.1 = true; + changed.notify_all(); + drop(state); + assert_eq!( + background_running, 3, + "background prefetch consumed the worker reserved for visible attaches" + ); + } else { + drop(state); + } + + app.session.attach_surface(4, Some((100, 30))); + let state = lock.lock().unwrap(); + let (mut state, _) = + changed.wait_timeout_while(state, Duration::from_secs(1), |state| state.0 < 4).unwrap(); + let promoted_running = state.0; + state.1 = true; + changed.notify_all(); + drop(state); + assert_eq!(promoted_running, 4, "the visible attach did not leave the background queue"); + + for _ in 0..4 { + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::SurfaceAttachSettled { .. } + )); + } + } + + #[test] + fn visible_remote_attach_updates_a_running_prefetch_size() { + let (session, attach_started, release_attach) = test_remote_session_with_deferred_attach(); + let (app, events) = test_app_with_events(session); + let surface_id = 7; + + app.session.attach_surface(surface_id, None); + attach_started.recv_timeout(Duration::from_secs(1)).unwrap(); + app.session.attach_surface(surface_id, Some((100, 30))); + release_attach.send(()).unwrap(); + + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::Success { .. } + } + )); + let surface = app.session.surface(surface_id).unwrap(); + assert!( + !surface.resize_needed(100, 30, false), + "the promoted size was not reported by the running attach" + ); + } + + #[test] + fn latest_promoted_resize_success_supersedes_an_earlier_failure() { + let fixture = test_remote_session_with_deferred_attach_and_first_resize_failure(); + let (app, events) = test_app_with_events(fixture.session); + let surface_id = 7; + + app.session.attach_surface(surface_id, None); + fixture.attach_started.recv_timeout(Duration::from_secs(1)).unwrap(); + app.session.attach_surface(surface_id, Some((100, 30))); + fixture.release_attach.send(()).unwrap(); + fixture.resize_started.recv_timeout(Duration::from_secs(1)).unwrap(); + app.session.attach_surface(surface_id, Some((120, 40))); + fixture.release_resize.send(()).unwrap(); + + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)).unwrap(), + AppEvent::SurfaceAttachSettled { + outcome: super::SessionMutationOutcome::Success { .. } + } + )); + let surface = app.session.surface(surface_id).unwrap(); + assert!( + !surface.resize_needed(120, 40, false), + "the latest promoted size was not reported after recovery" + ); + assert!(!app.session.surface_resize_failures.lock().unwrap().contains_key(&surface_id)); + } + + #[test] + fn remote_attach_admission_failure_uses_the_selected_locale() { + const CHILD_ENV: &str = "CMUX_REMOTE_ATTACH_ADMISSION_LOCALE_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("app::tests::remote_attach_admission_failure_uses_the_selected_locale") + .arg("--exact") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .env("LC_ALL", "ja_JP.UTF-8") + .output() + .unwrap(); + assert!( + output.status.success(), + "Japanese remote attach admission child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let mux = Mux::new("remote-attach-admission-locale-test", SurfaceOptions::default()); + let (mut app, events) = test_app_with_events(Session::Local(mux)); + app.session.remote = true; + let executor = super::RemoteSurfaceAttachExecutor::new().unwrap(); + executor.shutdown(); + *app.session.remote_surface_attaches.lock().unwrap() = Some(executor); + + app.session.attach_surface(77, None); + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + + assert_eq!( + app.status_message.as_deref(), + Some( + "サーフェス 77 の接続に失敗しました。再試行は制限されています: \ + リモートサーフェス接続キューがいっぱいです" + ) + ); + } + #[test] fn explicit_sidebar_relaunch_is_a_barrier_before_passive_sync() { let mux = Mux::new("sidebar-plugin-relaunch-barrier-test", SurfaceOptions::default()); @@ -31315,6 +32850,9 @@ mod tests { pending_graphics_affected_rect: None, last_graphics_snapshot: Vec::new(), graphics_supported: false, + graphics_host_scene_reset_pending: false, + graphics_scene_cache: GraphicsSceneCache::default(), + graphics_dirty_surfaces: HashSet::new(), stdout_lock: Arc::new(StdoutLock::new(())), pane_areas: Vec::new(), viewport_projection: ViewportPaneAreaProjection::default(), @@ -31325,6 +32863,7 @@ mod tests { viewport_offset: 0, pane_focus_history: PaneFocusHistory::default(), rendered_terminal_bounds: HashMap::new(), + rendered_kitty_graphics: HashMap::new(), visible_size_surfaces: HashSet::new(), pending_size_releases: HashSet::new(), prefix_armed: false, diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index dba3d1dcc0..ae8f7e5fd3 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -3100,7 +3100,12 @@ mod tests { ); mux.set_default_colors(defaults); let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); - surface.try_with_terminal(|term| term.vt_write(b"\x1b[31mR")).unwrap(); + surface + .try_with_terminal(|term| { + term.vt_write(b"\x1b[31mR"); + term.vt_write(b"\x1b_Ga=T,t=d,f=32,i=75,p=1,s=1,v=1,c=1,r=1,q=2;/wAAfw==\x1b\\"); + }) + .unwrap(); // Re-applying through the mux exercises the existing-surface path and // publishes a fresh immutable render frame for the protocol server. mux.set_default_colors(defaults); @@ -3134,6 +3139,10 @@ mod tests { .find(|run| run["text"].as_str().is_some_and(|text| text.contains('R'))) .expect("configured palette run"); assert_eq!(red_run["fg"], "#445566"); + assert_eq!(state["graphics"]["images"][0]["id"], 75); + assert_eq!(state["graphics"]["images"][0]["format"], "rgba"); + assert_eq!(state["graphics"]["images"][0]["data"], "/wAAfw=="); + assert_eq!(state["graphics"]["placements"][0]["image_id"], 75); let colors = surface.attach_stream().unwrap().colors; assert_eq!(colors.selection_bg, Some(Rgb { r: 0x22, g: 0x33, b: 0x44 })); diff --git a/cmux-tui/crates/cmux-tui/src/localization.rs b/cmux-tui/crates/cmux-tui/src/localization.rs index a07645ea83..ee14078eaa 100644 --- a/cmux-tui/crates/cmux-tui/src/localization.rs +++ b/cmux-tui/crates/cmux-tui/src/localization.rs @@ -23,6 +23,63 @@ pub(crate) struct ForeignViewportMessages { pub terminal_grid: &'static str, } +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct GraphicsMessages { + pub output_failed: &'static str, + pub parser_recovery_failed: &'static str, + kitty_image_budget_worker_start_failed: &'static str, + kitty_image_budget_update_retrying: &'static str, + kitty_image_budget_update_exhausted: &'static str, + cell_pixel_update_retries_exhausted: &'static str, + browser_surface_resize_failed: &'static str, +} + +impl GraphicsMessages { + pub(crate) fn kitty_image_budget_worker_start_failed(&self, error: &str) -> String { + self.kitty_image_budget_worker_start_failed.replace("{error}", error) + } + + pub(crate) fn kitty_image_budget_update_failed( + &self, + retry_exhausted: bool, + summary: &str, + ) -> String { + let template = if retry_exhausted { + self.kitty_image_budget_update_exhausted + } else { + self.kitty_image_budget_update_retrying + }; + template.replace("{summary}", summary) + } + + pub(crate) fn cell_pixel_update_retries_exhausted( + &self, + attempts: u8, + remaining: usize, + cell_pixels: (u16, u16), + ) -> String { + self.cell_pixel_update_retries_exhausted + .replace("{attempts}", &attempts.to_string()) + .replace("{remaining}", &remaining.to_string()) + .replace("{width}", &cell_pixels.0.to_string()) + .replace("{height}", &cell_pixels.1.to_string()) + } + + pub(crate) fn browser_surface_resize_failed( + &self, + surface: u64, + cols: u16, + rows: u16, + error: &str, + ) -> String { + self.browser_surface_resize_failed + .replace("{surface}", &surface.to_string()) + .replace("{cols}", &cols.to_string()) + .replace("{rows}", &rows.to_string()) + .replace("{error}", error) + } +} + #[derive(Debug, PartialEq, Eq)] pub(crate) struct TerminalMessages { pub clear_history_help: &'static str, @@ -274,6 +331,13 @@ impl ConfigMessages { #[derive(Debug, PartialEq, Eq)] pub(crate) struct AttachMessages { pub filtered_subscription_unavailable: &'static str, + pub remote_attach_queue_full: &'static str, + remote_attach_workers_failed_template: &'static str, + surface_sync_failed_template: &'static str, + surface_sync_unknown_template: &'static str, + surface_sync_attach: &'static str, + surface_sync_resize: &'static str, + surface_sync_operation: &'static str, unknown_terminal_prefix: &'static str, unknown_terminal_suffix: &'static str, ambiguous_terminal_prefix: &'static str, @@ -283,6 +347,32 @@ pub(crate) struct AttachMessages { } impl AttachMessages { + pub fn remote_attach_workers_failed(&self, error: &str) -> String { + self.remote_attach_workers_failed_template.replace("{error}", error) + } + + fn surface_sync_operation(&self, operation: &str) -> &'static str { + match operation { + "attach" => self.surface_sync_attach, + "resize" => self.surface_sync_resize, + _ => self.surface_sync_operation, + } + } + + pub fn surface_sync_failed(&self, surface: u64, operation: &str, error: &str) -> String { + self.surface_sync_failed_template + .replace("{surface}", &surface.to_string()) + .replace("{operation}", self.surface_sync_operation(operation)) + .replace("{error}", error) + } + + pub fn surface_sync_unknown(&self, surface: u64, operation: &str, error: &str) -> String { + self.surface_sync_unknown_template + .replace("{surface}", &surface.to_string()) + .replace("{operation}", self.surface_sync_operation(operation)) + .replace("{error}", error) + } + pub fn unknown_terminal(&self, reference: &str) -> String { format!("{}{reference:?}{}", self.unknown_terminal_prefix, self.unknown_terminal_suffix) } @@ -481,6 +571,7 @@ pub(crate) struct Catalog { pub startup: StartupMessages, pub pairing: PairingMessages, pub foreign_viewport: ForeignViewportMessages, + pub graphics: GraphicsMessages, pub terminal: TerminalMessages, pub machine_agent: MachineAgentMessages, pub menu: MenuMessages, @@ -521,6 +612,15 @@ static ENGLISH: Catalog = Catalog { approve: "[ Approve enter ]", }, foreign_viewport: ForeignViewportMessages { terminal_grid: "terminal grid" }, + graphics: GraphicsMessages { + output_failed: "Terminal graphics output failed; restoring the terminal", + parser_recovery_failed: "Terminal graphics output could not reset the terminal parser; restoring the terminal", + kitty_image_budget_worker_start_failed: "Failed to start Kitty image budget worker: {error}", + kitty_image_budget_update_retrying: "Kitty image budget update failed, retrying: {summary}", + kitty_image_budget_update_exhausted: "Kitty image budget update failed, stopped after exhausting retries: {summary}", + cell_pixel_update_retries_exhausted: "Cell pixel update stopped after {attempts} retry attempts with {remaining} unconverged surface(s) at {width}x{height}; a later host acknowledgement can still recover", + browser_surface_resize_failed: "Browser surface {surface} resize to {cols}x{rows} failed: {error}", + }, terminal: TerminalMessages { clear_history_help: "Clear PTY history while preserving its active prompt.", clear_history_failed: "Could not clear terminal history", @@ -659,6 +759,13 @@ edits shell files. Authenticate with the configured host before retrying. }, attach: AttachMessages { filtered_subscription_unavailable: "single-terminal attach requires a newer cmux-tui server; restart the session", + remote_attach_queue_full: "remote surface attach queue is full", + remote_attach_workers_failed_template: "could not start surface attach workers: {error}", + surface_sync_failed_template: "surface {surface} {operation} failed; retries are rate-limited: {error}", + surface_sync_unknown_template: "surface {surface} {operation} outcome is unknown; detach and reconnect before sending more input: {error}", + surface_sync_attach: "attach", + surface_sync_resize: "resize", + surface_sync_operation: "operation", unknown_terminal_prefix: "unknown terminal ", unknown_terminal_suffix: "; use `cmux terminal list` to list terminal IDs", ambiguous_terminal_prefix: "ambiguous terminal reference ", @@ -772,6 +879,15 @@ static JAPANESE: Catalog = Catalog { approve: "[ 承認 enter ]", }, foreign_viewport: ForeignViewportMessages { terminal_grid: "端末グリッド" }, + graphics: GraphicsMessages { + output_failed: "ターミナル画像の出力に失敗したため、ターミナルを復元します", + parser_recovery_failed: "ターミナル画像の出力後にパーサーをリセットできなかったため、ターミナルを復元します", + kitty_image_budget_worker_start_failed: "Kitty 画像予算ワーカーを開始できませんでした: {error}", + kitty_image_budget_update_retrying: "Kitty 画像予算の更新に失敗しました。再試行しています: {summary}", + kitty_image_budget_update_exhausted: "Kitty 画像予算の更新に失敗し、再試行回数の上限に達したため停止しました: {summary}", + cell_pixel_update_retries_exhausted: "セルピクセル更新は {attempts} 回の再試行後に停止しました。{width}x{height} で未収束のサーフェスが {remaining} 個あります。後続のホスト確認応答で復旧できます", + browser_surface_resize_failed: "ブラウザサーフェス {surface} の {cols}x{rows} へのサイズ変更に失敗しました: {error}", + }, terminal: TerminalMessages { clear_history_help: "アクティブなプロンプトを保持したまま PTY 履歴を消去します。", clear_history_failed: "ターミナル履歴を消去できませんでした", @@ -910,6 +1026,13 @@ cmux machine-agent - ローカルの cmux セッションをリモートサー }, attach: AttachMessages { filtered_subscription_unavailable: "単一ターミナルへの接続には新しい cmux-tui サーバーが必要です。セッションを再起動してください", + remote_attach_queue_full: "リモートサーフェス接続キューがいっぱいです", + remote_attach_workers_failed_template: "リモートサーフェス接続ワーカーを開始できませんでした: {error}", + surface_sync_failed_template: "サーフェス {surface} の{operation}に失敗しました。再試行は制限されています: {error}", + surface_sync_unknown_template: "サーフェス {surface} の{operation}結果は不明です。入力を続ける前に切断して再接続してください: {error}", + surface_sync_attach: "接続", + surface_sync_resize: "サイズ変更", + surface_sync_operation: "操作", unknown_terminal_prefix: "ターミナル ", unknown_terminal_suffix: " が見つかりません。`cmux terminal list` でターミナル ID 一覧を確認してください", ambiguous_terminal_prefix: "ターミナル参照 ", @@ -1055,6 +1178,19 @@ mod tests { JAPANESE.attach.filtered_subscription_unavailable, "単一ターミナルへの接続には新しい cmux-tui サーバーが必要です。セッションを再起動してください" ); + assert_eq!(ENGLISH.attach.remote_attach_queue_full, "remote surface attach queue is full"); + assert_eq!( + JAPANESE.attach.remote_attach_queue_full, + "リモートサーフェス接続キューがいっぱいです" + ); + assert_eq!( + ENGLISH.attach.remote_attach_workers_failed("os detail"), + "could not start surface attach workers: os detail" + ); + assert_eq!( + JAPANESE.attach.remote_attach_workers_failed("os detail"), + "リモートサーフェス接続ワーカーを開始できませんでした: os detail" + ); assert_eq!( ENGLISH.attach.unknown_terminal("missing"), "unknown terminal \"missing\"; use `cmux terminal list` to list terminal IDs" diff --git a/cmux-tui/crates/cmux-tui/src/machine_provider_client.rs b/cmux-tui/crates/cmux-tui/src/machine_provider_client.rs index 29f5915c6e..2363823fc4 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_provider_client.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_provider_client.rs @@ -1358,6 +1358,15 @@ impl From for ProviderClientError { fn read_bounded_frame( reader: &mut R, limit: usize, +) -> Result>, FrameReadFailure> { + read_bounded_frame_with_progress(reader, limit, &mut |_| {}) +} + +#[cfg(unix)] +fn read_bounded_frame_with_progress( + reader: &mut R, + limit: usize, + on_progress: &mut dyn FnMut(&[u8]), ) -> Result>, FrameReadFailure> { let mut frame = Vec::new(); loop { @@ -1372,6 +1381,7 @@ fn read_bounded_frame( } frame.extend_from_slice(&available[..newline]); reader.consume(newline + 1); + on_progress(&frame); if frame.last() == Some(&b'\r') { frame.pop(); } @@ -1384,6 +1394,7 @@ fn read_bounded_frame( let consumed = available.len(); frame.extend_from_slice(available); reader.consume(consumed); + on_progress(&frame); } } @@ -1417,8 +1428,19 @@ struct BoundedRemoteReader { #[cfg(unix)] impl RemoteMessageReader for BoundedRemoteReader { fn receive(&mut self) -> io::Result> { - let frame = read_bounded_frame(&mut self.inner, MAX_TRANSPORT_FRAME_BYTES) - .map_err(frame_read_io_error)?; + self.receive_with_progress(&mut |_| {}) + } + + fn receive_with_progress( + &mut self, + on_progress: &mut dyn FnMut(&[u8]), + ) -> io::Result> { + let frame = read_bounded_frame_with_progress( + &mut self.inner, + MAX_TRANSPORT_FRAME_BYTES, + on_progress, + ) + .map_err(frame_read_io_error)?; frame .map(|bytes| { String::from_utf8(bytes) diff --git a/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs b/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs index 322d533e73..3677a4bc74 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs @@ -3709,6 +3709,7 @@ mod tests { let mut enrolled = catalog.clone(); enrolled.revision = 2; enrolled.selected_machine_id = Some(id("machine-1")); + enrolled.machines[0].connectable = true; let server_catalog = catalog; let server = thread::spawn(move || { let (mut stream, mut reader) = serve_initial_snapshot_with_capabilities( diff --git a/cmux-tui/crates/cmux-tui/src/machine_runtime.rs b/cmux-tui/crates/cmux-tui/src/machine_runtime.rs index f692127da4..62f9ef4c49 100644 --- a/cmux-tui/crates/cmux-tui/src/machine_runtime.rs +++ b/cmux-tui/crates/cmux-tui/src/machine_runtime.rs @@ -25,6 +25,7 @@ use crate::process_diagnostics::BoundedDiagnosticBuffer; use crate::session::{ REMOTE_CONTROL_MESSAGE_MAX_BYTES, RemoteMessageReader, RemoteMessageWriter, RemoteSession, RemoteTransport, RemoteTransportAbort, Session, read_bounded_json_line, + read_json_line_with_progress, }; const SSH_DIAGNOSTIC_BYTES: usize = 4096; @@ -446,6 +447,19 @@ struct ProcessReader { process: Arc, } +impl ProcessReader { + fn receive_inner(&mut self, on_progress: &mut dyn FnMut(&[u8])) -> io::Result> { + let _keep_alive = &self.process; + let message = read_json_line_with_progress(&mut self.inner, on_progress)?; + if message.is_none() + && let Some(diagnostic) = self.process.diagnostic_after_stdout_eof() + { + return Err(io::Error::other(format!("ssh transport closed: {diagnostic}"))); + } + Ok(message) + } +} + impl RemoteMessageReader for ProcessReader { fn receive(&mut self) -> io::Result> { let _keep_alive = &self.process; @@ -457,6 +471,13 @@ impl RemoteMessageReader for ProcessReader { } Ok(message) } + + fn receive_with_progress( + &mut self, + on_progress: &mut dyn FnMut(&[u8]), + ) -> io::Result> { + self.receive_inner(on_progress) + } } struct ProcessWriter { diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 6cca211595..f8a18aaf37 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -30,7 +30,9 @@ use ghostty_vt::{ use serde::Deserialize; use serde_json::json; -pub(crate) use remote::{REMOTE_CONTROL_MESSAGE_MAX_BYTES, read_bounded_json_line}; +pub(crate) use remote::{ + REMOTE_CONTROL_MESSAGE_MAX_BYTES, read_bounded_json_line, read_json_line_with_progress, +}; pub use remote::{ RemoteMessageReader, RemoteMessageWriter, RemoteSession, RemoteSurface, RemoteTransport, RemoteTransportAbort, @@ -1448,6 +1450,7 @@ impl SurfaceHandle { frame: rs.build_frame()?, content_generation: surface.content_generation.load(Ordering::Acquire), scrollback_rows: term.history_rows(), + history_epoch: term.history_epoch(), pointer_semantics: term.pointer_semantic_snapshot(), palette_colors, palette_overridden, @@ -1705,6 +1708,16 @@ impl SurfaceHandle { } } + pub fn browser_frame_metadata(&self) -> Option<(u64, u32, u32, Option)> { + match self { + SurfaceHandle::Local(surface, _) => surface.browser_frame_metadata(), + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Browser => { + surface.browser_frame_metadata() + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + #[cfg(test)] pub fn browser_frame_seq(&self) -> Option { match self { @@ -2075,6 +2088,35 @@ pub(crate) fn test_remote_session_with_provider_authority_without_guard() -> Ses Session::Remote(remote::test_session_with_provider_authority_without_guard()) } +#[cfg(test)] +pub(crate) fn test_remote_session_with_deferred_attach() +-> (Session, std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) { + let (session, started, release) = remote::test_session_with_deferred_attach(); + (Session::Remote(session), started, release) +} + +#[cfg(test)] +pub(crate) struct DeferredAttachResizeFailureFixture { + pub session: Session, + pub attach_started: std::sync::mpsc::Receiver<()>, + pub release_attach: std::sync::mpsc::Sender<()>, + pub resize_started: std::sync::mpsc::Receiver<()>, + pub release_resize: std::sync::mpsc::Sender<()>, +} + +#[cfg(test)] +pub(crate) fn test_remote_session_with_deferred_attach_and_first_resize_failure() +-> DeferredAttachResizeFailureFixture { + let fixture = remote::test_session_with_deferred_attach_and_first_resize_failure(); + DeferredAttachResizeFailureFixture { + session: Session::Remote(fixture.session), + attach_started: fixture.attach_started, + release_attach: fixture.release_attach, + resize_started: fixture.resize_started, + release_resize: fixture.release_resize, + } +} + #[cfg(test)] pub(crate) fn test_remote_session_with_blocked_attach_transport_failure( reached: Arc, diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 063e1223a2..792040428a 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -7,7 +7,7 @@ use std::io::{self, BufRead, BufReader, Write}; use std::net::Shutdown; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{Sender, channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; @@ -15,9 +15,10 @@ use base64::Engine; use cmux_tui_core::server::{VIEWPORT_COLUMN_RESIZE_CAPABILITY, VIEWPORT_SPLITS_CAPABILITY}; use cmux_tui_core::{ BrowserFrame, BrowserFrameUpdate, BrowserSource, BrowserStatus, ClearHistoryDelivery, - ClearHistoryFailure, DefaultColors, GuardedMouseEncode, MuxEvent, MuxEventBroadcaster, - MuxEventReceiver, NotificationEvent, NotificationLevel, PairingChallenge, PointerSemanticProbe, - PointerSnapshotProbe, Rgb, SurfaceId, SurfaceKind, TerminalPointerSnapshot, + ClearHistoryFailure, DefaultColors, GraphicsStatus, GuardedMouseEncode, MuxEvent, + MuxEventBroadcaster, MuxEventReceiver, NotificationEvent, NotificationLevel, PairingChallenge, + PointerSemanticProbe, PointerSnapshotProbe, REMOTE_SESSION_MESSAGE_MAX_BYTES, Rgb, SurfaceId, + SurfaceKind, TerminalPointerSnapshot, platform::transport, server::{ CLEAR_HISTORY_CAPABILITY, CLEAR_HISTORY_KEY_CAPABILITY, GUARDED_BROWSER_POINTER_CAPABILITY, @@ -26,8 +27,9 @@ use cmux_tui_core::{ }; use cmux_tui_machine_protocol::BearerToken; use ghostty_vt::{ - Callbacks, CursorShape, KeyInput, MouseEncoders, MouseInput, RenderState, Terminal, - TerminalColorOverrides, TerminalPointerSemanticSnapshot, parse_color, + Callbacks, CursorShape, KeyInput, KittyGraphicsLimits, KittyImageIdCursors, KittyReplayState, + MouseEncoders, MouseInput, RenderState, Terminal, TerminalColorOverrides, + TerminalPointerSemanticSnapshot, parse_color, }; use serde_json::{Value, json}; use zeroize::{Zeroize, Zeroizing}; @@ -43,7 +45,7 @@ const SURFACE_OVERFLOW_RETRY_DELAYS: [Duration; 3] = const SURFACE_OVERFLOW_STABLE: Duration = Duration::from_secs(5); const INTERACTIVE_WRITE_QUEUE_CAPACITY: usize = 512; const INTERACTIVE_WRITE_QUEUE_BYTES: usize = 8 * 1024 * 1024; -pub(crate) const REMOTE_CONTROL_MESSAGE_MAX_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const REMOTE_CONTROL_MESSAGE_MAX_BYTES: usize = REMOTE_SESSION_MESSAGE_MAX_BYTES; const REMOTE_TERMINAL_DIMENSION_MAX: u64 = 10_000; const REMOTE_TERMINAL_CELL_MAX: u64 = 1024 * 1024; const INTERACTIVE_LATENCY_BUCKET_UPPER_US: [u64; 18] = [ @@ -70,7 +72,18 @@ const INTERACTIVE_LATENCY_BUCKET_UPPER_US: [u64; 18] = [ const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(test)] const REMOTE_WRITE_TIMEOUT: Duration = Duration::from_millis(100); +#[cfg(not(test))] const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(test)] +const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_millis(100); +#[cfg(not(test))] +const REMOTE_ATTACH_IDLE_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(test)] +const REMOTE_ATTACH_IDLE_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(not(test))] +const REMOTE_ATTACH_MAX_TIMEOUT: Duration = Duration::from_secs(15 * 60); +#[cfg(test)] +const REMOTE_ATTACH_MAX_TIMEOUT: Duration = Duration::from_secs(3); #[cfg(not(test))] const GUARDED_POINTER_REQUEST_TIMEOUT: Duration = REMOTE_REQUEST_TIMEOUT; #[cfg(test)] @@ -82,6 +95,31 @@ fn zeroize_string(value: &mut str) { value.zeroize(); } +fn parse_graphics_status(value: &Value) -> Option { + match value.get("kind").and_then(Value::as_str)? { + "kitty-image-budget-worker-start-failed" => { + Some(GraphicsStatus::KittyImageBudgetWorkerStartFailed { + error: Arc::::from(value.get("error")?.as_str()?), + }) + } + "kitty-image-budget-update-failed" => Some(GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted: value.get("retry_exhausted")?.as_bool()?, + summary: Arc::::from(value.get("summary")?.as_str()?), + }), + "cell-pixel-update-retries-exhausted" => { + Some(GraphicsStatus::CellPixelUpdateRetriesExhausted { + attempts: u8::try_from(value.get("attempts")?.as_u64()?).ok()?, + remaining: usize::try_from(value.get("remaining")?.as_u64()?).ok()?, + cell_pixels: ( + u16::try_from(value.get("cell_width")?.as_u64()?).ok()?, + u16::try_from(value.get("cell_height")?.as_u64()?).ok()?, + ), + }) + } + _ => None, + } +} + fn validate_remote_identity(ident: &Value) -> anyhow::Result<()> { if ident.get("app").and_then(Value::as_str) != Some("cmux-tui") { anyhow::bail!("socket endpoint is not a cmux-tui session"); @@ -324,6 +362,9 @@ impl RemoteTreeCache { } } +#[cfg(test)] +type RemoteGeometryTestHook = Arc; + /// A surface mirrored from a remote session. pub struct RemoteSurface { pub id: SurfaceId, @@ -331,6 +372,10 @@ pub struct RemoteSurface { pub term: Mutex, mouse_encoders: Mutex, pub dirty: AtomicBool, + geometry_lifecycle: Mutex<()>, + cell_pixels: Mutex<(u16, u16)>, + #[cfg(test)] + geometry_test_hook: Mutex>, pub(super) content_generation: AtomicU64, reported_size: Mutex>, browser: Mutex, @@ -347,6 +392,14 @@ struct RemoteTerminalColors { } impl RemoteSurface { + #[cfg(test)] + fn run_geometry_test_hook(&self, step: RemoteGeometryTestStep) { + let hook = self.geometry_test_hook.lock().unwrap().clone(); + if let Some(hook) = hook { + hook(step); + } + } + pub(super) fn sync_mouse_encoders(&self, terminal: &Terminal) { self.mouse_encoders.lock().unwrap().sync_from_terminal(terminal); } @@ -522,43 +575,91 @@ impl RemoteSurface { } /// Apply an ordered attach-stream resize marker to the mirror terminal. - pub(super) fn apply_stream_resize(&self, cols: u16, rows: u16, replay: Option<&[u8]>) -> bool { - self.apply_stream_resize_with_colors(cols, rows, replay, None) + pub(super) fn apply_stream_resize( + &self, + cols: u16, + rows: u16, + replay: Option<&[u8]>, + kitty_image_aliases: &[ghostty_vt::KittyImageAlias], + ) -> ghostty_vt::Result<()> { + self.apply_stream_resize_with_colors(cols, rows, replay, kitty_image_aliases, None, None) } - /// Apply one authoritative replay and its coupled color state before the - /// mirror can be observed at the new size. + /// Apply one authoritative replay and its coupled Kitty alias and color + /// state before the mirror can be observed at the new size. fn apply_stream_resize_with_colors( &self, cols: u16, rows: u16, replay: Option<&[u8]>, + kitty_image_aliases: &[ghostty_vt::KittyImageAlias], + kitty_state: Option, colors: Option<&RemoteTerminalColors>, - ) -> bool { + ) -> ghostty_vt::Result<()> { + #[cfg(test)] + self.run_geometry_test_hook(RemoteGeometryTestStep::StreamResizeStarted); + let _geometry_lifecycle = self.geometry_lifecycle.lock().unwrap(); let (cols, rows) = (cols.max(1), rows.max(1)); + let cell_pixels = *self.cell_pixels.lock().unwrap(); + #[cfg(test)] + self.run_geometry_test_hook(RemoteGeometryTestStep::StreamResizeCommitBoundary); let mut term = self.term.lock().unwrap(); let owned_replay; - let replay = match replay { - Some(replay) => replay, + let (replay, replay_aliases, replay_state) = match replay { + Some(replay) => ( + replay, + kitty_image_aliases, + kitty_state.unwrap_or_else(KittyReplayState::disabled), + ), None => { - owned_replay = match term.vt_replay_bounded(REMOTE_CONTROL_MESSAGE_MAX_BYTES) { - Ok(replay) => replay, - Err(_) => return false, - }; - &owned_replay + if !kitty_image_aliases.is_empty() || kitty_state.is_some() { + return Err(ghostty_vt::Error::NoValue); + } + owned_replay = term.vt_replay_bounded(REMOTE_CONTROL_MESSAGE_MAX_BYTES)?; + ( + owned_replay.bytes.as_slice(), + owned_replay.kitty_image_aliases.as_slice(), + owned_replay.kitty_state, + ) } }; - let Ok(mut fresh) = Terminal::new(cols, rows, 10_000, Callbacks::default()) else { - return false; - }; - fresh.vt_write(replay); + let mut fresh = Terminal::new(cols, rows, 10_000, Callbacks::default())?; + fresh.resize(cols, rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + fresh.apply_vt_replay_parts(replay, replay_aliases, replay_state)?; if let Some(colors) = colors { apply_terminal_colors(&mut fresh, colors); } *term = fresh; self.sync_mouse_encoders(&term); self.content_generation.fetch_add(1, Ordering::AcqRel); - true + Ok(()) + } + + fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) -> ghostty_vt::Result { + #[cfg(test)] + self.run_geometry_test_hook(RemoteGeometryTestStep::CellPixelStarted); + let _geometry_lifecycle = self.geometry_lifecycle.lock().unwrap(); + let next = (width_px.max(1), height_px.max(1)); + if *self.cell_pixels.lock().unwrap() == next { + return Ok(false); + } + if self.kind == SurfaceKind::Pty { + let mut term = self.term.lock().unwrap(); + let size = (term.cols(), term.rows()); + term.resize(size.0, size.1, u32::from(next.0), u32::from(next.1))?; + *self.cell_pixels.lock().unwrap() = next; + } else { + *self.cell_pixels.lock().unwrap() = next; + } + #[cfg(test)] + self.run_geometry_test_hook(RemoteGeometryTestStep::CellPixelCommitBoundary); + self.content_generation.fetch_add(1, Ordering::AcqRel); + Ok(true) + } + + fn cell_pixel_size(&self) -> (u16, u16) { + let _geometry_lifecycle = self.geometry_lifecycle.lock().unwrap(); + *self.cell_pixels.lock().unwrap() } pub(super) fn reported_size(&self) -> Option<(u16, u16)> { @@ -590,6 +691,22 @@ impl RemoteSurface { } } + pub fn browser_frame_metadata(&self) -> Option<(u64, u32, u32, Option)> { + let browser = self.browser.lock().unwrap(); + if matches!(browser.status, BrowserStatus::Failed(_)) { + None + } else { + browser.frame.as_ref().map(|frame| { + ( + frame.frame.seq, + frame.frame.css_width, + frame.frame.css_height, + browser.pointer_frame_seq, + ) + }) + } + } + pub fn browser_frame_update(&self) -> Option { let browser = self.browser.lock().unwrap(); if matches!(browser.status, BrowserStatus::Failed(_)) { @@ -731,6 +848,15 @@ impl RemoteSurface { } } +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RemoteGeometryTestStep { + StreamResizeStarted, + StreamResizeCommitBoundary, + CellPixelStarted, + CellPixelCommitBoundary, +} + fn pointer_frame_is_in_range(browser: &RemoteBrowserState, frame_seq: u64) -> bool { browser .pointer_frame_floor_seq @@ -759,6 +885,25 @@ struct SubscriptionRecoveryState { in_flight: bool, } +#[derive(Clone, Copy)] +enum RequestDeadline { + Standard, + Attach, + Fixed(Duration), +} + +struct PendingRemoteRequest { + response: Sender, + progress: Arc, + attach_surface: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RemoteProgressTarget { + Request(u64), + AttachSurface(SurfaceId), +} + struct InteractiveWrite { message: String, enqueued_at: Instant, @@ -1118,8 +1263,9 @@ fn interactive_writer_worker( pub struct RemoteSession { interactive_writer: InteractiveWriter, - pending: Mutex>>, + pending: Mutex>, next_id: AtomicU64, + attach_progress: AtomicU64, shutdown: AtomicBool, surfaces: Mutex>>, exited_surfaces: Mutex>, @@ -1133,6 +1279,8 @@ pub struct RemoteSession { primed_subscription: Mutex>, frame_logs: Mutex>>, surface_overflow_recovery: Mutex>, + cell_pixel_lifecycle: Mutex<()>, + cell_pixels: Mutex<(u16, u16)>, capabilities: Mutex>, provider_workspace_authority: Option, provider_workspaces_guarded: AtomicBool, @@ -1145,6 +1293,38 @@ pub struct RemoteSession { /// their native message boundaries. pub trait RemoteMessageReader: Send { fn receive(&mut self) -> io::Result>; + + fn receive_with_progress( + &mut self, + on_progress: &mut dyn FnMut(&[u8]), + ) -> io::Result> { + let message = self.receive()?; + if let Some(message) = message.as_deref() { + on_progress(message.as_bytes()); + } + Ok(message) + } +} + +fn decimal_after_prefix(bytes: &[u8], prefix: &[u8]) -> Option { + let tail = bytes.strip_prefix(prefix)?; + let digits = tail.iter().take_while(|byte| byte.is_ascii_digit()).count(); + if digits == 0 || !matches!(tail.get(digits), Some(b',') | Some(b'}')) { + return None; + } + std::str::from_utf8(&tail[..digits]).ok()?.parse().ok() +} + +fn remote_progress_target(partial: &[u8]) -> Option { + decimal_after_prefix(partial, br#"{"id":"#).map(RemoteProgressTarget::Request).or_else(|| { + [ + br#"{"event":"vt-state","surface":"#.as_slice(), + br#"{"event":"browser-state","surface":"#.as_slice(), + ] + .into_iter() + .find_map(|prefix| decimal_after_prefix(partial, prefix)) + .map(RemoteProgressTarget::AttachSurface) + }) } /// Send complete JSON protocol messages over one transport. @@ -1204,9 +1384,65 @@ struct JsonLineReader { inner: BufReader>, } +pub(crate) fn read_json_line_with_progress( + reader: &mut R, + on_progress: &mut dyn FnMut(&[u8]), +) -> io::Result> { + read_json_line_with_progress_bounded(reader, on_progress, REMOTE_SESSION_MESSAGE_MAX_BYTES) +} + +fn read_json_line_with_progress_bounded( + reader: &mut R, + on_progress: &mut dyn FnMut(&[u8]), + max_message_bytes: usize, +) -> io::Result> { + let mut bytes = Zeroizing::new(Vec::new()); + let mut complete_line = false; + loop { + let (consumed, complete) = { + let available = reader.fill_buf()?; + if available.is_empty() { + break; + } + let complete_at = available.iter().position(|byte| *byte == b'\n'); + let payload_bytes = complete_at.unwrap_or(available.len()); + if payload_bytes > max_message_bytes.saturating_sub(bytes.len()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("remote session message exceeds the {max_message_bytes}-byte limit"), + )); + } + let consumed = complete_at.map_or(available.len(), |index| index + 1); + bytes.extend_from_slice(&available[..payload_bytes]); + (consumed, complete_at.is_some()) + }; + reader.consume(consumed); + on_progress(bytes.as_slice()); + if complete { + complete_line = true; + break; + } + } + + if bytes.is_empty() && !complete_line { + return Ok(None); + } + if complete_line && bytes.last() == Some(&b'\r') { + bytes.pop(); + } + decode_json_line(bytes).map(Some) +} + impl RemoteMessageReader for JsonLineReader { fn receive(&mut self) -> io::Result> { - read_bounded_json_line(&mut self.inner, REMOTE_CONTROL_MESSAGE_MAX_BYTES) + self.receive_with_progress(&mut |_| {}) + } + + fn receive_with_progress( + &mut self, + on_progress: &mut dyn FnMut(&[u8]), + ) -> io::Result> { + read_json_line_with_progress(&mut self.inner, on_progress) } } @@ -1351,6 +1587,7 @@ impl RemoteSession { interactive_writer, pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + attach_progress: AtomicU64::new(0), shutdown: AtomicBool::new(false), surfaces: Mutex::new(HashMap::new()), exited_surfaces: Mutex::new(HashSet::new()), @@ -1364,6 +1601,8 @@ impl RemoteSession { primed_subscription: Mutex::new(None), frame_logs: Mutex::new(HashMap::new()), surface_overflow_recovery: Mutex::new(HashMap::new()), + cell_pixel_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), capabilities: Mutex::new(HashSet::new()), provider_workspace_authority, provider_workspaces_guarded: AtomicBool::new(false), @@ -1371,7 +1610,15 @@ impl RemoteSession { let reader_session = Arc::downgrade(&session); std::thread::Builder::new().name("remote-reader".into()).spawn(move || { - while let Ok(Some(mut message)) = reader.receive() { + let mut report_progress = |partial: &[u8]| { + if let Some(session) = reader_session.upgrade() { + session.report_read_progress(partial); + } + }; + while let Ok(Some(mut message)) = reader.receive_with_progress(&mut report_progress) { + if message.len() > REMOTE_SESSION_MESSAGE_MAX_BYTES { + break; + } let value = serde_json::from_str::(&message); zeroize_string(&mut message); let Ok(value) = value else { continue }; @@ -1533,6 +1780,38 @@ impl RemoteSession { } } + fn report_read_progress(&self, partial: &[u8]) { + let Some(target) = remote_progress_target(partial) else { return }; + let pending = self.pending.lock().unwrap(); + let attach_progressed = match target { + RemoteProgressTarget::Request(id) => { + if let Some(request) = pending.get(&id) { + request.progress.fetch_add(1, Ordering::Release); + request.attach_surface.is_some() + } else { + false + } + } + RemoteProgressTarget::AttachSurface(surface) => { + let mut progressed = false; + for request in + pending.values().filter(|request| request.attach_surface == Some(surface)) + { + request.progress.fetch_add(1, Ordering::Release); + progressed = true; + } + progressed + } + }; + drop(pending); + if attach_progressed { + // A JSON-lines transport serializes complete messages. An attach + // queued behind this progressing snapshot cannot receive its own + // bytes yet, so its pre-response idle window follows this epoch. + self.attach_progress.fetch_add(1, Ordering::Release); + } + } + fn handle_line(self: &Arc, value: Value) { let surface_id = || value.get("surface").and_then(|v| v.as_u64()); let event = value.get("event").and_then(Value::as_str); @@ -1543,8 +1822,8 @@ impl RemoteSession { None => { // Response: route to the waiting request. let Some(id) = value.get("id").and_then(|v| v.as_u64()) else { return }; - if let Some(tx) = self.pending.lock().unwrap().remove(&id) { - let _ = tx.send(value); + if let Some(request) = self.pending.lock().unwrap().remove(&id) { + let _ = request.response.send(value); } } Some("vt-state") => { @@ -1559,13 +1838,27 @@ impl RemoteSession { id, format!("vt-state cols={cols} rows={rows} bytes={}", replay.len()), ); + let Ok(kitty_image_aliases) = parse_kitty_image_aliases(&value) else { + self.disconnect_transport(); + return; + }; + let Ok(kitty_state) = parse_kitty_replay_state(&value) else { + self.disconnect_transport(); + return; + }; if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { - if !surface.apply_stream_resize_with_colors( - cols, - rows, - Some(&replay), - colors.as_ref(), - ) { + if surface + .apply_stream_resize_with_colors( + cols, + rows, + Some(&replay), + &kitty_image_aliases, + Some(kitty_state), + colors.as_ref(), + ) + .is_err() + { + self.disconnect_transport(); return; } surface.dirty.store(true, Ordering::Release); @@ -1626,11 +1919,29 @@ impl RemoteSession { Some("resized") => { let Some(id) = surface_id() else { return }; let Some((cols, rows)) = remote_terminal_size(&value) else { return }; - let replay = value - .get("replay") - .or_else(|| value.get("data")) - .and_then(|v| v.as_str()) - .and_then(|data| base64::engine::general_purpose::STANDARD.decode(data).ok()); + let replay = match value.get("replay").or_else(|| value.get("data")) { + Some(data) => { + let Some(data) = data.as_str() else { + self.disconnect_transport(); + return; + }; + let Ok(replay) = base64::engine::general_purpose::STANDARD.decode(data) + else { + self.disconnect_transport(); + return; + }; + Some(replay) + } + None => None, + }; + let Ok(kitty_image_aliases) = parse_kitty_image_aliases(&value) else { + self.disconnect_transport(); + return; + }; + let Ok(kitty_state) = parse_kitty_replay_state(&value) else { + self.disconnect_transport(); + return; + }; let colors = value.get("colors").and_then(parse_terminal_colors); self.log_frame( id, @@ -1640,12 +1951,18 @@ impl RemoteSession { ), ); if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { - if !surface.apply_stream_resize_with_colors( - cols, - rows, - replay.as_deref(), - colors.as_ref(), - ) { + if surface + .apply_stream_resize_with_colors( + cols, + rows, + replay.as_deref(), + &kitty_image_aliases, + Some(kitty_state), + colors.as_ref(), + ) + .is_err() + { + self.disconnect_transport(); return; } surface.dirty.store(true, Ordering::Release); @@ -1676,7 +1993,8 @@ impl RemoteSession { let Some(id) = surface_id() else { return }; if let Some(surface) = self.surfaces.lock().unwrap().get(&id).cloned() { let Some((cols, rows)) = remote_terminal_size(&value) else { return }; - if !surface.apply_stream_resize(cols, rows, None) { + if surface.apply_stream_resize(cols, rows, None, &[]).is_err() { + self.disconnect_transport(); return; } surface.update_browser_state(&value); @@ -1796,6 +2114,11 @@ impl RemoteSession { self.emit(MuxEvent::Status(message.to_string())); } } + Some("graphics-status") => { + if let Some(status) = parse_graphics_status(&value) { + self.emit(MuxEvent::GraphicsStatus(status)); + } + } Some("config-reload-requested") => self.emit(MuxEvent::ConfigReloadRequested), Some("window-title-requested") => { if let Some(title) = value.get("title").and_then(|v| v.as_str()) { @@ -1935,11 +2258,21 @@ impl RemoteSession { } pub fn request(&self, cmd: Value) -> anyhow::Result { - self.request_with_timeout(cmd, REMOTE_REQUEST_TIMEOUT) + self.request_with_deadline(cmd, RequestDeadline::Standard) } - fn request_with_timeout(&self, mut cmd: Value, timeout: Duration) -> anyhow::Result { + fn request_with_deadline( + &self, + mut cmd: Value, + deadline: RequestDeadline, + ) -> anyhow::Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let progress = Arc::new(AtomicU64::new(0)); + let attach_progress = matches!(deadline, RequestDeadline::Attach) + .then(|| self.attach_progress.load(Ordering::Acquire)); + let attach_surface = matches!(deadline, RequestDeadline::Attach) + .then(|| cmd.get("surface").and_then(Value::as_u64)) + .flatten(); cmd["id"] = json!(id); let message = serde_json::to_string(&cmd) .map_err(RemoteRequestError::Encode) @@ -1949,7 +2282,10 @@ impl RemoteSession { } let (tx, rx) = channel(); - self.pending.lock().unwrap().insert(id, tx); + self.pending.lock().unwrap().insert( + id, + PendingRemoteRequest { response: tx, progress: progress.clone(), attach_surface }, + ); let sequence = match self.interactive_writer.enqueue(message, false) { Ok(sequence) => sequence, Err(error) => { @@ -1967,14 +2303,14 @@ impl RemoteSession { return Err(RemoteRequestError::Shutdown.into()); } - let response = match rx.recv_timeout(timeout) { + let response = match self.wait_for_response(rx, deadline, progress, attach_progress) { Ok(response) => response, - Err(_) => { + Err(error) => { // Drop the pending entry so a half-open session does not // accumulate abandoned senders (and a late response is // not delivered to a receiver nobody holds). self.pending.lock().unwrap().remove(&id); - return Err(RemoteRequestError::Timeout.into()); + return Err(error.into()); } }; if response.get("shutdown").and_then(Value::as_bool) == Some(true) { @@ -1994,6 +2330,73 @@ impl RemoteSession { } } + fn wait_for_response( + &self, + rx: Receiver, + deadline: RequestDeadline, + progress: Arc, + attach_progress: Option, + ) -> Result { + if let RequestDeadline::Standard | RequestDeadline::Fixed(_) = deadline { + let timeout = match deadline { + RequestDeadline::Standard => REMOTE_REQUEST_TIMEOUT, + RequestDeadline::Fixed(timeout) => timeout, + RequestDeadline::Attach => unreachable!(), + }; + return match rx.recv_timeout(timeout) { + Ok(response) => Ok(response), + Err(RecvTimeoutError::Timeout) => Err(RemoteRequestError::Timeout), + Err(RecvTimeoutError::Disconnected) if self.shutdown.load(Ordering::Acquire) => { + Err(RemoteRequestError::Shutdown) + } + Err(RecvTimeoutError::Disconnected) => Err(RemoteRequestError::Timeout), + }; + } + + let started = Instant::now(); + let maximum_deadline = started + REMOTE_ATTACH_MAX_TIMEOUT; + let mut idle_deadline = started + REMOTE_ATTACH_IDLE_TIMEOUT; + let mut observed_progress = progress.load(Ordering::Acquire); + let mut observed_attach_progress = attach_progress; + loop { + let now = Instant::now(); + if now >= maximum_deadline { + return Err(RemoteRequestError::Timeout); + } + let next_deadline = idle_deadline.min(maximum_deadline); + match rx.recv_timeout(next_deadline.saturating_duration_since(now)) { + Ok(response) => return Ok(response), + Err(RecvTimeoutError::Disconnected) if self.shutdown.load(Ordering::Acquire) => { + return Err(RemoteRequestError::Shutdown); + } + Err(RecvTimeoutError::Disconnected) => return Err(RemoteRequestError::Timeout), + Err(RecvTimeoutError::Timeout) => {} + } + if self.shutdown.load(Ordering::Acquire) { + return Err(RemoteRequestError::Shutdown); + } + let current_progress = progress.load(Ordering::Acquire); + if current_progress != observed_progress { + observed_progress = current_progress; + idle_deadline = Instant::now() + REMOTE_ATTACH_IDLE_TIMEOUT; + continue; + } + if observed_progress == 0 + && let Some(observed) = observed_attach_progress.as_mut() + { + let current = self.attach_progress.load(Ordering::Acquire); + if current != *observed { + *observed = current; + idle_deadline = Instant::now() + REMOTE_ATTACH_IDLE_TIMEOUT; + continue; + } + } + if Instant::now() >= idle_deadline { + return Err(RemoteRequestError::Timeout); + } + } + } + /// Write latency-sensitive input in order without waiting for the mux /// command acknowledgement. The response reader still drains the reply; /// its unknown request id is intentionally ignored. Reliable remote @@ -2024,7 +2427,8 @@ impl RemoteSession { cmd: Value, lifecycle: GuardedPointerLifecycle, ) -> anyhow::Result { - let result = self.request_with_timeout(cmd, GUARDED_POINTER_REQUEST_TIMEOUT); + let result = self + .request_with_deadline(cmd, RequestDeadline::Fixed(GUARDED_POINTER_REQUEST_TIMEOUT)); if lifecycle == GuardedPointerLifecycle::CaptureMutation && result .as_ref() @@ -2128,8 +2532,8 @@ impl RemoteSession { self.shutdown.store(true, Ordering::Release); self.provider_workspaces_guarded.store(false, Ordering::Release); let pending = std::mem::take(&mut *self.pending.lock().unwrap()); - for (_, sender) in pending { - let _ = sender.send(json!({"shutdown": true})); + for (_, request) in pending { + let _ = request.response.send(json!({"shutdown": true})); } if let Ok(Some(sequence)) = self.interactive_writer.last_enqueued_sequence() { let _ = self.wait_for_ordered_write(sequence); @@ -2158,11 +2562,55 @@ impl RemoteSession { width_px: u16, height_px: u16, ) -> anyhow::Result { - let response = self.request(json!({ + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let next = (width_px.max(1), height_px.max(1)); + let previous_global = *self.cell_pixels.lock().unwrap(); + let surfaces = self.surfaces.lock().unwrap().values().cloned().collect::>(); + let snapshots = surfaces + .iter() + .map(|surface| (surface.clone(), surface.cell_pixel_size())) + .collect::>(); + for (index, surface) in surfaces.iter().enumerate() { + if let Err(error) = surface.set_cell_pixel_size(next.0, next.1) { + let rollback = Self::restore_cell_pixels(&snapshots[..index]); + return match rollback { + Ok(()) => Err(anyhow::anyhow!( + "could not update cell pixels for remote mirror {}: {error}", + surface.id + )), + Err(rollback_error) => Err(anyhow::anyhow!( + "could not update cell pixels for remote mirror {}: {error}; \ + local rollback also failed: {rollback_error}", + surface.id + )), + }; + } + } + let response = match self.request(json!({ "cmd": "set-cell-pixels", - "width_px": width_px, - "height_px": height_px, - }))?; + "width_px": next.0, + "height_px": next.1, + })) { + Ok(response) => response, + Err(error) => { + let known_not_applied = matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Encode(_) | RemoteRequestError::Rejected { .. }) + ); + if known_not_applied { + *self.cell_pixels.lock().unwrap() = previous_global; + return match Self::restore_cell_pixels(&snapshots) { + Ok(()) => Err(error), + Err(rollback_error) => Err(anyhow::anyhow!( + "{error}; local cell-pixel rollback also failed: {rollback_error}" + )), + }; + } + *self.cell_pixels.lock().unwrap() = next; + let _ = self.reconcile_cell_pixels_from_remote(); + return Err(error); + } + }; let resizes = response .get("resizes") .and_then(Value::as_array) @@ -2178,7 +2626,7 @@ impl RemoteSession { resize.get("reservation_id").and_then(Value::as_u64), )) }) - .collect(); + .collect::>(); let failures = response .get("failures") .and_then(Value::as_array) @@ -2188,12 +2636,93 @@ impl RemoteSession { Some(( failure.get("surface")?.as_u64()?, failure.get("error")?.as_str()?.to_string(), + failure.get("deferred").and_then(Value::as_bool).unwrap_or(false), )) }) - .collect(); + .collect::>(); + let failed_surfaces = failures + .iter() + .filter_map(|(surface, _, deferred)| (!deferred).then_some(*surface)) + .collect::>(); + let use_target_for_creation = + failures.is_empty() || failures.iter().all(|(_, _, deferred)| *deferred); + let failed_snapshots = snapshots + .iter() + .filter(|(surface, _)| failed_surfaces.contains(&surface.id)) + .cloned() + .collect::>(); + Self::restore_cell_pixels(&failed_snapshots)?; + if use_target_for_creation { + *self.cell_pixels.lock().unwrap() = next; + } + let failures = + failures.into_iter().map(|(surface, error, _)| (surface, error)).collect::>(); Ok(RemoteCellPixelUpdate { resizes, failures }) } + fn reconcile_cell_pixels_from_remote(&self) -> anyhow::Result<()> { + let response = self.request(json!({"cmd": "get-cell-pixels"}))?; + let width_px = response + .get("width_px") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| anyhow::anyhow!("remote cell-pixel query omitted width_px"))?; + let height_px = response + .get("height_px") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| anyhow::anyhow!("remote cell-pixel query omitted height_px"))?; + let surface_metrics = response + .get("surfaces") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("remote cell-pixel query omitted surfaces"))? + .iter() + .map(|surface| { + let id = surface + .get("surface") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("remote cell-pixel query omitted surface id"))?; + let width_px = surface + .get("width_px") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + anyhow::anyhow!("remote cell-pixel query omitted surface width_px") + })?; + let height_px = surface + .get("height_px") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + anyhow::anyhow!("remote cell-pixel query omitted surface height_px") + })?; + Ok((id, (width_px, height_px))) + }) + .collect::>>()?; + let surfaces = self.surfaces.lock().unwrap().values().cloned().collect::>(); + for surface in surfaces { + if let Some(metric) = surface_metrics.get(&surface.id) { + surface.set_cell_pixel_size(metric.0, metric.1)?; + } + } + *self.cell_pixels.lock().unwrap() = (width_px, height_px); + Ok(()) + } + + fn restore_cell_pixels(snapshots: &[(Arc, (u16, u16))]) -> anyhow::Result<()> { + let mut failures = Vec::new(); + for (surface, previous) in snapshots { + if let Err(error) = surface.set_cell_pixel_size(previous.0, previous.1) { + failures.push(format!("surface {}: {error}", surface.id)); + } + } + if failures.is_empty() { Ok(()) } else { anyhow::bail!("{}", failures.join("; ")) } + } + pub fn set_default_colors(&self, colors: DefaultColors) -> anyhow::Result<()> { let palette = colors .palette @@ -2298,44 +2827,72 @@ impl RemoteSession { kind: SurfaceKind, size: Option<(u16, u16)>, ) -> anyhow::Result>> { - if self.exited_surfaces.lock().unwrap().contains(&id) { - return Ok(None); - } - if !self.can_attach_after_overflow(id) { - return Ok(None); - } - if let Some(surface) = self.surfaces.lock().unwrap().get(&id) { - return Ok(Some(surface.clone())); - } let source = { let tree = self.tree.lock().unwrap(); browser_source_from_tree(&tree.view, id) }; let (cols, rows) = size.unwrap_or((80, 24)); - let term = Terminal::new(cols, rows, 10_000, Callbacks::default())?; - let surface = Arc::new(RemoteSurface { - id, - kind, - term: Mutex::new(term), - mouse_encoders: Mutex::new(MouseEncoders::new()?), - dirty: AtomicBool::new(false), - content_generation: AtomicU64::new(1), - reported_size: Mutex::new(None), - browser: Mutex::new(RemoteBrowserState::default()), - }); - surface.update_browser_source(source); - self.surfaces.lock().unwrap().insert(id, surface.clone()); let initial_size = size.map(|(cols, rows)| (cols.max(1), rows.max(1))).filter(|_| { self.supports_capability(cmux_tui_core::server::ATTACH_INITIAL_SIZE_CAPABILITY) }); + let surface = { + // Coordinate only the local mirror commit with cell-metric + // updates. The remote attach can stream for minutes and must not + // retain this lifecycle lock while it waits. + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + if self.exited_surfaces.lock().unwrap().contains(&id) { + return Ok(None); + } + if !self.can_attach_after_overflow(id) { + return Ok(None); + } + if let Some(surface) = self.surfaces.lock().unwrap().get(&id) { + return Ok(Some(surface.clone())); + } + let cell_pixels = *self.cell_pixels.lock().unwrap(); + let mut term = Terminal::new(cols, rows, 10_000, Callbacks::default())?; + term.resize(cols, rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1))?; + let surface = Arc::new(RemoteSurface { + id, + kind, + term: Mutex::new(term), + mouse_encoders: Mutex::new(MouseEncoders::new()?), + dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new(cell_pixels), + #[cfg(test)] + geometry_test_hook: Mutex::new(None), + content_generation: AtomicU64::new(1), + reported_size: Mutex::new(None), + browser: Mutex::new(RemoteBrowserState::default()), + }); + surface.update_browser_source(source); + self.surfaces.lock().unwrap().insert(id, surface.clone()); + surface + }; let mut request = json!({"cmd": "attach-surface", "surface": id}); if let Some((cols, rows)) = initial_size { request["cols"] = json!(cols); request["rows"] = json!(rows); } // The vt-state event that follows fills the mirror. - if let Err(error) = self.request(request) { - self.surfaces.lock().unwrap().remove(&id); + if let Err(error) = self.request_with_deadline(request, RequestDeadline::Attach) { + { + let _cell_pixel_lifecycle = self.cell_pixel_lifecycle.lock().unwrap(); + let mut surfaces = self.surfaces.lock().unwrap(); + if surfaces.get(&id).is_some_and(|current| Arc::ptr_eq(current, &surface)) { + surfaces.remove(&id); + } + } + if error + .downcast_ref::() + .is_some_and(RemoteRequestError::is_timeout) + { + // The server registers the stream before it queues the attach + // response. Closing the connection is the only protocol-level + // cancellation that guarantees a timed-out stream is released. + self.disconnect_transport(); + } return Err(error); } if let Some(size) = initial_size { @@ -2471,6 +3028,76 @@ impl Drop for RemoteSession { } } +fn parse_kitty_image_aliases( + value: &Value, +) -> Result, &'static str> { + let Some(aliases) = value.get("kitty_image_aliases") else { + return Ok(Vec::new()); + }; + let aliases = aliases.as_array().ok_or("kitty_image_aliases must be an array")?; + if aliases.len() > cmux_tui_core::terminal_host_protocol::MAX_KITTY_IMAGE_ALIASES { + return Err("kitty_image_aliases has too many entries"); + } + let aliases = aliases + .iter() + .map(|alias| { + let image_id = alias + .get("image_id") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or("kitty image alias has an invalid image_id")?; + let image_number = alias + .get("image_number") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or("kitty image alias has an invalid image_number")?; + Ok(ghostty_vt::KittyImageAlias { image_id, image_number }) + }) + .collect::, &'static str>>()?; + cmux_tui_core::terminal_host_runtime::validate_kitty_image_aliases(&aliases) + .map_err(|_| "kitty_image_aliases violates terminal-host invariants")?; + Ok(aliases) +} + +fn parse_kitty_replay_state(value: &Value) -> Result { + let Some(state) = value.get("kitty_graphics_state") else { + return Ok(KittyReplayState::disabled()); + }; + let state = state.as_object().ok_or("kitty_graphics_state must be an object")?; + if state.len() != 9 { + return Err("kitty_graphics_state has unexpected fields"); + } + let u64_field = |name| { + state.get(name).and_then(Value::as_u64).ok_or("kitty_graphics_state has an invalid limit") + }; + let u32_field = |name| { + state + .get(name) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or("kitty_graphics_state has an invalid image ID cursor") + }; + KittyReplayState { + limits: KittyGraphicsLimits { + image_bytes: u64_field("image_bytes")?, + inflight_bytes: u64_field("inflight_bytes")?, + images: u64_field("images")?, + placements: u64_field("placements")?, + }, + replay_cursor_offset: u32_field("replay_cursor_offset")?, + replay_next_image_ids: KittyImageIdCursors { + primary: u32_field("primary_replay_next_image_id")?, + alternate: u32_field("alternate_replay_next_image_id")?, + }, + next_image_ids: KittyImageIdCursors { + primary: u32_field("primary_next_image_id")?, + alternate: u32_field("alternate_next_image_id")?, + }, + } + .validate() + .map_err(|_| "kitty_graphics_state violates terminal limits") +} + fn dump_mirror(surface: &RemoteSurface) -> String { let mut out = String::new(); let mut term = surface.term.lock().unwrap(); @@ -2680,6 +3307,7 @@ fn test_session_with_writer( interactive_writer: InteractiveWriter::spawn(writer, Arc::new(NoopTransportAbort)).unwrap(), pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + attach_progress: AtomicU64::new(0), shutdown: AtomicBool::new(false), surfaces: Mutex::new(HashMap::new()), exited_surfaces: Mutex::new(HashSet::new()), @@ -2693,12 +3321,147 @@ fn test_session_with_writer( primed_subscription: Mutex::new(None), frame_logs: Mutex::new(HashMap::new()), surface_overflow_recovery: Mutex::new(HashMap::new()), + cell_pixel_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), capabilities: Mutex::new(capabilities), provider_workspace_authority, provider_workspaces_guarded: AtomicBool::new(false), }) } +#[cfg(test)] +struct DeferredAttachTestWriter { + session: Arc>>>, + attach_started: std::sync::mpsc::SyncSender<()>, + release_attach: Option>, + first_resize_failure: Option<(std::sync::mpsc::SyncSender<()>, Receiver<()>)>, +} + +#[cfg(test)] +impl RemoteMessageWriter for DeferredAttachTestWriter { + fn send(&mut self, message: &str) -> io::Result<()> { + let request: Value = serde_json::from_str(message).map_err(io::Error::other)?; + let id = request + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| io::Error::other("remote request omitted its id"))?; + let session = self + .session + .lock() + .unwrap() + .as_ref() + .cloned() + .ok_or_else(|| io::Error::other("test remote session was dropped"))?; + if request.get("cmd").and_then(Value::as_str) == Some("attach-surface") { + self.attach_started.send(()).map_err(io::Error::other)?; + let release = self + .release_attach + .take() + .ok_or_else(|| io::Error::other("attach release already consumed"))?; + std::thread::spawn(move || { + let _ = release.recv(); + let Some(session) = session.upgrade() else { return }; + let Some(response) = session.pending.lock().unwrap().remove(&id) else { + return; + }; + let _ = response.response.send(json!({"id": id, "ok": true, "data": null})); + }); + return Ok(()); + } + let reject_resize = if request.get("cmd").and_then(Value::as_str) == Some("resize-surface") + { + if let Some((started, release)) = self.first_resize_failure.take() { + started.send(()).map_err(io::Error::other)?; + release.recv().map_err(io::Error::other)?; + true + } else { + false + } + } else { + false + }; + let session = + session.upgrade().ok_or_else(|| io::Error::other("test remote session was dropped"))?; + let pending_response = session + .pending + .lock() + .unwrap() + .remove(&id) + .ok_or_else(|| io::Error::other("remote request was not pending"))?; + let data = if request.get("cmd").and_then(Value::as_str) == Some("set-cell-pixels") { + json!({"resizes": [], "failures": []}) + } else { + Value::Null + }; + let response = if reject_resize { + json!({"id": id, "ok": false, "error": "scripted promoted resize failure"}) + } else { + json!({"id": id, "ok": true, "data": data}) + }; + pending_response + .response + .send(response) + .map_err(|_| io::Error::other("remote response receiver was dropped")) + } + + fn close(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +pub(super) fn test_session_with_deferred_attach() -> (Arc, Receiver<()>, Sender<()>) +{ + test_session_with_deferred_attach_control(None) +} + +#[cfg(test)] +fn test_session_with_deferred_attach_control( + first_resize_failure: Option<(std::sync::mpsc::SyncSender<()>, Receiver<()>)>, +) -> (Arc, Receiver<()>, Sender<()>) { + let session_slot = Arc::new(Mutex::new(None)); + let (attach_started_tx, attach_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_attach_tx, release_attach_rx) = channel(); + let session = test_session_with_writer( + Box::new(DeferredAttachTestWriter { + session: session_slot.clone(), + attach_started: attach_started_tx, + release_attach: Some(release_attach_rx), + first_resize_failure, + }), + None, + HashSet::new(), + ); + *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); + session.tree_stale.store(false, Ordering::Release); + (session, attach_started_rx, release_attach_tx) +} + +#[cfg(test)] +pub(super) struct DeferredAttachResizeFailureFixture { + pub session: Arc, + pub attach_started: Receiver<()>, + pub release_attach: Sender<()>, + pub resize_started: Receiver<()>, + pub release_resize: Sender<()>, +} + +#[cfg(test)] +pub(super) fn test_session_with_deferred_attach_and_first_resize_failure() +-> DeferredAttachResizeFailureFixture { + let (resize_started_tx, resize_started_rx) = std::sync::mpsc::sync_channel(1); + let (release_resize_tx, release_resize_rx) = channel(); + let (session, attach_started, release_attach) = + test_session_with_deferred_attach_control(Some((resize_started_tx, release_resize_rx))); + DeferredAttachResizeFailureFixture { + session, + attach_started, + release_attach, + resize_started: resize_started_rx, + release_resize: release_resize_tx, + } +} + #[cfg(test)] fn test_session_with_provider_context( provider_workspace_authority: Option, @@ -2759,6 +3522,9 @@ pub(super) fn test_session_with_browser_pointer_range( term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState { @@ -2912,6 +3678,65 @@ mod tests { validate_remote_identity(&json!({"app": "cmux-tui", "protocol": 10})).unwrap(); } + #[test] + fn partial_message_progress_targets_only_its_request_or_attach() { + assert_eq!( + remote_progress_target(br#"{"id":41,"ok":true,"data":"partial"#), + Some(RemoteProgressTarget::Request(41)) + ); + assert_eq!( + remote_progress_target(br#"{"event":"vt-state","surface":7,"cols":80,"data":"partial"#), + Some(RemoteProgressTarget::AttachSurface(7)) + ); + assert_eq!( + remote_progress_target( + br#"{"event":"browser-state","surface":8,"frame":{"data":"partial"# + ), + Some(RemoteProgressTarget::AttachSurface(8)) + ); + assert_eq!( + remote_progress_target(br#"{"event":"output","surface":7,"id":41,"data":"partial"#), + None + ); + assert_eq!(remote_progress_target(br#"{"id":41"#), None); + } + + #[test] + fn json_line_reader_rejects_oversized_frames_before_buffering_them() { + let mut reader = BufReader::with_capacity(4, io::Cursor::new(b"123456789\n".to_vec())); + let mut largest_progress = 0; + let error = read_json_line_with_progress_bounded( + &mut reader, + &mut |partial| largest_progress = largest_progress.max(partial.len()), + 8, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(error.to_string(), "remote session message exceeds the 8-byte limit"); + assert!(largest_progress <= 8); + } + + #[test] + fn json_line_reader_accepts_a_frame_at_the_exact_limit() { + let mut reader = BufReader::with_capacity(3, io::Cursor::new(b"12345678\n".to_vec())); + + let line = + read_json_line_with_progress_bounded(&mut reader, &mut |_| {}, 8).unwrap().unwrap(); + + assert_eq!(line, "12345678"); + } + + #[test] + fn json_line_reader_preserves_an_empty_delimited_frame() { + let mut reader = BufReader::new(io::Cursor::new(b"\n".to_vec())); + + let line = + read_json_line_with_progress_bounded(&mut reader, &mut |_| {}, 8).unwrap().unwrap(); + + assert!(line.is_empty()); + } + #[test] fn browser_frame_parses_image_dimensions_with_legacy_fallback() { let frame = parse_browser_frame(&json!({ @@ -3222,6 +4047,7 @@ mod tests { .remove(&id) .ok_or_else(|| io::Error::other("remote request was not pending"))?; response + .response .send(json!({"id": id, "ok": true, "data": null})) .map_err(|_| io::Error::other("remote response receiver was dropped")) } @@ -3258,6 +4084,7 @@ mod tests { .remove(&id) .ok_or_else(|| io::Error::other("remote request was not pending"))?; response + .response .send(json!({"id": id, "ok": true, "data": null})) .map_err(|_| io::Error::other("remote response receiver was dropped")) } @@ -3290,6 +4117,7 @@ mod tests { interactive_writer: InteractiveWriter::spawn(writer, abort).unwrap(), pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + attach_progress: AtomicU64::new(0), shutdown: AtomicBool::new(false), surfaces: Mutex::new(HashMap::new()), exited_surfaces: Mutex::new(HashSet::new()), @@ -3303,6 +4131,8 @@ mod tests { primed_subscription: Mutex::new(None), frame_logs: Mutex::new(HashMap::new()), surface_overflow_recovery: Mutex::new(HashMap::new()), + cell_pixel_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), capabilities: Mutex::new(capabilities), provider_workspace_authority, provider_workspaces_guarded: AtomicBool::new(false), @@ -3425,6 +4255,183 @@ mod tests { test_session_with_provider_context(writer, HashSet::new(), None) } + fn test_remote_pty_surface( + id: SurfaceId, + cols: u16, + rows: u16, + cell_pixels: (u16, u16), + ) -> Arc { + let mut term = Terminal::new(cols, rows, 100, Callbacks::default()).unwrap(); + term.resize(cols, rows, u32::from(cell_pixels.0), u32::from(cell_pixels.1)).unwrap(); + Arc::new(RemoteSurface { + id, + kind: SurfaceKind::Pty, + term: Mutex::new(term), + mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), + dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new(cell_pixels), + geometry_test_hook: Mutex::new(None), + content_generation: AtomicU64::new(1), + reported_size: Mutex::new(None), + browser: Mutex::new(RemoteBrowserState::default()), + }) + } + + struct RejectingWriter { + session: Arc>>>, + } + + impl RemoteMessageWriter for RejectingWriter { + fn send(&mut self, message: &str) -> io::Result<()> { + let request: Value = serde_json::from_str(message).map_err(io::Error::other)?; + let id = request + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| io::Error::other("remote request omitted its id"))?; + let session = self + .session + .lock() + .unwrap() + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(|| io::Error::other("test remote session was dropped"))?; + let response = session + .pending + .lock() + .unwrap() + .remove(&id) + .ok_or_else(|| io::Error::other("remote request was not pending"))?; + response + .response + .send(json!({"id": id, "ok": false, "error": "injected rejection"})) + .map_err(|_| io::Error::other("remote response receiver was dropped")) + } + + fn close(&mut self) -> io::Result<()> { + Ok(()) + } + } + + struct CellPixelFanoutWriter { + session: Arc>>>, + fail_next: bool, + deferred_failure: bool, + } + + impl RemoteMessageWriter for CellPixelFanoutWriter { + fn send(&mut self, message: &str) -> io::Result<()> { + let request: Value = serde_json::from_str(message).map_err(io::Error::other)?; + let id = request + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| io::Error::other("remote request omitted its id"))?; + let session = self + .session + .lock() + .unwrap() + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(|| io::Error::other("test remote session was dropped"))?; + let response = session + .pending + .lock() + .unwrap() + .remove(&id) + .ok_or_else(|| io::Error::other("remote request was not pending"))?; + let data = match request.get("cmd").and_then(Value::as_str) { + Some("set-cell-pixels") if std::mem::take(&mut self.fail_next) => { + json!({ + "resizes": [], + "failures": [{ + "surface": 7, + "error": "injected fan-out failure", + "deferred": self.deferred_failure, + }], + }) + } + Some("set-cell-pixels") => json!({"resizes": [], "failures": []}), + Some("attach-surface") => Value::Null, + command => { + return Err(io::Error::other(format!("unexpected test command: {command:?}"))); + } + }; + response + .response + .send(json!({"id": id, "ok": true, "data": data})) + .map_err(|_| io::Error::other("remote response receiver was dropped")) + } + + fn close(&mut self) -> io::Result<()> { + Ok(()) + } + } + + struct SilentWriter; + + impl RemoteMessageWriter for SilentWriter { + fn send(&mut self, _message: &str) -> io::Result<()> { + Ok(()) + } + + fn close(&mut self) -> io::Result<()> { + Ok(()) + } + } + + struct AmbiguousCellPixelWriter { + session: Arc>>>, + } + + impl RemoteMessageWriter for AmbiguousCellPixelWriter { + fn send(&mut self, message: &str) -> io::Result<()> { + let request: Value = serde_json::from_str(message).map_err(io::Error::other)?; + if request.get("cmd").and_then(Value::as_str) == Some("set-cell-pixels") { + return Ok(()); + } + if request.get("cmd").and_then(Value::as_str) != Some("get-cell-pixels") { + return Err(io::Error::other("unexpected ambiguous cell-pixel test command")); + } + let id = request + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| io::Error::other("remote request omitted its id"))?; + let session = self + .session + .lock() + .unwrap() + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(|| io::Error::other("test remote session was dropped"))?; + let response = session + .pending + .lock() + .unwrap() + .remove(&id) + .ok_or_else(|| io::Error::other("remote request was not pending"))?; + response + .response + .send(json!({ + "id": id, + "ok": true, + "data": { + "width_px": 10, + "height_px": 20, + "surfaces": [{ + "surface": 7, + "width_px": 11, + "height_px": 22, + }], + }, + })) + .map_err(|_| io::Error::other("remote response receiver was dropped")) + } + + fn close(&mut self) -> io::Result<()> { + Ok(()) + } + } + fn blocking_test_session(writer: BlockingWriteStream) -> Arc { let abort = Arc::new(BlockingWriteAbort { control: writer.control.clone() }); test_session_with_abort_and_context(Box::new(writer), abort, HashSet::new(), None) @@ -3439,19 +4446,7 @@ mod tests { requests: requests.clone(), })); *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); - session.surfaces.lock().unwrap().insert( - 7, - Arc::new(RemoteSurface { - id: 7, - kind: SurfaceKind::Pty, - term: Mutex::new(Terminal::new(80, 24, 1_000, Callbacks::default()).unwrap()), - mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), - dirty: AtomicBool::new(false), - content_generation: AtomicU64::new(1), - reported_size: Mutex::new(None), - browser: Mutex::new(RemoteBrowserState::default()), - }), - ); + session.surfaces.lock().unwrap().insert(7, test_remote_pty_surface(7, 80, 24, (8, 16))); let fallback = KeyInput { key: ghostty_vt::sys::GHOSTTY_KEY_L, mods: Mods::CTRL, @@ -3521,19 +4516,7 @@ mod tests { requests: requests.clone(), })); *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); - session.surfaces.lock().unwrap().insert( - 7, - Arc::new(RemoteSurface { - id: 7, - kind: SurfaceKind::Pty, - term: Mutex::new(Terminal::new(80, 24, 1_000, Callbacks::default()).unwrap()), - mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), - dirty: AtomicBool::new(false), - content_generation: AtomicU64::new(1), - reported_size: Mutex::new(None), - browser: Mutex::new(RemoteBrowserState::default()), - }), - ); + session.surfaces.lock().unwrap().insert(7, test_remote_pty_surface(7, 80, 24, (8, 16))); let fallback = KeyInput { key: ghostty_vt::sys::GHOSTTY_KEY_K, mods: Mods::SUPER, @@ -3559,19 +4542,7 @@ mod tests { None, ); *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); - session.surfaces.lock().unwrap().insert( - 7, - Arc::new(RemoteSurface { - id: 7, - kind: SurfaceKind::Pty, - term: Mutex::new(Terminal::new(80, 24, 1_000, Callbacks::default()).unwrap()), - mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), - dirty: AtomicBool::new(false), - content_generation: AtomicU64::new(1), - reported_size: Mutex::new(None), - browser: Mutex::new(RemoteBrowserState::default()), - }), - ); + session.surfaces.lock().unwrap().insert(7, test_remote_pty_surface(7, 80, 24, (8, 16))); let fallback = KeyInput { key: ghostty_vt::sys::GHOSTTY_KEY_L, mods: Mods::CTRL, @@ -3646,6 +4617,7 @@ mod tests { .remove(&id) .ok_or_else(|| io::Error::other("remote request was not pending"))?; response + .response .send(json!({ "id": id, "ok": false, @@ -3866,6 +4838,264 @@ mod tests { test_session(Box::new(JsonLineWriter { inner: Box::new(stream) })) } + #[cfg(unix)] + #[test] + fn browser_attach_deadline_advances_while_a_large_initial_frame_is_arriving() { + let (client, server) = UnixStream::pair().unwrap(); + let (release_tx, release_rx) = channel(); + let peer = std::thread::spawn(move || { + let mut peer = BufReader::new(server); + for expected_command in ["identify", "set-client-info", "subscribe"] { + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["cmd"], expected_command); + let data = if expected_command == "identify" { + json!({"app": "cmux-tui", "protocol": SUPPORTED_PROTOCOL_VERSION}) + } else { + Value::Null + }; + writeln!( + peer.get_mut(), + "{}", + json!({"id": request["id"], "ok": true, "data": data}) + ) + .unwrap(); + } + + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["cmd"], "attach-surface"); + let frame = concat!( + "{\"event\":\"browser-state\",\"surface\":7,", + "\"cols\":80,\"rows\":24,\"status\":\"live\",", + "\"frame\":{\"seq\":1,\"width\":800,\"height\":600,\"data\":\"\"}}" + ); + let first = frame.find(",\"cols\"").unwrap() + 1; + let second = first + (frame.len() - first) / 2; + for (index, fragment) in [ + &frame.as_bytes()[..first], + &frame.as_bytes()[first..second], + &frame.as_bytes()[second..], + ] + .into_iter() + .enumerate() + { + peer.get_mut().write_all(fragment).unwrap(); + peer.get_mut().flush().unwrap(); + if index < 2 { + std::thread::sleep(Duration::from_millis(150)); + } + } + peer.get_mut().write_all(b"\n").unwrap(); + writeln!(peer.get_mut(), "{}", json!({"id": request["id"], "ok": true, "data": null})) + .unwrap(); + release_rx.recv().unwrap(); + }); + let session = RemoteSession::connect_stream(Box::new(client)).unwrap(); + + let started = Instant::now(); + let surface = + session.try_ensure_surface_with_kind(7, SurfaceKind::Browser, None).unwrap().unwrap(); + + assert_eq!(surface.id, 7); + assert_eq!(surface.kind, SurfaceKind::Browser); + assert!( + started.elapsed() > REMOTE_ATTACH_IDLE_TIMEOUT, + "attach completed before exercising the progress-aware deadline" + ); + assert!(!session.shutdown.load(Ordering::Acquire)); + release_tx.send(()).unwrap(); + peer.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn queued_attach_waits_while_an_earlier_snapshot_is_progressing() { + let (client, server) = UnixStream::pair().unwrap(); + let (first_seen_tx, first_seen_rx) = channel(); + let (release_tx, release_rx) = channel(); + let peer = std::thread::spawn(move || { + let mut peer = BufReader::new(server); + for expected_command in ["identify", "set-client-info", "subscribe"] { + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["cmd"], expected_command); + let data = if expected_command == "identify" { + json!({"app": "cmux-tui", "protocol": SUPPORTED_PROTOCOL_VERSION}) + } else { + Value::Null + }; + writeln!( + peer.get_mut(), + "{}", + json!({"id": request["id"], "ok": true, "data": data}) + ) + .unwrap(); + } + + let mut first_line = String::new(); + peer.read_line(&mut first_line).unwrap(); + let first: Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first["cmd"], "attach-surface"); + first_seen_tx.send(()).unwrap(); + + let mut second_line = String::new(); + peer.read_line(&mut second_line).unwrap(); + let second: Value = serde_json::from_str(&second_line).unwrap(); + assert_eq!(second["cmd"], "attach-surface"); + + let first_surface = first["surface"].as_u64().unwrap(); + let event = format!( + "{{\"event\":\"vt-state\",\"surface\":{first_surface},\"cols\":80,\"rows\":24,\"data\":\"\",\"padding\":\"{}\"}}", + "x".repeat(768) + ); + let splits = [64, 256, 512, event.len()]; + let mut copied = 0; + for (index, end) in splits.into_iter().enumerate() { + peer.get_mut().write_all(&event.as_bytes()[copied..end]).unwrap(); + peer.get_mut().flush().unwrap(); + copied = end; + if index + 1 < splits.len() { + std::thread::sleep(REMOTE_ATTACH_IDLE_TIMEOUT * 3 / 4); + } + } + peer.get_mut().write_all(b"\n").unwrap(); + writeln!(peer.get_mut(), "{}", json!({"id": first["id"], "ok": true, "data": null})) + .unwrap(); + + let second_surface = second["surface"].as_u64().unwrap(); + writeln!( + peer.get_mut(), + "{}", + json!({ + "event": "vt-state", + "surface": second_surface, + "cols": 80, + "rows": 24, + "data": "", + }) + ) + .unwrap(); + writeln!(peer.get_mut(), "{}", json!({"id": second["id"], "ok": true, "data": null})) + .unwrap(); + release_rx.recv().unwrap(); + }); + let session = RemoteSession::connect_stream(Box::new(client)).unwrap(); + + let first_session = session.clone(); + let first = std::thread::spawn(move || { + first_session.try_ensure_surface_with_kind(7, SurfaceKind::Pty, None) + }); + first_seen_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let second_session = session.clone(); + let second = std::thread::spawn(move || { + second_session.try_ensure_surface_with_kind(8, SurfaceKind::Pty, None) + }); + + assert!(first.join().unwrap().unwrap().is_some()); + assert!(second.join().unwrap().unwrap().is_some()); + assert!(!session.shutdown.load(Ordering::Acquire)); + release_tx.send(()).unwrap(); + peer.join().unwrap(); + } + + #[test] + fn inflight_attach_does_not_hold_the_cell_pixel_lifecycle() { + let (session, attach_started_rx, release_attach_tx) = test_session_with_deferred_attach(); + let attaching = session.clone(); + let worker = std::thread::spawn(move || { + attaching.try_ensure_surface_with_kind(7, SurfaceKind::Pty, Some((80, 24))) + }); + attach_started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let update = session.set_cell_pixel_size(9, 18).unwrap(); + + assert!(update.failures.is_empty()); + assert_eq!(*session.cell_pixels.lock().unwrap(), (9, 18)); + assert_eq!(session.surface(7).unwrap().cell_pixel_size(), (9, 18)); + release_attach_tx.send(()).unwrap(); + assert!(worker.join().unwrap().unwrap().is_some()); + } + + #[cfg(unix)] + #[test] + fn unrelated_remote_traffic_does_not_extend_attach_idle_deadline() { + let (client, server) = UnixStream::pair().unwrap(); + let peer = std::thread::spawn(move || { + let mut peer = BufReader::new(server); + for expected_command in ["identify", "set-client-info", "subscribe"] { + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["cmd"], expected_command); + let data = if expected_command == "identify" { + json!({"app": "cmux-tui", "protocol": SUPPORTED_PROTOCOL_VERSION}) + } else { + Value::Null + }; + writeln!( + peer.get_mut(), + "{}", + json!({"id": request["id"], "ok": true, "data": data}) + ) + .unwrap(); + } + + let mut line = String::new(); + peer.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["cmd"], "attach-surface"); + let traffic_deadline = Instant::now() + REMOTE_ATTACH_IDLE_TIMEOUT * 8; + while Instant::now() < traffic_deadline { + if writeln!(peer.get_mut(), "{}", json!({"event": "tree-changed"})).is_err() { + break; + } + std::thread::sleep(REMOTE_ATTACH_IDLE_TIMEOUT / 4); + } + }); + let session = RemoteSession::connect_stream(Box::new(client)).unwrap(); + + let started = Instant::now(); + let error = session + .try_ensure_surface_with_kind(7, SurfaceKind::Pty, None) + .err() + .expect("missing attach response must time out"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Timeout) + )); + assert!( + started.elapsed() < REMOTE_ATTACH_IDLE_TIMEOUT * 3, + "unrelated traffic extended the attach idle deadline to {:?}", + started.elapsed() + ); + peer.join().unwrap(); + } + + #[test] + fn timed_out_attach_closes_transport_and_removes_local_mirror() { + let closed = Arc::new(AtomicBool::new(false)); + let session = test_session(Box::new(CloseTrackingWriter { closed: closed.clone() })); + + let error = session + .try_ensure_surface_with_kind(7, SurfaceKind::Pty, None) + .err() + .expect("silent attach must time out"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Timeout) + )); + assert!(!session.has_surface(7)); + assert!(session.pending.lock().unwrap().is_empty()); + assert!(session.shutdown.load(Ordering::Acquire)); + assert!(closed.load(Ordering::Acquire)); + } + #[cfg(unix)] #[test] fn eof_cancels_a_pending_request_without_waiting_for_the_request_timeout() { @@ -4607,6 +5837,9 @@ mod tests { term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4693,6 +5926,9 @@ mod tests { term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4752,6 +5988,9 @@ mod tests { term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4811,6 +6050,9 @@ mod tests { term: Mutex::new(Terminal::new(10, 5, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4877,6 +6119,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4922,6 +6167,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -4962,6 +6210,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -5057,6 +6308,205 @@ mod tests { } } + #[test] + fn remote_surface_resize_and_cell_pixel_update_are_one_geometry_transaction() { + let surface = test_remote_pty_surface(1, 80, 24, (8, 16)); + let (resize_entered_tx, resize_entered_rx) = channel(); + let (release_resize_tx, release_resize_rx) = channel(); + let (cell_started_tx, cell_started_rx) = channel(); + let release_resize_rx = Arc::new(Mutex::new(release_resize_rx)); + *surface.geometry_test_hook.lock().unwrap() = Some(Arc::new({ + move |step| match step { + RemoteGeometryTestStep::StreamResizeCommitBoundary => { + resize_entered_tx.send(()).unwrap(); + release_resize_rx.lock().unwrap().recv().unwrap(); + } + RemoteGeometryTestStep::CellPixelStarted => { + cell_started_tx.send(()).unwrap(); + } + _ => {} + } + })); + + let resizing_surface = surface.clone(); + let resizing = std::thread::spawn(move || { + resizing_surface.apply_stream_resize(100, 30, None, &[]).unwrap(); + }); + resize_entered_rx.recv().unwrap(); + + let updating_surface = surface.clone(); + let (cell_done_tx, cell_done_rx) = channel(); + let updating = std::thread::spawn(move || { + updating_surface.set_cell_pixel_size(9, 18).unwrap(); + cell_done_tx.send(()).unwrap(); + }); + cell_started_rx.recv().unwrap(); + let cell_completed_while_resize_was_uncommitted = + cell_done_rx.recv_timeout(Duration::from_millis(100)).is_ok(); + + release_resize_tx.send(()).unwrap(); + resizing.join().unwrap(); + updating.join().unwrap(); + + assert!( + !cell_completed_while_resize_was_uncommitted, + "cell pixels committed while the ordered stream resize was paused" + ); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (9, 18)); + let term = surface.term.lock().unwrap(); + assert_eq!((term.cols(), term.rows()), (100, 30)); + } + + #[test] + fn stream_resize_without_pixel_dimensions_preserves_last_cell_measurement() { + let surface = test_remote_pty_surface(1, 80, 24, (8, 16)); + surface.set_cell_pixel_size(11, 19).unwrap(); + + surface.apply_stream_resize(90, 31, None, &[]).unwrap(); + + assert_eq!(*surface.cell_pixels.lock().unwrap(), (11, 19)); + let term = surface.term.lock().unwrap(); + assert_eq!((term.cols(), term.rows()), (90, 31)); + } + + #[test] + fn rejected_cell_pixel_request_rolls_back_session_and_mirror_geometry() { + let session_slot: Arc>>> = Arc::new(Mutex::new(None)); + let session = test_session(Box::new(RejectingWriter { session: session_slot.clone() })); + *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let error = session.set_cell_pixel_size(9, 18).err().expect("injected rejection must fail"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Rejected { + error, + delivery: None, + .. + }) if error == "injected rejection" + )); + assert_eq!(*session.cell_pixels.lock().unwrap(), (8, 16)); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (8, 16)); + } + + #[test] + fn timed_out_cell_pixel_request_preserves_session_and_mirror_geometry() { + let session = test_session(Box::new(SilentWriter)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let error = session.set_cell_pixel_size(9, 18).err().expect("silent remote must time out"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Timeout) + )); + assert_eq!(*session.cell_pixels.lock().unwrap(), (9, 18)); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (9, 18)); + assert!(session.pending.lock().unwrap().is_empty()); + } + + #[test] + fn ambiguous_cell_pixel_timeout_does_not_overwrite_the_requested_geometry() { + let session = test_session(Box::new(SilentWriter)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let error = session.set_cell_pixel_size(9, 18).err().expect("silent remote must time out"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Timeout) + )); + assert_eq!( + *session.cell_pixels.lock().unwrap(), + (9, 18), + "an ambiguous timeout restored a stale global geometry mirror" + ); + assert_eq!( + *surface.cell_pixels.lock().unwrap(), + (9, 18), + "an ambiguous timeout overwrote geometry that the server may have committed" + ); + } + + #[test] + fn ambiguous_cell_pixel_timeout_reconciles_from_an_ordered_server_query() { + let session_slot: Arc>>> = Arc::new(Mutex::new(None)); + let session = + test_session(Box::new(AmbiguousCellPixelWriter { session: session_slot.clone() })); + *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let error = session.set_cell_pixel_size(9, 18).err().expect("first reply must be lost"); + + assert!(matches!( + error.downcast_ref::(), + Some(RemoteRequestError::Timeout) + )); + assert_eq!(*session.cell_pixels.lock().unwrap(), (10, 20)); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (11, 22)); + assert!(session.pending.lock().unwrap().is_empty()); + } + + #[test] + fn partial_cell_pixel_fanout_retries_before_publishing_the_remote_default() { + let session_slot: Arc>>> = Arc::new(Mutex::new(None)); + let session = test_session(Box::new(CellPixelFanoutWriter { + session: session_slot.clone(), + fail_next: true, + deferred_failure: false, + })); + *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let first = session.set_cell_pixel_size(9, 18).unwrap(); + assert_eq!(first.failures, vec![(7, "injected fan-out failure".to_string())]); + assert_eq!(*session.cell_pixels.lock().unwrap(), (8, 16)); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (8, 16)); + + let retried = session.set_cell_pixel_size(9, 18).unwrap(); + assert!(retried.failures.is_empty()); + assert_eq!(*session.cell_pixels.lock().unwrap(), (9, 18)); + assert_eq!(*surface.cell_pixels.lock().unwrap(), (9, 18)); + } + + #[test] + fn deferred_cell_pixel_failure_preserves_target_for_late_resize() { + let session_slot: Arc>>> = Arc::new(Mutex::new(None)); + let session = test_session(Box::new(CellPixelFanoutWriter { + session: session_slot.clone(), + fail_next: true, + deferred_failure: true, + })); + *session_slot.lock().unwrap() = Some(Arc::downgrade(&session)); + let surface = test_remote_pty_surface(7, 80, 24, (8, 16)); + session.surfaces.lock().unwrap().insert(surface.id, surface.clone()); + + let update = session.set_cell_pixel_size(9, 18).unwrap(); + assert_eq!(update.failures, vec![(7, "injected fan-out failure".to_string())]); + surface.apply_stream_resize(90, 31, None, &[]).unwrap(); + let created = session + .try_ensure_surface_with_kind(8, SurfaceKind::Pty, Some((80, 24))) + .unwrap() + .unwrap(); + + assert_eq!( + surface.cell_pixel_size(), + (9, 18), + "a late authoritative resize used the geometry from before the deferred request" + ); + assert_eq!( + created.cell_pixel_size(), + (9, 18), + "a newly discovered surface used the geometry from before the deferred request" + ); + } + #[test] fn resize_replay_replaces_mirror_with_server_truth_without_duplication() { let mut server = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); @@ -5067,7 +6517,7 @@ mod tests { let server_text = server.plain_text().unwrap(); let server_oldest = server.selection_text_absolute((0, 0), (4, 0)).unwrap(); assert_eq!(server_oldest, "srv00"); - let replay = server.vt_replay().unwrap(); + let replay = server.vt_replay_bytes().unwrap(); let surface = RemoteSurface { id: 1, @@ -5075,6 +6525,9 @@ mod tests { term: Mutex::new(Terminal::new(20, 6, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -5084,7 +6537,7 @@ mod tests { mirror.vt_write(b"mirror-only\r\nstate\r\n"); } - surface.apply_stream_resize(8, 4, Some(&replay)); + surface.apply_stream_resize(8, 4, Some(&replay), &[]).unwrap(); let scrollback_rows = { let mut mirror = surface.term.lock().unwrap(); assert_eq!(mirror.plain_text().unwrap(), server_text); @@ -5092,12 +6545,173 @@ mod tests { mirror.scrollback_rows() }; - surface.apply_stream_resize(8, 4, Some(&replay)); + surface.apply_stream_resize(8, 4, Some(&replay), &[]).unwrap(); let mut mirror = surface.term.lock().unwrap(); assert_eq!(mirror.plain_text().unwrap(), server_text); assert_eq!(mirror.scrollback_rows(), scrollback_rows); } + #[test] + fn failed_resize_alias_restore_keeps_the_previous_mirror() { + let surface = test_remote_pty_surface(7, 12, 4, (8, 16)); + surface.term.lock().unwrap().vt_write(b"previous"); + let previous = surface.term.lock().unwrap().plain_text().unwrap(); + let mut authoritative = Terminal::new(8, 4, 100, Callbacks::default()).unwrap(); + authoritative.vt_write(b"replacement"); + let replay = authoritative.vt_replay().unwrap(); + + surface + .apply_stream_resize_with_colors( + 8, + 4, + Some(&replay.bytes), + &[ghostty_vt::KittyImageAlias { image_id: 999, image_number: 77 }], + Some(replay.kitty_state), + None, + ) + .unwrap_err(); + + let mut mirror = surface.term.lock().unwrap(); + assert_eq!(mirror.cols(), 12); + assert_eq!(mirror.plain_text().unwrap(), previous); + } + + #[test] + fn malformed_resize_alias_sidecar_keeps_the_previous_mirror() { + let session = test_session(Box::new(SilentWriter)); + let surface = test_remote_pty_surface(7, 12, 4, (8, 16)); + surface.term.lock().unwrap().vt_write(b"previous"); + let previous = surface.term.lock().unwrap().plain_text().unwrap(); + session.surfaces.lock().unwrap().insert(7, surface.clone()); + let mut authoritative = Terminal::new(8, 4, 100, Callbacks::default()).unwrap(); + authoritative.vt_write(b"replacement"); + let replay = authoritative.vt_replay_bytes().unwrap(); + + session.handle_line(json!({ + "event": "resized", + "surface": 7, + "cols": 8, + "rows": 4, + "replay": base64::engine::general_purpose::STANDARD.encode(replay), + "kitty_image_aliases": [{"image_id": 7}], + })); + + let mut mirror = surface.term.lock().unwrap(); + assert_eq!(mirror.cols(), 12); + assert_eq!(mirror.plain_text().unwrap(), previous); + } + + #[test] + fn remote_kitty_alias_sidecar_rejects_more_than_the_terminal_host_limit() { + let aliases = (1..=cmux_tui_core::terminal_host_protocol::MAX_KITTY_IMAGE_ALIASES + 1) + .map(|image_id| json!({"image_id": image_id, "image_number": image_id})) + .collect::>(); + let value = json!({"kitty_image_aliases": aliases}); + + assert!(parse_kitty_image_aliases(&value).is_err()); + } + + #[test] + fn remote_kitty_alias_sidecar_rejects_zero_values() { + for alias in + [json!({"image_id": 0, "image_number": 1}), json!({"image_id": 1, "image_number": 0})] + { + let value = json!({"kitty_image_aliases": [alias]}); + assert!(parse_kitty_image_aliases(&value).is_err()); + } + } + + #[test] + fn remote_kitty_alias_sidecar_rejects_duplicate_image_ids() { + let value = json!({ + "kitty_image_aliases": [ + {"image_id": 7, "image_number": 41}, + {"image_id": 7, "image_number": 42}, + ], + }); + + assert!(parse_kitty_image_aliases(&value).is_err()); + } + + #[test] + fn remote_kitty_replay_state_is_bounded_and_strict() { + let limits = KittyGraphicsLimits::default(); + let valid = json!({ + "kitty_graphics_state": { + "image_bytes": limits.image_bytes, + "inflight_bytes": limits.inflight_bytes, + "images": limits.images, + "placements": limits.placements, + "replay_cursor_offset": 9, + "primary_replay_next_image_id": 41, + "primary_next_image_id": 42, + "alternate_replay_next_image_id": 43, + "alternate_next_image_id": 44, + } + }); + assert_eq!( + parse_kitty_replay_state(&valid).unwrap(), + KittyReplayState { + limits, + replay_cursor_offset: 9, + replay_next_image_ids: KittyImageIdCursors { primary: 41, alternate: 43 }, + next_image_ids: KittyImageIdCursors { primary: 42, alternate: 44 }, + } + ); + assert_eq!(parse_kitty_replay_state(&json!({})).unwrap(), KittyReplayState::disabled()); + + let mut oversized = valid.clone(); + oversized["kitty_graphics_state"]["image_bytes"] = json!(u64::MAX); + assert!(parse_kitty_replay_state(&oversized).is_err()); + let mut zero_cursor = valid.clone(); + zero_cursor["kitty_graphics_state"]["alternate_next_image_id"] = json!(0); + assert!(parse_kitty_replay_state(&zero_cursor).is_err()); + let mut extra = valid; + extra["kitty_graphics_state"]["future"] = json!(1); + assert!(parse_kitty_replay_state(&extra).is_err()); + } + + #[test] + fn resized_event_preserves_the_automatic_kitty_image_id_cursor() { + let session = test_session(Box::new(SilentWriter)); + let surface = test_remote_pty_surface(7, 12, 4, (8, 16)); + session.surfaces.lock().unwrap().insert(7, surface.clone()); + let mut authoritative = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + authoritative.vt_write(b"\x1b_Ga=t,t=d,f=24,I=1,s=1,v=1,q=2;/wAA\x1b\\"); + let first_id = authoritative.kitty_graphics_snapshot().unwrap().images[0].id; + authoritative.vt_write(format!("\x1b_Ga=d,d=I,i={first_id},q=2;\x1b\\").as_bytes()); + let replay = authoritative.vt_replay().unwrap(); + let state = replay.kitty_state; + + session.handle_line(json!({ + "event": "resized", + "surface": 7, + "cols": 12, + "rows": 4, + "replay": base64::engine::general_purpose::STANDARD.encode(&replay.bytes), + "kitty_image_aliases": [], + "kitty_graphics_state": { + "image_bytes": state.limits.image_bytes, + "inflight_bytes": state.limits.inflight_bytes, + "images": state.limits.images, + "placements": state.limits.placements, + "replay_cursor_offset": state.replay_cursor_offset, + "primary_replay_next_image_id": state.replay_next_image_ids.primary, + "primary_next_image_id": state.next_image_ids.primary, + "alternate_replay_next_image_id": state.replay_next_image_ids.alternate, + "alternate_next_image_id": state.next_image_ids.alternate, + }, + })); + + let next = b"\x1b_Ga=t,t=d,f=24,I=2,s=1,v=1,q=2;AP8A\x1b\\"; + authoritative.vt_write(next); + surface.term.lock().unwrap().vt_write(next); + assert_eq!( + surface.term.lock().unwrap().kitty_graphics_snapshot().unwrap().images[0].id, + authoritative.kitty_graphics_snapshot().unwrap().images[0].id + ); + } + #[cfg(unix)] #[test] fn resized_event_decodes_protocol_replay_field() { @@ -5109,6 +6723,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -5121,7 +6738,7 @@ mod tests { } authoritative.resize(8, 4, 8, 16).unwrap(); let expected = authoritative.plain_text().unwrap(); - let replay = authoritative.vt_replay().unwrap(); + let replay = authoritative.vt_replay_bytes().unwrap(); session.handle_line(json!({ "event": "resized", "surface": 7, @@ -5133,6 +6750,88 @@ mod tests { assert_eq!(surface.term.lock().unwrap().plain_text().unwrap(), expected); } + #[cfg(unix)] + #[test] + fn real_server_attach_and_resize_preserve_kitty_number_aliases() { + let mux = cmux_tui_core::Mux::new( + format!("remote-kitty-aliases-{}", std::process::id()), + cmux_tui_core::SurfaceOptions { + command: Some(vec!["/bin/cat".to_string()]), + ..Default::default() + }, + ); + let authoritative = mux.new_workspace(None, Some((20, 4))).unwrap(); + authoritative + .try_with_terminal(|terminal| { + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,I=77,s=1,v=1,q=2;/wAA\x1b\\"); + }) + .unwrap(); + let image_id = authoritative + .try_with_terminal(|terminal| terminal.kitty_graphics_snapshot().unwrap().images[0].id) + .unwrap(); + + let socket = cmux_tui_core::server::serve(mux.clone(), None).unwrap(); + let remote = RemoteSession::connect(&socket).unwrap(); + let mirror = remote + .try_ensure_surface_with_kind(authoritative.id, SurfaceKind::Pty, Some((20, 4))) + .unwrap() + .unwrap(); + + let wait_for = |mut predicate: Box bool>| { + let deadline = Instant::now() + Duration::from_secs(5); + while !predicate() { + assert!(Instant::now() < deadline, "remote mirror did not converge"); + std::thread::sleep(Duration::from_millis(10)); + } + }; + wait_for(Box::new({ + let mirror = mirror.clone(); + move || { + mirror + .term + .lock() + .unwrap() + .kitty_graphics_snapshot() + .unwrap() + .image(image_id) + .is_some_and(|image| image.number == 77) + } + })); + + mux.resize_surface(authoritative.id, 21, 4).unwrap(); + wait_for(Box::new({ + let mirror = mirror.clone(); + move || { + let terminal = mirror.term.lock().unwrap(); + terminal.cols() == 21 + && terminal + .kitty_graphics_snapshot() + .unwrap() + .image(image_id) + .is_some_and(|image| image.number == 77) + } + })); + + authoritative.write_bytes(b"\x1b_Ga=p,I=77,p=12,c=1,r=1,q=2;\x1b\\\n").unwrap(); + wait_for(Box::new({ + move || { + mirror + .term + .lock() + .unwrap() + .kitty_graphics_snapshot() + .unwrap() + .placements + .iter() + .any(|placement| placement.image_id == image_id && placement.placement_id == 12) + } + })); + + remote.begin_shutdown(); + let _ = mux.close_surface(authoritative.id); + cmux_tui_core::server::cleanup(&socket); + } + #[cfg(unix)] #[test] fn surface_resized_event_is_forwarded_without_changing_reported_size() { @@ -5145,6 +6844,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(Some((12, 4))), browser: Mutex::new(RemoteBrowserState::default()), @@ -5177,6 +6879,9 @@ mod tests { term: Mutex::new(Terminal::new(12, 4, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(Some((90, 31))), browser: Mutex::new(RemoteBrowserState::default()), @@ -5205,6 +6910,31 @@ mod tests { ))); } + #[cfg(unix)] + #[test] + fn graphics_status_event_preserves_localization_fields() { + let (client, _server) = UnixStream::pair().unwrap(); + let session = socket_test_session(client); + let events = session.subscribe(); + + session.handle_line(json!({ + "event": "graphics-status", + "kind": "kitty-image-budget-update-failed", + "retry_exhausted": true, + "summary": "surface 7: offline", + })); + + assert!(events.try_iter().any(|event| matches!( + event, + MuxEvent::GraphicsStatus( + GraphicsStatus::KittyImageBudgetUpdateFailed { + retry_exhausted: true, + summary, + } + ) if summary.as_ref() == "surface 7: offline" + ))); + } + #[cfg(unix)] #[test] fn notification_event_preserves_payload_without_invalidating_tree() { @@ -5371,6 +7101,9 @@ mod tests { term: Mutex::new(Terminal::new(80, 24, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), @@ -5395,10 +7128,10 @@ mod tests { fn ordered_resize_replay_recovers_from_stale_initial_replay() { let mut server = Terminal::new(12, 3, 100, Callbacks::default()).unwrap(); server.vt_write(b"\x1b[7m%\x1b[0m"); - let stale_replay = server.vt_replay().unwrap(); + let stale_replay = server.vt_replay_bytes().unwrap(); server.resize(10, 3, 8, 16).unwrap(); - let resize_replay = server.vt_replay().unwrap(); + let resize_replay = server.vt_replay_bytes().unwrap(); let prompt = b"\r\x1b[Klawrence"; server.vt_write(prompt); let server_text = server.plain_text().unwrap(); @@ -5410,13 +7143,16 @@ mod tests { term: Mutex::new(Terminal::new(12, 3, 100, Callbacks::default()).unwrap()), mouse_encoders: Mutex::new(MouseEncoders::new().unwrap()), dirty: AtomicBool::new(false), + geometry_lifecycle: Mutex::new(()), + cell_pixels: Mutex::new((8, 16)), + geometry_test_hook: Mutex::new(None), content_generation: AtomicU64::new(1), reported_size: Mutex::new(None), browser: Mutex::new(RemoteBrowserState::default()), }; - surface.apply_stream_resize(12, 3, None); + surface.apply_stream_resize(12, 3, None, &[]).unwrap(); surface.term.lock().unwrap().vt_write(&stale_replay); - surface.apply_stream_resize(10, 3, Some(&resize_replay)); + surface.apply_stream_resize(10, 3, Some(&resize_replay), &[]).unwrap(); let mut mirror = surface.term.lock().unwrap(); mirror.vt_write(prompt); diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs index fdc37a5877..004ed33100 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs @@ -1,124 +1,897 @@ +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::sync::Arc; +#[cfg(test)] +use std::sync::{Mutex, OnceLock, mpsc::Sender}; use std::time::Duration; #[cfg(unix)] use std::time::Instant; +use base64::Engine as _; use cmux_tui_core::{BrowserFrame, Rect, SurfaceId}; +use ghostty_vt::{KittyImage, KittyImageFormat, KittyPlacement}; const ESC: &str = "\x1b"; const CHUNK: usize = 4096; -const PLACEMENT_ID: u32 = 1; pub(crate) const PROCESSING_FENCE_ID_BASE: u32 = 2_000_000_001; const PROCESSING_FENCE_ID_COUNT: u64 = 2_000_000_000; -#[derive(Debug, Clone)] -pub struct GraphicPlacement { - pub surface: SurfaceId, - pub rect: Rect, - pub pointer_frame_seq: Option, - pub source_crop_px: Option<(u32, u32)>, - pub frame: Arc, +#[cfg(test)] +static IMAGE_TRANSMISSION_OBSERVER: OnceLock>>> = + OnceLock::new(); + +#[cfg(test)] +pub(super) fn observe_image_transmissions(observer: Sender) { + *IMAGE_TRANSMISSION_OBSERVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(observer); } -#[derive(Clone, Default)] -pub struct GraphicsState { - session_generation: Option, - transmitted: HashMap, - visible: HashSet, +#[cfg(test)] +pub(super) fn clear_image_transmission_observer() { + *IMAGE_TRANSMISSION_OBSERVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = None; } -impl GraphicsState { - pub fn frame_batches( - &mut self, - session_generation: u64, - placements: &[GraphicPlacement], - ) -> Vec> { - if self.session_generation != Some(session_generation) { - self.session_generation = Some(session_generation); - self.transmitted.clear(); - } - let visible_placements = placements - .iter() - .filter(|placement| placement.rect.width > 0 && placement.rect.height > 0) - .collect::>(); - let now_visible = visible_placements.iter().map(|p| p.surface).collect::>(); - let mut out = Vec::new(); - - for old in self.visible.difference(&now_visible) { - out.push(delete_image(*old)); - self.transmitted.remove(old); - } - - for placement in visible_placements { - let mut batch = Vec::new(); - let already_sent = self - .transmitted - .get(&placement.surface) - .is_some_and(|seq| *seq == placement.frame.seq); - if !already_sent { - batch.extend(transmit_png(placement.surface, &placement.frame.data_b64)); - self.transmitted.insert(placement.surface, placement.frame.seq); - } - batch.extend(place_image_cropped( - placement.surface, - placement.rect, - placement.source_crop_px, - )); - if !batch.is_empty() { - out.push(batch); - } - } - - self.visible = now_visible; - out +#[cfg(test)] +fn record_image_transmission(key: GraphicImageKey) { + if let Some(observer) = + IMAGE_TRANSMISSION_OBSERVER.get_or_init(|| Mutex::new(None)).lock().unwrap().as_ref() + { + let _ = observer.send(key); } } -pub fn image_id(surface: SurfaceId) -> u32 { - ((surface % 2_000_000_000) + 1) as u32 +#[cfg(not(test))] +fn record_image_transmission(_key: GraphicImageKey) {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GraphicImageKey { + pub namespace: u64, + pub surface: SurfaceId, + pub image_id: u32, } -pub fn transmit_png(surface: SurfaceId, data_b64: &str) -> Vec { - let id = image_id(surface); +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GraphicPlacementKey { + pub image: GraphicImageKey, + pub placement_id: u32, + pub ordinal: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicFormat { + Png, + Rgb, + Rgba, +} + +impl GraphicFormat { + fn kitty_value(self) -> u8 { + match self { + Self::Png => 100, + Self::Rgb => 24, + Self::Rgba => 32, + } + } +} + +#[derive(Debug, Clone)] +pub enum GraphicData { + #[cfg(test)] + Base64(Arc), + BrowserFrame(Arc), + Bytes(Arc<[u8]>), +} + +impl GraphicData { + fn base64(&self) -> Cow<'_, str> { + match self { + #[cfg(test)] + Self::Base64(encoded) => Cow::Borrowed(encoded), + Self::BrowserFrame(frame) => Cow::Borrowed(&frame.data_b64), + Self::Bytes(bytes) => { + Cow::Owned(base64::engine::general_purpose::STANDARD.encode(bytes)) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct GraphicImage { + pub key: GraphicImageKey, + pub generation: u64, + pub width: u32, + pub height: u32, + pub format: GraphicFormat, + pub data: GraphicData, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GraphicSourceRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone)] +pub struct GraphicPlacement { + pub key: GraphicPlacementKey, + pub image: Arc, + pub rect: Rect, + /// Browser input authority paired with these pixels. Terminal images + /// leave this unset. + pub pointer_frame_seq: Option, + pub columns: Option, + pub rows: Option, + pub source: Option, + pub x_offset: u32, + pub y_offset: u32, + pub z: i32, +} + +impl GraphicPlacement { + pub fn is_browser_frame(&self) -> bool { + match &self.image.data { + GraphicData::BrowserFrame(_) => true, + #[cfg(test)] + GraphicData::Base64(_) => self.image.key.image_id == 0, + GraphicData::Bytes(_) => false, + } + } + + pub fn browser_frame( + namespace: u64, + surface: SurfaceId, + rect: Rect, + frame: Arc, + pointer_frame_seq: Option, + source: Option, + ) -> Self { + let image_key = GraphicImageKey { namespace, surface, image_id: 0 }; + Self { + key: GraphicPlacementKey { image: image_key, placement_id: 0, ordinal: 0 }, + image: Arc::new(GraphicImage { + key: image_key, + generation: frame.seq, + width: frame.image_width, + height: frame.image_height, + format: GraphicFormat::Png, + data: GraphicData::BrowserFrame(frame), + }), + rect, + pointer_frame_seq, + columns: Some(u32::from(rect.width)), + rows: Some(u32::from(rect.height)), + source, + x_offset: 0, + y_offset: 0, + z: 0, + } + } + + #[cfg(test)] + pub fn browser( + namespace: u64, + surface: SurfaceId, + rect: Rect, + generation: u64, + width: u32, + height: u32, + data_b64: String, + ) -> Self { + let image_key = GraphicImageKey { namespace, surface, image_id: 0 }; + Self { + key: GraphicPlacementKey { image: image_key, placement_id: 0, ordinal: 0 }, + image: Arc::new(GraphicImage { + key: image_key, + generation, + width, + height, + format: GraphicFormat::Png, + data: GraphicData::Base64(Arc::from(data_b64)), + }), + rect, + pointer_frame_seq: Some(generation), + columns: Some(u32::from(rect.width)), + rows: Some(u32::from(rect.height)), + source: None, + x_offset: 0, + y_offset: 0, + z: 0, + } + } +} + +pub fn kitty_graphic_image( + namespace: u64, + surface: SurfaceId, + image: &KittyImage, +) -> Arc { + Arc::new(GraphicImage { + key: GraphicImageKey { namespace, surface, image_id: image.id }, + generation: image.generation, + width: image.width, + height: image.height, + format: match image.format { + KittyImageFormat::Rgb => GraphicFormat::Rgb, + KittyImageFormat::Rgba => GraphicFormat::Rgba, + }, + data: GraphicData::Bytes(image.data.clone()), + }) +} + +/// Resolve a libghostty viewport placement into the outer terminal grid. +/// +/// Negative origins and right/bottom overflow are cropped proportionally +/// in source-pixel space so images never bleed outside their pane. +pub fn kitty_graphic_placement( + pane: Rect, + viewport_col_offset: u16, + cell_pixels: (u16, u16), + image: Arc, + placement: &KittyPlacement, +) -> Option { + if !placement.viewport_visible + || pane.width == 0 + || pane.height == 0 + || placement.pixel_width == 0 + || placement.pixel_height == 0 + || placement.source_width == 0 + || placement.source_height == 0 + { + return None; + } + + let cell_width = u32::from(cell_pixels.0.max(1)); + let cell_height = u32::from(cell_pixels.1.max(1)); + let cell_width_i64 = i64::from(cell_width); + let cell_height_i64 = i64::from(cell_height); + let pane_width = i64::from(pane.width) * cell_width_i64; + let pane_height = i64::from(pane.height) * cell_height_i64; + let image_left = (i64::from(placement.viewport_col) - i64::from(viewport_col_offset)) + * cell_width_i64 + + i64::from(placement.x_offset); + let image_top = + i64::from(placement.viewport_row) * cell_height_i64 + i64::from(placement.y_offset); + let image_right = image_left.saturating_add(i64::from(placement.pixel_width)); + let image_bottom = image_top.saturating_add(i64::from(placement.pixel_height)); + let visible_left = image_left.max(0); + let visible_top = image_top.max(0); + let mut visible_width = image_right.min(pane_width).saturating_sub(visible_left); + let mut visible_height = image_bottom.min(pane_height).saturating_sub(visible_top); + if visible_width <= 0 || visible_height <= 0 { + return None; + } + + // Explicit Kitty axes can only occupy whole cells. Keep the inferred axes + // omitted and conservatively discard only an unrepresentable trailing + // partial cell on explicit axes. + if placement.columns > 0 { + visible_width -= visible_width % cell_width_i64; + } + if placement.rows > 0 { + visible_height -= visible_height % cell_height_i64; + } + if visible_width <= 0 || visible_height <= 0 { + return None; + } + + let source_left = proportional_boundary( + placement.source_width, + u32::try_from(visible_left.saturating_sub(image_left)).ok()?, + placement.pixel_width, + ); + let source_right = proportional_boundary( + placement.source_width, + u32::try_from(visible_left.saturating_add(visible_width).saturating_sub(image_left)) + .ok()?, + placement.pixel_width, + ); + let source_top = proportional_boundary( + placement.source_height, + u32::try_from(visible_top.saturating_sub(image_top)).ok()?, + placement.pixel_height, + ); + let source_bottom = proportional_boundary( + placement.source_height, + u32::try_from(visible_top.saturating_add(visible_height).saturating_sub(image_top)).ok()?, + placement.pixel_height, + ); + let mut source = GraphicSourceRect { + x: placement.source_x.saturating_add(source_left), + y: placement.source_y.saturating_add(source_top), + width: source_right.saturating_sub(source_left), + height: source_bottom.saturating_sub(source_top), + }; + if source.width == 0 || source.height == 0 { + return None; + } + + let columns = if placement.columns > 0 { + Some(u32::try_from(visible_width).ok()?.checked_div(cell_width)?) + } else { + None + }; + let rows = if placement.rows > 0 { + Some(u32::try_from(visible_height).ok()?.checked_div(cell_height)?) + } else { + None + }; + if placement.columns > 0 && columns == Some(0) || placement.rows > 0 && rows == Some(0) { + return None; + } + + // Source-boundary rounding can make an inferred axis one pixel too large. + // Trim only the trailing source edge until the actual Kitty result fits. + if columns.is_some() && rows.is_none() { + source.height = fit_inferred_source_dimension( + columns?.saturating_mul(cell_width), + source.width, + source.height, + u32::try_from(visible_height).ok()?, + ); + } else if columns.is_none() && rows.is_some() { + source.width = fit_inferred_source_dimension( + rows?.saturating_mul(cell_height), + source.height, + source.width, + u32::try_from(visible_width).ok()?, + ); + } else if columns.is_none() && rows.is_none() { + source.width = source.width.min(u32::try_from(visible_width).ok()?); + source.height = source.height.min(u32::try_from(visible_height).ok()?); + } + if source.width == 0 || source.height == 0 { + return None; + } + + let (rendered_width, rendered_height) = + rendered_pixel_size(source, columns, rows, cell_width, cell_height)?; + let output_right = visible_left.saturating_add(i64::from(rendered_width)); + let output_bottom = visible_top.saturating_add(i64::from(rendered_height)); + if output_right > pane_width || output_bottom > pane_height { + return None; + } + let cursor_col = u32::try_from(visible_left).ok()?.checked_div(cell_width)?; + let cursor_row = u32::try_from(visible_top).ok()?.checked_div(cell_height)?; + let x_offset = u32::try_from(visible_left).ok()? % cell_width; + let y_offset = u32::try_from(visible_top).ok()? % cell_height; + let grid_cols = x_offset.saturating_add(rendered_width).div_ceil(cell_width); + let grid_rows = y_offset.saturating_add(rendered_height).div_ceil(cell_height); + + Some(GraphicPlacement { + key: GraphicPlacementKey { + image: image.key, + placement_id: placement.placement_id, + ordinal: placement.key.ordinal, + }, + image, + rect: Rect { + x: pane.x.saturating_add(u16::try_from(cursor_col).ok()?), + y: pane.y.saturating_add(u16::try_from(cursor_row).ok()?), + width: u16::try_from(grid_cols).ok()?, + height: u16::try_from(grid_rows).ok()?, + }, + pointer_frame_seq: None, + columns, + rows, + source: Some(source), + x_offset, + y_offset, + z: placement.z, + }) +} + +fn proportional_boundary(source_pixels: u32, output_pixels: u32, rendered_pixels: u32) -> u32 { + if rendered_pixels == 0 { + return 0; + } + u32::try_from( + u128::from(source_pixels) * u128::from(output_pixels) / u128::from(rendered_pixels), + ) + .unwrap_or(source_pixels) + .min(source_pixels) +} + +fn rounded_ratio(value: u32, numerator: u32, denominator: u32) -> Option { + if denominator == 0 { + return None; + } + u32::try_from( + (u128::from(value) * u128::from(numerator) + u128::from(denominator) / 2) + / u128::from(denominator), + ) + .ok() +} + +fn rendered_pixel_size( + source: GraphicSourceRect, + columns: Option, + rows: Option, + cell_width: u32, + cell_height: u32, +) -> Option<(u32, u32)> { + match (columns, rows) { + (None, None) => Some((source.width, source.height)), + (Some(columns), None) => { + let width = columns.checked_mul(cell_width)?; + Some((width, rounded_ratio(width, source.height, source.width)?)) + } + (None, Some(rows)) => { + let height = rows.checked_mul(cell_height)?; + Some((rounded_ratio(height, source.width, source.height)?, height)) + } + (Some(columns), Some(rows)) => { + Some((columns.checked_mul(cell_width)?, rows.checked_mul(cell_height)?)) + } + } +} + +fn fit_inferred_source_dimension( + explicit_pixels: u32, + fixed_source: u32, + inferred_source: u32, + maximum_pixels: u32, +) -> u32 { + let mut low = 0; + let mut high = inferred_source; + while low < high { + let candidate = low + (high - low).div_ceil(2); + if rounded_ratio(explicit_pixels, candidate, fixed_source) + .is_some_and(|pixels| pixels <= maximum_pixels) + { + low = candidate; + } else { + high = candidate - 1; + } + } + low +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PlacementFingerprint { + rect: Rect, + columns: Option, + rows: Option, + source: Option, + x_offset: u32, + y_offset: u32, + z: i32, +} + +impl From<&GraphicPlacement> for PlacementFingerprint { + fn from(placement: &GraphicPlacement) -> Self { + Self { + rect: placement.rect, + columns: placement.columns, + rows: placement.rows, + source: placement.source, + x_offset: placement.x_offset, + y_offset: placement.y_offset, + z: placement.z, + } + } +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct GraphicsOperationCounts { + image_id_allocation_checks: usize, + placement_id_allocation_checks: usize, + stale_image_retain_passes: usize, + stale_image_retain_visits: usize, +} + +#[derive(Clone)] +pub struct GraphicsState { + next_placement_id: u32, + used_image_ids: HashSet, + used_placement_ids: HashSet, + image_ids: HashMap, + placement_ids: HashMap, + placement_fingerprints: HashMap, + transmitted: HashMap, + visible: HashSet, + #[cfg(test)] + operation_counts: GraphicsOperationCounts, +} + +pub(crate) struct GraphicsFrameBatches<'state, 'placement> { + state: &'state mut GraphicsState, + placements: std::vec::IntoIter<&'placement GraphicPlacement>, + prefix_batches: std::vec::IntoIter>, + now_visible: Option>, + retransmitted_images: HashSet, +} + +impl Iterator for GraphicsFrameBatches<'_, '_> { + type Item = Vec; + + fn next(&mut self) -> Option { + if let Some(batch) = self.prefix_batches.next() { + return Some(batch); + } + loop { + let Some(placement) = self.placements.next() else { + if let Some(now_visible) = self.now_visible.take() { + self.state.visible = now_visible; + } + return None; + }; + let fingerprint = PlacementFingerprint::from(placement); + let previous = self.state.placement_fingerprints.get(&placement.key).copied(); + let image_id = self.state.image_id(placement.image.key); + let placement_id = self.state.placement_id(placement.key); + let mut batch = Vec::new(); + match self.state.transmitted.get(&placement.image.key).copied() { + Some(generation) if generation == placement.image.generation => {} + Some(_) => { + batch.extend(delete_image(image_id)); + batch.extend(transmit_image(image_id, &placement.image)); + self.state.transmitted.insert(placement.image.key, placement.image.generation); + self.retransmitted_images.insert(placement.image.key); + } + None => { + batch.extend(transmit_image(image_id, &placement.image)); + self.state.transmitted.insert(placement.image.key, placement.image.generation); + self.retransmitted_images.insert(placement.image.key); + } + } + let image_was_retransmitted = self.retransmitted_images.contains(&placement.image.key); + let geometry_changed = previous.is_some_and(|previous| previous != fingerprint); + if geometry_changed && !image_was_retransmitted { + batch.extend(delete_placement(image_id, placement_id)); + } + if previous.is_none() || geometry_changed || image_was_retransmitted { + batch.extend(place_image(image_id, placement_id, placement)); + self.state.placement_fingerprints.insert(placement.key, fingerprint); + } + if !batch.is_empty() { + return Some(batch); + } + } + } +} + +impl Default for GraphicsState { + fn default() -> Self { + Self { + next_placement_id: 1, + used_image_ids: HashSet::new(), + used_placement_ids: HashSet::new(), + image_ids: HashMap::new(), + placement_ids: HashMap::new(), + placement_fingerprints: HashMap::new(), + transmitted: HashMap::new(), + visible: HashSet::new(), + #[cfg(test)] + operation_counts: GraphicsOperationCounts::default(), + } + } +} + +impl GraphicsState { + /// Forget all host-side Kitty state after the outer terminal clears it. + /// + /// The next frame must retransmit both pixels and placements even when + /// its logical scene is identical to the previous frame. + pub fn invalidate_host_scene(&mut self) { + *self = Self::default(); + } + + pub fn frame_batches(&mut self, placements: &[GraphicPlacement]) -> Vec> { + self.frame_batch_stream(placements).collect() + } + + /// Plan metadata eagerly, then encode at most one bounded image batch + /// before yielding so the writer can observe cancellation or supersession. + pub(crate) fn frame_batch_stream<'state, 'placement>( + &'state mut self, + placements: &'placement [GraphicPlacement], + ) -> GraphicsFrameBatches<'state, 'placement> { + let mut placements = placements + .iter() + .filter(|placement| placement.rect.width > 0 && placement.rect.height > 0) + .collect::>(); + // Ghostty resolves equal-z Kitty placements by image ID. Allocate the + // outer IDs in that order so forwarding does not change compositing. + placements.sort_by_key(|placement| { + ( + placement.z, + placement.key.image.image_id, + placement.key, + placement.rect.y, + placement.rect.x, + ) + }); + let now_visible = placements.iter().map(|placement| placement.key).collect::>(); + let now_images = + placements.iter().map(|placement| placement.image.key).collect::>(); + let mut batches = Vec::new(); + + let mut stale_placements = + self.visible.difference(&now_visible).copied().collect::>(); + stale_placements.sort_unstable(); + for key in stale_placements { + if let (Some(&image_id), Some(&placement_id)) = + (self.image_ids.get(&key.image), self.placement_ids.get(&key)) + { + batches.push(delete_placement(image_id, placement_id)); + } + if let Some(placement_id) = self.placement_ids.remove(&key) { + let removed = self.used_placement_ids.remove(&placement_id); + debug_assert!(removed, "placement ID set must track every placement mapping"); + } + self.placement_fingerprints.remove(&key); + } + + let mut stale_images = self + .transmitted + .keys() + .filter(|key| !now_images.contains(key)) + .copied() + .collect::>(); + stale_images.sort_unstable(); + let had_stale_images = !stale_images.is_empty(); + for key in stale_images { + if let Some(image_id) = self.image_ids.remove(&key) { + let removed = self.used_image_ids.remove(&image_id); + debug_assert!(removed, "image ID set must track every image mapping"); + batches.push(delete_image(image_id)); + } + self.transmitted.remove(&key); + } + if had_stale_images { + #[cfg(test)] + { + self.operation_counts.stale_image_retain_passes += 1; + self.operation_counts.stale_image_retain_visits += self.placement_ids.len(); + } + let used_placement_ids = &mut self.used_placement_ids; + let placement_fingerprints = &mut self.placement_fingerprints; + self.placement_ids.retain(|placement, placement_id| { + let keep = now_images.contains(&placement.image); + if !keep { + let removed = used_placement_ids.remove(placement_id); + debug_assert!(removed, "placement ID set must track every placement mapping"); + placement_fingerprints.remove(placement); + } + keep + }); + } + + let mut ordered_images = now_images.iter().copied().collect::>(); + ordered_images.sort_unstable_by_key(|key| (key.image_id, *key)); + self.prepare_image_ids(&ordered_images, &mut batches); + + GraphicsFrameBatches { + state: self, + placements: placements.into_iter(), + prefix_batches: batches.into_iter(), + now_visible: Some(now_visible), + retransmitted_images: HashSet::new(), + } + } + + fn prepare_image_ids( + &mut self, + ordered_images: &[GraphicImageKey], + batches: &mut Vec>, + ) { + if self.image_ids.is_empty() && !ordered_images.is_empty() { + let denominator = ordered_images.len() as u64 + 1; + for (index, key) in ordered_images.iter().enumerate() { + let image_id = (u64::from(u32::MAX) * (index as u64 + 1) / denominator) as u32; + let inserted = self.used_image_ids.insert(image_id); + debug_assert!(inserted, "even image-ID allocation must be unique"); + self.image_ids.insert(*key, image_id); + #[cfg(test)] + { + self.operation_counts.image_id_allocation_checks += 1; + } + } + return; + } + + for (index, key) in ordered_images.iter().copied().enumerate() { + if self.image_ids.contains_key(&key) { + continue; + } + if let Some(image_id) = self.image_id_between_neighbors(ordered_images, index) { + let inserted = self.used_image_ids.insert(image_id); + debug_assert!(inserted, "ordered image-ID allocation must be unique"); + self.image_ids.insert(key, image_id); + } else { + self.relabel_image_window(ordered_images, index, batches); + } + #[cfg(test)] + { + self.operation_counts.image_id_allocation_checks += 1; + } + } + } + + fn image_id_between_neighbors( + &self, + ordered_images: &[GraphicImageKey], + index: usize, + ) -> Option { + let previous = + ordered_images[..index].iter().rev().find_map(|key| self.image_ids.get(key).copied()); + let next = + ordered_images[index + 1..].iter().find_map(|key| self.image_ids.get(key).copied()); + match (previous, next) { + (None, None) => Some(u32::MAX / 2), + (None, Some(next)) => next.checked_sub(1).filter(|id| *id != 0), + (Some(previous), None) => previous.checked_add(1), + (Some(previous), Some(next)) + if previous.checked_add(1).is_some_and(|adjacent| adjacent < next) => + { + Some(previous + (next - previous) / 2) + } + _ => None, + } + } + + fn relabel_image_window( + &mut self, + ordered_images: &[GraphicImageKey], + insertion_index: usize, + batches: &mut Vec>, + ) { + let inserted_key = ordered_images[insertion_index]; + let assigned = ordered_images + .iter() + .copied() + .filter(|key| *key == inserted_key || self.image_ids.contains_key(key)) + .collect::>(); + let insertion_index = assigned + .iter() + .position(|key| *key == inserted_key) + .expect("inserted image is in relabel set"); + let mut radius = 8_usize; + let (window_start, window_end, lower, upper) = loop { + let start = insertion_index.saturating_sub(radius); + let end = (insertion_index + radius + 1).min(assigned.len()); + let lower = if start == 0 { 0 } else { self.image_ids[&assigned[start - 1]] }; + let upper = + if end == assigned.len() { u32::MAX } else { self.image_ids[&assigned[end]] }; + let count = end - start; + let available = u64::from(upper) - u64::from(lower) - 1; + if available >= (count as u64 + 1) * 4 || start == 0 && end == assigned.len() { + break (start, end, lower, upper); + } + radius = radius.saturating_mul(2); + }; + + let window = &assigned[window_start..window_end]; + let available = u64::from(upper) - u64::from(lower) - 1; + let step = available / (window.len() as u64 + 1); + debug_assert!(step > 0, "u32 image-ID space must fit the bounded active image set"); + let mut remapped = Vec::new(); + for key in window { + if let Some(old_id) = self.image_ids.remove(key) { + let removed = self.used_image_ids.remove(&old_id); + debug_assert!(removed, "image ID set must track every mapping"); + remapped.push((*key, old_id, self.transmitted.remove(key).is_some())); + } + } + let remapped_keys = remapped.iter().map(|(key, _, _)| *key).collect::>(); + self.placement_fingerprints + .retain(|placement, _| !remapped_keys.contains(&placement.image)); + let mut deleted = remapped + .iter() + .filter_map(|(_, old_id, transmitted)| transmitted.then_some(*old_id)) + .collect::>(); + deleted.sort_unstable(); + for old_id in deleted { + batches.push(delete_image(old_id)); + } + for (offset, key) in window.iter().enumerate() { + let image_id = u32::try_from(u64::from(lower) + step * (offset as u64 + 1)) + .expect("bounded image-ID relabel must fit u32"); + let inserted = self.used_image_ids.insert(image_id); + debug_assert!(inserted, "relabelled image IDs must be unique"); + self.image_ids.insert(*key, image_id); + } + } + + fn image_id(&self, key: GraphicImageKey) -> u32 { + self.image_ids[&key] + } + + fn placement_id(&mut self, key: GraphicPlacementKey) -> u32 { + if let Some(id) = self.placement_ids.get(&key) { + return *id; + } + let (id, _allocation_checks) = + allocate_id(&mut self.next_placement_id, &mut self.used_placement_ids); + #[cfg(test)] + { + self.operation_counts.placement_id_allocation_checks += _allocation_checks; + } + self.placement_ids.insert(key, id); + id + } + + #[cfg(test)] + fn reset_operation_counts(&mut self) { + self.operation_counts = GraphicsOperationCounts::default(); + } +} + +fn allocate_id(next: &mut u32, used: &mut HashSet) -> (u32, usize) { + let mut checks = 0; + loop { + // Monotonic allocation preserves stable IDs while the maintained set + // makes wraparound collision checks constant-time. + let candidate = (*next).max(1); + *next = candidate.wrapping_add(1).max(1); + checks += 1; + if used.insert(candidate) { + return (candidate, checks); + } + } +} + +fn transmit_image(image_id: u32, image: &GraphicImage) -> Vec { + record_image_transmission(image.key); + let data = image.data.base64(); let mut out = Vec::new(); - let chunks = data_b64.as_bytes().chunks(CHUNK).collect::>(); - for (idx, chunk) in chunks.iter().enumerate() { - let more = usize::from(idx + 1 < chunks.len()); - let header = if idx == 0 { - format!("{ESC}_Ga=t,f=100,i={id},q=2,m={more};") + for (index, chunk) in data.as_bytes().chunks(CHUNK).enumerate() { + let more = usize::from((index + 1) * CHUNK < data.len()); + let header = if index == 0 { + match image.format { + GraphicFormat::Png => format!( + "{ESC}_Ga=t,t=d,f={},i={image_id},q=2,m={more};", + image.format.kitty_value() + ), + GraphicFormat::Rgb | GraphicFormat::Rgba => format!( + "{ESC}_Ga=t,t=d,f={},i={image_id},s={},v={},q=2,m={more};", + image.format.kitty_value(), + image.width, + image.height + ), + } } else { format!("{ESC}_Gq=2,m={more};") }; out.extend_from_slice(header.as_bytes()); out.extend_from_slice(chunk); - out.extend_from_slice(format!("{ESC}\\").as_bytes()); + out.extend_from_slice(b"\x1b\\"); } out } -pub fn place_image_cropped( - surface: SurfaceId, - rect: Rect, - source_crop_px: Option<(u32, u32)>, -) -> Vec { - let id = image_id(surface); - let crop = - source_crop_px.map_or_else(String::new, |(x, width)| format!(",x={x},w={}", width.max(1))); - format!( - "{ESC}7{ESC}[{};{}H{ESC}_Ga=p,i={id},p={PLACEMENT_ID}{crop},c={},r={},q=2;{ESC}\\{ESC}8", - rect.y + 1, - rect.x + 1, - rect.width.max(1), - rect.height.max(1) - ) - .into_bytes() +fn place_image(image_id: u32, placement_id: u32, placement: &GraphicPlacement) -> Vec { + let mut command = format!( + "{ESC}7{ESC}[{};{}H{ESC}_Ga=p,i={image_id},p={placement_id}", + placement.rect.y.saturating_add(1), + placement.rect.x.saturating_add(1) + ); + if let Some(source) = placement.source { + command.push_str(&format!( + ",x={},y={},w={},h={}", + source.x, source.y, source.width, source.height + )); + } + command.push_str(&format!(",X={},Y={}", placement.x_offset, placement.y_offset)); + if let Some(columns) = placement.columns { + command.push_str(&format!(",c={columns}")); + } + if let Some(rows) = placement.rows { + command.push_str(&format!(",r={rows}")); + } + command.push_str(&format!(",z={},C=1,q=2;{ESC}\\{ESC}8", placement.z)); + command.into_bytes() } -pub fn delete_image(surface: SurfaceId) -> Vec { - let id = image_id(surface); - format!("{ESC}_Ga=d,d=i,i={id},q=2;{ESC}\\").into_bytes() +fn delete_placement(image_id: u32, placement_id: u32) -> Vec { + format!("{ESC}_Ga=d,d=i,i={image_id},p={placement_id},q=2;{ESC}\\").into_bytes() +} + +fn delete_image(image_id: u32) -> Vec { + format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ESC}\\").into_bytes() } pub(crate) fn processing_fence_id(submission: u64) -> u32 { @@ -132,23 +905,93 @@ pub(crate) fn processing_fence(id: u32) -> Vec { format!("{ESC}_Gi={id},s=1,v=1,a=q,t=d,f=24;AAAA{ESC}\\").into_bytes() } -pub fn probe_kitty_graphics() -> bool { - let mut stdout = std::io::stdout(); - let _ = write!(stdout, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); - let _ = stdout.flush(); - let bytes = read_stdin_for(Duration::from_millis(180)); - kitty_probe_succeeded(&bytes) +const FALLBACK_CELL_PIXELS: (u16, u16) = (8, 16); +const TERMINAL_PROBE_TIMEOUT: Duration = Duration::from_millis(180); +const STARTUP_INPUT_MAX_INCOMPLETE_BYTES: usize = 4 * 1024; +#[cfg(unix)] +const STARTUP_INPUT_CONTINUATION_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Debug, Default)] +pub struct StartupTerminalInput { + events: Vec, + incomplete: Vec, } -const FALLBACK_CELL_PIXELS: (u16, u16) = (8, 16); +impl StartupTerminalInput { + fn append(&mut self, bytes: &[u8]) { + let mut remaining = bytes; + while !remaining.is_empty() { + let available = + STARTUP_INPUT_MAX_INCOMPLETE_BYTES.saturating_sub(self.incomplete.len()); + let take = available.min(remaining.len()); + self.incomplete.extend_from_slice(&remaining[..take]); + remaining = &remaining[take..]; + + let parsed = split_pending_input(&self.incomplete); + self.events.extend(parsed.events); + self.incomplete = parsed.incomplete; + if remaining.is_empty() { + break; + } + if self.incomplete.len() >= STARTUP_INPUT_MAX_INCOMPLETE_BYTES { + self.events.extend(parse_incomplete_input_lossy(&self.incomplete)); + self.incomplete.clear(); + } + } + } +} + +#[derive(Debug)] +pub struct StartupTerminalProbe { + pub cell_pixels: (u16, u16), + pub graphics_supported: bool, + pub pending_input: StartupTerminalInput, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct ParsedTerminalProbe { + window_pixels: Option<(u32, u32)>, + kitty_supported: Option, + primary_device_attributes: bool, + pending_input: Vec, +} + +/// Probe terminal capabilities in one exchange and return any user input read +/// alongside the replies. The final DA1 request acts as an ordering marker: +/// any preceding Kitty reply advertises support, while its absence does not +/// make terminals that ignore the Kitty APC wait for the full timeout. +pub fn probe_terminal(known_cell_pixels: Option<(u16, u16)>) -> StartupTerminalProbe { + let ioctl_pixels = ioctl_cell_pixels(); + let terminal_size = crossterm::terminal::size().ok(); + let query_window_pixels = + ioctl_pixels.is_none() && terminal_size.is_some_and(|(cols, rows)| cols > 0 && rows > 0); + + let mut stdout = std::io::stdout(); + if query_window_pixels { + let _ = write!(stdout, "\x1b[14t"); + } + let _ = write!(stdout, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); + let _ = stdout.flush(); + + let bytes = read_stdin_until(TERMINAL_PROBE_TIMEOUT, terminal_probe_complete); + let parsed = parse_terminal_probe(&bytes); + let queried_pixels = + terminal_size.zip(parsed.window_pixels).and_then(|((cols, rows), (width, height))| { + cell_pixels_from_window_size(cols, rows, width, height) + }); + StartupTerminalProbe { + cell_pixels: resolve_cell_pixels(known_cell_pixels, ioctl_pixels.or(queried_pixels)), + graphics_supported: parsed.kitty_supported.unwrap_or(false), + pending_input: split_pending_input(&parsed.pending_input), + } +} /// Resolve host cell metrics without treating an absent resize-time ioctl /// value as a new measurement. Some outer terminals zero `ws_xpixel` and /// `ws_ypixel` after `TIOCSWINSZ`; in that case the last real measurement is /// more accurate than the synthetic startup fallback. -pub fn detect_cell_pixels(known: Option<(u16, u16)>, query_fallback: bool) -> (u16, u16) { - let detected = ioctl_cell_pixels().or_else(|| query_fallback.then(query_cell_pixels).flatten()); - resolve_cell_pixels(known, detected) +pub fn detect_cell_pixels(known: Option<(u16, u16)>) -> (u16, u16) { + resolve_cell_pixels(known, ioctl_cell_pixels()) } fn resolve_cell_pixels(known: Option<(u16, u16)>, detected: Option<(u16, u16)>) -> (u16, u16) { @@ -167,6 +1010,20 @@ fn cell_pixels_from_terminal_size( Some(((width_px / cols).max(1), (height_px / rows).max(1))) } +fn cell_pixels_from_window_size( + cols: u16, + rows: u16, + width_px: u32, + height_px: u32, +) -> Option<(u16, u16)> { + if cols == 0 || rows == 0 || width_px == 0 || height_px == 0 { + return None; + } + let width = (width_px / u32::from(cols)).clamp(1, u32::from(u16::MAX)); + let height = (height_px / u32::from(rows)).clamp(1, u32::from(u16::MAX)); + Some((width as u16, height as u16)) +} + #[cfg(unix)] fn ioctl_cell_pixels() -> Option<(u16, u16)> { let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; @@ -180,30 +1037,15 @@ fn ioctl_cell_pixels() -> Option<(u16, u16)> { None } -fn query_cell_pixels() -> Option<(u16, u16)> { - let (cols, rows) = crossterm::terminal::size().ok()?; - if cols == 0 || rows == 0 { - return None; - } - let mut stdout = std::io::stdout(); - let _ = write!(stdout, "\x1b[14t"); - let _ = stdout.flush(); - let bytes = read_stdin_for(Duration::from_millis(120)); - let response = String::from_utf8_lossy(&bytes); - let start = response.find("\x1b[4;")?; - let tail = &response[start + 4..]; - let end = tail.find('t')?; - let mut parts = tail[..end].split(';'); - let height = parts.next()?.parse::().ok()?; - let width = parts.next()?.parse::().ok()?; - Some((((width / cols as u32).max(1)) as u16, ((height / rows as u32).max(1)) as u16)) -} - #[cfg(unix)] -fn read_stdin_for(timeout: Duration) -> Vec { +fn read_stdin_until(timeout: Duration, complete: impl Fn(&[u8]) -> bool) -> Vec { let start = Instant::now(); let mut out = Vec::new(); while start.elapsed() < timeout { + let available = STARTUP_INPUT_MAX_INCOMPLETE_BYTES.saturating_sub(out.len()); + if available == 0 { + break; + } let remaining = timeout.saturating_sub(start.elapsed()); let poll_ms = remaining.min(Duration::from_millis(20)).as_millis() as i32; let mut fd = libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 }; @@ -212,25 +1054,28 @@ fn read_stdin_for(timeout: Duration) -> Vec { continue; } let mut buf = [0u8; 1024]; - let n = unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr().cast(), buf.len()) }; + let n = unsafe { + libc::read(libc::STDIN_FILENO, buf.as_mut_ptr().cast(), buf.len().min(available)) + }; if n <= 0 { break; } out.extend_from_slice(&buf[..n as usize]); - // DA1 is emitted as an inexpensive progress marker, but it is not a - // completion fence: terminals can produce the Kitty APC reply on a - // different render/output lane. Drain the entire bounded probe window - // so that a valid reply arriving after DA1 cannot leak into crossterm - // input (or the shell that resumes after cmux exits). + if complete(&out) { + // Drain bytes that are already queued with the replies so an + // input sequence split at this read boundary remains intact. + let mut pending = + libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 }; + if unsafe { libc::poll(&mut pending, 1, 0) } <= 0 { + break; + } + } } out } -// Raw non-blocking stdin reads need poll(2); without them the graphics -// probes can't collect replies, so report "no response" and let callers -// fall back (no kitty graphics, default cell size). #[cfg(not(unix))] -fn read_stdin_for(_timeout: Duration) -> Vec { +fn read_stdin_until(_timeout: Duration, _complete: impl Fn(&[u8]) -> bool) -> Vec { Vec::new() } @@ -238,12 +1083,186 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|window| window == needle) } -fn kitty_probe_succeeded(bytes: &[u8]) -> bool { - find_bytes(bytes, b"_Gi=31;OK").is_some() +fn terminal_probe_complete(bytes: &[u8]) -> bool { + parse_terminal_probe(bytes).primary_device_attributes +} + +fn parse_terminal_probe(bytes: &[u8]) -> ParsedTerminalProbe { + let mut parsed = ParsedTerminalProbe::default(); + let mut offset = 0; + while offset < bytes.len() { + if bytes[offset..].starts_with(b"\x1b[") + && let Some(relative_end) = + bytes[offset + 2..].iter().position(|byte| (0x40..=0x7e).contains(byte)) + { + let end = offset + 2 + relative_end + 1; + let sequence = &bytes[offset..end]; + let parameters = &sequence[2..sequence.len() - 1]; + let final_byte = sequence[sequence.len() - 1]; + if final_byte == b'c' && parameters.starts_with(b"?") { + parsed.primary_device_attributes = true; + offset = end; + continue; + } + if final_byte == b't' + && let Some(pixels) = parse_window_pixel_response(parameters) + { + parsed.window_pixels = Some(pixels); + offset = end; + continue; + } + } + if bytes[offset..].starts_with(b"\x1b_") + && let Some(relative_end) = find_bytes(&bytes[offset + 2..], b"\x1b\\") + { + let end = offset + 2 + relative_end + 2; + let sequence = &bytes[offset..end]; + if let Some(supported) = parse_kitty_probe_response(sequence) { + parsed.kitty_supported = Some(supported); + offset = end; + continue; + } + } + parsed.pending_input.push(bytes[offset]); + offset += 1; + } + parsed +} + +fn parse_window_pixel_response(parameters: &[u8]) -> Option<(u32, u32)> { + let response = std::str::from_utf8(parameters).ok()?; + let mut parts = response.split(';'); + if parts.next()? != "4" { + return None; + } + let height = parts.next()?.parse().ok()?; + let width = parts.next()?.parse().ok()?; + (parts.next().is_none() && width > 0 && height > 0).then_some((width, height)) +} + +fn parse_kitty_probe_response(sequence: &[u8]) -> Option { + let marker = b"Gi=31;"; + let status_start = find_bytes(sequence, marker)? + marker.len(); + let status_end = find_bytes(&sequence[status_start..], b"\x1b\\")? + status_start; + Some(sequence[status_start..status_end].starts_with(b"OK")) +} + +fn split_pending_input(bytes: &[u8]) -> StartupTerminalInput { + let mut events = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + if let Some((event, consumed)) = parse_one_input_event(&bytes[offset..]) { + events.push(event); + offset += consumed; + continue; + } + break; + } + StartupTerminalInput { events, incomplete: bytes[offset..].to_vec() } +} + +#[cfg(test)] +fn parse_pending_input(bytes: &[u8]) -> Vec { + split_pending_input(bytes).events +} + +fn parse_incomplete_input_lossy(bytes: &[u8]) -> Vec { + let mut events = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + if let Some((event, consumed)) = parse_one_input_event(&bytes[offset..]) { + events.push(event); + offset += consumed; + continue; + } + let code = if bytes[offset] == b'\x1b' { + crossterm::event::KeyCode::Esc + } else { + crossterm::event::KeyCode::Char(char::REPLACEMENT_CHARACTER) + }; + events.push(crossterm::event::Event::Key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + ))); + offset += 1; + } + events +} + +/// Finish an input sequence whose prefix was consumed while probing the host +/// terminal, then return to crossterm for normal live input. Waiting is +/// bounded so a literal Escape key cannot stall startup indefinitely. +pub(crate) fn finish_startup_input( + mut pending: StartupTerminalInput, +) -> Vec { + #[cfg(unix)] + { + let deadline = Instant::now() + STARTUP_INPUT_CONTINUATION_TIMEOUT; + while !pending.incomplete.is_empty() { + let mut read_more = false; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll_ms = remaining.min(Duration::from_millis(20)).as_millis().max(1) as i32; + let mut fd = + libc::pollfd { fd: libc::STDIN_FILENO, events: libc::POLLIN, revents: 0 }; + let ready = unsafe { libc::poll(&mut fd, 1, poll_ms) }; + if ready < 0 { + if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + break; + } + if ready == 0 { + continue; + } + let mut bytes = [0_u8; 1024]; + let count = unsafe { + libc::read(libc::STDIN_FILENO, bytes.as_mut_ptr().cast(), bytes.len()) + }; + if count <= 0 { + break; + } + pending.append(&bytes[..count as usize]); + read_more = true; + break; + } + if !read_more { + break; + } + } + } + + if !pending.incomplete.is_empty() { + pending.events.extend(parse_incomplete_input_lossy(&pending.incomplete)); + } + pending.events +} + +fn parse_one_input_event(bytes: &[u8]) -> Option<(crossterm::event::Event, usize)> { + let minimum = usize::from(bytes.first() == Some(&b'\x1b') && bytes.len() > 1) + 1; + for end in minimum..=bytes.len() { + let Some(event) = parse_startup_input_event(&bytes[..end]) else { continue }; + return Some((event, end)); + } + None +} + +#[cfg(unix)] +fn parse_startup_input_event(bytes: &[u8]) -> Option { + crossterm::event::parse_event_from_bytes(bytes, true).ok().flatten() +} + +#[cfg(not(unix))] +fn parse_startup_input_event(bytes: &[u8]) -> Option { + let event = terminput::Event::parse_from(bytes).ok().flatten()?; + terminput_crossterm::to_crossterm(event).ok() } #[cfg(test)] mod tests { + use base64::Engine as _; + use ghostty_vt::{Callbacks, KittyPlacement, KittyPlacementKey, Terminal}; + use super::*; #[test] @@ -264,49 +1283,106 @@ mod tests { } #[test] - fn kitty_probe_accepts_ok_before_da1() { - assert!(kitty_probe_succeeded(b"\x1b_Gi=31;OK\x1b\\\x1b[?62;c")); + fn terminal_probe_finishes_after_kitty_and_da1_in_either_order() { + assert!(terminal_probe_complete(b"\x1b_Gi=31;OK\x1b\\\x1b[?62;c")); + assert!(terminal_probe_complete(b"\x1b[?62;c\x1b_Gi=31;OK\x1b\\")); + assert!(terminal_probe_complete(b"\x1b_Gi=31;EINVAL\x1b\\\x1b[?62;c")); } #[test] - fn kitty_probe_accepts_async_ok_after_da1() { - assert!(kitty_probe_succeeded(b"\x1b[?62;c\x1b_Gi=31;OK\x1b\\")); + fn terminal_probe_finishes_at_da1_ordering_marker() { + assert!(terminal_probe_complete(b"\x1b[?62;c")); + assert!(!terminal_probe_complete(b"\x1b_Gi=31;OK\x1b\\")); } #[test] - fn kitty_probe_rejects_da1_or_error_without_ok() { - assert!(!kitty_probe_succeeded(b"\x1b[?62;c")); - assert!(!kitty_probe_succeeded(b"\x1b_Gi=31;EINVAL\x1b\\")); + fn terminal_probe_strips_replies_and_replays_user_input() { + use crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; + + let parsed = parse_terminal_probe( + b"a\x1b[4;800;1200t\x1b[I\x1b_Gi=31;OK\x1b\\\ + \x1b[200~pasted\x1b[201~\x1b[?62;c\x1b[<0;3;4Mb", + ); + assert_eq!(parsed.window_pixels, Some((1200, 800))); + assert_eq!(parsed.kitty_supported, Some(true)); + assert!(parsed.primary_device_attributes); + assert_eq!(cell_pixels_from_window_size(120, 40, 1200, 800), Some((10, 20)),); + + let events = parse_pending_input(&parsed.pending_input); + assert!(matches!( + events.as_slice(), + [ + Event::Key(first), + Event::FocusGained, + Event::Paste(paste), + Event::Mouse(mouse), + Event::Key(last), + ] if first.code == KeyCode::Char('a') + && paste == "pasted" + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && mouse.column == 2 + && mouse.row == 3 + && last.code == KeyCode::Char('b') + )); } #[test] - fn transmits_png_in_quiet_chunks() { - let data = format!("{}{}", "a".repeat(4096), "b".repeat(4)); - let bytes = String::from_utf8(transmit_png(7, &data)).unwrap(); - assert_eq!( - bytes, - format!( - "\x1b_Ga=t,f=100,i=8,q=2,m=1;{}\x1b\\\x1b_Gq=2,m=0;bbbb\x1b\\", - "a".repeat(4096) - ) + fn terminal_probe_preserves_fragmented_escape_input_without_dropping_bytes() { + use crossterm::event::{Event, KeyCode}; + + let events = parse_pending_input(b"\x1b["); + assert!(events.is_empty(), "an incomplete CSI became synthetic key events: {events:?}"); + + let mut pending = split_pending_input(b"\x1b["); + pending.append(b"A"); + assert!(matches!( + pending.events.as_slice(), + [Event::Key(up)] if up.code == KeyCode::Up + )); + assert!(pending.incomplete.is_empty()); + } + + #[test] + fn terminal_probe_preserves_enhanced_csi_u_metadata() { + use crossterm::event::{Event, KeyCode, KeyModifiers}; + + let events = parse_pending_input(b"\x1b[38:49:49;4;49u"); + + assert!(matches!( + events.as_slice(), + [Event::EnhancedKey(key)] + if key.key_event.code == KeyCode::Char('&') + && key.key_event.modifiers == KeyModifiers::SHIFT | KeyModifiers::ALT + && key.shifted_key == Some('1') + && key.base_layout_key == Some('1') + && key.text == "1" + )); + } + + #[test] + fn terminal_probe_bounds_an_unfinished_startup_paste() { + const EXPECTED_MAX_INCOMPLETE_BYTES: usize = 4 * 1024; + + let mut pending = split_pending_input(b"\x1b[200~"); + pending.append(&vec![b'x'; EXPECTED_MAX_INCOMPLETE_BYTES + 1]); + + assert!( + pending.incomplete.len() <= EXPECTED_MAX_INCOMPLETE_BYTES, + "an unfinished startup sequence retained {} bytes", + pending.incomplete.len() ); } #[test] - fn places_at_cursor_rect_with_save_restore() { - let bytes = String::from_utf8(place_image_cropped( - 2, - Rect { x: 4, y: 6, width: 80, height: 24 }, - None, - )) - .unwrap(); - assert_eq!(bytes, "\x1b7\x1b[7;5H\x1b_Ga=p,i=3,p=1,c=80,r=24,q=2;\x1b\\\x1b8"); - } + fn existing_base64_graphic_payload_is_borrowed() { + let encoded: Arc = Arc::from("AAAAAA=="); + let data = GraphicData::Base64(encoded.clone()); - #[test] - fn deletes_by_image_id_quietly() { - let bytes = String::from_utf8(delete_image(41)).unwrap(); - assert_eq!(bytes, "\x1b_Ga=d,d=i,i=42,q=2;\x1b\\"); + let Cow::Borrowed(borrowed) = data.base64() else { + panic!("existing base64 payload was copied"); + }; + assert_eq!(borrowed, encoded.as_ref()); + assert_eq!(borrowed.as_ptr(), encoded.as_ptr()); } #[test] @@ -319,75 +1395,849 @@ mod tests { ); } - #[test] - fn cropped_placement_selects_a_horizontal_source_slice() { - let bytes = String::from_utf8(place_image_cropped( - 2, - Rect { x: 4, y: 6, width: 40, height: 24 }, - Some((80, 320)), - )) - .unwrap(); - assert!(bytes.contains(",x=80,w=320,c=40,r=24,")); + fn image( + surface: SurfaceId, + image_id: u32, + generation: u64, + format: GraphicFormat, + data: &[u8], + ) -> Arc { + Arc::new(GraphicImage { + key: GraphicImageKey { namespace: 0, surface, image_id }, + generation, + width: 2, + height: 2, + format, + data: GraphicData::Bytes(Arc::from(data)), + }) + } + + fn placement( + image: Arc, + placement_id: u32, + ordinal: u32, + rect: Rect, + ) -> GraphicPlacement { + GraphicPlacement { + key: GraphicPlacementKey { image: image.key, placement_id, ordinal }, + image, + rect, + pointer_frame_seq: None, + columns: Some(u32::from(rect.width)), + rows: Some(u32::from(rect.height)), + source: None, + x_offset: 0, + y_offset: 0, + z: 0, + } + } + + fn joined(batches: &[Vec]) -> String { + String::from_utf8(batches.concat()).unwrap() + } + + fn decoded_terminal_placement( + width: u32, + height: u32, + sizing: &str, + ) -> (KittyImage, KittyPlacement) { + let mut terminal = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + terminal.resize(20, 8, 10, 20).unwrap(); + let pixels = vec![255; usize::try_from(width * height * 3).unwrap()]; + let payload = base64::engine::general_purpose::STANDARD.encode(pixels); + terminal.vt_write( + format!( + "\x1b_Ga=T,t=d,f=24,i=201,p=1,s={width},v={height}{sizing},q=2;{payload}\x1b\\" + ) + .as_bytes(), + ); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.placements.len(), 1); + (snapshot.image(201).unwrap().clone(), snapshot.placements[0].clone()) + } + + fn translate_terminal_placement( + image: &KittyImage, + placement: &KittyPlacement, + pane: Rect, + ) -> GraphicPlacement { + kitty_graphic_placement(pane, 0, (10, 20), kitty_graphic_image(0, 9, image), placement) + .unwrap() + } + + fn translated_terminal_placement( + width: u32, + height: u32, + sizing: &str, + pane: Rect, + ) -> GraphicPlacement { + let (image, placement) = decoded_terminal_placement(width, height, sizing); + translate_terminal_placement(&image, &placement, pane) + } + + fn emitted_placement(value: GraphicPlacement) -> String { + let mut state = GraphicsState::default(); + let output = joined(&state.frame_batches(&[value])); + let start = output.find("\x1b_Ga=p").expect("placement command"); + let command = &output[start..]; + let end = command.find("\x1b\\").expect("placement terminator") + 2; + command[..end].to_string() + } + + fn rounded_ratio(value: u32, numerator: u32, denominator: u32) -> u32 { + u32::try_from( + (u128::from(value) * u128::from(numerator) + u128::from(denominator) / 2) + / u128::from(denominator), + ) + .unwrap() + } + + fn rendered_pixel_size(value: &GraphicPlacement) -> (u32, u32) { + let source = value.source.unwrap_or(GraphicSourceRect { + x: 0, + y: 0, + width: value.image.width, + height: value.image.height, + }); + match (value.columns, value.rows) { + (None, None) => (source.width, source.height), + (Some(columns), None) => { + let width = columns * 10; + (width, rounded_ratio(width, source.height, source.width)) + } + (None, Some(rows)) => { + let height = rows * 20; + (rounded_ratio(height, source.width, source.height), height) + } + (Some(columns), Some(rows)) => (columns * 10, rows * 20), + } + } + + fn assert_pixel_geometry( + value: &GraphicPlacement, + pane: Rect, + source: GraphicSourceRect, + sizing: (Option, Option), + pixels: (u32, u32), + ) { + assert_eq!(value.source, Some(source)); + assert_eq!((value.columns, value.rows), sizing); + assert_eq!(rendered_pixel_size(value), pixels); + let left = u32::from(value.rect.x - pane.x) * 10 + value.x_offset; + let top = u32::from(value.rect.y - pane.y) * 20 + value.y_offset; + assert!(left + pixels.0 <= u32::from(pane.width) * 10); + assert!(top + pixels.1 <= u32::from(pane.height) * 20); } #[test] - fn zero_sized_placement_hides_a_previously_visible_image() { - let visible = GraphicPlacement { - surface: 7, - rect: Rect { x: 4, y: 6, width: 80, height: 24 }, - pointer_frame_seq: Some(1), - source_crop_px: None, - frame: Arc::new(BrowserFrame { - session_id: "test".to_string(), - data_b64: "frame".to_string(), - css_width: 80, - css_height: 24, - image_width: 80, - image_height: 24, - seq: 1, - }), - }; - let collapsed = - GraphicPlacement { rect: Rect { height: 0, ..visible.rect }, ..visible.clone() }; + fn raw_rgb_and_rgba_payloads_are_chunked_with_dimensions() { + let rgb = image(1, 1, 1, GraphicFormat::Rgb, &vec![255; 3_075]); + let rgba = image(2, 2, 1, GraphicFormat::Rgba, &[255; 16]); + let rgb_key = rgb.key; + let rgba_key = rgba.key; let mut state = GraphicsState::default(); - - assert!(!state.frame_batches(1, &[visible]).is_empty()); - assert_eq!(state.frame_batches(1, &[collapsed]), vec![delete_image(7)]); - } - - #[test] - fn replacement_session_retransmits_a_reused_surface_sequence() { - let old = GraphicPlacement { - surface: 7, - rect: Rect { x: 4, y: 6, width: 80, height: 24 }, - pointer_frame_seq: Some(1), - source_crop_px: None, - frame: Arc::new(BrowserFrame { - session_id: "old".to_string(), - data_b64: "old-frame".to_string(), - css_width: 80, - css_height: 24, - image_width: 80, - image_height: 24, - seq: 1, - }), - }; - let replacement = GraphicPlacement { - frame: Arc::new(BrowserFrame { - session_id: "replacement".to_string(), - data_b64: "replacement-frame".to_string(), - ..(*old.frame).clone() - }), - ..old.clone() - }; - let mut state = GraphicsState::default(); - - state.frame_batches(1, &[old]); - let output = state.frame_batches(2, &[replacement]).concat(); - + let output = joined(&state.frame_batches(&[ + placement(rgb, 0, 0, Rect { x: 0, y: 0, width: 2, height: 2 }), + placement(rgba, 0, 0, Rect { x: 3, y: 0, width: 2, height: 2 }), + ])); assert!( - output.windows(b"replacement-frame".len()).any(|bytes| bytes == b"replacement-frame"), - "a new machine session must retransmit even when surface and frame counters restart" + output.contains(&format!( + "a=t,t=d,f=24,i={},s=2,v=2,q=2,m=1;", + state.image_ids[&rgb_key] + )) + ); + assert!(output.contains("\x1b_Gq=2,m=0;")); + assert!( + output.contains(&format!( + "a=t,t=d,f=32,i={},s=2,v=2,q=2,m=0;", + state.image_ids[&rgba_key] + )) ); } + + #[test] + fn dynamic_ids_do_not_alias_distant_surfaces_or_anonymous_placements() { + let first = image(1, 7, 1, GraphicFormat::Rgb, &[255; 12]); + let distant = image(2_000_000_001, 7, 1, GraphicFormat::Rgb, &[0; 12]); + let mut state = GraphicsState::default(); + let output = joined(&state.frame_batches(&[ + placement(first.clone(), 0, 0, Rect { x: 0, y: 0, width: 1, height: 1 }), + placement(first, 0, 1, Rect { x: 1, y: 0, width: 1, height: 1 }), + placement(distant, 0, 0, Rect { x: 2, y: 0, width: 1, height: 1 }), + ])); + assert_eq!(state.image_ids.len(), 2); + assert_eq!(state.placement_ids.len(), 3); + assert_eq!(output.matches("a=t,t=d").count(), 2, "one transmit per image"); + assert_eq!(output.matches("a=p").count(), 3); + assert_eq!(state.image_ids.values().copied().collect::>().len(), 2); + assert_eq!(state.placement_ids.values().copied().collect::>().len(), 3); + } + + #[test] + fn large_scene_id_allocation_performs_linear_work() { + const IMAGE_COUNT: u32 = 256; + let placements = (1..=IMAGE_COUNT) + .rev() + .map(|image_id| { + placement( + image(19, image_id, 1, GraphicFormat::Rgb, &[255; 12]), + image_id, + 0, + Rect { + x: u16::try_from(image_id % 80).unwrap(), + y: u16::try_from(image_id / 80).unwrap(), + width: 1, + height: 1, + }, + ) + }) + .collect::>(); + let mut state = GraphicsState::default(); + + state.frame_batches(&placements); + + assert_eq!( + state.operation_counts.image_id_allocation_checks, IMAGE_COUNT as usize, + "image allocation must perform one used-ID check per image: {:?}", + state.operation_counts + ); + assert_eq!( + state.operation_counts.placement_id_allocation_checks, IMAGE_COUNT as usize, + "placement allocation must perform one used-ID check per placement: {:?}", + state.operation_counts + ); + } + + #[test] + fn stale_image_cleanup_uses_one_linear_retain_pass() { + const SURVIVOR_COUNT: u32 = 128; + let initial = (1..=SURVIVOR_COUNT * 2) + .map(|image_id| { + placement( + image(20, image_id, 1, GraphicFormat::Rgb, &[255; 12]), + image_id, + 0, + Rect { + x: u16::try_from(image_id % 80).unwrap(), + y: u16::try_from(image_id / 80).unwrap(), + width: 1, + height: 1, + }, + ) + }) + .collect::>(); + let survivors = initial[..SURVIVOR_COUNT as usize].to_vec(); + let mut state = GraphicsState::default(); + state.frame_batches(&initial); + state.reset_operation_counts(); + + state.frame_batches(&survivors); + + assert_eq!( + state.operation_counts.stale_image_retain_passes, 1, + "stale-image cleanup repeated a retain pass: {:?}", + state.operation_counts + ); + assert_eq!( + state.operation_counts.stale_image_retain_visits, SURVIVOR_COUNT as usize, + "stale-image cleanup must visit each survivor once: {:?}", + state.operation_counts + ); + } + + #[test] + fn late_lower_inner_image_id_preserves_outer_ids_and_equal_z_compositing() { + let mut lower = placement( + image(21, 7, 1, GraphicFormat::Rgb, &[7; 12]), + 1, + 0, + Rect { x: 40, y: 20, width: 1, height: 1 }, + ); + let mut higher = placement( + image(21, 90, 1, GraphicFormat::Rgb, &[90; 12]), + 1, + 0, + Rect { x: 0, y: 0, width: 1, height: 1 }, + ); + lower.z = 8; + higher.z = -8; + let lower_key = lower.image.key; + let higher_key = higher.image.key; + let mut state = GraphicsState::default(); + + state.frame_batches(std::slice::from_ref(&higher)); + let old_higher_id = state.image_ids[&higher_key]; + let update = joined(&state.frame_batches(&[higher.clone(), lower.clone()])); + + assert!( + state.image_ids[&lower_key] < state.image_ids[&higher_key], + "outer IDs must preserve inner image-ID order even when allocation first saw another z: {:?}", + state.image_ids + ); + assert_eq!(state.image_ids[&higher_key], old_higher_id); + assert!(!update.contains("a=d,d=I"), "{update:?}"); + assert_eq!(update.matches("a=t,t=d").count(), 1, "{update:?}"); + assert_eq!(update.matches("a=p").count(), 1, "{update:?}"); + + lower.z = 0; + higher.z = 0; + state.frame_batches(&[higher, lower]); + assert!( + state.image_ids[&lower_key] < state.image_ids[&higher_key], + "equal-z outer IDs must ignore input order and y position: {:?}", + state.image_ids + ); + } + + #[test] + fn descending_image_insertion_keeps_existing_ids_and_linear_transmission() { + const IMAGE_COUNT: u32 = 256; + let mut state = GraphicsState::default(); + let mut placements = Vec::new(); + let mut transmissions = 0; + let mut deletions = 0; + + for image_id in (1..=IMAGE_COUNT).rev() { + placements.push(placement( + image(22, image_id, 1, GraphicFormat::Rgb, &[255; 12]), + image_id, + 0, + Rect { + x: u16::try_from(image_id % 80).unwrap(), + y: u16::try_from(image_id / 80).unwrap(), + width: 1, + height: 1, + }, + )); + let output = joined(&state.frame_batches(&placements)); + transmissions += output.matches("a=t,t=d").count(); + deletions += output.matches("a=d,d=I").count(); + } + + assert_eq!( + transmissions, IMAGE_COUNT as usize, + "inserting one lower image retransmitted existing pixel payloads" + ); + assert_eq!(deletions, 0, "stable image IDs must not delete existing host images"); + let mut ordered = state.image_ids.iter().collect::>(); + ordered.sort_unstable_by_key(|(key, _)| **key); + assert!( + ordered.windows(2).all(|pair| pair[0].1 < pair[1].1), + "outer image IDs must preserve inner image ordering: {:?}", + state.image_ids + ); + } + + #[test] + fn relabel_finds_inserted_image_in_compositing_order_across_surfaces() { + let inserted = GraphicImageKey { namespace: 9, surface: 9, image_id: 1 }; + let existing = GraphicImageKey { namespace: 0, surface: 1, image_id: 2 }; + let mut state = GraphicsState::default(); + state.image_ids.insert(existing, 1); + state.used_image_ids.insert(1); + let mut batches = Vec::new(); + + state.prepare_image_ids(&[inserted, existing], &mut batches); + + assert!(state.image_ids[&inserted] < state.image_ids[&existing]); + } + + #[test] + fn reconnect_namespace_prevents_surface_and_generation_reuse_from_aliasing() { + let rect = Rect { x: 0, y: 0, width: 2, height: 2 }; + let first = GraphicPlacement::browser(10, 7, rect, 1, 20, 20, "AAAA".to_string()); + let reconnected = GraphicPlacement::browser(11, 7, rect, 1, 20, 20, "BBBB".to_string()); + assert_ne!(first.image.key, reconnected.image.key); + + let mut state = GraphicsState::default(); + state.frame_batches(&[first]); + let output = joined(&state.frame_batches(&[reconnected])); + assert!(output.contains("a=d,d=I")); + assert!(output.contains("a=t,t=d")); + assert_eq!(state.image_ids.len(), 1); + } + + #[test] + fn placement_preserves_crop_offsets_and_z() { + let image = image(4, 9, 1, GraphicFormat::Rgba, &[255; 16]); + let image_key = image.key; + let mut value = placement(image, 12, 0, Rect { x: 4, y: 6, width: 2, height: 3 }); + value.source = Some(GraphicSourceRect { x: 1, y: 2, width: 3, height: 4 }); + value.x_offset = 5; + value.y_offset = 6; + value.z = -7; + let mut state = GraphicsState::default(); + let output = joined(&state.frame_batches(&[value])); + assert!(output.contains(&format!( + "\x1b7\x1b[7;5H\x1b_Ga=p,i={},p=1,x=1,y=2,w=3,h=4,X=5,Y=6,c=2,r=3,z=-7,C=1,q=2;\x1b\\\x1b8", + state.image_ids[&image_key] + ))); + } + + #[test] + fn retransmit_deletes_old_generation_before_replacing_pixels() { + let key_image = image(5, 1, 1, GraphicFormat::Rgb, &[255; 12]); + let mut state = GraphicsState::default(); + state.frame_batches(&[placement( + key_image, + 1, + 0, + Rect { x: 0, y: 0, width: 1, height: 1 }, + )]); + let replacement = image(5, 1, 2, GraphicFormat::Rgb, &[0; 12]); + let output = joined(&state.frame_batches(&[placement( + replacement, + 1, + 0, + Rect { x: 0, y: 0, width: 1, height: 1 }, + )])); + let delete = output.find("a=d,d=I").unwrap(); + let transmit = output.find("a=t,t=d").unwrap(); + assert!(delete < transmit); + } + + #[test] + fn unchanged_frame_does_not_replace_existing_placement() { + let image = image(5, 2, 1, GraphicFormat::Rgb, &[255; 12]); + let value = placement(image, 1, 0, Rect { x: 2, y: 3, width: 1, height: 1 }); + let mut state = GraphicsState::default(); + state.frame_batches(std::slice::from_ref(&value)); + + let output = joined(&state.frame_batches(&[value])); + assert!(!output.contains("a=p"), "{output:?}"); + assert!(output.is_empty(), "{output:?}"); + } + + #[test] + fn host_scene_invalidation_restores_an_unchanged_image_after_clear() { + let image = image(5, 20, 1, GraphicFormat::Rgb, &[255; 12]); + let value = placement(image, 1, 0, Rect { x: 2, y: 3, width: 2, height: 2 }); + let mut state = GraphicsState::default(); + let mut host = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + host.resize(20, 8, 10, 20).unwrap(); + + for batch in state.frame_batches(std::slice::from_ref(&value)) { + host.vt_write(&batch); + } + assert_eq!(host.kitty_graphics_snapshot().unwrap().placements.len(), 1); + + host.vt_write(b"\x1b[2J"); + assert!(host.kitty_graphics_snapshot().unwrap().is_empty()); + + state.invalidate_host_scene(); + for batch in state.frame_batches(&[value]) { + host.vt_write(&batch); + } + let restored = host.kitty_graphics_snapshot().unwrap(); + assert_eq!(restored.images.len(), 1); + assert_eq!(restored.placements.len(), 1); + } + + #[test] + fn changed_geometry_deletes_old_placement_before_replacing_it() { + let image = image(5, 3, 1, GraphicFormat::Rgb, &[255; 12]); + let initial = placement(image.clone(), 1, 0, Rect { x: 2, y: 3, width: 1, height: 1 }); + let moved = placement(image, 1, 0, Rect { x: 4, y: 5, width: 2, height: 1 }); + let mut state = GraphicsState::default(); + state.frame_batches(&[initial]); + + let output = joined(&state.frame_batches(&[moved])); + let delete = output.find("a=d,d=i").expect("old placement deletion"); + let replace = output.find("a=p").expect("replacement placement"); + assert!(delete < replace, "{output:?}"); + } + + #[test] + fn image_retransmit_restores_every_placement_after_image_deletion() { + let first = image(5, 4, 1, GraphicFormat::Rgb, &[255; 12]); + let mut state = GraphicsState::default(); + state.frame_batches(&[ + placement(first.clone(), 1, 0, Rect { x: 0, y: 0, width: 1, height: 1 }), + placement(first, 2, 0, Rect { x: 2, y: 0, width: 1, height: 1 }), + ]); + + let replacement = image(5, 4, 2, GraphicFormat::Rgb, &[0; 12]); + let output = joined(&state.frame_batches(&[ + placement(replacement.clone(), 1, 0, Rect { x: 0, y: 0, width: 1, height: 1 }), + placement(replacement, 2, 0, Rect { x: 2, y: 0, width: 1, height: 1 }), + ])); + let delete = output.find("a=d,d=I").expect("old image deletion"); + let transmit = output.find("a=t,t=d").expect("replacement transmission"); + let first_place = output.find("a=p").expect("first restored placement"); + assert!(delete < transmit && transmit < first_place, "{output:?}"); + assert_eq!(output.matches("a=p").count(), 2, "{output:?}"); + } + + #[test] + fn stale_placement_is_deleted_without_dropping_shared_image_then_image_is_freed() { + let shared = image(6, 1, 1, GraphicFormat::Rgb, &[255; 12]); + let one = placement(shared.clone(), 0, 0, Rect { x: 0, y: 0, width: 1, height: 1 }); + let two = placement(shared, 0, 1, Rect { x: 1, y: 0, width: 1, height: 1 }); + let mut state = GraphicsState::default(); + state.frame_batches(&[one.clone(), two]); + + let placement_cleanup = joined(&state.frame_batches(&[one])); + assert!(placement_cleanup.contains("a=d,d=i")); + assert!(!placement_cleanup.contains("d=I")); + assert_eq!(state.transmitted.len(), 1); + + let image_cleanup = joined(&state.frame_batches(&[])); + assert!(image_cleanup.contains("a=d,d=I")); + assert!(state.transmitted.is_empty()); + assert!(state.image_ids.is_empty()); + assert!(state.placement_ids.is_empty()); + } + + #[test] + fn terminal_placement_clips_to_pane_and_adjusts_source_pixels() { + let inner_image = KittyImage { + id: 3, + number: 0, + generation: 1, + width: 100, + height: 80, + format: KittyImageFormat::Rgba, + data: Arc::from(vec![0; 100 * 80 * 4]), + }; + let inner = KittyPlacement { + key: KittyPlacementKey { image_id: 3, placement_id: 0, ordinal: 1 }, + image_id: 3, + placement_id: 0, + is_internal: true, + x_offset: 4, + y_offset: 5, + source_x: 10, + source_y: 20, + source_width: 80, + source_height: 40, + columns: 0, + rows: 0, + grid_cols: 9, + grid_rows: 3, + pixel_width: 80, + pixel_height: 40, + viewport_col: -2, + viewport_row: -1, + viewport_visible: true, + anchor: None, + z: 3, + }; + let outer = kitty_graphic_placement( + Rect { x: 10, y: 5, width: 5, height: 2 }, + 0, + (10, 20), + kitty_graphic_image(0, 9, &inner_image), + &inner, + ) + .unwrap(); + assert_eq!(outer.rect, Rect { x: 10, y: 5, width: 5, height: 2 }); + assert_eq!(outer.source, Some(GraphicSourceRect { x: 26, y: 35, width: 50, height: 25 })); + assert_eq!((outer.columns, outer.rows), (None, None)); + assert_eq!((outer.x_offset, outer.y_offset), (0, 0)); + assert_eq!(outer.z, 3); + } + + #[test] + fn terminal_offsets_at_right_and_bottom_edges_are_contained_inside_pane() { + let inner_image = KittyImage { + id: 4, + number: 0, + generation: 1, + width: 100, + height: 100, + format: KittyImageFormat::Rgba, + data: Arc::from(vec![0; 100 * 100 * 4]), + }; + let inner = KittyPlacement { + key: KittyPlacementKey { image_id: 4, placement_id: 2, ordinal: 0 }, + image_id: 4, + placement_id: 2, + is_internal: false, + x_offset: 4, + y_offset: 5, + source_x: 0, + source_y: 0, + source_width: 100, + source_height: 100, + columns: 2, + rows: 2, + grid_cols: 2, + grid_rows: 2, + pixel_width: 20, + pixel_height: 40, + viewport_col: 3, + viewport_row: 2, + viewport_visible: true, + anchor: None, + z: 0, + }; + let pane = Rect { x: 10, y: 5, width: 5, height: 4 }; + let outer = kitty_graphic_placement( + pane, + 0, + (10, 20), + kitty_graphic_image(0, 9, &inner_image), + &inner, + ) + .unwrap(); + assert_eq!(outer.rect, Rect { x: 13, y: 7, width: 2, height: 2 }); + assert_eq!(outer.source, Some(GraphicSourceRect { x: 0, y: 0, width: 50, height: 50 })); + assert_eq!((outer.x_offset, outer.y_offset), (4, 5)); + let pixels = rendered_pixel_size(&outer); + assert!( + u32::from(outer.rect.x - pane.x) * 10 + outer.x_offset + pixels.0 + <= u32::from(pane.width) * 10 + ); + assert!( + u32::from(outer.rect.y - pane.y) * 20 + outer.y_offset + pixels.1 + <= u32::from(pane.height) * 20 + ); + } + + #[test] + fn terminal_native_size_omits_outer_columns_and_rows() { + let command = emitted_placement(translated_terminal_placement( + 3, + 2, + "", + Rect { x: 0, y: 0, width: 20, height: 8 }, + )); + assert!(!command.contains(",c="), "{command:?}"); + assert!(!command.contains(",r="), "{command:?}"); + } + + #[test] + fn terminal_column_only_size_omits_outer_rows() { + let command = emitted_placement(translated_terminal_placement( + 20, + 10, + ",c=2", + Rect { x: 0, y: 0, width: 20, height: 8 }, + )); + assert!(command.contains(",c=2"), "{command:?}"); + assert!(!command.contains(",r="), "{command:?}"); + } + + #[test] + fn terminal_row_only_size_omits_outer_columns() { + let command = emitted_placement(translated_terminal_placement( + 10, + 40, + ",r=2", + Rect { x: 0, y: 0, width: 20, height: 8 }, + )); + assert!(!command.contains(",c="), "{command:?}"); + assert!(command.contains(",r=2"), "{command:?}"); + } + + #[test] + fn terminal_explicit_cell_size_emits_both_outer_axes() { + let command = emitted_placement(translated_terminal_placement( + 20, + 40, + ",c=2,r=2", + Rect { x: 0, y: 0, width: 20, height: 8 }, + )); + assert!(command.contains(",c=2,r=2"), "{command:?}"); + } + + #[test] + fn terminal_clipping_resolves_both_axes_and_crops_source_inside_pane() { + let outer = translated_terminal_placement( + 20, + 40, + ",X=4,Y=5,c=2,r=2", + Rect { x: 0, y: 0, width: 2, height: 2 }, + ); + assert_eq!(outer.rect, Rect { x: 0, y: 0, width: 2, height: 2 }); + assert_eq!(outer.source, Some(GraphicSourceRect { x: 0, y: 0, width: 10, height: 20 })); + assert_eq!((outer.x_offset, outer.y_offset), (4, 5)); + + let command = emitted_placement(outer); + assert!(command.contains("x=0,y=0,w=10,h=20,X=4,Y=5,c=1,r=1"), "{command:?}"); + } + + #[test] + fn native_clipping_uses_rendered_pixels_on_every_edge() { + let horizontal_pane = Rect { x: 10, y: 5, width: 1, height: 1 }; + let (horizontal_image, horizontal) = decoded_terminal_placement(15, 10, ",X=4"); + + let right = translate_terminal_placement(&horizontal_image, &horizontal, horizontal_pane); + assert_eq!((right.x_offset, right.y_offset), (4, 0)); + assert_pixel_geometry( + &right, + horizontal_pane, + GraphicSourceRect { x: 0, y: 0, width: 6, height: 10 }, + (None, None), + (6, 10), + ); + + let mut left_placement = horizontal; + left_placement.viewport_col = -1; + let left = + translate_terminal_placement(&horizontal_image, &left_placement, horizontal_pane); + assert_eq!((left.x_offset, left.y_offset), (0, 0)); + assert_pixel_geometry( + &left, + horizontal_pane, + GraphicSourceRect { x: 6, y: 0, width: 9, height: 10 }, + (None, None), + (9, 10), + ); + + let vertical_pane = Rect { x: 10, y: 5, width: 2, height: 1 }; + let (vertical_image, vertical) = decoded_terminal_placement(15, 10, ",Y=15"); + let bottom = translate_terminal_placement(&vertical_image, &vertical, vertical_pane); + assert_eq!((bottom.x_offset, bottom.y_offset), (0, 15)); + assert_pixel_geometry( + &bottom, + vertical_pane, + GraphicSourceRect { x: 0, y: 0, width: 15, height: 5 }, + (None, None), + (15, 5), + ); + + let mut top_placement = vertical; + top_placement.viewport_row = -1; + let top = translate_terminal_placement(&vertical_image, &top_placement, vertical_pane); + assert_eq!((top.x_offset, top.y_offset), (0, 0)); + assert_pixel_geometry( + &top, + vertical_pane, + GraphicSourceRect { x: 0, y: 5, width: 15, height: 5 }, + (None, None), + (15, 5), + ); + } + + #[test] + fn column_only_clipping_preserves_inferred_rows_on_every_edge() { + let horizontal_pane = Rect { x: 10, y: 5, width: 2, height: 1 }; + let (horizontal_image, horizontal) = decoded_terminal_placement(20, 10, ",X=4,c=2"); + + let right = translate_terminal_placement(&horizontal_image, &horizontal, horizontal_pane); + assert_eq!((right.x_offset, right.y_offset), (4, 0)); + assert_pixel_geometry( + &right, + horizontal_pane, + GraphicSourceRect { x: 0, y: 0, width: 10, height: 10 }, + (Some(1), None), + (10, 10), + ); + + let mut left_placement = horizontal; + left_placement.viewport_col = -1; + let left = + translate_terminal_placement(&horizontal_image, &left_placement, horizontal_pane); + assert_eq!((left.x_offset, left.y_offset), (0, 0)); + assert_pixel_geometry( + &left, + horizontal_pane, + GraphicSourceRect { x: 6, y: 0, width: 10, height: 10 }, + (Some(1), None), + (10, 10), + ); + + let vertical_pane = Rect { x: 10, y: 5, width: 2, height: 1 }; + let (vertical_image, vertical) = decoded_terminal_placement(20, 10, ",Y=15,c=2"); + let bottom = translate_terminal_placement(&vertical_image, &vertical, vertical_pane); + assert_eq!((bottom.x_offset, bottom.y_offset), (0, 15)); + assert_pixel_geometry( + &bottom, + vertical_pane, + GraphicSourceRect { x: 0, y: 0, width: 20, height: 5 }, + (Some(2), None), + (20, 5), + ); + + let mut top_placement = vertical; + top_placement.viewport_row = -1; + let top = translate_terminal_placement(&vertical_image, &top_placement, vertical_pane); + assert_eq!((top.x_offset, top.y_offset), (0, 0)); + assert_pixel_geometry( + &top, + vertical_pane, + GraphicSourceRect { x: 0, y: 5, width: 20, height: 5 }, + (Some(2), None), + (20, 5), + ); + } + + #[test] + fn row_only_clipping_preserves_inferred_columns_on_every_edge() { + let horizontal_pane = Rect { x: 10, y: 5, width: 1, height: 2 }; + let (horizontal_image, horizontal) = decoded_terminal_placement(10, 40, ",X=5,r=2"); + + let right = translate_terminal_placement(&horizontal_image, &horizontal, horizontal_pane); + assert_eq!((right.x_offset, right.y_offset), (5, 0)); + assert_pixel_geometry( + &right, + horizontal_pane, + GraphicSourceRect { x: 0, y: 0, width: 5, height: 40 }, + (None, Some(2)), + (5, 40), + ); + + let mut left_placement = horizontal; + left_placement.viewport_col = -1; + let left = + translate_terminal_placement(&horizontal_image, &left_placement, horizontal_pane); + assert_eq!((left.x_offset, left.y_offset), (0, 0)); + assert_pixel_geometry( + &left, + horizontal_pane, + GraphicSourceRect { x: 5, y: 0, width: 5, height: 40 }, + (None, Some(2)), + (5, 40), + ); + + let vertical_pane = Rect { x: 10, y: 5, width: 1, height: 2 }; + let (vertical_image, vertical) = decoded_terminal_placement(10, 40, ",Y=5,r=2"); + let bottom = translate_terminal_placement(&vertical_image, &vertical, vertical_pane); + assert_eq!((bottom.x_offset, bottom.y_offset), (0, 5)); + assert_pixel_geometry( + &bottom, + vertical_pane, + GraphicSourceRect { x: 0, y: 0, width: 10, height: 20 }, + (None, Some(1)), + (10, 20), + ); + + let mut top_placement = vertical; + top_placement.viewport_row = -1; + let top = translate_terminal_placement(&vertical_image, &top_placement, vertical_pane); + assert_eq!((top.x_offset, top.y_offset), (0, 0)); + assert_pixel_geometry( + &top, + vertical_pane, + GraphicSourceRect { x: 0, y: 15, width: 10, height: 20 }, + (None, Some(1)), + (10, 20), + ); + } + + #[test] + fn browser_png_and_terminal_raw_images_share_one_scene() { + let browser = GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 4, + 100, + 50, + "AAAA".to_string(), + ); + let terminal = placement( + image(2, 3, 5, GraphicFormat::Rgba, &[255; 16]), + 0, + 0, + Rect { x: 10, y: 0, width: 2, height: 2 }, + ); + let mut state = GraphicsState::default(); + let output = joined(&state.frame_batches(&[browser, terminal])); + assert!(output.contains("f=100")); + assert!(output.contains("f=32")); + assert_eq!(state.image_ids.len(), 2); + } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs index d3d6173973..61fd35f71c 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs @@ -1,9 +1,15 @@ -use std::collections::{BTreeSet, VecDeque}; +use std::collections::VecDeque; +use std::io; +#[cfg(test)] use std::io::Write; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +#[cfg(test)] +use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex, MutexGuard}; -use std::thread::JoinHandle; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::thread::{JoinHandle, ThreadId}; use std::time::{Duration, Instant}; use cmux_tui_core::{Rect, SurfaceId}; @@ -17,61 +23,24 @@ use super::graphics::{ pub struct StdoutLock { mutex: ReentrantMutex<()>, - pending_recovery: Mutex, -} - -#[derive(Default)] -struct PendingStreamRecovery { - required: bool, - possibly_visible: BTreeSet, } impl StdoutLock { pub fn new(_: ()) -> Self { - Self { - mutex: ReentrantMutex::new(()), - pending_recovery: Mutex::new(PendingStreamRecovery::default()), - } + Self { mutex: ReentrantMutex::new(()) } } pub fn lock(&self) -> ReentrantMutexGuard<'_, ()> { self.mutex.lock() } + #[cfg(test)] pub fn try_lock(&self) -> Option> { self.mutex.try_lock() } - fn pending_stream_recovery(&self) -> Option> { - let pending = self.pending_recovery.lock().unwrap(); - pending.required.then(|| pending.possibly_visible.clone()) - } - - fn mark_stream_recovery(&self, possibly_visible: &BTreeSet) { - let mut pending = self.pending_recovery.lock().unwrap(); - pending.required = true; - pending.possibly_visible.extend(possibly_visible); - } - - fn clear_stream_recovery(&self) { - let mut pending = self.pending_recovery.lock().unwrap(); - pending.required = false; - pending.possibly_visible.clear(); - } - - pub(crate) fn recover_stream_locked(&self) -> std::io::Result<()> { - let Some(possibly_visible) = self.pending_stream_recovery() else { - return Ok(()); - }; - let mode = GraphicsOutputMode::stdout(); - let mut output = std::io::stdout(); - let mut nonblocking = NonblockingOutputGuard::begin(mode)?; - let result = recover_partial_graphics_output(&mut output, mode, &possibly_visible); - let restored = nonblocking.restore(); - if result.is_ok() { - self.clear_stream_recovery(); - } - result.and(restored) + pub(crate) const fn recover_stream_locked(&self) -> io::Result<()> { + Ok(()) } } @@ -82,87 +51,6 @@ const INCOMPLETE_GRAPHICS_RESPONSE_GRACE: Duration = Duration::from_millis(200); const MAX_RETIRED_FENCES: usize = 4; const MAX_GRAPHICS_RESPONSE_EVENTS: usize = 128; const MAX_CONSECUTIVE_GRAPHICS_FENCE_TIMEOUTS: u8 = 2; -const GRAPHICS_OUTPUT_TIMEOUT: Duration = Duration::from_secs(1); -const GRAPHICS_OUTPUT_RECOVERY_TIMEOUT: Duration = Duration::from_millis(250); -const GRAPHICS_OUTPUT_POLL: Duration = Duration::from_millis(25); -const KITTY_STRING_TERMINATOR: &[u8] = b"\x1b\\"; - -#[derive(Clone, Copy)] -enum GraphicsOutputMode { - #[cfg_attr(all(unix, not(test)), allow(dead_code))] - Cooperative, - #[cfg(unix)] - NonblockingFd(std::os::fd::RawFd), -} - -impl GraphicsOutputMode { - fn stdout() -> Self { - #[cfg(unix)] - { - Self::NonblockingFd(libc::STDOUT_FILENO) - } - #[cfg(not(unix))] - { - Self::Cooperative - } - } -} - -struct NonblockingOutputGuard { - #[cfg(unix)] - restore: Option<(std::os::fd::RawFd, libc::c_int)>, -} - -impl NonblockingOutputGuard { - fn begin(mode: GraphicsOutputMode) -> std::io::Result { - #[cfg(unix)] - if let GraphicsOutputMode::NonblockingFd(fd) = mode { - // SAFETY: `fd` is the live stdout descriptor supplied by the - // process, and F_GETFL does not mutate memory. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; - if flags < 0 { - return Err(std::io::Error::last_os_error()); - } - if flags & libc::O_NONBLOCK == 0 { - // SAFETY: the descriptor remains live while the graphics - // writer owns the shared stdout lock. - if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { - return Err(std::io::Error::last_os_error()); - } - return Ok(Self { restore: Some((fd, flags)) }); - } - } - Ok(Self { - #[cfg(unix)] - restore: None, - }) - } - - fn restore(&mut self) -> std::io::Result<()> { - #[cfg(unix)] - if let Some((fd, flags)) = self.restore.take() { - // SAFETY: the guard only stores a descriptor after a successful - // F_GETFL/F_SETFL pair, and restoration runs before releasing the - // shared stdout lock. - if unsafe { libc::fcntl(fd, libc::F_SETFL, flags) } < 0 { - return Err(std::io::Error::last_os_error()); - } - } - Ok(()) - } -} - -impl Drop for NonblockingOutputGuard { - fn drop(&mut self) { - let _ = self.restore(); - } -} - -struct GraphicsSubmission { - id: u64, - session_generation: u64, - placements: Vec, -} #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProcessedGraphic { @@ -279,35 +167,35 @@ impl GraphicsFenceWaiter { } #[cfg(test)] - fn wait_for(&self, expected: u32) -> std::io::Result<()> { + fn wait_for(&self, expected: u32) -> io::Result<()> { self.wait_for_shutdown(expected, &AtomicBool::new(false)) } - fn wait_for_shutdown(&self, expected: u32, shutdown: &AtomicBool) -> std::io::Result<()> { + fn wait_for_shutdown(&self, expected: u32, shutdown: &AtomicBool) -> io::Result<()> { let deadline = Instant::now() + PROCESSING_FENCE_TIMEOUT; let response = loop { if shutdown.load(Ordering::Acquire) { self.cancel(expected); - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, + return Err(io::Error::new( + io::ErrorKind::Interrupted, "graphics processing fence wait interrupted by shutdown", )); } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { self.cancel(expected); - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, + return Err(io::Error::new( + io::ErrorKind::TimedOut, "graphics processing fence timed out", )); } match self.responses.recv_timeout(remaining.min(PROCESSING_FENCE_SHUTDOWN_POLL)) { Ok(response) => break response, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { self.cancel(expected); - return Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, "graphics processing fence channel disconnected", )); } @@ -315,8 +203,8 @@ impl GraphicsFenceWaiter { }; self.cancel(expected); if response.id != expected { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, + return Err(io::Error::new( + io::ErrorKind::InvalidData, format!( "graphics processing fence replied out of order: expected {expected}, got {}", response.id @@ -324,7 +212,7 @@ impl GraphicsFenceWaiter { )); } if !response.ok { - return Err(std::io::Error::other(format!( + return Err(io::Error::other(format!( "host rejected graphics processing fence {expected}" ))); } @@ -523,7 +411,7 @@ fn parse_graphics_response(payload: &str) -> Option { trait ProcessingFence: Send + 'static { fn prepare(&mut self, _id: u32) {} fn cancel(&mut self, _id: u32) {} - fn wait(&mut self, id: u32, shutdown: &AtomicBool) -> std::io::Result<()>; + fn wait(&mut self, id: u32, shutdown: &AtomicBool) -> io::Result<()>; } impl ProcessingFence for GraphicsFenceWaiter { @@ -535,7 +423,7 @@ impl ProcessingFence for GraphicsFenceWaiter { GraphicsFenceWaiter::cancel(self, id); } - fn wait(&mut self, id: u32, shutdown: &AtomicBool) -> std::io::Result<()> { + fn wait(&mut self, id: u32, shutdown: &AtomicBool) -> io::Result<()> { self.wait_for_shutdown(id, shutdown) } } @@ -546,12 +434,12 @@ struct ClosureProcessingFence(F); #[cfg(test)] impl ProcessingFence for ClosureProcessingFence where - F: FnMut() -> std::io::Result<()> + Send + 'static, + F: FnMut() -> io::Result<()> + Send + 'static, { - fn wait(&mut self, _id: u32, shutdown: &AtomicBool) -> std::io::Result<()> { + fn wait(&mut self, _id: u32, shutdown: &AtomicBool) -> io::Result<()> { if shutdown.load(Ordering::Acquire) { - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, + return Err(io::Error::new( + io::ErrorKind::Interrupted, "graphics processing fence wait interrupted by shutdown", )); } @@ -559,130 +447,631 @@ where } } +/// Bound one stdout-lock hold while preserving complete Kitty APC commands. +/// This lets ordinary terminal draws make progress during multi-megabyte +/// image uploads without ever interleaving bytes inside one protocol command. +const MAX_LOCKED_GRAPHICS_WRITE_BYTES: usize = 64 * 1024; +const CONTROL_STRING_CANCEL: u8 = 0x18; +const DELETE_ALL_GRAPHICS: &[u8] = b"\x1b_Ga=d,d=A,q=2;\x1b\\"; +const TERMINATE_MULTIPART_GRAPHICS: &[u8] = b"\x1b_Gq=2,m=0;\x1b\\\x1b_Ga=d,d=A,q=2;\x1b\\"; +#[cfg(unix)] +const OUTPUT_POLL_INTERVAL_MS: i32 = 20; +#[cfg(unix)] +const CONTROL_STRING_ABORT_TIMEOUT: Duration = Duration::from_millis(200); +#[cfg(unix)] +const GRAPHICS_SEGMENT_WRITE_TIMEOUT: Duration = Duration::from_millis(200); + +trait GraphicsOutput: Send + 'static { + /// Write one complete group of Kitty APC commands. `Ok(false)` means + /// cancellation or snapshot supersession won before the complete group + /// was emitted. `emitted` reports accepted bytes even when the write + /// stops early, so completed multipart chunks can still be recovered. + fn write_segment( + &mut self, + bytes: &[u8], + permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result; + + /// Write protocol recovery bytes without honoring the permit that caused + /// the interruption. Production implementations must keep this bounded. + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()>; +} + +#[cfg(unix)] +struct InterruptibleStdout { + fd: OwnedFd, +} + +#[cfg(unix)] +impl InterruptibleStdout { + fn open() -> io::Result { + // `dup` would share file-status flags with stdout, so setting + // O_NONBLOCK on the duplicate would also make ratatui's stdout + // writes nonblocking. Opening the controlling terminal creates an + // independent open-file description for the same terminal. + let raw = unsafe { + libc::open(c"/dev/tty".as_ptr(), libc::O_WRONLY | libc::O_NONBLOCK | libc::O_CLOEXEC) + }; + if raw < 0 { + return Err(io::Error::last_os_error()); + } + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; + Ok(Self { fd }) + } + + fn abort_partial_control_string(&mut self) -> io::Result<()> { + let deadline = Instant::now() + CONTROL_STRING_ABORT_TIMEOUT; + loop { + if Instant::now() >= deadline { + // The terminal output queue is shared with Ratatui and other + // descriptors. A graphics timeout may abandon this operation, + // but it must never flush their bytes. + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal stayed blocked while canceling a partial control string", + )); + } + let written = unsafe { + libc::write(self.fd.as_raw_fd(), (&CONTROL_STRING_CANCEL as *const u8).cast(), 1) + }; + if written == 1 { + return Ok(()); + } + if written == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "terminal accepted zero cancellation bytes", + )); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::EINTR) => continue, + Some(libc::EAGAIN) => { + let mut poll_fd = + libc::pollfd { fd: self.fd.as_raw_fd(), events: libc::POLLOUT, revents: 0 }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll_ms = remaining + .min(Duration::from_millis(OUTPUT_POLL_INTERVAL_MS as u64)) + .as_millis() as i32; + let ready = unsafe { libc::poll(&mut poll_fd, 1, poll_ms) }; + if ready < 0 && io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return Err(io::Error::last_os_error()); + } + } + _ => return Err(error), + } + } + } +} + +#[cfg(unix)] +impl GraphicsOutput for InterruptibleStdout { + fn write_segment( + &mut self, + bytes: &[u8], + permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + let mut offset = 0; + *emitted = 0; + let deadline = Instant::now() + GRAPHICS_SEGMENT_WRITE_TIMEOUT; + while offset < bytes.len() { + if permit.should_abort() { + if offset != 0 { + self.abort_partial_control_string()?; + } + return Ok(false); + } + if Instant::now() >= deadline { + if offset != 0 { + self.abort_partial_control_string()?; + } + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal stayed blocked while writing a graphics segment", + )); + } + let written = unsafe { + libc::write( + self.fd.as_raw_fd(), + bytes[offset..].as_ptr().cast(), + bytes.len() - offset, + ) + }; + if written > 0 { + offset += written as usize; + *emitted = offset; + continue; + } + if written == 0 { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(io::Error::new(io::ErrorKind::WriteZero, "stdout accepted zero bytes")); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::EINTR) => continue, + Some(libc::EAGAIN) => { + #[cfg(test)] + permit.control.report_write_attempt(); + let mut poll_fd = + libc::pollfd { fd: self.fd.as_raw_fd(), events: libc::POLLOUT, revents: 0 }; + let ready = unsafe { libc::poll(&mut poll_fd, 1, OUTPUT_POLL_INTERVAL_MS) }; + if ready < 0 && io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return Err(io::Error::last_os_error()); + } + } + _ => { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(error); + } + } + } + Ok(true) + } + + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()> { + let mut offset = 0; + let deadline = Instant::now() + CONTROL_STRING_ABORT_TIMEOUT; + while offset < bytes.len() { + if Instant::now() >= deadline { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal stayed blocked while recovering a graphics command", + )); + } + let written = unsafe { + libc::write( + self.fd.as_raw_fd(), + bytes[offset..].as_ptr().cast(), + bytes.len() - offset, + ) + }; + if written > 0 { + offset += written as usize; + continue; + } + if written == 0 { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "terminal accepted zero recovery bytes", + )); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::EINTR) => continue, + Some(libc::EAGAIN) => { + let mut poll_fd = + libc::pollfd { fd: self.fd.as_raw_fd(), events: libc::POLLOUT, revents: 0 }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll_ms = remaining + .min(Duration::from_millis(OUTPUT_POLL_INTERVAL_MS as u64)) + .as_millis() as i32; + let ready = unsafe { libc::poll(&mut poll_fd, 1, poll_ms) }; + if ready < 0 && io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(io::Error::last_os_error()); + } + } + _ => { + if offset != 0 { + let _ = self.abort_partial_control_string(); + } + return Err(error); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +struct TestOutput(W); + +#[cfg(test)] +impl GraphicsOutput for TestOutput +where + W: Write + Send + 'static, +{ + fn write_segment( + &mut self, + bytes: &[u8], + permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + *emitted = 0; + if permit.should_abort() { + return Ok(false); + } + self.0.write_all(bytes)?; + self.0.flush()?; + *emitted = bytes.len(); + Ok(true) + } + + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()> { + self.0.write_all(bytes)?; + self.0.flush() + } +} + +pub(crate) type GraphicsScene = Vec>; + +struct GraphicsSubmission { + id: u64, + session_generation: u64, + scene: GraphicsScene, +} + +#[derive(Default)] +struct PendingGraphics { + submission: Option, + host_scene_epoch: u64, + revision: u64, +} + +struct PendingUpdate { + submission: Option, + host_scene_epoch: u64, + revision: u64, +} + +struct WriterLoopState { + slot: Arc>, + completion: Arc>>, + notifications: Receiver<()>, + stdout_lock: Arc, + control: Arc, +} + +struct WritePermit<'a> { + slot: &'a Mutex, + control: &'a WriterControl, + revision: u64, +} + +impl WritePermit<'_> { + fn superseded(&self) -> bool { + lock_recover(self.slot).revision != self.revision + } + + fn should_abort(&self) -> bool { + self.control.is_cancelled() || self.superseded() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GraphicsWriterFailure { + pub(crate) parser_reset_required: bool, +} + +#[derive(Default)] +struct WriterControlState { + done: bool, + failure: Option, +} + +#[derive(Default)] +struct WriterControl { + stop_requested: AtomicBool, + cancelled: AtomicBool, + worker_thread: OnceLock, + state: Mutex, + changed: Condvar, + #[cfg(test)] + write_attempt_observer: Mutex>>, +} + +impl WriterControl { + fn request_stop(&self, notify: &SyncSender<()>) { + self.stop_requested.store(true, Ordering::Release); + notify_writer(notify); + } + + fn request_cancel(&self, notify: &SyncSender<()>) { + self.cancelled.store(true, Ordering::Release); + self.changed.notify_all(); + notify_writer(notify); + } + + fn is_stopping(&self) -> bool { + self.stop_requested.load(Ordering::Acquire) + || self.is_cancelled() + || self.failure().is_some() + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + + fn mark_done(&self) { + self.state.lock().unwrap().done = true; + self.changed.notify_all(); + } + + fn record_failure(&self, parser_reset_required: bool) { + let mut state = self.state.lock().unwrap(); + state.failure = Some(GraphicsWriterFailure { + parser_reset_required: state + .failure + .is_some_and(|failure| failure.parser_reset_required) + || parser_reset_required, + }); + self.changed.notify_all(); + } + + fn failure(&self) -> Option { + self.state.lock().unwrap().failure + } + + fn wait_done(&self) { + let state = self.state.lock().unwrap(); + drop(self.changed.wait_while(state, |state| !state.done).unwrap()); + } + + fn wait_done_timeout(&self, timeout: Duration) -> bool { + let state = self.state.lock().unwrap(); + if state.done { + return true; + } + let (state, _) = + self.changed.wait_timeout_while(state, timeout, |state| !state.done).unwrap(); + state.done + } + + #[cfg(test)] + fn wait_until_cancelled(&self) { + let state = self.state.lock().unwrap(); + drop(self.changed.wait_while(state, |_| !self.cancelled.load(Ordering::Acquire)).unwrap()); + } + + #[cfg(test)] + fn observe_write_attempts(&self, observer: SyncSender<()>) { + *self.write_attempt_observer.lock().unwrap() = Some(observer); + } + + #[cfg(test)] + fn report_write_attempt(&self) { + if let Some(observer) = self.write_attempt_observer.lock().unwrap().as_ref() { + let _ = observer.try_send(()); + } + } +} + +#[derive(Clone)] +pub(crate) struct GraphicsWriterShutdown { + control: Arc, + notify: SyncSender<()>, +} + +impl GraphicsWriterShutdown { + /// Stop the writer and wait until no future host-terminal writes are + /// possible. Callers must not own the stdout lock while waiting. + pub(crate) fn cancel_and_wait(&self) { + self.control.request_cancel(&self.notify); + if self + .control + .worker_thread + .get() + .is_none_or(|worker| *worker != std::thread::current().id()) + { + self.control.wait_done(); + } + } + + pub(crate) fn cancel_for_panic_hook(&self) { + // A renderer panic can still own the reentrant stdout lock. Only + // signal here; the writer observes cancellation before writing after + // that lock is released, and normal unwind cleanup performs the join. + self.control.request_cancel(&self.notify); + } + + #[cfg(test)] + fn wait_until_cancelled(&self) { + self.control.wait_until_cancelled(); + } +} + pub struct GraphicsWriter { - slot: Arc>>, + slot: Arc>, completion: Arc>>, notify: Option>, - done: Option>, + control: Arc, handle: Option>, - shutdown: Arc, } impl GraphicsWriter { + pub(crate) const fn platform_supported() -> bool { + cfg!(unix) + } + + #[cfg(unix)] pub fn spawn( stdout_lock: Arc, processing_fence: GraphicsFenceWaiter, on_ready: impl Fn() + Send + 'static, - ) -> std::io::Result { - Self::spawn_with_fence( + ) -> io::Result { + Self::spawn_with_graphics_output( stdout_lock, - std::io::stdout(), - GraphicsOutputMode::stdout(), + InterruptibleStdout::open()?, processing_fence, on_ready, ) } - #[cfg(test)] - fn spawn_with_output( - stdout_lock: Arc, - output: impl Write + Send + 'static, - on_ready: impl Fn() + Send + 'static, - ) -> std::io::Result { - Self::spawn_with_output_and_fence(stdout_lock, output, || Ok(()), on_ready) + #[cfg(not(unix))] + pub fn spawn( + _stdout_lock: Arc, + _processing_fence: GraphicsFenceWaiter, + _on_ready: impl Fn() + Send + 'static, + ) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "inline graphics output requires an interruptible terminal writer", + )) } #[cfg(test)] - fn spawn_with_output_and_fence( + fn spawn_with_output(stdout_lock: Arc, output: W) -> io::Result + where + W: Write + Send + 'static, + { + Self::spawn_with_output_and_fence(stdout_lock, output, || Ok(()), || {}) + } + + #[cfg(test)] + fn spawn_with_output_and_fence( stdout_lock: Arc, - output: impl Write + Send + 'static, - processing_fence: impl FnMut() -> std::io::Result<()> + Send + 'static, + output: W, + processing_fence: impl FnMut() -> io::Result<()> + Send + 'static, on_ready: impl Fn() + Send + 'static, - ) -> std::io::Result { - Self::spawn_with_fence( + ) -> io::Result + where + W: Write + Send + 'static, + { + Self::spawn_with_graphics_output( stdout_lock, - output, - GraphicsOutputMode::Cooperative, + TestOutput(output), ClosureProcessingFence(processing_fence), on_ready, ) } - fn spawn_with_fence( + #[cfg(test)] + fn spawn_with_test_graphics_output( stdout_lock: Arc, - output: impl Write + Send + 'static, - output_mode: GraphicsOutputMode, + output: O, + ) -> io::Result + where + O: GraphicsOutput, + { + Self::spawn_with_graphics_output( + stdout_lock, + output, + ClosureProcessingFence(|| Ok(())), + || {}, + ) + } + + fn spawn_with_graphics_output( + stdout_lock: Arc, + output: O, processing_fence: impl ProcessingFence, on_ready: impl Fn() + Send + 'static, - ) -> std::io::Result { + ) -> io::Result + where + O: GraphicsOutput, + { let (tx, rx) = sync_channel(1); - let (done_tx, done_rx) = sync_channel(1); - let slot = Arc::new(Mutex::new(None)); + let slot = Arc::new(Mutex::new(PendingGraphics::default())); let completion = Arc::new(Mutex::new(None)); - let shutdown = Arc::new(AtomicBool::new(false)); + let control = Arc::new(WriterControl::default()); let handle = std::thread::Builder::new().name("mux-graphics-writer".into()).spawn({ let slot = slot.clone(); let completion = completion.clone(); - let shutdown = shutdown.clone(); + let control = control.clone(); move || { - writer_loop(WriterLoop { - slot, - completion, - rx, - stdout_lock, + writer_loop( + WriterLoopState { slot, completion, notifications: rx, stdout_lock, control }, output, - output_mode, - processing_fence_waiter: processing_fence, + processing_fence, on_ready, - done_tx, - shutdown, - }); + ); } })?; - Ok(Self { - slot, - completion, - notify: Some(tx), - done: Some(done_rx), - handle: Some(handle), - shutdown, - }) + Ok(Self { slot, completion, notify: Some(tx), control, handle: Some(handle) }) } + pub(crate) fn shutdown_control(&self) -> GraphicsWriterShutdown { + GraphicsWriterShutdown { + control: self.control.clone(), + notify: self.notify.as_ref().expect("active graphics writer").clone(), + } + } + + pub(crate) fn failure(&self) -> Option { + self.control.failure() + } + + #[cfg(test)] pub fn submit( &self, id: u64, session_generation: u64, placements: Vec, ) -> bool { - if self.shutdown.load(Ordering::Acquire) { + self.submit_scene(id, session_generation, vec![Arc::from(placements)]) + } + + #[cfg(test)] + fn submit_test(&self, placements: Vec) -> bool { + static NEXT_SUBMISSION: AtomicU64 = AtomicU64::new(1); + self.submit(NEXT_SUBMISSION.fetch_add(1, Ordering::Relaxed), 1, placements) + } + + pub(crate) fn submit_scene( + &self, + id: u64, + session_generation: u64, + scene: GraphicsScene, + ) -> bool { + if self.control.is_stopping() { return false; } let Some(tx) = &self.notify else { return false }; - submit_snapshot(&self.slot, tx, GraphicsSubmission { id, session_generation, placements }) + submit_snapshot(&self.slot, tx, GraphicsSubmission { id, session_generation, scene }) } pub fn take_completion(&self) -> Option { self.completion.lock().unwrap().take() } + /// Mark the host terminal's Kitty scene as cleared. + /// + /// The epoch remains in the latest-wins slot until the writer observes + /// it, so later snapshot replacement cannot discard the invalidation. + pub fn invalidate_host_scene(&self) { + if self.control.is_stopping() { + return; + } + let Some(tx) = &self.notify else { return }; + let mut pending = lock_recover(&self.slot); + pending.revision = pending.revision.wrapping_add(1).max(1); + pending.host_scene_epoch = pending.host_scene_epoch.wrapping_add(1); + if pending.host_scene_epoch == 0 { + pending.host_scene_epoch = 1; + } + drop(pending); + notify_writer(tx); + } + pub fn shutdown(&mut self, timeout: Duration) { - self.shutdown.store(true, Ordering::Release); - self.notify.take(); let Some(handle) = self.handle.take() else { return }; - let Some(done) = self.done.take() else { + let Some(notify) = self.notify.as_ref() else { let _ = handle.join(); return; }; - match done.recv_timeout(timeout) { - Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = handle.join(); - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - self.done = Some(done); - self.handle = Some(handle); - } + self.control.request_stop(notify); + if !self.control.wait_done_timeout(timeout) { + self.control.request_cancel(notify); + // The production stdout descriptor is nonblocking and polls + // cancellation at most every OUTPUT_POLL_INTERVAL_MS. + self.control.wait_done(); } + let _ = handle.join(); + self.notify.take(); } } @@ -693,535 +1082,1106 @@ impl Drop for GraphicsWriter { } fn submit_snapshot( - slot: &Arc>>, + slot: &Arc>, tx: &SyncSender<()>, submission: GraphicsSubmission, ) -> bool { - *lock_recover(slot) = Some(submission); + let mut pending = lock_recover(slot); + pending.revision = pending.revision.wrapping_add(1).max(1); + pending.submission = Some(submission); + drop(pending); + notify_writer(tx) +} + +fn notify_writer(tx: &SyncSender<()>) -> bool { match tx.try_send(()) { Ok(()) | Err(TrySendError::Full(())) => true, Err(TrySendError::Disconnected(())) => false, } } -struct WriterLoop { - slot: Arc>>, - completion: Arc>>, - rx: Receiver<()>, - stdout_lock: Arc, - output: W, - output_mode: GraphicsOutputMode, - processing_fence_waiter: P, +fn take_pending_update( + slot: &Arc>, + applied_host_scene_epoch: u64, +) -> Option { + let mut pending = lock_recover(slot); + if pending.submission.is_none() && pending.host_scene_epoch == applied_host_scene_epoch { + return None; + } + Some(PendingUpdate { + submission: pending.submission.take(), + host_scene_epoch: pending.host_scene_epoch, + revision: pending.revision, + }) +} + +fn writer_loop( + state: WriterLoopState, + mut output: O, + mut processing_fence_waiter: P, on_ready: F, - done_tx: SyncSender<()>, - shutdown: Arc, -} - -fn graphics_output_interrupted() -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::Interrupted, "graphics output interrupted by shutdown") -} - -fn graphics_output_timed_out() -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::TimedOut, "graphics output timed out") -} - -fn check_graphics_output_deadline( - deadline: Instant, - shutdown: &AtomicBool, -) -> std::io::Result { - if shutdown.load(Ordering::Acquire) { - return Err(graphics_output_interrupted()); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(graphics_output_timed_out()); - } - Ok(remaining) -} - -fn wait_for_graphics_output( - mode: GraphicsOutputMode, - deadline: Instant, - shutdown: &AtomicBool, -) -> std::io::Result<()> { - let wait = check_graphics_output_deadline(deadline, shutdown)?.min(GRAPHICS_OUTPUT_POLL); - #[cfg(unix)] - if let GraphicsOutputMode::NonblockingFd(fd) = mode { - let timeout_ms = wait.as_millis().clamp(1, i32::MAX as u128) as i32; - let mut descriptor = libc::pollfd { fd, events: libc::POLLOUT, revents: 0 }; - // SAFETY: `descriptor` points to one initialized pollfd for the live - // stdout descriptor, and poll only mutates its revents field. - let ready = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; - if ready < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() != std::io::ErrorKind::Interrupted { - return Err(error); - } - return Ok(()); - } - let terminal_events = libc::POLLERR | libc::POLLHUP | libc::POLLNVAL; - if descriptor.revents & terminal_events != 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "graphics output descriptor closed", - )); - } - return Ok(()); - } - std::thread::sleep(wait); - Ok(()) -} - -fn write_graphics_bytes( - output: &mut W, - mode: GraphicsOutputMode, - mut bytes: &[u8], - deadline: Instant, - shutdown: &AtomicBool, - wrote_any: &mut bool, -) -> std::io::Result<()> { - while !bytes.is_empty() { - check_graphics_output_deadline(deadline, shutdown)?; - match output.write(bytes) { - Ok(0) => { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "graphics output made no progress", - )); - } - Ok(written) => { - *wrote_any = true; - bytes = &bytes[written..]; - } - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - wait_for_graphics_output(mode, deadline, shutdown)?; - } - Err(error) => return Err(error), - } - } - Ok(()) -} - -fn flush_graphics_output( - output: &mut W, - mode: GraphicsOutputMode, - deadline: Instant, - shutdown: &AtomicBool, -) -> std::io::Result<()> { - loop { - check_graphics_output_deadline(deadline, shutdown)?; - match output.flush() { - Ok(()) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - wait_for_graphics_output(mode, deadline, shutdown)?; - } - Err(error) => return Err(error), - } - } -} - -fn recover_partial_graphics_output( - output: &mut W, - mode: GraphicsOutputMode, - possibly_visible: &BTreeSet, -) -> std::io::Result<()> { - let deadline = Instant::now() + GRAPHICS_OUTPUT_RECOVERY_TIMEOUT; - let recovery = AtomicBool::new(false); - let mut wrote_any = false; - let mut result = write_graphics_bytes( - output, - mode, - KITTY_STRING_TERMINATOR, - deadline, - &recovery, - &mut wrote_any, - ); - for surface in possibly_visible { - if result.is_err() { - break; - } - result = write_graphics_bytes( - output, - mode, - &super::graphics::delete_image(*surface), - deadline, - &recovery, - &mut wrote_any, - ); - } - let terminated = write_graphics_bytes( - output, - mode, - KITTY_STRING_TERMINATOR, - deadline, - &recovery, - &mut wrote_any, - ) - .and_then(|()| flush_graphics_output(output, mode, deadline, &recovery)); - result.and(terminated) -} - -fn write_graphics_output( - stdout_lock: &StdoutLock, - output: &mut W, - output_mode: GraphicsOutputMode, - chunks: I, - possibly_visible: &BTreeSet, - shutdown: &AtomicBool, -) -> std::io::Result<()> -where - W: Write, - I: IntoIterator, - B: AsRef<[u8]>, -{ - let deadline = Instant::now() + GRAPHICS_OUTPUT_TIMEOUT; - let _guard = loop { - check_graphics_output_deadline(deadline, shutdown)?; - if let Some(guard) = stdout_lock.try_lock() { - break guard; - } - std::thread::sleep( - check_graphics_output_deadline(deadline, shutdown)?.min(GRAPHICS_OUTPUT_POLL), - ); - }; - let mut nonblocking = NonblockingOutputGuard::begin(output_mode)?; - if let Some(pending) = stdout_lock.pending_stream_recovery() { - let recovered = recover_partial_graphics_output(output, output_mode, &pending); - if recovered.is_ok() { - stdout_lock.clear_stream_recovery(); - } else { - let restored = nonblocking.restore(); - return recovered.and(restored); - } - } - let mut wrote_any = false; - let result = (|| { - for chunk in chunks { - write_graphics_bytes( - output, - output_mode, - chunk.as_ref(), - deadline, - shutdown, - &mut wrote_any, - )?; - } - flush_graphics_output(output, output_mode, deadline, shutdown) - })(); - if result.is_err() - && wrote_any - && recover_partial_graphics_output(output, output_mode, possibly_visible).is_err() - { - stdout_lock.mark_stream_recovery(possibly_visible); - } - let restored = nonblocking.restore(); - result.and(restored) -} - -fn writer_loop(worker: WriterLoop) -where - W: Write, +) where + O: GraphicsOutput, P: ProcessingFence, F: Fn(), { - let WriterLoop { - slot, - completion, - rx, - stdout_lock, - mut output, - output_mode, - mut processing_fence_waiter, - on_ready, - done_tx, - shutdown, - } = worker; - let _done = DoneOnDrop(done_tx); + let WriterLoopState { slot, completion, notifications, stdout_lock, control } = state; + let _ = control.worker_thread.set(std::thread::current().id()); + let _done = DoneOnDrop(control.clone()); let mut graphics = GraphicsState::default(); - let mut acknowledged_visible = BTreeSet::new(); - let mut possibly_visible = BTreeSet::new(); + let mut applied_host_scene_epoch = 0; + let mut host_reset_required = false; let mut consecutive_fence_timeouts = 0_u8; - while rx.recv().is_ok() { - if shutdown.load(Ordering::Acquire) { - return; + 'writer: loop { + if control.is_cancelled() { + break; } - loop { - if shutdown.load(Ordering::Acquire) { - return; + while let Some(update) = take_pending_update(&slot, applied_host_scene_epoch) { + if update.host_scene_epoch != applied_host_scene_epoch { + graphics.invalidate_host_scene(); + applied_host_scene_epoch = update.host_scene_epoch; + host_reset_required = false; } - let next = lock_recover(&slot).take(); - let Some(submission) = next else { break }; - let processed_graphics = submission - .placements + let Some(submission) = update.submission else { + continue; + }; + if host_reset_required { + match write_batch( + &mut output, + &stdout_lock, + &slot, + &control, + update.revision, + DELETE_ALL_GRAPHICS, + ) { + BatchWriteOutcome::Complete => { + graphics.invalidate_host_scene(); + host_reset_required = false; + } + BatchWriteOutcome::Superseded => continue, + BatchWriteOutcome::Stopped => break 'writer, + } + } + let placements = submission + .scene .iter() + .flat_map(|placements| placements.iter().cloned()) + .collect::>(); + let processed_graphics = placements + .iter() + .filter(|placement| placement.is_browser_frame()) .map(|placement| ProcessedGraphic { - surface: placement.surface, + surface: placement.key.image.surface, rect: placement.rect, - seq: placement.frame.seq, + seq: placement.image.generation, pointer_frame_seq: placement.pointer_frame_seq, }) - .collect(); - let submitted_visible = submission - .placements - .iter() - .filter(|placement| placement.rect.width > 0 && placement.rect.height > 0) - .map(|placement| placement.surface) - .collect::>(); - let acknowledged_graphics = graphics.clone(); - let mut batches = possibly_visible - .difference(&acknowledged_visible) - .filter(|surface| !submitted_visible.contains(surface)) - .copied() - .map(super::graphics::delete_image) .collect::>(); - batches.extend( - graphics.frame_batches(submission.session_generation, &submission.placements), - ); - possibly_visible.extend(submitted_visible.iter().copied()); + let mut next_graphics = graphics.clone(); + let mut completed = true; + let mut wrote_batch = false; + let mut batches = next_graphics.frame_batch_stream(&placements); + loop { + if control.is_cancelled() { + break 'writer; + } + if lock_recover(&slot).revision != update.revision { + host_reset_required |= wrote_batch; + completed = false; + break; + } + let Some(batch) = batches.next() else { + break; + }; + match write_batch( + &mut output, + &stdout_lock, + &slot, + &control, + update.revision, + &batch, + ) { + BatchWriteOutcome::Complete => wrote_batch = true, + BatchWriteOutcome::Superseded => { + host_reset_required = true; + completed = false; + break; + } + BatchWriteOutcome::Stopped => break 'writer, + } + } + drop(batches); + if !completed { + continue; + } + let fence_id = processing_fence_id(submission.id); processing_fence_waiter.prepare(fence_id); - let output_result = write_graphics_output( - &stdout_lock, + match write_batch( &mut output, - output_mode, - batches.into_iter().chain(std::iter::once(processing_fence(fence_id))), - &possibly_visible, - &shutdown, - ); - if output_result.is_err() { - processing_fence_waiter.cancel(fence_id); - if shutdown.load(Ordering::Acquire) { - return; - } - *completion.lock().unwrap() = Some(GraphicsCompletion::Failed); - on_ready(); - return; - } - let mut processed = processing_fence_waiter.wait(fence_id, &shutdown); - if shutdown.load(Ordering::Acquire) { - processing_fence_waiter.cancel(fence_id); - return; - } - if processed.as_ref().is_err_and(|error| error.kind() == std::io::ErrorKind::TimedOut) { - // The graphics bytes were written successfully. A second - // ordered query distinguishes a delayed response from a - // failed output stream without retransmitting image data. - processing_fence_waiter.prepare(fence_id); - let retry_output = write_graphics_output( - &stdout_lock, - &mut output, - output_mode, - std::iter::once(processing_fence(fence_id)), - &possibly_visible, - &shutdown, - ); - if retry_output.is_err() { + &stdout_lock, + &slot, + &control, + update.revision, + &processing_fence(fence_id), + ) { + BatchWriteOutcome::Complete => {} + BatchWriteOutcome::Superseded => { processing_fence_waiter.cancel(fence_id); - if shutdown.load(Ordering::Acquire) { - return; - } - *completion.lock().unwrap() = Some(GraphicsCompletion::Failed); - on_ready(); - return; + host_reset_required = true; + continue; + } + BatchWriteOutcome::Stopped => { + processing_fence_waiter.cancel(fence_id); + break 'writer; } - processed = processing_fence_waiter.wait(fence_id, &shutdown); } - if shutdown.load(Ordering::Acquire) { - processing_fence_waiter.cancel(fence_id); - return; - } - if let Err(error) = processed { - processing_fence_waiter.cancel(fence_id); - if error.kind() == std::io::ErrorKind::TimedOut { - consecutive_fence_timeouts = consecutive_fence_timeouts.saturating_add(1); - graphics = acknowledged_graphics; - if consecutive_fence_timeouts >= MAX_CONSECUTIVE_GRAPHICS_FENCE_TIMEOUTS { - let cleanup = possibly_visible - .iter() - .copied() - .map(super::graphics::delete_image) - .collect::>(); - let _ = write_graphics_output( - &stdout_lock, - &mut output, - output_mode, - cleanup, - &possibly_visible, - &shutdown, - ); - *completion.lock().unwrap() = Some(GraphicsCompletion::Failed); - on_ready(); - return; + + let mut processed = processing_fence_waiter.wait(fence_id, &control.cancelled); + if processed.as_ref().is_err_and(|error| error.kind() == io::ErrorKind::TimedOut) { + processing_fence_waiter.prepare(fence_id); + match write_batch( + &mut output, + &stdout_lock, + &slot, + &control, + update.revision, + &processing_fence(fence_id), + ) { + BatchWriteOutcome::Complete => { + processed = processing_fence_waiter.wait(fence_id, &control.cancelled); } - // One bounded re-probe may retransmit the unconfirmed - // image data. Persistent fence loss disables graphics. - *completion.lock().unwrap() = Some(GraphicsCompletion::TimedOut { + BatchWriteOutcome::Superseded => { + processing_fence_waiter.cancel(fence_id); + host_reset_required = true; + continue; + } + BatchWriteOutcome::Stopped => { + processing_fence_waiter.cancel(fence_id); + break 'writer; + } + } + } + + match processed { + Ok(()) => { + consecutive_fence_timeouts = 0; + graphics = next_graphics; + *lock_recover(&completion) = + Some(GraphicsCompletion::Processed(GraphicsProcessing { + id: submission.id, + session_generation: submission.session_generation, + graphics: processed_graphics, + })); + on_ready(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => { + processing_fence_waiter.cancel(fence_id); + break 'writer; + } + Err(error) if error.kind() == io::ErrorKind::TimedOut => { + processing_fence_waiter.cancel(fence_id); + consecutive_fence_timeouts = consecutive_fence_timeouts.saturating_add(1); + host_reset_required = true; + if consecutive_fence_timeouts >= MAX_CONSECUTIVE_GRAPHICS_FENCE_TIMEOUTS { + control.record_failure(false); + *lock_recover(&completion) = Some(GraphicsCompletion::Failed); + on_ready(); + break 'writer; + } + *lock_recover(&completion) = Some(GraphicsCompletion::TimedOut { id: submission.id, session_generation: submission.session_generation, }); on_ready(); - continue; } - *completion.lock().unwrap() = Some(GraphicsCompletion::Failed); - on_ready(); - return; + Err(_) => { + processing_fence_waiter.cancel(fence_id); + control.record_failure(false); + *lock_recover(&completion) = Some(GraphicsCompletion::Failed); + on_ready(); + break 'writer; + } } - consecutive_fence_timeouts = 0; - acknowledged_visible = submitted_visible.clone(); - possibly_visible = submitted_visible; - *completion.lock().unwrap() = Some(GraphicsCompletion::Processed(GraphicsProcessing { - id: submission.id, - session_generation: submission.session_generation, - graphics: processed_graphics, - })); - on_ready(); + } + if control.stop_requested.load(Ordering::Acquire) { + break; + } + if notifications.recv().is_err() { + break; } } + if !control.is_cancelled() && control.failure().is_none() { + let revision = lock_recover(&slot).revision; + for batch in graphics.frame_batches(&[]) { + if !matches!( + write_batch(&mut output, &stdout_lock, &slot, &control, revision, &batch,), + BatchWriteOutcome::Complete + ) { + break; + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BatchWriteOutcome { + Complete, + Superseded, + Stopped, +} + +fn write_batch( + output: &mut O, + stdout_lock: &Arc, + slot: &Arc>, + control: &WriterControl, + revision: u64, + batch: &[u8], +) -> BatchWriteOutcome { + let permit = WritePermit { slot, control, revision }; + let mut offset = 0; + let mut multipart_active = false; + while offset < batch.len() { + if control.is_cancelled() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Stopped, + ); + } + if permit.superseded() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Superseded, + ); + } + let Some(end) = next_graphics_write_end(batch, offset) else { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + true, + BatchWriteOutcome::Stopped, + ); + }; + #[cfg(test)] + control.report_write_attempt(); + { + let _guard = stdout_lock.lock(); + if control.is_cancelled() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Stopped, + ); + } + if permit.superseded() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Superseded, + ); + } + let mut emitted = 0; + let result = output.write_segment(&batch[offset..end], &permit, &mut emitted); + let emitted_end = offset.saturating_add(emitted).min(end); + multipart_active = multipart_state_after_complete_commands( + &batch[offset..emitted_end], + multipart_active, + ); + let parser_reset_required = + emitted != 0 && !batch[offset..emitted_end].ends_with(b"\x1b\\"); + match result { + Ok(true) if emitted == end - offset => {} + Ok(true) => { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + parser_reset_required, + true, + BatchWriteOutcome::Stopped, + ); + } + Ok(false) if permit.superseded() => { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + parser_reset_required, + false, + BatchWriteOutcome::Superseded, + ); + } + Ok(false) => { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + parser_reset_required, + !control.is_cancelled(), + BatchWriteOutcome::Stopped, + ); + } + Err(_) => { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + parser_reset_required, + true, + BatchWriteOutcome::Stopped, + ); + } + } + } + offset = end; + if control.is_cancelled() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Stopped, + ); + } + if permit.superseded() { + return finish_interrupted_batch( + output, + stdout_lock, + control, + multipart_active, + false, + false, + BatchWriteOutcome::Superseded, + ); + } + } + BatchWriteOutcome::Complete +} + +fn finish_interrupted_batch( + output: &mut O, + stdout_lock: &Arc, + control: &WriterControl, + multipart_active: bool, + parser_reset_required: bool, + writer_failed: bool, + outcome: BatchWriteOutcome, +) -> BatchWriteOutcome { + if !parser_reset_required && !multipart_active { + if writer_failed && !control.is_cancelled() { + control.record_failure(false); + return BatchWriteOutcome::Stopped; + } + return outcome; + } + let _guard = stdout_lock.lock(); + let mut parser_grounded = true; + let mut recovery_failed = false; + if parser_reset_required && output.write_recovery(&[CONTROL_STRING_CANCEL]).is_err() { + parser_grounded = false; + recovery_failed = true; + } + if parser_grounded + && multipart_active + && output.write_recovery(TERMINATE_MULTIPART_GRAPHICS).is_err() + { + recovery_failed = true; + if output.write_recovery(&[CONTROL_STRING_CANCEL]).is_err() { + parser_grounded = false; + } + } + if writer_failed || recovery_failed { + if !control.is_cancelled() { + // Record the fatal state before releasing the shared stdout lock. + // A waiting Ratatui draw observes it before emitting normal bytes. + control.record_failure(!parser_grounded); + } + return BatchWriteOutcome::Stopped; + } + outcome +} + +#[cfg(test)] +fn assert_writer_failed(control: &WriterControl, expected_parser_reset_required: bool) { + assert_eq!( + control.failure(), + Some(GraphicsWriterFailure { parser_reset_required: expected_parser_reset_required }) + ); +} + +fn multipart_state_after_complete_commands(bytes: &[u8], mut active: bool) -> bool { + let mut offset = 0; + while let Some(start) = + bytes[offset..].windows(3).position(|window| window == b"\x1b_G").map(|at| offset + at) + { + let header_start = start + 3; + let Some(end) = bytes[header_start..] + .windows(2) + .position(|window| window == b"\x1b\\") + .map(|at| header_start + at) + else { + break; + }; + if let Some(header_end) = bytes[header_start..end].iter().position(|byte| *byte == b';') { + for parameter in + bytes[header_start..header_start + header_end].split(|byte| *byte == b',') + { + match parameter { + b"m=1" => active = true, + b"m=0" => active = false, + _ => {} + } + } + } + offset = end + 2; + } + active +} + +fn next_graphics_write_end(batch: &[u8], start: usize) -> Option { + let mut end = start; + while end < batch.len() { + let Some(terminator) = batch[end..] + .windows(2) + .position(|bytes| bytes == b"\x1b\\") + .map(|offset| end + offset + 2) + else { + if end > start && !batch[end..].windows(3).any(|bytes| bytes == b"\x1b_G") { + return Some(batch.len()); + } + return (end > start).then_some(end); + }; + if end > start && terminator - start > MAX_LOCKED_GRAPHICS_WRITE_BYTES { + break; + } + end = terminator; + if end - start >= MAX_LOCKED_GRAPHICS_WRITE_BYTES { + break; + } + } + (end > start).then_some(end) } fn lock_recover(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } -struct DoneOnDrop(SyncSender<()>); +struct DoneOnDrop(Arc); impl Drop for DoneOnDrop { fn drop(&mut self) { - let _ = self.0.try_send(()); + self.0.mark_done(); } } #[cfg(test)] mod tests { use super::*; - use crate::ui::graphics::delete_image; - use cmux_tui_core::{BrowserFrame, Rect}; + use crate::ui::graphics::{ + GraphicData, GraphicFormat, GraphicImage, GraphicImageKey, GraphicPlacementKey, + clear_image_transmission_observer, observe_image_transmissions, + }; + use cmux_tui_core::Rect; + use ghostty_vt::{Callbacks, Terminal}; + use std::sync::atomic::{AtomicBool, Ordering}; - fn test_frame(seq: u64, data_b64: &str) -> Arc { - Arc::new(BrowserFrame { - session_id: "test".to_string(), - data_b64: data_b64.to_string(), - css_width: 10, - css_height: 5, - image_width: 10, - image_height: 5, - seq, - }) + struct BlockingOutput { + entered: SyncSender<()>, + release: Receiver<()>, + restored: Arc, + writes_after_restore: Arc>>, } - fn test_placement( - surface: SurfaceId, - rect: Rect, - seq: u64, - pointer_frame_seq: Option, - ) -> GraphicPlacement { - GraphicPlacement { - surface, - rect, - pointer_frame_seq, - source_crop_px: None, - frame: test_frame(seq, "AAAA"), - } - } - - struct FailingOutput; - - impl Write for FailingOutput { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - Err(std::io::Error::other("injected graphics output failure")) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - #[derive(Clone, Default)] - struct SharedOutput(Arc>>); - - impl SharedOutput { - fn bytes(&self) -> Vec { - self.0.lock().unwrap().clone() - } - } - - impl Write for SharedOutput { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); + impl Write for BlockingOutput { + fn write(&mut self, buf: &[u8]) -> io::Result { + let _ = self.entered.try_send(()); + self.release.recv().unwrap(); + self.writes_after_restore.lock().unwrap().push(self.restored.load(Ordering::Acquire)); Ok(buf.len()) } - fn flush(&mut self) -> std::io::Result<()> { + fn flush(&mut self) -> io::Result<()> { Ok(()) } } - struct BackpressuredOutput { - attempts: Arc, + struct ObservedOutput { + bytes: Arc>>, + flushed: SyncSender<()>, } - impl Write for BackpressuredOutput { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - self.attempts.fetch_add(1, Ordering::AcqRel); - Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)) + impl Write for ObservedOutput { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) } - fn flush(&mut self) -> std::io::Result<()> { + fn flush(&mut self) -> io::Result<()> { + let _ = self.flushed.try_send(()); Ok(()) } } - #[derive(Clone, Default)] - struct PartialThenBackpressuredOutput(Arc>); + struct SupersedingOutput { + bytes: Arc>>, + entered: Option>, + release: Option>, + flushed: SyncSender<()>, + } - #[derive(Default)] - struct PartialThenBackpressuredState { + impl GraphicsOutput for SupersedingOutput { + fn write_segment( + &mut self, + bytes: &[u8], + _permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + *emitted = 0; + self.bytes.lock().unwrap().extend_from_slice(bytes); + *emitted = bytes.len(); + let _ = self.flushed.try_send(()); + if let Some(entered) = self.entered.take() { + entered.send(()).unwrap(); + self.release.take().unwrap().recv().unwrap(); + } + Ok(true) + } + + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()> { + self.bytes.lock().unwrap().extend_from_slice(bytes); + let _ = self.flushed.try_send(()); + Ok(()) + } + } + + struct PrefixInterruptOutput { bytes: Vec, - blocked_until: Option, } - impl PartialThenBackpressuredOutput { - fn bytes(&self) -> Vec { - self.0.lock().unwrap().bytes.clone() + impl GraphicsOutput for PrefixInterruptOutput { + fn write_segment( + &mut self, + bytes: &[u8], + _permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + let end = bytes + .windows(2) + .position(|window| window == b"\x1b\\") + .map(|at| at + 2) + .expect("graphics segment must contain a complete APC"); + self.bytes.extend_from_slice(&bytes[..end]); + *emitted = end; + Ok(false) + } + + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()> { + self.bytes.extend_from_slice(bytes); + Ok(()) } } - impl Write for PartialThenBackpressuredOutput { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let mut state = self.0.lock().unwrap(); - match state.blocked_until { - None => { - let written = buf.len().min(8); - state.bytes.extend_from_slice(&buf[..written]); - state.blocked_until = - Some(Instant::now() + GRAPHICS_OUTPUT_TIMEOUT + Duration::from_millis(50)); - Ok(written) - } - Some(blocked_until) if Instant::now() < blocked_until => { - Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)) - } - Some(_) => { - state.bytes.extend_from_slice(buf); - Ok(buf.len()) - } + struct RecoveringPartialOutput { + bytes: Vec, + recovery_attempts: usize, + } + + impl GraphicsOutput for RecoveringPartialOutput { + fn write_segment( + &mut self, + bytes: &[u8], + _permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + let partial = bytes.len().min(8); + self.bytes.extend_from_slice(&bytes[..partial]); + *emitted = partial; + Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal stayed blocked after a partial APC", + )) + } + + fn write_recovery(&mut self, bytes: &[u8]) -> io::Result<()> { + self.recovery_attempts += 1; + self.bytes.extend_from_slice(bytes); + Ok(()) + } + } + + struct PermanentRecoveryFailureOutput { + attempted: SyncSender<()>, + } + + impl GraphicsOutput for PermanentRecoveryFailureOutput { + fn write_segment( + &mut self, + bytes: &[u8], + _permit: &WritePermit<'_>, + emitted: &mut usize, + ) -> io::Result { + *emitted = bytes.len().min(8); + Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal stayed blocked after a partial APC", + )) + } + + fn write_recovery(&mut self, _bytes: &[u8]) -> io::Result<()> { + let _ = self.attempted.try_send(()); + Err(io::Error::new(io::ErrorKind::BrokenPipe, "terminal output is gone")) + } + } + + fn rgba_placement(image_id: u32, generation: u64, x: u16, rgba: [u8; 4]) -> GraphicPlacement { + rgba_placement_in_namespace(91, image_id, generation, x, rgba) + } + + fn rgba_placement_in_namespace( + namespace: u64, + image_id: u32, + generation: u64, + x: u16, + rgba: [u8; 4], + ) -> GraphicPlacement { + let image_key = GraphicImageKey { namespace, surface: 7, image_id }; + GraphicPlacement { + key: GraphicPlacementKey { image: image_key, placement_id: 1, ordinal: 0 }, + image: Arc::new(GraphicImage { + key: image_key, + generation, + width: 1, + height: 1, + format: GraphicFormat::Rgba, + data: GraphicData::Bytes(Arc::from(rgba)), + }), + rect: Rect { x, y: 0, width: 1, height: 1 }, + pointer_frame_seq: None, + columns: Some(1), + rows: Some(1), + source: None, + x_offset: 0, + y_offset: 0, + z: 0, + } + } + + fn wait_for_output( + flushed: &Receiver<()>, + bytes: &Arc>>, + predicate: impl Fn(&[u8]) -> bool, + ) { + loop { + flushed.recv_timeout(Duration::from_secs(2)).expect("graphics writer flush"); + if predicate(&bytes.lock().unwrap()) { + return; } } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - fn occurrences(haystack: &[u8], needle: &[u8]) -> usize { - haystack.windows(needle.len()).filter(|window| *window == needle).count() } fn key(ch: char, modifiers: KeyModifiers) -> Event { Event::Key(KeyEvent::new(KeyCode::Char(ch), modifiers)) } + #[test] + fn large_batches_split_only_between_complete_kitty_commands() { + let command = |payload: u8| { + let mut command = b"\x1b_Gq=2,m=1;".to_vec(); + command.extend(std::iter::repeat_n(payload, 4_096)); + command.extend_from_slice(b"\x1b\\"); + command + }; + let commands = (0..40).map(command).collect::>(); + let batch = commands.concat(); + + let mut offset = 0; + let mut segments = Vec::new(); + while offset < batch.len() { + let end = next_graphics_write_end(&batch, offset).expect("complete Kitty segment"); + let segment = &batch[offset..end]; + assert!(segment.ends_with(b"\x1b\\")); + assert!( + segment.len() <= MAX_LOCKED_GRAPHICS_WRITE_BYTES, + "bounded command grouping held stdout for {} bytes", + segment.len() + ); + segments.extend_from_slice(segment); + offset = end; + } + assert_eq!(segments, batch); + assert!(next_graphics_write_end(b"\x1b_Gunterminated", 0).is_none()); + } + + #[cfg(unix)] + #[test] + fn shutdown_cancels_a_writer_blocked_by_terminal_backpressure() { + let mut raw_fds = [-1; 2]; + assert_eq!(unsafe { libc::pipe(raw_fds.as_mut_ptr()) }, 0); + let read_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[0]) }; + let write_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[1]) }; + let flags = unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let fill = [0_u8; 4_096]; + loop { + let written = + unsafe { libc::write(write_fd.as_raw_fd(), fill.as_ptr().cast(), fill.len()) }; + if written >= 0 { + continue; + } + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EAGAIN)); + break; + } + + let mut writer = GraphicsWriter::spawn_with_test_graphics_output( + Arc::new(StdoutLock::new(())), + InterruptibleStdout { fd: write_fd }, + ) + .unwrap(); + let (attempt_tx, attempt_rx) = sync_channel(1); + writer.control.observe_write_attempts(attempt_tx); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )]); + attempt_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let started = Instant::now(); + writer.shutdown(Duration::ZERO); + assert!( + started.elapsed() < Duration::from_secs(1), + "cancelable stdout did not stop within its bounded poll interval" + ); + drop(read_fd); + } + + #[cfg(not(unix))] + #[test] + fn production_graphics_writer_is_disabled_without_interruptible_output() { + let result = GraphicsWriter::spawn(Arc::new(StdoutLock::new(()))); + let Err(error) = result else { + panic!("non-Unix graphics output must be disabled"); + }; + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[cfg(unix)] + #[test] + fn segment_backpressure_has_a_total_deadline_without_external_supersession() { + let mut raw_fds = [-1; 2]; + assert_eq!(unsafe { libc::pipe(raw_fds.as_mut_ptr()) }, 0); + let _read_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[0]) }; + let write_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[1]) }; + let flags = unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let fill = [0_u8; MAX_LOCKED_GRAPHICS_WRITE_BYTES]; + let mut fill_len = fill.len(); + loop { + let written = + unsafe { libc::write(write_fd.as_raw_fd(), fill.as_ptr().cast(), fill_len) }; + if written > 0 { + continue; + } + assert_ne!(written, 0, "pipe accepted a zero-length fill write"); + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EAGAIN)); + if fill_len == 1 { + break; + } + fill_len = (fill_len / 2).max(1); + } + + let control = Arc::new(WriterControl::default()); + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let (attempt_tx, attempt_rx) = sync_channel(1); + control.observe_write_attempts(attempt_tx); + let (done_tx, done_rx) = sync_channel(1); + let worker_control = control.clone(); + let mut segment = b"\x1b_Gq=2;".to_vec(); + segment.resize(MAX_LOCKED_GRAPHICS_WRITE_BYTES - 2, b'A'); + segment.extend_from_slice(b"\x1b\\"); + let worker = std::thread::spawn(move || { + let mut output = InterruptibleStdout { fd: write_fd }; + let mut emitted = 0; + let result = output.write_segment( + &segment, + &WritePermit { slot: &slot, control: &worker_control, revision: 1 }, + &mut emitted, + ); + let _ = done_tx.send(result); + }); + + attempt_rx + .recv_timeout(Duration::from_secs(1)) + .expect("writer never reached terminal backpressure"); + let result = match done_rx.recv_timeout(Duration::from_secs(1)) { + Ok(result) => result, + Err(_) => { + control.cancelled.store(true, Ordering::Release); + worker.join().unwrap(); + panic!("graphics segment ignored its total output deadline"); + } + }; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + worker.join().unwrap(); + } + + #[cfg(unix)] + #[test] + fn cancellation_terminates_a_partially_written_apc_before_returning() { + let mut raw_fds = [-1; 2]; + assert_eq!(unsafe { libc::pipe(raw_fds.as_mut_ptr()) }, 0); + let read_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[0]) }; + let write_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[1]) }; + let flags = unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + + let mut command = b"\x1b_Gq=2;".to_vec(); + command.resize(256 * 1024, b'A'); + command.extend_from_slice(b"\x1b\\"); + let control = Arc::new(WriterControl::default()); + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let worker_control = control.clone(); + let worker_slot = slot; + let worker = std::thread::spawn(move || { + let mut output = InterruptibleStdout { fd: write_fd }; + let mut emitted = 0; + output.write_segment( + &command, + &WritePermit { slot: &worker_slot, control: &worker_control, revision: 1 }, + &mut emitted, + ) + }); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let mut available: libc::c_int = 0; + assert_eq!( + unsafe { libc::ioctl(read_fd.as_raw_fd(), libc::FIONREAD, &mut available) }, + 0 + ); + if available > 0 { + break; + } + assert!(Instant::now() < deadline, "writer never emitted its APC prefix"); + std::thread::yield_now(); + } + control.cancelled.store(true, Ordering::Release); + + let mut emitted = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + while !emitted.contains(&CONTROL_STRING_CANCEL) { + let count = unsafe { + libc::read(read_fd.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) + }; + assert!(count > 0, "pipe closed before the APC cancellation byte"); + emitted.extend_from_slice(&buffer[..count as usize]); + } + assert!(!worker.join().unwrap().unwrap()); + assert!(emitted.starts_with(b"\x1b_Gq=2;")); + assert!( + emitted.iter().position(|byte| *byte == CONTROL_STRING_CANCEL).unwrap() < 256 * 1024, + "writer completed the large payload instead of canceling its partial APC" + ); + } + + #[cfg(unix)] + #[test] + fn partial_apc_abort_is_bounded_when_output_never_becomes_writable() { + let mut raw_fds = [-1; 2]; + assert_eq!(unsafe { libc::pipe(raw_fds.as_mut_ptr()) }, 0); + let read_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[0]) }; + let write_fd = unsafe { OwnedFd::from_raw_fd(raw_fds[1]) }; + let flags = unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(write_fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + + let fill = [0_u8; 4_096]; + loop { + let written = + unsafe { libc::write(write_fd.as_raw_fd(), fill.as_ptr().cast(), fill.len()) }; + if written >= 0 { + continue; + } + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EAGAIN)); + break; + } + let mut full_bytes: libc::c_int = 0; + assert_eq!(unsafe { libc::ioctl(read_fd.as_raw_fd(), libc::FIONREAD, &mut full_bytes) }, 0); + let mut drained = [0_u8; 4_096]; + let drain_bytes = usize::try_from(full_bytes).unwrap().min(drained.len()); + assert_eq!( + unsafe { libc::read(read_fd.as_raw_fd(), drained.as_mut_ptr().cast(), drain_bytes) }, + drain_bytes as isize + ); + + let mut command = b"\x1b_Gq=2;".to_vec(); + command.resize(256 * 1024, b'A'); + command.extend_from_slice(b"\x1b\\"); + let control = Arc::new(WriterControl::default()); + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let (blocked_tx, blocked_rx) = sync_channel(1); + control.observe_write_attempts(blocked_tx); + let worker_control = control.clone(); + let worker_slot = slot; + let worker = std::thread::spawn(move || { + let mut output = InterruptibleStdout { fd: write_fd }; + let mut emitted = 0; + output.write_segment( + &command, + &WritePermit { slot: &worker_slot, control: &worker_control, revision: 1 }, + &mut emitted, + ) + }); + + blocked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("writer never reached terminal backpressure"); + let started = Instant::now(); + control.cancelled.store(true, Ordering::Release); + let error = worker.join().unwrap().unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!( + started.elapsed() < Duration::from_secs(1), + "partial APC cancellation remained blocked after its deadline" + ); + } + + #[cfg(unix)] + fn stopped_pty_output_with_sentinel(sentinel: &[u8]) -> (OwnedFd, InterruptibleStdout) { + let mut master = -1; + let mut slave = -1; + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + let master = unsafe { OwnedFd::from_raw_fd(master) }; + let slave = unsafe { OwnedFd::from_raw_fd(slave) }; + assert_eq!(unsafe { libc::tcflow(slave.as_raw_fd(), libc::TCOOFF) }, 0); + let flags = unsafe { libc::fcntl(slave.as_raw_fd(), libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(slave.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + assert_eq!( + unsafe { libc::write(slave.as_raw_fd(), sentinel.as_ptr().cast(), sentinel.len()) }, + sentinel.len() as isize + ); + let fill = [b'x'; 4_096]; + loop { + let written = + unsafe { libc::write(slave.as_raw_fd(), fill.as_ptr().cast(), fill.len()) }; + if written >= 0 { + continue; + } + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EAGAIN)); + break; + } + (master, InterruptibleStdout { fd: slave }) + } + + #[cfg(unix)] + fn resume_pty_and_read_prefix(master: &OwnedFd, output: &InterruptibleStdout) -> Vec { + assert_eq!(unsafe { libc::tcflow(output.fd.as_raw_fd(), libc::TCOON) }, 0); + let mut bytes = vec![0_u8; 8 * 1024]; + let count = + unsafe { libc::read(master.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) }; + assert!(count > 0, "resumed pseudo-terminal produced no queued output"); + bytes.truncate(count as usize); + bytes + } + + #[cfg(unix)] + #[test] + fn partial_apc_timeout_does_not_flush_unrelated_terminal_output() { + let sentinel = b"ratatui-frame-before-graphics"; + let (master, mut output) = stopped_pty_output_with_sentinel(sentinel); + + let result = output.abort_partial_control_string(); + let queued = resume_pty_and_read_prefix(&master, &output); + + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + assert!( + queued.windows(sentinel.len()).any(|window| window == sentinel), + "graphics cancellation discarded output queued by another terminal writer" + ); + } + + #[cfg(unix)] + #[test] + fn recovery_timeout_does_not_flush_unrelated_terminal_output() { + let sentinel = b"ratatui-frame-before-recovery"; + let (master, mut output) = stopped_pty_output_with_sentinel(sentinel); + + let result = output.write_recovery(DELETE_ALL_GRAPHICS); + let queued = resume_pty_and_read_prefix(&master, &output); + + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + assert!( + queued.windows(sentinel.len()).any(|window| window == sentinel), + "graphics recovery discarded output queued by another terminal writer" + ); + } + #[test] fn kitty_graphics_response_completes_matching_processing_fence() { let (waiter, notifier) = graphics_fence_channel(); @@ -1255,123 +2215,6 @@ mod tests { assert_eq!(replayed, events); } - #[test] - fn impossible_graphics_response_prefix_replays_during_active_fence() { - let (waiter, notifier) = graphics_fence_channel(); - let mut filter = GraphicsResponseFilter::new(notifier); - let id = processing_fence_id(12); - waiter.prepare(id); - let boundary = key('_', KeyModifiers::ALT); - let impossible_prefix = key('x', KeyModifiers::NONE); - assert!(filter.filter(boundary.clone()).is_empty()); - - assert_eq!( - filter.filter(impossible_prefix.clone()), - vec![boundary, impossible_prefix], - "an impossible APC prefix must not wait for the active fence to expire" - ); - let following = key('z', KeyModifiers::NONE); - assert_eq!(filter.filter(following.clone()), vec![following]); - waiter.cancel(id); - } - - #[test] - fn late_canceled_processing_fence_reply_is_consumed() { - let (waiter, notifier) = graphics_fence_channel(); - let mut filter = GraphicsResponseFilter::new(notifier); - let id = processing_fence_id(13); - waiter.prepare(id); - waiter.cancel(id); - let response = format!("Gi={id};OK"); - let replayed = std::iter::once(key('_', KeyModifiers::ALT)) - .chain(response.chars().map(|ch| { - key(ch, if ch.is_uppercase() { KeyModifiers::SHIFT } else { KeyModifiers::NONE }) - })) - .chain(std::iter::once(key('\\', KeyModifiers::ALT))) - .flat_map(|event| filter.filter(event)) - .collect::>(); - - assert!( - replayed.is_empty(), - "a late terminal response for a canceled fence must not become user input" - ); - } - - #[test] - fn incomplete_canceled_graphics_response_replays_after_deadline() { - let (waiter, notifier) = graphics_fence_channel(); - let mut filter = GraphicsResponseFilter::new(notifier); - let id = processing_fence_id(14); - waiter.prepare(id); - let boundary = key('_', KeyModifiers::ALT); - let graphics_prefix = key('G', KeyModifiers::SHIFT); - assert!(filter.filter(boundary.clone()).is_empty()); - assert!(filter.filter(graphics_prefix.clone()).is_empty()); - - waiter.cancel(id); - let buffered_key = key('i', KeyModifiers::NONE); - assert!(filter.filter(buffered_key.clone()).is_empty()); - std::thread::sleep(Duration::from_millis(300)); - let following = key('z', KeyModifiers::NONE); - - assert_eq!( - filter.take_expired(), - vec![boundary, graphics_prefix, buffered_key], - "an unterminated late response must replay without waiting for more keyboard input" - ); - assert_eq!(filter.filter(following.clone()), vec![following]); - } - - #[test] - fn snapshot_slot_is_latest_wins_and_shutdown_is_clean() { - let (tx, rx) = sync_channel(1); - let slot = Arc::new(Mutex::new(None)); - submit_snapshot( - &slot, - &tx, - GraphicsSubmission { - id: 1, - session_generation: 1, - placements: vec![GraphicPlacement { - surface: 1, - rect: Rect { x: 0, y: 0, width: 10, height: 5 }, - pointer_frame_seq: Some(1), - source_crop_px: None, - frame: test_frame(1, "AAAA"), - }], - }, - ); - submit_snapshot( - &slot, - &tx, - GraphicsSubmission { - id: 2, - session_generation: 1, - placements: vec![GraphicPlacement { - surface: 1, - rect: Rect { x: 1, y: 1, width: 11, height: 6 }, - pointer_frame_seq: Some(1), - source_crop_px: None, - frame: test_frame(2, "BBBB"), - }], - }, - ); - - let latest = slot.lock().unwrap().take().expect("latest snapshot"); - assert_eq!(latest.id, 2); - assert_eq!(latest.placements.len(), 1); - assert_eq!(latest.placements[0].frame.seq, 2); - assert_eq!(latest.placements[0].rect.x, 1); - rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(rx.try_recv().is_err()); - - let lock = Arc::new(StdoutLock::new(())); - let (fence, _notifier) = graphics_fence_channel(); - let mut writer = GraphicsWriter::spawn(lock, fence, || {}).unwrap(); - writer.shutdown(Duration::from_secs(1)); - assert!(writer.handle.as_ref().is_none_or(|handle| handle.is_finished())); - } - #[test] fn processing_completion_waits_for_host_fence_after_stdout_flush() { let lock = Arc::new(StdoutLock::new(())); @@ -1392,12 +2235,18 @@ mod tests { }, ) .unwrap(); - - assert!(writer.submit( - 7, + let mut placement = GraphicPlacement::browser( 1, - vec![test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, 13, Some(8),)] - )); + 11, + Rect { x: 1, y: 2, width: 3, height: 4 }, + 13, + 3, + 4, + "AAAA".to_string(), + ); + placement.pointer_frame_seq = Some(8); + + assert!(writer.submit(7, 1, vec![placement])); assert!( processed_rx.recv_timeout(Duration::from_millis(50)).is_err(), "submission must not complete while output is blocked" @@ -1438,420 +2287,569 @@ mod tests { } #[test] - fn shutdown_cancels_backpressured_output_and_releases_stdout_lock() { - let lock = Arc::new(StdoutLock::new(())); - let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output( - lock.clone(), - BackpressuredOutput { attempts: attempts.clone() }, - move || { - ready_tx.send(()).unwrap(); + fn snapshot_slot_is_latest_wins_and_shutdown_is_clean() { + let (tx, rx) = sync_channel(1); + let slot = Arc::new(Mutex::new(PendingGraphics::default())); + submit_snapshot( + &slot, + &tx, + GraphicsSubmission { + id: 1, + session_generation: 1, + scene: vec![Arc::from(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )])], }, - ) - .unwrap(); - - assert!(writer.submit( - 8, - 1, - vec![test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, 14, Some(8),)] - )); - let attempt_deadline = Instant::now() + Duration::from_secs(1); - while attempts.load(Ordering::Acquire) == 0 { - assert!( - Instant::now() < attempt_deadline, - "writer never attempted backpressured output" - ); - std::thread::sleep(Duration::from_millis(5)); - } - let completed_before_shutdown = ready_rx.recv_timeout(Duration::from_millis(50)).is_ok(); - - writer.shutdown(Duration::from_millis(200)); - let stopped = writer.handle.as_ref().is_none_or(|handle| handle.is_finished()); - let lock_released = lock.try_lock().is_some(); - - assert!( - !completed_before_shutdown, - "temporary stdout backpressure must remain retryable until cancellation or deadline" ); - assert!( - attempts.load(Ordering::Acquire) > 1, - "the writer must retry temporary stdout backpressure" + submit_snapshot( + &slot, + &tx, + GraphicsSubmission { + id: 2, + session_generation: 1, + scene: vec![Arc::from(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 1, y: 1, width: 11, height: 6 }, + 2, + 11, + 6, + "BBBB".to_string(), + )])], + }, ); - assert!(stopped, "shutdown must cancel a backpressured graphics write within its budget"); - assert!(lock_released, "canceled graphics output must release the shared stdout lock"); - } - #[test] - fn partial_output_failure_terminates_apc_and_cleans_possible_images() { + let latest = take_pending_update(&slot, 0) + .and_then(|update| update.submission) + .map(|submission| submission.scene) + .expect("latest snapshot"); + assert_eq!(latest.len(), 1); + assert_eq!(latest[0][0].image.generation, 2); + assert_eq!(latest[0][0].rect.x, 1); + rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(rx.try_recv().is_err()); + let lock = Arc::new(StdoutLock::new(())); - let output = PartialThenBackpressuredOutput::default(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = - GraphicsWriter::spawn_with_output(lock.clone(), output.clone(), move || { - ready_tx.send(()).unwrap(); - }) - .unwrap(); - - assert!(writer.submit( - 36, - 1, - vec![test_placement(15, Rect { x: 1, y: 2, width: 3, height: 4 }, 21, Some(21),)] - )); - let partial_deadline = Instant::now() + Duration::from_secs(1); - while output.bytes().is_empty() { - assert!(Instant::now() < partial_deadline, "writer never emitted the partial APC"); - std::thread::sleep(Duration::from_millis(5)); - } - std::thread::sleep(GRAPHICS_OUTPUT_TIMEOUT + Duration::from_millis(20)); - let released_before_recovery = lock.try_lock().is_some(); - ready_rx - .recv_timeout(GRAPHICS_OUTPUT_TIMEOUT + Duration::from_secs(1)) - .expect("partial graphics output must settle after bounded stream recovery"); - assert_eq!(writer.take_completion(), Some(GraphicsCompletion::Failed)); + let mut writer = GraphicsWriter::spawn_with_output(lock, io::sink()).unwrap(); writer.shutdown(Duration::from_secs(1)); - - let bytes = output.bytes(); - assert!( - bytes.windows(2).any(|window| window == b"\x1b\\"), - "a partial Kitty APC must be terminated before stdout ownership is released" - ); - assert!( - occurrences(&bytes, &delete_image(15)) >= 1, - "a partially emitted image must be deleted before text fallback can render" - ); - assert!( - !released_before_recovery, - "stdout ownership must remain exclusive until the partial APC is recovered" - ); - assert!(lock.try_lock().is_some(), "stream recovery must release the shared stdout lock"); + assert!(writer.handle.as_ref().is_none_or(|handle| handle.is_finished())); } #[test] - fn transient_processing_timeout_retries_without_stopping_writer() { - let lock = Arc::new(StdoutLock::new(())); - let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - Vec::new(), - move || { - if attempts.fetch_add(1, Ordering::AcqRel) == 0 { - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected transient fence timeout", - )) - } else { - Ok(()) - } - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = - |seq| test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, seq, Some(seq)); - - assert!(writer.submit(9, 1, vec![placement(15)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - let first_processed = matches!( - writer.take_completion(), - Some(GraphicsCompletion::Processed(GraphicsProcessing { id: 9, .. })) - ); - let second_accepted = writer.submit(10, 1, vec![placement(16)]); - let second_processed = if second_accepted { - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - matches!( - writer.take_completion(), - Some(GraphicsCompletion::Processed(GraphicsProcessing { id: 10, .. })) - ) - } else { - false + fn host_scene_invalidation_survives_latest_wins_coalescing() { + let (tx, rx) = sync_channel(1); + let slot = Arc::new(Mutex::new(PendingGraphics::default())); + let writer = GraphicsWriter { + slot: slot.clone(), + completion: Arc::new(Mutex::new(None)), + notify: Some(tx), + control: Arc::new(WriterControl::default()), + handle: None, }; - writer.shutdown(Duration::from_secs(1)); - assert!(first_processed, "one fence timeout must retry the accepted submission"); - assert!(second_accepted, "a transient timeout must not stop the graphics writer"); - assert!(second_processed, "the writer must process later graphics after recovery"); - } - - #[test] - fn timed_out_deletion_is_reemitted_until_acknowledged() { - let lock = Arc::new(StdoutLock::new(())); - let output = SharedOutput::default(); - let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - output.clone(), - move || match attempts.fetch_add(1, Ordering::AcqRel) { - 0 | 3 => Ok(()), - _ => Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected ambiguous deletion timeout", - )), - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, 17, Some(17)); - - assert!(writer.submit(21, 1, vec![placement])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::Processed(GraphicsProcessing { id: 21, .. })) - )); - assert!(writer.submit(22, 1, Vec::new())); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::TimedOut { id: 22, .. }) - )); - assert!(writer.submit(23, 1, Vec::new())); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::Processed(GraphicsProcessing { id: 23, .. })) - )); - writer.shutdown(Duration::from_secs(1)); - - let deletion = delete_image(11); - assert_eq!( - occurrences(&output.bytes(), &deletion), + writer.invalidate_host_scene(); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )]); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 1, y: 1, width: 11, height: 6 }, 2, - "an ambiguous deletion must remain pending until a fence acknowledges its retry" - ); + 11, + 6, + "BBBB".to_string(), + )]); + + let update = take_pending_update(&slot, 0).expect("pending scene update"); + assert_ne!(update.host_scene_epoch, 0); + let latest = update.submission.expect("latest submission").scene; + assert_eq!(latest.len(), 1); + assert_eq!(latest[0][0].image.generation, 2); + assert_eq!(latest[0][0].rect.x, 1); + rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(rx.try_recv().is_err()); } #[test] - fn persistent_fence_loss_emits_final_cleanup_for_known_images() { - let lock = Arc::new(StdoutLock::new(())); - let output = SharedOutput::default(); - let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - output.clone(), - move || { - if attempts.fetch_add(1, Ordering::AcqRel) == 0 { - Ok(()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected persistent deletion timeout", - )) - } - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = test_placement(12, Rect { x: 1, y: 2, width: 3, height: 4 }, 18, Some(18)); + fn host_scene_invalidation_discards_a_pre_clear_write_waiting_on_stdout() { + let stdout_lock = Arc::new(StdoutLock::new(())); + let bytes = Arc::new(Mutex::new(Vec::new())); + let (flushed_tx, flushed_rx) = sync_channel(8); + let output = ObservedOutput { bytes: bytes.clone(), flushed: flushed_tx }; + let mut writer = GraphicsWriter::spawn_with_output(stdout_lock.clone(), output).unwrap(); + let first = rgba_placement(41, 1, 0, [255, 0, 0, 255]); + let second = rgba_placement(42, 1, 1, [0, 255, 0, 255]); - assert!(writer.submit(31, 1, vec![placement])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::Processed(GraphicsProcessing { id: 31, .. })) - )); - assert!(writer.submit(32, 1, Vec::new())); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::TimedOut { id: 32, .. }) - )); - assert!(writer.submit(33, 1, Vec::new())); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert_eq!(writer.take_completion(), Some(GraphicsCompletion::Failed)); - writer.shutdown(Duration::from_secs(1)); + writer.submit_test(vec![first.clone(), second]); + wait_for_output(&flushed_rx, &bytes, |bytes| { + String::from_utf8_lossy(bytes).matches("a=p").count() == 2 + }); + bytes.lock().unwrap().clear(); - let deletion = delete_image(12); + let draw_guard = stdout_lock.lock(); + let (attempt_tx, attempt_rx) = sync_channel(1); + writer.control.observe_write_attempts(attempt_tx); + let changed_second = rgba_placement(42, 2, 1, [0, 0, 255, 255]); + writer.submit_test(vec![first, changed_second.clone()]); + attempt_rx + .recv_timeout(Duration::from_secs(2)) + .expect("pre-clear graphics batch must be waiting on stdout"); + + writer.invalidate_host_scene(); + writer.submit_test(vec![changed_second]); + drop(draw_guard); + + wait_for_output(&flushed_rx, &bytes, |bytes| { + String::from_utf8_lossy(bytes).matches("a=p").count() == 1 + }); + let raced_output = bytes.lock().unwrap().clone(); + let mut host = Terminal::new(8, 4, 0, Callbacks::default()).unwrap(); + host.resize(8, 4, 1, 1).unwrap(); + host.vt_write(&raced_output); + let snapshot = host.kitty_graphics_snapshot().unwrap(); assert_eq!( - occurrences(&output.bytes(), &deletion), - 3, - "disabling graphics must make one final best-effort deletion of every known image" + snapshot.images.len(), + 1, + "stale pre-clear transmission left a duplicate host image: {snapshot:?}" ); + assert_eq!( + snapshot.placements.len(), + 1, + "stale pre-clear placement survived host-scene invalidation: {snapshot:?}" + ); + + writer.shutdown(Duration::from_secs(1)); } #[test] - fn persistent_fence_loss_cleans_every_unacknowledged_image() { - let lock = Arc::new(StdoutLock::new(())); - let output = SharedOutput::default(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - output.clone(), - || { - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected persistent fence timeout", - )) - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = |surface, seq| { - test_placement(surface, Rect { x: 1, y: 2, width: 3, height: 4 }, seq, Some(seq)) + fn newer_snapshot_supersedes_an_inflight_scene_and_resets_the_host() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let (flushed_tx, flushed_rx) = sync_channel(8); + let output = SupersedingOutput { + bytes: bytes.clone(), + entered: Some(entered_tx), + release: Some(release_rx), + flushed: flushed_tx, }; + let mut writer = + GraphicsWriter::spawn_with_test_graphics_output(Arc::new(StdoutLock::new(())), output) + .unwrap(); + let old = rgba_placement(41, 1, 0, [255, 0, 0, 255]); + let latest = rgba_placement(41, 2, 0, [0, 0, 255, 255]); - assert!(writer.submit(34, 1, vec![placement(13, 19)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!( - writer.take_completion(), - Some(GraphicsCompletion::TimedOut { id: 34, .. }) - )); - assert!(writer.submit(35, 1, vec![placement(14, 20)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert_eq!(writer.take_completion(), Some(GraphicsCompletion::Failed)); + writer.submit_test(vec![old]); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + writer.submit_test(vec![latest]); + release_tx.send(()).unwrap(); + + wait_for_output(&flushed_rx, &bytes, |bytes| { + bytes.windows(DELETE_ALL_GRAPHICS.len()).any(|window| window == DELETE_ALL_GRAPHICS) + && String::from_utf8_lossy(bytes).contains("AAD//w==") + }); + + let emitted = bytes.lock().unwrap().clone(); + let mut host = Terminal::new(8, 4, 0, Callbacks::default()).unwrap(); + host.resize(8, 4, 1, 1).unwrap(); + host.vt_write(&emitted); + let snapshot = host.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.images.len(), 1); + assert_eq!(snapshot.images[0].data.as_ref(), &[0, 0, 255, 255]); + assert_eq!(snapshot.placements.len(), 1); writer.shutdown(Duration::from_secs(1)); - - let bytes = output.bytes(); - assert!( - occurrences(&bytes, &delete_image(13)) >= 1, - "final cleanup must delete an image whose first submission was never acknowledged" - ); - assert!( - occurrences(&bytes, &delete_image(14)) >= 1, - "final cleanup must delete an image whose last submission was never acknowledged" - ); } #[test] - fn exhausted_processing_timeout_remains_recoverable() { - let lock = Arc::new(StdoutLock::new(())); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - Vec::new(), - || { - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected persistent fence timeout", - )) - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = - |seq| test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, seq, Some(seq)); + fn superseded_scene_does_not_encode_images_beyond_the_active_batch() { + let namespace = u64::MAX - 17; + let (observed_tx, observed_rx) = std::sync::mpsc::channel(); + observe_image_transmissions(observed_tx); + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let (flushed_tx, _flushed_rx) = sync_channel(8); + let output = SupersedingOutput { + bytes, + entered: Some(entered_tx), + release: Some(release_rx), + flushed: flushed_tx, + }; + let mut writer = + GraphicsWriter::spawn_with_test_graphics_output(Arc::new(StdoutLock::new(())), output) + .unwrap(); + let old = (0..3) + .map(|index| { + rgba_placement_in_namespace( + namespace, + 41 + index, + 1, + u16::try_from(index).unwrap(), + [u8::try_from(index).unwrap(), 0, 0, 255], + ) + }) + .collect(); - assert!(writer.submit(11, 1, vec![placement(17)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - let completion = writer.take_completion(); - let later_accepted = writer.submit(12, 1, vec![placement(18)]); - if later_accepted { - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - } - writer.shutdown(Duration::from_secs(1)); - - assert!( - completion.is_some() && !matches!(completion, Some(GraphicsCompletion::Failed)), - "an exhausted fence timeout must request recovery without disabling graphics" - ); - assert!(later_accepted, "the writer must remain available for a later re-probe"); - } - - #[test] - fn persistent_processing_timeouts_eventually_stop_writer() { - let lock = Arc::new(StdoutLock::new(())); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output_and_fence( - lock, - Vec::new(), - || { - Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "injected persistent fence timeout", - )) - }, - move || { - ready_tx.send(()).unwrap(); - }, - ) - .unwrap(); - let placement = - |seq| test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, seq, Some(seq)); - - assert!(writer.submit(13, 1, vec![placement(19)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - assert!(matches!(writer.take_completion(), Some(GraphicsCompletion::TimedOut { .. }))); - assert!(writer.submit(14, 1, vec![placement(20)])); - ready_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - let completion = writer.take_completion(); + writer.submit_test(old); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let encoded_before_supersession = + observed_rx.try_iter().filter(|key| key.namespace == namespace).count(); + writer.submit_test(Vec::new()); + release_tx.send(()).unwrap(); writer.shutdown(Duration::from_secs(1)); + clear_image_transmission_observer(); assert_eq!( - completion, - Some(GraphicsCompletion::Failed), - "persistent fence loss must disable graphics instead of retransmitting forever" + encoded_before_supersession, 1, + "the writer encoded later images before checking for a newer scene" ); } #[test] - fn shutdown_interrupts_unanswered_processing_fence_before_retry() { - let lock = Arc::new(StdoutLock::new(())); - let output = SharedOutput::default(); - let (fence, _notifier) = graphics_fence_channel(); - let mut writer = GraphicsWriter::spawn_with_fence( - lock, - output.clone(), - GraphicsOutputMode::Cooperative, - fence, - || {}, - ) - .unwrap(); - let id = 15; - let query = processing_fence(processing_fence_id(id)); - assert!(writer.submit( - id, + fn superseding_a_multipart_upload_does_not_poison_the_next_image() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let (flushed_tx, flushed_rx) = sync_channel(8); + let output = SupersedingOutput { + bytes: bytes.clone(), + entered: Some(entered_tx), + release: Some(release_rx), + flushed: flushed_tx, + }; + let mut writer = + GraphicsWriter::spawn_with_test_graphics_output(Arc::new(StdoutLock::new(())), output) + .unwrap(); + let old = GraphicPlacement::browser( + 0, + 7, + Rect { x: 0, y: 0, width: 1, height: 1 }, 1, - vec![test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, 21, Some(21),)] - )); - let deadline = Instant::now() + Duration::from_secs(1); - while occurrences(&output.bytes(), &query) == 0 { - assert!(Instant::now() < deadline, "writer never emitted its initial fence query"); - std::thread::sleep(Duration::from_millis(5)); + 1, + 1, + "A".repeat(MAX_LOCKED_GRAPHICS_WRITE_BYTES * 2), + ); + let latest = rgba_placement(41, 1, 0, [0, 0, 255, 255]); + + writer.submit_test(vec![old]); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + writer.submit_test(vec![latest]); + release_tx.send(()).unwrap(); + + wait_for_output(&flushed_rx, &bytes, |bytes| { + bytes.windows(DELETE_ALL_GRAPHICS.len()).any(|window| window == DELETE_ALL_GRAPHICS) + && String::from_utf8_lossy(bytes).contains("AAD//w==") + }); + let emitted = bytes.lock().unwrap().clone(); + let mut host = Terminal::new(8, 4, 0, Callbacks::default()).unwrap(); + host.resize(8, 4, 1, 1).unwrap(); + host.vt_write(&emitted); + let snapshot = host.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.images.len(), 1, "{snapshot:?}"); + assert_eq!(snapshot.images[0].data.as_ref(), &[0, 0, 255, 255]); + assert_eq!(snapshot.placements.len(), 1); + writer.shutdown(Duration::from_secs(1)); + } + + #[test] + fn cancellation_terminates_a_completed_multipart_chunk() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let (flushed_tx, _flushed_rx) = sync_channel(8); + let output = SupersedingOutput { + bytes: bytes.clone(), + entered: Some(entered_tx), + release: Some(release_rx), + flushed: flushed_tx, + }; + let mut writer = + GraphicsWriter::spawn_with_test_graphics_output(Arc::new(StdoutLock::new(())), output) + .unwrap(); + let shutdown = writer.shutdown_control(); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 7, + Rect { x: 0, y: 0, width: 1, height: 1 }, + 1, + 1, + 1, + "A".repeat(MAX_LOCKED_GRAPHICS_WRITE_BYTES * 2), + )]); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let cleanup = std::thread::spawn(move || writer.shutdown(Duration::ZERO)); + shutdown.wait_until_cancelled(); + release_tx.send(()).unwrap(); + cleanup.join().unwrap(); + + let mut host = Terminal::new(8, 4, 0, Callbacks::default()).unwrap(); + host.resize(8, 4, 1, 1).unwrap(); + host.vt_write(&bytes.lock().unwrap()); + let mut next = GraphicsState::default(); + let latest = rgba_placement(41, 1, 0, [0, 0, 255, 255]); + for batch in next.frame_batches(&[latest]) { + host.vt_write(&batch); } - - writer.shutdown(Duration::from_millis(200)); - let stopped = writer.handle.as_ref().is_none_or(|handle| handle.is_finished()); - std::thread::sleep(Duration::from_millis(1_100)); - let query_count = occurrences(&output.bytes(), &query); - - assert!(stopped, "shutdown must interrupt an unanswered fence within its wait budget"); - assert_eq!( - query_count, 1, - "the graphics worker must not emit a retry query after terminal restoration" - ); + let snapshot = host.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.images.len(), 1, "{snapshot:?}"); + assert_eq!(snapshot.images[0].data.as_ref(), &[0, 0, 255, 255]); + assert_eq!(snapshot.placements.len(), 1); } #[test] - fn output_failure_notifies_the_app_to_settle_the_submission() { - let lock = Arc::new(StdoutLock::new(())); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let mut writer = GraphicsWriter::spawn_with_output(lock, FailingOutput, move || { - ready_tx.send(()).unwrap(); - }) - .unwrap(); - - assert!(writer.submit( - 9, + fn partial_segment_progress_recovers_completed_multipart_chunks() { + let old = GraphicPlacement::browser( + 0, + 7, + Rect { x: 0, y: 0, width: 1, height: 1 }, 1, - vec![test_placement(11, Rect { x: 1, y: 2, width: 3, height: 4 }, 15, Some(9),)] - )); - ready_rx + 1, + 1, + "A".repeat(4_096 * 2), + ); + let mut graphics = GraphicsState::default(); + let batch = graphics.frame_batches(&[old]).remove(0); + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let control = WriterControl::default(); + let stdout_lock = Arc::new(StdoutLock::new(())); + let mut output = PrefixInterruptOutput { bytes: Vec::new() }; + + assert_eq!( + write_batch(&mut output, &stdout_lock, &slot, &control, 1, &batch), + BatchWriteOutcome::Stopped + ); + + let mut host = Terminal::new(8, 4, 0, Callbacks::default()).unwrap(); + host.resize(8, 4, 1, 1).unwrap(); + host.vt_write(&output.bytes); + let mut next = GraphicsState::default(); + for batch in next.frame_batches(&[rgba_placement(41, 1, 0, [0, 0, 255, 255])]) { + host.vt_write(&batch); + } + let snapshot = host.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.images.len(), 1, "{snapshot:?}"); + assert_eq!(snapshot.images[0].data.as_ref(), &[0, 0, 255, 255]); + assert_eq!(snapshot.placements.len(), 1); + } + + #[test] + fn failed_partial_apc_recovery_precedes_later_terminal_output() { + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let control = WriterControl::default(); + let stdout_lock = Arc::new(StdoutLock::new(())); + let mut output = RecoveringPartialOutput { bytes: Vec::new(), recovery_attempts: 0 }; + let command = b"\x1b_Gq=2;payload\x1b\\"; + + assert_eq!( + write_batch(&mut output, &stdout_lock, &slot, &control, 1, command), + BatchWriteOutcome::Stopped + ); + assert_writer_failed(&control, false); + output.bytes.extend_from_slice(b"visible-after-recovery"); + + let mut host = Terminal::new(80, 4, 0, Callbacks::default()).unwrap(); + host.resize(80, 4, 1, 1).unwrap(); + host.vt_write(&output.bytes); + assert!( + host.viewport_text().unwrap().contains("visible-after-recovery"), + "normal terminal output was consumed by an unterminated Kitty APC" + ); + assert_eq!(output.recovery_attempts, 1); + } + + #[test] + fn permanent_parser_recovery_failure_returns_without_retrying_forever() { + let slot = + Arc::new(Mutex::new(PendingGraphics { revision: 1, ..PendingGraphics::default() })); + let control = Arc::new(WriterControl::default()); + let stdout_lock = Arc::new(StdoutLock::new(())); + let (attempted_tx, attempted_rx) = sync_channel(1); + let (done_tx, done_rx) = sync_channel(1); + let worker_control = control.clone(); + let worker = std::thread::spawn(move || { + let mut output = PermanentRecoveryFailureOutput { attempted: attempted_tx }; + let outcome = write_batch( + &mut output, + &stdout_lock, + &slot, + &worker_control, + 1, + b"\x1b_Gq=2;payload\x1b\\", + ); + let _ = done_tx.send(outcome); + }); + + attempted_rx .recv_timeout(Duration::from_secs(1)) - .expect("a failed accepted submission must wake the app"); - assert_eq!(writer.take_completion(), Some(GraphicsCompletion::Failed)); - writer.shutdown(Duration::from_secs(1)); + .expect("writer never attempted parser recovery"); + let outcome = match done_rx.recv_timeout(Duration::from_millis(250)) { + Ok(outcome) => outcome, + Err(_) => { + control.cancelled.store(true, Ordering::Release); + worker.join().unwrap(); + panic!("permanent parser recovery failure retried forever"); + } + }; + assert_eq!(outcome, BatchWriteOutcome::Stopped); + assert_writer_failed(&control, true); + worker.join().unwrap(); + } + + #[test] + fn shutdown_quiesces_a_blocked_writer_before_terminal_restore() { + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let restored = Arc::new(AtomicBool::new(false)); + let writes_after_restore = Arc::new(Mutex::new(Vec::new())); + let output = BlockingOutput { + entered: entered_tx, + release: release_rx, + restored: restored.clone(), + writes_after_restore: writes_after_restore.clone(), + }; + let mut writer = + GraphicsWriter::spawn_with_output(Arc::new(StdoutLock::new(())), output).unwrap(); + let shutdown = writer.shutdown_control(); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )]); + entered_rx.recv().unwrap(); + + let (shutdown_done_tx, shutdown_done_rx) = sync_channel(1); + let restored_for_shutdown = restored.clone(); + std::thread::spawn(move || { + writer.shutdown(Duration::ZERO); + restored_for_shutdown.store(true, Ordering::Release); + shutdown_done_tx.send(()).unwrap(); + }); + + shutdown.wait_until_cancelled(); + release_tx.send(()).unwrap(); + shutdown_done_rx.recv().unwrap(); + + assert!(restored.load(Ordering::Acquire)); + assert!( + writes_after_restore.lock().unwrap().iter().all(|after_restore| !after_restore), + "graphics bytes were written after terminal restoration" + ); + } + + #[test] + fn panic_shutdown_control_quiesces_before_terminal_restore() { + let (entered_tx, entered_rx) = sync_channel(1); + let (release_tx, release_rx) = sync_channel(1); + let restored = Arc::new(AtomicBool::new(false)); + let writes_after_restore = Arc::new(Mutex::new(Vec::new())); + let output = BlockingOutput { + entered: entered_tx, + release: release_rx, + restored: restored.clone(), + writes_after_restore: writes_after_restore.clone(), + }; + let writer = + GraphicsWriter::spawn_with_output(Arc::new(StdoutLock::new(())), output).unwrap(); + let shutdown = writer.shutdown_control(); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )]); + entered_rx.recv().unwrap(); + + let (panic_hook_done_tx, panic_hook_done_rx) = sync_channel(1); + let panic_shutdown = shutdown.clone(); + let restored_for_hook = restored.clone(); + std::thread::spawn(move || { + panic_shutdown.cancel_and_wait(); + restored_for_hook.store(true, Ordering::Release); + panic_hook_done_tx.send(()).unwrap(); + }); + + shutdown.wait_until_cancelled(); + release_tx.send(()).unwrap(); + panic_hook_done_rx.recv().unwrap(); + + assert!(restored.load(Ordering::Acquire)); + assert!( + writes_after_restore.lock().unwrap().iter().all(|after_restore| !after_restore), + "panic restoration raced a graphics write" + ); + } + + #[test] + fn panic_hook_cancellation_does_not_join_writer_waiting_on_owned_stdout() { + const CHILD_ENV: &str = "CMUX_TEST_PANIC_GRAPHICS_OWNER"; + if std::env::var_os(CHILD_ENV).is_some() { + let stdout_lock = Arc::new(StdoutLock::new(())); + let writer = GraphicsWriter::spawn_with_output(stdout_lock.clone(), io::sink()) + .expect("spawn graphics writer"); + let shutdown = writer.shutdown_control(); + let (attempt_tx, attempt_rx) = sync_channel(1); + writer.control.observe_write_attempts(attempt_tx); + let guard = stdout_lock.lock(); + writer.submit_test(vec![GraphicPlacement::browser( + 0, + 1, + Rect { x: 0, y: 0, width: 10, height: 5 }, + 1, + 10, + 5, + "AAAA".to_string(), + )]); + attempt_rx + .recv_timeout(Duration::from_secs(1)) + .expect("graphics writer never waited on the owned stdout lock"); + + shutdown.cancel_for_panic_hook(); + drop(guard); + return; + } + + let current_exe = std::env::current_exe().expect("resolve current test executable"); + let mut child = std::process::Command::new(current_exe) + .arg("panic_hook_cancellation_does_not_join_writer_waiting_on_owned_stdout") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .spawn() + .expect("spawn isolated panic-hook regression"); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().expect("poll panic-hook regression") { + assert!(status.success(), "isolated panic-hook regression failed: {status}"); + break; + } + if Instant::now() >= deadline { + child.kill().expect("kill deadlocked panic-hook regression"); + let _ = child.wait(); + panic!("panic hook joined a graphics writer waiting on its stdout lock"); + } + std::thread::sleep(Duration::from_millis(10)); + } } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/mod.rs b/cmux-tui/crates/cmux-tui/src/ui/mod.rs index 8a8d8fedd5..29e2a1c2ab 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/mod.rs @@ -27,6 +27,7 @@ use crate::config::Action; use crate::localization::catalog; use crate::machine::DurableNoticeLevel; +pub(crate) use overlay::toast_rect; pub(crate) use scrollbar::{ ScrollbarState, ScrollbarStyle, horizontal_drag_offset, horizontal_offset_at, horizontal_thumb_geometry, thumb_geometry, viewport_drag_offset, viewport_jump_offset, diff --git a/cmux-tui/crates/cmux-tui/src/ui/overlay.rs b/cmux-tui/crates/cmux-tui/src/ui/overlay.rs index 02f5ad57e8..f5a8f1203c 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/overlay.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/overlay.rs @@ -410,19 +410,28 @@ pub fn draw_shortcut_help(app: &mut App, frame: &mut Frame) { pub fn draw_toast(app: &App, frame: &mut Frame) { let Some(toast) = app.toast.as_ref() else { return }; - let area = app.content_area; - if area.width == 0 || area.height == 0 { - return; - } let label = format!(" {} ", toast.text); - let width = label_width(&label).min(area.width); - if width == 0 { - return; - } - let x = area.x + area.width.saturating_sub(width + 1); - let y = area.y + area.height.saturating_sub(2); + let Some(rect) = toast_rect_for_label(app.content_area, &label) else { return }; let style = Style::default().bg(app.chrome.toast_bg).fg(app.chrome.toast_fg); - frame.buffer_mut().set_stringn(x, y, &label, width as usize, style); + frame.buffer_mut().set_stringn(rect.x, rect.y, &label, rect.width as usize, style); +} + +pub(crate) fn toast_rect(app: &App) -> Option { + let toast = app.toast.as_ref()?; + toast_rect_for_label(app.content_area, &format!(" {} ", toast.text)) +} + +fn toast_rect_for_label(area: Rect, label: &str) -> Option { + if area.width == 0 || area.height == 0 { + return None; + } + let width = label_width(label).min(area.width); + (width > 0).then_some(Rect { + x: area.x + area.width.saturating_sub(width + 1), + y: area.y + area.height.saturating_sub(2), + width, + height: 1, + }) } fn set_cell(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) { @@ -459,6 +468,9 @@ fn label_width(label: &str) -> u16 { #[cfg(test)] mod tests { + use cmux_tui_core::Rect; + + use super::toast_rect_for_label; use crate::localization::catalog_for_locale; #[test] @@ -466,4 +478,12 @@ mod tests { assert_eq!(catalog_for_locale("en_US.UTF-8").pairing.title, "Approve browser?"); assert_eq!(catalog_for_locale("ja_JP.UTF-8").pairing.title, "ブラウザを承認しますか?"); } + + #[test] + fn toast_occlusion_uses_the_rendered_character_clamp() { + assert_eq!( + toast_rect_for_label(Rect { x: 10, y: 2, width: 10, height: 5 }, " 界 "), + Some(Rect { x: 16, y: 5, width: 3, height: 1 }) + ); + } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/pane.rs b/cmux-tui/crates/cmux-tui/src/ui/pane.rs index 5d2b2627a5..3ff61bff7a 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/pane.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/pane.rs @@ -100,6 +100,7 @@ pub fn draw_all(app: &mut App, frame: &mut Frame) -> DrawCursors { let areas = app.pane_areas.clone(); let visible_surfaces: HashSet<_> = areas.iter().map(|area| area.surface).collect(); app.rendered_terminal_bounds.retain(|surface, _| visible_surfaces.contains(surface)); + app.rendered_kitty_graphics.retain(|surface, _| visible_surfaces.contains(surface)); app.rendered_terminal_pointer_semantics.retain(|surface, _| visible_surfaces.contains(surface)); app.rendered_pane_content_generations.retain(|surface, _| visible_surfaces.contains(surface)); let mut input_cursor = None; @@ -407,6 +408,7 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool fn draw_content(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool) -> DrawCursors { let rect = area.content; app.rendered_terminal_bounds.remove(&area.surface); + app.rendered_kitty_graphics.remove(&area.surface); app.rendered_terminal_pointer_semantics.remove(&area.surface); if matches!( app.rendered_pane_content_generations.get(&area.surface), @@ -442,6 +444,7 @@ fn draw_content(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool let live = super::terminal_grid::rendered_viewport_rect_cropped(rect, frame.area(), &render, source_x); app.rendered_terminal_bounds.insert(area.surface, live); + app.rendered_kitty_graphics.insert(area.surface, render.frame.kitty_graphics.clone()); app.rendered_terminal_sizes.insert(area.surface, render.frame.size); app.rendered_terminal_pointer_semantics.insert(area.surface, render.pointer_semantics); app.rendered_pane_content_generations diff --git a/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs b/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs index 870519351d..a51dd1a175 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs @@ -432,6 +432,7 @@ mod tests { frame: state.build_frame().unwrap(), content_generation: 1, scrollback_rows: 0, + history_epoch: terminal.history_epoch(), pointer_semantics: terminal.pointer_semantic_snapshot(), palette_colors: std::array::from_fn(|idx| state.palette_color(idx as u8)), palette_overridden: std::array::from_fn(|idx| state.palette_overridden(idx as u8)), @@ -473,6 +474,7 @@ mod tests { frame: state.build_frame().unwrap(), content_generation: 1, scrollback_rows: 0, + history_epoch: terminal.history_epoch(), pointer_semantics: terminal.pointer_semantic_snapshot(), palette_colors: std::array::from_fn(|idx| state.palette_color(idx as u8)), palette_overridden: std::array::from_fn(|idx| state.palette_overridden(idx as u8)), @@ -508,6 +510,7 @@ mod tests { frame: state.build_frame().unwrap(), content_generation: 1, scrollback_rows: 0, + history_epoch: terminal.history_epoch(), pointer_semantics: terminal.pointer_semantic_snapshot(), palette_colors: std::array::from_fn(|idx| state.palette_color(idx as u8)), palette_overridden: std::array::from_fn(|idx| state.palette_overridden(idx as u8)), @@ -571,6 +574,7 @@ mod tests { frame: state.build_frame().unwrap(), content_generation: 1, scrollback_rows: 0, + history_epoch: terminal.history_epoch(), pointer_semantics: terminal.pointer_semantic_snapshot(), palette_colors: [Rgb::default(); 256], palette_overridden: [false; 256], diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs index c92a50cef1..8eedde7124 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs @@ -26,6 +26,8 @@ use cmux_tui_core::terminal_host_runtime::{ }; use ghostty_vt::{Rgb, TerminalColorOverrides}; +const KITTY_REPLAY_STATE_ENCODED_LEN: usize = 52; + struct RecoveryHarness { child: Option, dir: PathBuf, @@ -33,6 +35,7 @@ struct RecoveryHarness { state: PathBuf, session: String, host_ready_delay_ms: Option, + reconnect_completion_failures: Option, adoption_insert_failures: Option, } @@ -48,6 +51,7 @@ impl RecoveryHarness { state: dir.join("state"), session: "host-recovery".into(), host_ready_delay_ms: None, + reconnect_completion_failures: None, adoption_insert_failures: None, dir, }; @@ -62,6 +66,13 @@ impl RecoveryHarness { harness } + fn start_with_reconnect_completion_failures(name: &str, failures: u64) -> Self { + let mut harness = Self::start_unstarted(name); + harness.reconnect_completion_failures = Some(failures); + harness.restart(); + harness + } + fn start_in_own_session(name: &str) -> Self { let mut harness = Self::start_unstarted(name); let mut command = harness.daemon_command(); @@ -98,6 +109,7 @@ impl RecoveryHarness { state: dir.join("state"), session: "host-recovery".into(), host_ready_delay_ms: None, + reconnect_completion_failures: None, adoption_insert_failures: None, dir, } @@ -122,6 +134,10 @@ impl RecoveryHarness { if let Some(delay_ms) = self.host_ready_delay_ms { command.env("CMUX_TUI_TEST_HOST_READY_DELAY_MS", delay_ms.to_string()); } + if let Some(failures) = self.reconnect_completion_failures { + command.env("CMUX_TUI_TEST_RECONNECT_COMPLETION_FAILURES", failures.to_string()); + command.env("CMUX_TUI_TEST_DISCONNECT_HOST_AFTER_SPAWN_MS", "1000"); + } if let Some(failures) = self.adoption_insert_failures { command.env("CMUX_TUI_TEST_ADOPTION_INSERT_FAILURES", failures.to_string()); } @@ -2003,6 +2019,75 @@ fn daemon_admin_backpressure_reconnects_without_restarting_host_or_renderer() { wait_for_no_host_records(&harness.host_root()); } +#[test] +fn failed_reconnect_completion_disconnects_and_retries_without_freezing() { + let harness = RecoveryHarness::start_with_reconnect_completion_failures("completion-retry", 1); + let created = request( + &harness.socket, + serde_json::json!({ + "id":1,"cmd":"run","argv":["/bin/sh"],"new_workspace":true, + "cols":80,"rows":24, + }), + ); + let surface = created["surface"].as_u64().unwrap(); + let terminal_id = created["terminal_id"].as_str().unwrap().to_string(); + let incarnation = created["terminal_incarnation"].as_str().unwrap().to_string(); + let before_record = wait_for_host_records(&harness.host_root(), 1).remove(0).1; + let terminal_snapshot = + request(&harness.socket, serde_json::json!({"id":2,"cmd":"list-terminals"})); + let before_revision = terminal_snapshot["terminal_revision"].as_u64().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(15); + let kinds = loop { + let resolved = request( + &harness.socket, + serde_json::json!({"id":3,"cmd":"resolve-terminal","terminal_id":terminal_id}), + ); + let lifecycle_events = request( + &harness.socket, + serde_json::json!({ + "id":4,"cmd":"terminal-events","after_revision":before_revision, + }), + ); + let kinds = lifecycle_events["events"] + .as_array() + .unwrap() + .iter() + .map(|event| event["kind"].as_str().unwrap().to_string()) + .collect::>(); + let adopting = kinds.iter().filter(|kind| kind.as_str() == "terminal-adopting").count(); + let ready = kinds.iter().filter(|kind| kind.as_str() == "terminal-ready").count(); + if resolved["lifecycle"] == "running" && adopting == 2 && ready == 2 { + assert_eq!(resolved["terminal_incarnation"], incarnation); + break kinds; + } + assert!( + Instant::now() < deadline, + "terminal never completed its reconnect retry: lifecycle={}, events={kinds:?}", + resolved["lifecycle"] + ); + std::thread::sleep(Duration::from_millis(25)); + }; + assert_eq!(kinds.iter().filter(|kind| kind.as_str() == "terminal-adopting").count(), 2); + assert_eq!(kinds.iter().filter(|kind| kind.as_str() == "terminal-ready").count(), 2); + + let marker = format!("completion-retry-live-{}", std::process::id()); + request( + &harness.socket, + serde_json::json!({ + "id":5,"cmd":"send","surface":surface,"text":format!("printf '{marker}\\n'\n"), + }), + ); + assert!(wait_for_screen(&harness.socket, surface, &marker).contains(&marker)); + let after_record = wait_for_host_records(&harness.host_root(), 1).remove(0).1; + assert_eq!(after_record.host_pid, before_record.host_pid); + assert_eq!(after_record.host_start_nonce, before_record.host_start_nonce); + assert_eq!(after_record.incarnation, before_record.incarnation); + + request(&harness.socket, serde_json::json!({"id":6,"cmd":"close-surface","surface":surface})); + wait_for_no_host_records(&harness.host_root()); +} + #[test] fn failed_terminate_and_rejected_resize_leave_live_record_discoverable() { let harness = RecoveryHarness::start("failed-control"); @@ -2100,7 +2185,10 @@ fn failed_terminate_and_rejected_resize_leave_live_record_discoverable() { assert_eq!(resized.flags, FLAG_COLORS_FOLLOW); assert_eq!(&resized.payload[..4], &[120, 0, 40, 0]); let replay_len = u32::from_le_bytes(resized.payload[4..8].try_into().unwrap()) as usize; - assert_eq!(resized.payload.len(), 8 + replay_len); + let alias_count_offset = 8 + replay_len; + assert_eq!(resized.payload.len(), alias_count_offset + 6 + KITTY_REPLAY_STATE_ENCODED_LEN); + assert_eq!(&resized.payload[alias_count_offset..alias_count_offset + 2], &0u16.to_le_bytes()); + assert_eq!(&resized.payload[alias_count_offset + 2..alias_count_offset + 6], &[8, 0, 16, 0]); let colors = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); assert_eq!(colors.sequence, renderer.next_sequence); renderer.next_sequence = renderer.next_sequence.wrapping_add(1); @@ -2151,10 +2239,17 @@ fn direct_renderer_becomes_sole_viewer_after_control_client_disconnect() { }), ); let surface = created["surface"].as_u64().unwrap(); + let metrics = request( + &harness.socket, + serde_json::json!({ + "id":2,"cmd":"set-cell-pixels","width_px":9,"height_px":18, + }), + ); + assert_eq!(metrics["failures"], serde_json::json!([])); let grant = request( &harness.socket, serde_json::json!({ - "id":2,"cmd":"mint-terminal-renderer","surface":surface,"ttl_ms":10_000, + "id":3,"cmd":"mint-terminal-renderer","surface":surface,"ttl_ms":10_000, }), ); let mut renderer = connect_host_detailed( @@ -2175,6 +2270,7 @@ fn direct_renderer_becomes_sole_viewer_after_control_client_disconnect() { assert_eq!(resized.kind, MessageKind::Resized); assert_eq!(resized.flags, FLAG_COLORS_FOLLOW); assert_eq!(&resized.payload[..4], &[120, 0, 40, 0]); + assert_eq!(resize_cell_pixels(&resized.payload), (9, 18)); let colors = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); assert_eq!(colors.kind, MessageKind::Colors); @@ -2276,6 +2372,50 @@ fn negotiated_viewer_size_ack_skips_unchanged_replay_and_follows_changed_pair() assert_eq!(renderer.hello_flags, FLAG_VIEWER_SIZE_ACKS); renderer.stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut cell_pixels = Frame::new(MessageKind::SetCellPixelSize, Vec::new()); + cell_pixels.request_id = 41; + cell_pixels.payload.extend_from_slice(&11u16.to_le_bytes()); + cell_pixels.payload.extend_from_slice(&22u16.to_le_bytes()); + write_frame(&mut renderer.stream, &cell_pixels).unwrap(); + let resized = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); + assert_eq!(resized.kind, MessageKind::Resized); + assert_eq!(resized.flags, FLAG_COLORS_FOLLOW); + assert_eq!(resized.request_id, 0); + assert_eq!(resized.sequence, renderer.next_sequence); + renderer.next_sequence = renderer.next_sequence.wrapping_add(1); + assert_eq!(resize_cell_pixels(&resized.payload), (11, 22)); + let colors = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); + assert_eq!(colors.kind, MessageKind::Colors); + assert_eq!(colors.request_id, 0); + assert_eq!(colors.sequence, renderer.next_sequence); + renderer.next_sequence = renderer.next_sequence.wrapping_add(1); + let ack = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); + assert_eq!(ack.kind, MessageKind::CellPixelSizeAck); + assert_eq!(ack.request_id, 41); + assert_eq!(ack.sequence, 0); + assert_eq!(ack.payload, vec![11, 0, 22, 0]); + + let resnapshot_grant = request( + &harness.socket, + serde_json::json!({ + "id":3,"cmd":"mint-terminal-renderer","surface":surface,"ttl_ms":10_000, + }), + ); + let resnapshot = connect_host_detailed( + resnapshot_grant["endpoint"].as_str().unwrap(), + resnapshot_grant["terminal_id"].as_str().unwrap(), + resnapshot_grant["token"].as_str().unwrap(), + ClientRole::Renderer, + CapabilityRights::RENDERER, + ) + .unwrap(); + assert_eq!( + snapshot_cell_pixels(&resnapshot.snapshot.payload), + (11, 22), + "a reconnect snapshot must expose the host's committed cell geometry" + ); + drop(resnapshot); + let mut unchanged = Frame::new(MessageKind::ViewerSize, Vec::new()); unchanged.request_id = 42; unchanged.payload.extend_from_slice(&80u16.to_le_bytes()); @@ -2301,7 +2441,10 @@ fn negotiated_viewer_size_ack_skips_unchanged_replay_and_follows_changed_pair() renderer.next_sequence = renderer.next_sequence.wrapping_add(1); assert_eq!(&resized.payload[..4], &[70, 0, 20, 0]); let replay_len = u32::from_le_bytes(resized.payload[4..8].try_into().unwrap()) as usize; - assert_eq!(resized.payload.len(), 8 + replay_len); + let alias_count_offset = 8 + replay_len; + assert_eq!(resized.payload.len(), alias_count_offset + 6 + KITTY_REPLAY_STATE_ENCODED_LEN); + assert_eq!(&resized.payload[alias_count_offset..alias_count_offset + 2], &0u16.to_le_bytes()); + assert_eq!(&resized.payload[alias_count_offset + 2..alias_count_offset + 6], &[11, 0, 22, 0]); let colors = read_frame(&mut renderer.stream, MAX_FRAME_PAYLOAD).unwrap().unwrap(); assert_eq!(colors.kind, MessageKind::Colors); assert_eq!(colors.flags, 0); @@ -2319,7 +2462,7 @@ fn negotiated_viewer_size_ack_skips_unchanged_replay_and_follows_changed_pair() changed_ack.extend_from_slice(&RESIZE_ACK_CANONICAL_CHANGED.to_le_bytes()); assert_eq!(ack.payload, changed_ack); - request(&harness.socket, serde_json::json!({"id":3,"cmd":"close-surface","surface":surface})); + request(&harness.socket, serde_json::json!({"id":4,"cmd":"close-surface","surface":surface})); wait_for_no_host_records(&harness.host_root()); } @@ -3144,6 +3287,38 @@ fn snapshot_replay(payload: &[u8]) -> &[u8] { &payload[12..end] } +fn snapshot_cell_pixels(payload: &[u8]) -> (u16, u16) { + assert!( + payload.len() >= 4 + KITTY_REPLAY_STATE_ENCODED_LEN, + "Snapshot payload omitted cell geometry" + ); + let offset = payload.len() - KITTY_REPLAY_STATE_ENCODED_LEN - 4; + ( + u16::from_le_bytes(payload[offset..offset + 2].try_into().unwrap()), + u16::from_le_bytes(payload[offset + 2..offset + 4].try_into().unwrap()), + ) +} + +fn resize_cell_pixels(payload: &[u8]) -> (u16, u16) { + assert!(payload.len() >= 8, "Resized payload was truncated"); + let replay_len = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize; + let alias_count_offset = 8usize.checked_add(replay_len).expect("replay length overflow"); + assert!(alias_count_offset + 2 <= payload.len(), "Resized payload omitted Kitty alias count"); + let alias_count = + u16::from_le_bytes(payload[alias_count_offset..alias_count_offset + 2].try_into().unwrap()) + as usize; + let offset = alias_count_offset + 2 + alias_count * 8; + assert_eq!( + payload.len(), + offset + 4 + KITTY_REPLAY_STATE_ENCODED_LEN, + "Resized payload has an invalid suffix" + ); + ( + u16::from_le_bytes(payload[offset..offset + 2].try_into().unwrap()), + u16::from_le_bytes(payload[offset + 2..offset + 4].try_into().unwrap()), + ) +} + fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|window| window == needle) } diff --git a/cmux-tui/crates/ghostty-vt/Cargo.toml b/cmux-tui/crates/ghostty-vt/Cargo.toml index 78dc0e9975..b0b8ff34cc 100644 --- a/cmux-tui/crates/ghostty-vt/Cargo.toml +++ b/cmux-tui/crates/ghostty-vt/Cargo.toml @@ -11,4 +11,6 @@ description = "Safe Rust wrapper around libghostty-vt" workspace = true [dependencies] +base64.workspace = true ghostty-vt-sys.workspace = true +png.workspace = true diff --git a/cmux-tui/crates/ghostty-vt/src/kitty.rs b/cmux-tui/crates/ghostty-vt/src/kitty.rs new file mode 100644 index 0000000000..a88a9d4c99 --- /dev/null +++ b/cmux-tui/crates/ghostty-vt/src/kitty.rs @@ -0,0 +1,1357 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::c_void; +use std::io::Cursor; +use std::mem::size_of; +use std::ptr; +use std::sync::{Arc, OnceLock}; + +use ghostty_vt_sys as sys; + +use crate::terminal::Terminal; +use crate::{Error, Result, check}; + +pub const MAX_KITTY_IMAGE_BYTES: usize = 10_000_000; +pub const MAX_KITTY_IMAGES: u64 = 4_096; +pub const MAX_KITTY_PLACEMENTS: u64 = 16_384; +const KITTY_INFLIGHT_REPLAY_FRAMING_MAX_BYTES: u64 = 256 * 1024; + +/// Wire bytes reserved for an incomplete direct upload with this decoded-byte +/// allowance. The reservation covers exact base64 expansion and a proportional +/// share of the bounded command-framing headroom. +pub const fn kitty_inflight_replay_limit_for_image_bytes(image_bytes: u64) -> u64 { + let maximum = MAX_KITTY_IMAGE_BYTES as u64; + let bounded = if image_bytes < maximum { image_bytes } else { maximum }; + let encoded = bounded.div_ceil(3).saturating_mul(4); + let framing = bounded.saturating_mul(KITTY_INFLIGHT_REPLAY_FRAMING_MAX_BYTES).div_ceil(maximum); + encoded.saturating_add(framing) +} + +/// Maximum retained byte prefix for a valid incomplete direct Kitty upload. +pub const KITTY_INFLIGHT_REPLAY_MAX_BYTES: usize = + kitty_inflight_replay_limit_for_image_bytes(MAX_KITTY_IMAGE_BYTES as u64) as usize; +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + +#[cfg(test)] +std::thread_local! { + static SNAPSHOT_IMAGE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static SNAPSHOT_PLACEMENT_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PIXEL_CACHE_GENERATION_LOOKUPS: std::cell::Cell = + const { std::cell::Cell::new(0) }; + static PIXEL_CACHE_MISSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn record_snapshot_image_visit() { + SNAPSHOT_IMAGE_VISITS.with(|visits| visits.set(visits.get() + 1)); +} + +#[cfg(not(test))] +fn record_snapshot_image_visit() {} + +#[cfg(test)] +fn record_snapshot_placement_visit() { + SNAPSHOT_PLACEMENT_VISITS.with(|visits| visits.set(visits.get() + 1)); +} + +#[cfg(not(test))] +fn record_snapshot_placement_visit() {} + +#[cfg(test)] +fn record_pixel_cache_generation_lookup() { + PIXEL_CACHE_GENERATION_LOOKUPS.with(|lookups| lookups.set(lookups.get() + 1)); +} + +#[cfg(test)] +fn record_pixel_cache_miss() { + PIXEL_CACHE_MISSES.with(|misses| misses.set(misses.get() + 1)); +} + +#[cfg(not(test))] +fn record_pixel_cache_miss() {} + +/// Bounded copy of a Kitty direct transmission that libghostty is still +/// assembling. A fresh attach terminal must consume this exact prefix before +/// it can understand later continuation chunks from the live byte stream. +pub(crate) struct KittyInFlightTracker { + scan: KittyStreamScan, + prefix: Vec, + loading: bool, + overflowed: bool, + max_bytes: usize, +} + +impl Default for KittyInFlightTracker { + fn default() -> Self { + Self { + scan: KittyStreamScan::Ground, + prefix: Vec::new(), + loading: false, + overflowed: false, + max_bytes: KITTY_INFLIGHT_REPLAY_MAX_BYTES, + } + } +} + +impl KittyInFlightTracker { + pub(crate) fn write(&mut self, data: &[u8]) { + for &byte in data { + let state = std::mem::take(&mut self.scan); + self.scan = match state { + KittyStreamScan::Ground => KittyStreamScan::from_ground(byte), + KittyStreamScan::Utf8(mut continuation) => { + if !(continuation.next_min..=continuation.next_max).contains(&byte) { + KittyStreamScan::from_ground(byte) + } else if continuation.remaining == 1 { + KittyStreamScan::Ground + } else { + continuation.remaining -= 1; + continuation.next_min = 0x80; + continuation.next_max = 0xbf; + KittyStreamScan::Utf8(continuation) + } + } + KittyStreamScan::Escape => match byte { + b'_' => KittyStreamScan::ApcType(KittyApcIntroducer::Esc), + b'c' => { + self.clear_loading(); + KittyStreamScan::Ground + } + 0x1b => KittyStreamScan::Escape, + _ => KittyStreamScan::Ground, + }, + KittyStreamScan::ApcType(introducer) => match byte { + b'G' => { + let prefix_bytes = + if self.loading && !self.overflowed { self.prefix.len() } else { 0 }; + KittyStreamScan::Kitty(KittyCommand::new( + introducer, + self.max_bytes.saturating_sub(prefix_bytes), + )) + } + 0x1b => KittyStreamScan::OtherApc { saw_escape: true }, + 0x9c => KittyStreamScan::Ground, + _ => KittyStreamScan::OtherApc { saw_escape: false }, + }, + KittyStreamScan::Kitty(mut command) => { + if matches!(byte, 0x18 | 0x1a) { + // CAN and SUB abort only the current control string. + // Preserve a previously completed m=1 prefix so a + // later continuation can still finish that upload. + KittyStreamScan::Ground + } else { + command.push(byte); + if byte == 0x9c || (command.saw_escape && byte == b'\\') { + self.finish_command(command); + KittyStreamScan::Ground + } else { + command.saw_escape = byte == 0x1b; + KittyStreamScan::Kitty(command) + } + } + } + KittyStreamScan::OtherApc { saw_escape } => { + if matches!(byte, 0x18 | 0x1a) || byte == 0x9c || (saw_escape && byte == b'\\') + { + KittyStreamScan::Ground + } else { + KittyStreamScan::OtherApc { saw_escape: byte == 0x1b } + } + } + }; + } + } + + #[cfg(test)] + pub(crate) fn replay_prefix(&self, max_bytes: usize) -> Vec { + self.replay_prefix_checked(max_bytes).unwrap_or_default() + } + + fn replay_prefix_parts(&self) -> Result<(&[u8], &[u8])> { + if self.overflowed + || matches!(&self.scan, KittyStreamScan::Kitty(command) if command.overflowed) + { + return Err(Error::OutOfSpace); + } + let partial = match &self.scan { + KittyStreamScan::Escape => &b"\x1b"[..], + KittyStreamScan::ApcType(KittyApcIntroducer::Esc) => &b"\x1b_"[..], + KittyStreamScan::ApcType(KittyApcIntroducer::C1) => &b"\x9f"[..], + KittyStreamScan::Kitty(command) if !command.overflowed => &command.bytes, + _ => &[], + }; + let prefix = if self.loading { self.prefix.as_slice() } else { &[] }; + if prefix.len().checked_add(partial.len()).is_none_or(|total| total > self.max_bytes) { + return Err(Error::OutOfSpace); + } + Ok((prefix, partial)) + } + + pub(crate) fn replay_prefix_fits(&self, max_bytes: usize) -> Result<()> { + let (prefix, partial) = self.replay_prefix_parts()?; + let Some(total) = prefix.len().checked_add(partial.len()) else { + return Err(Error::OutOfSpace); + }; + if total > max_bytes { + return Err(Error::OutOfSpace); + } + Ok(()) + } + + pub(crate) fn replay_prefix_checked(&self, max_bytes: usize) -> Result> { + let (prefix, partial) = self.replay_prefix_parts()?; + let total = prefix.len().checked_add(partial.len()).ok_or(Error::OutOfSpace)?; + if total > max_bytes { + return Err(Error::OutOfSpace); + } + let mut replay = Vec::with_capacity(total); + replay.extend_from_slice(prefix); + replay.extend_from_slice(partial); + Ok(replay) + } + + fn finish_command(&mut self, command: KittyCommand) { + let more = match kitty_transmission_state(&command.bytes) { + KittyTransmissionState::More(more) => more, + KittyTransmissionState::NonTransmission => return, + KittyTransmissionState::Unknown => { + if command.overflowed { + self.prefix = Vec::new(); + self.loading = true; + self.overflowed = true; + } + return; + } + }; + if more { + if !self.loading { + self.prefix = Vec::new(); + self.overflowed = false; + } + self.loading = true; + if self.overflowed || command.overflowed { + self.prefix = Vec::new(); + self.overflowed = true; + return; + } + let Some(total) = self.prefix.len().checked_add(command.bytes.len()) else { + self.prefix = Vec::new(); + self.overflowed = true; + return; + }; + if total > self.max_bytes { + self.prefix = Vec::new(); + self.overflowed = true; + return; + } + if self.prefix.is_empty() { + self.prefix = command.bytes; + } else { + if self.prefix.capacity() < total { + self.prefix.reserve_exact(total - self.prefix.len()); + } + self.prefix.extend_from_slice(&command.bytes); + } + } else { + self.clear_loading(); + } + } + + fn clear_loading(&mut self) { + self.prefix = Vec::new(); + self.loading = false; + self.overflowed = false; + } + + pub(crate) fn set_max_bytes(&mut self, max_bytes: usize) { + self.max_bytes = max_bytes.min(KITTY_INFLIGHT_REPLAY_MAX_BYTES); + let prefix_bytes = if self.loading && !self.overflowed { self.prefix.len() } else { 0 }; + if prefix_bytes > self.max_bytes { + self.prefix = Vec::new(); + self.overflowed = true; + } else { + self.prefix.shrink_to_fit(); + } + let retained_prefix = if self.loading && !self.overflowed { self.prefix.len() } else { 0 }; + if let KittyStreamScan::Kitty(command) = &mut self.scan { + command.set_max_bytes(self.max_bytes.saturating_sub(retained_prefix)); + } + } + + pub(crate) fn max_bytes(&self) -> usize { + self.max_bytes + } +} + +#[derive(Default)] +enum KittyStreamScan { + #[default] + Ground, + Utf8(Utf8Continuation), + Escape, + ApcType(KittyApcIntroducer), + Kitty(KittyCommand), + OtherApc { + saw_escape: bool, + }, +} + +impl KittyStreamScan { + fn from_ground(byte: u8) -> Self { + // Match libghostty's parser and C1Normalizer: after a multibyte lead, + // every 0x80..=0xbf byte stays text even when strict UTF-8 would reject it. + match byte { + 0x1b => Self::Escape, + 0x9f => Self::ApcType(KittyApcIntroducer::C1), + 0xc2..=0xdf => Self::utf8(1, 0x80, 0xbf), + 0xe0..=0xef => Self::utf8(2, 0x80, 0xbf), + 0xf0..=0xf4 => Self::utf8(3, 0x80, 0xbf), + _ => Self::Ground, + } + } + + fn utf8(remaining: u8, next_min: u8, next_max: u8) -> Self { + Self::Utf8(Utf8Continuation { remaining, next_min, next_max }) + } +} + +struct Utf8Continuation { + remaining: u8, + next_min: u8, + next_max: u8, +} + +enum KittyApcIntroducer { + Esc, + C1, +} + +struct KittyCommand { + bytes: Vec, + saw_escape: bool, + overflowed: bool, + max_bytes: usize, +} + +impl KittyCommand { + fn new(introducer: KittyApcIntroducer, max_bytes: usize) -> Self { + let introducer = match introducer { + KittyApcIntroducer::Esc => &b"\x1b_G"[..], + KittyApcIntroducer::C1 => &b"\x9fG"[..], + }; + let retained = introducer.len().min(max_bytes); + let mut bytes = Vec::with_capacity(retained); + bytes.extend_from_slice(&introducer[..retained]); + Self { bytes, saw_escape: false, overflowed: retained != introducer.len(), max_bytes } + } + + fn push(&mut self, byte: u8) { + if self.bytes.len() < self.max_bytes { + if self.bytes.len() == self.bytes.capacity() { + let remaining = self.max_bytes - self.bytes.len(); + let growth = remaining.min(self.bytes.capacity().max(64)); + self.bytes.reserve_exact(growth); + } + self.bytes.push(byte); + } else { + self.overflowed = true; + } + } + + fn set_max_bytes(&mut self, max_bytes: usize) { + self.max_bytes = max_bytes; + if self.bytes.len() > max_bytes { + self.bytes.truncate(max_bytes); + self.overflowed = true; + } + self.bytes.shrink_to_fit(); + } +} + +enum KittyTransmissionState { + More(bool), + NonTransmission, + Unknown, +} + +fn kitty_u32(value: &[u8]) -> Option { + if value.is_empty() { + return None; + } + value.iter().try_fold(0_u32, |parsed, byte| { + let digit = byte.checked_sub(b'0').filter(|digit| *digit <= 9)?; + parsed.checked_mul(10)?.checked_add(u32::from(digit)) + }) +} + +fn kitty_transmission_state(command: &[u8]) -> KittyTransmissionState { + let header_start = if command.starts_with(b"\x1b_G") { + 3 + } else if command.starts_with(b"\x9fG") { + 2 + } else { + return KittyTransmissionState::Unknown; + }; + let Some(header_end) = + command[header_start..].iter().position(|byte| *byte == b';').map(|end| end + header_start) + else { + return KittyTransmissionState::Unknown; + }; + let mut action = b't'; + let mut direct = true; + let mut more = false; + for parameter in command[header_start..header_end].split(|byte| *byte == b',') { + let Some(separator) = parameter.iter().position(|byte| *byte == b'=') else { + continue; + }; + let key = ¶meter[..separator]; + let value = ¶meter[separator + 1..]; + match key { + b"a" => { + let Some(value) = value.first() else { + return KittyTransmissionState::Unknown; + }; + action = *value; + } + b"t" => direct = value == b"d", + b"m" => { + let Some(value) = kitty_u32(value) else { + return KittyTransmissionState::Unknown; + }; + more = value > 0; + } + _ => {} + } + } + if matches!(action, b't' | b'T') { + KittyTransmissionState::More(more && direct) + } else { + KittyTransmissionState::NonTransmission + } +} + +/// Pixel format stored in an owned Kitty graphics snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KittyImageFormat { + Rgb, + Rgba, +} + +impl KittyImageFormat { + pub fn kitty_protocol_value(self) -> u8 { + match self { + Self::Rgb => 24, + Self::Rgba => 32, + } + } + + pub fn bytes_per_pixel(self) -> usize { + match self { + Self::Rgb => 3, + Self::Rgba => 4, + } + } +} + +/// Decoded image pixels copied out of libghostty's borrowed storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KittyImage { + pub id: u32, + pub number: u32, + pub generation: u64, + pub width: u32, + pub height: u32, + pub format: KittyImageFormat, + pub data: Arc<[u8]>, +} + +/// The two aliases of a numbered Kitty image. +/// +/// Kitty forbids specifying `i` and `I` in one graphics command, so byte +/// replay restores the stable ID and attach transports this alias separately. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyImageAlias { + pub image_id: u32, + pub image_number: u32, +} + +/// Stable identity within one sorted snapshot. +/// +/// Kitty permits anonymous placements (`p=0`) and placement ID reuse +/// across images. The ordinal keeps those placements distinct without +/// relying on libghostty's unspecified iterator order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KittyPlacementKey { + pub image_id: u32, + pub placement_id: u32, + pub ordinal: u32, +} + +/// One non-virtual Kitty placement with resolved viewport geometry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KittyPlacement { + pub key: KittyPlacementKey, + pub image_id: u32, + pub placement_id: u32, + /// Whether `placement_id` is a libghostty storage identity for protocol + /// `p=0` rather than an ID supplied by the terminal application. + pub is_internal: bool, + pub x_offset: u32, + pub y_offset: u32, + pub source_x: u32, + pub source_y: u32, + pub source_width: u32, + pub source_height: u32, + /// Protocol `c` value, or zero when the placement omitted it. + pub columns: u32, + /// Protocol `r` value, or zero when the placement omitted it. + pub rows: u32, + pub grid_cols: u32, + pub grid_rows: u32, + pub pixel_width: u32, + pub pixel_height: u32, + pub viewport_col: i32, + pub viewport_row: i32, + pub viewport_visible: bool, + /// Absolute cell anchor in the retained screen, counted from its oldest row. + pub anchor: Option, + pub z: i32, +} + +/// Immutable Kitty graphics state captured at the same boundary as text. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KittyGraphicsSnapshot { + pub generation: u64, + pub images: Vec, + pub placements: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyPlacementAnchor { + pub col: u16, + pub row: u32, +} + +pub(crate) struct KittyReplaySnapshot { + pub graphics: KittyGraphicsSnapshot, + pub anchors: BTreeMap, +} + +impl KittyGraphicsSnapshot { + pub fn image(&self, id: u32) -> Option<&KittyImage> { + self.images.iter().find(|image| image.id == id) + } + + pub fn is_empty(&self) -> bool { + self.images.is_empty() && self.placements.is_empty() + } +} + +struct ImageIterator(sys::GhosttyKittyGraphicsImageIterator); + +impl Drop for ImageIterator { + fn drop(&mut self) { + unsafe { sys::ghostty_kitty_graphics_image_iterator_free(self.0) }; + } +} + +struct PlacementIterator(sys::GhosttyKittyGraphicsPlacementIterator); + +impl Drop for PlacementIterator { + fn drop(&mut self) { + unsafe { sys::ghostty_kitty_graphics_placement_iterator_free(self.0) }; + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PlacementSortKey { + image_id: u32, + placement_id: u32, + is_internal: bool, + z: i32, + viewport_row: i32, + viewport_col: i32, + source_y: u32, + source_x: u32, + source_height: u32, + source_width: u32, + rows: u32, + columns: u32, + grid_rows: u32, + grid_cols: u32, + y_offset: u32, + x_offset: u32, +} + +impl From<&KittyPlacement> for PlacementSortKey { + fn from(value: &KittyPlacement) -> Self { + Self { + image_id: value.image_id, + placement_id: value.placement_id, + is_internal: value.is_internal, + z: value.z, + viewport_row: value.viewport_row, + viewport_col: value.viewport_col, + source_y: value.source_y, + source_x: value.source_x, + source_height: value.source_height, + source_width: value.source_width, + rows: value.rows, + columns: value.columns, + grid_rows: value.grid_rows, + grid_cols: value.grid_cols, + y_offset: value.y_offset, + x_offset: value.x_offset, + } + } +} + +pub(crate) fn snapshot( + terminal: &Terminal, + pixel_cache: &mut HashMap>, + include_unplaced: bool, +) -> Result { + Ok(snapshot_impl(terminal, pixel_cache, include_unplaced)?.graphics) +} + +/// Consume libghostty's renderer-owned graphics damage and return a complete +/// snapshot only when content or placement geometry changed. `force` binds a +/// reused render state to a different terminal even if another renderer had +/// already cleared that terminal's damage flag. +pub(crate) fn snapshot_for_render( + terminal: &Terminal, + pixel_cache: &mut HashMap>, + force: bool, +) -> Result> { + let Some(graphics) = terminal_graphics(terminal)? else { + if force { + pixel_cache.clear(); + } + return Ok(force.then(KittyGraphicsSnapshot::default)); + }; + if !force { + let mut dirty = false; + check(unsafe { + sys::ghostty_kitty_graphics_get( + graphics, + sys::GHOSTTY_KITTY_GRAPHICS_DATA_DIRTY, + (&mut dirty as *mut bool).cast(), + ) + })?; + if !dirty { + return Ok(None); + } + } + + let snapshot = snapshot(terminal, pixel_cache, false)?; + let clean = false; + check(unsafe { + sys::ghostty_kitty_graphics_set( + graphics, + sys::GHOSTTY_KITTY_GRAPHICS_OPTION_DIRTY, + (&clean as *const bool).cast(), + ) + })?; + Ok(Some(snapshot)) +} + +pub(crate) fn snapshot_for_replay( + terminal: &Terminal, + pixel_cache: &mut HashMap>, + include_unplaced: bool, +) -> Result { + snapshot_impl(terminal, pixel_cache, include_unplaced) +} + +pub(crate) fn generation(terminal: &Terminal) -> Result { + let Some(graphics) = terminal_graphics(terminal)? else { + return Ok(0); + }; + graphics_generation(graphics) +} + +fn snapshot_impl( + terminal: &Terminal, + pixel_cache: &mut HashMap>, + include_unplaced: bool, +) -> Result { + let Some(graphics) = terminal_graphics(terminal)? else { + return Ok(KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot::default(), + anchors: BTreeMap::new(), + }); + }; + + let generation = graphics_generation(graphics)?; + if generation == 0 { + pixel_cache.clear(); + return Ok(KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot::default(), + anchors: BTreeMap::new(), + }); + } + + let mut images = BTreeMap::::new(); + if include_unplaced { + let mut raw_images: sys::GhosttyKittyGraphicsImageIterator = ptr::null_mut(); + check(unsafe { + sys::ghostty_kitty_graphics_image_iterator_new(ptr::null(), graphics, &mut raw_images) + })?; + let images_iterator = ImageIterator(raw_images); + loop { + let raw_image = unsafe { sys::ghostty_kitty_graphics_image_next(images_iterator.0) }; + if raw_image.is_null() { + break; + } + record_snapshot_image_visit(); + let image = copy_image(raw_image, pixel_cache)?; + images.insert(image.id, image); + } + } + + let mut raw_iterator: sys::GhosttyKittyGraphicsPlacementIterator = ptr::null_mut(); + check(unsafe { + sys::ghostty_kitty_graphics_placement_iterator_new(ptr::null(), &mut raw_iterator) + })?; + let mut iterator = PlacementIterator(raw_iterator); + check(unsafe { + sys::ghostty_kitty_graphics_get( + graphics, + sys::GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, + (&mut iterator.0 as *mut sys::GhosttyKittyGraphicsPlacementIterator).cast(), + ) + })?; + + let mut placements = Vec::new(); + while unsafe { sys::ghostty_kitty_graphics_placement_next(iterator.0) } { + record_snapshot_placement_visit(); + let image_id = placement_value::( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, + )?; + let placement_id = placement_value::( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, + )?; + let is_virtual = placement_value::( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, + )?; + if is_virtual { + continue; + } + + let raw_image = unsafe { sys::ghostty_kitty_graphics_image(graphics, image_id) }; + if raw_image.is_null() { + continue; + } + if let std::collections::btree_map::Entry::Vacant(entry) = images.entry(image_id) { + entry.insert(copy_image(raw_image, pixel_cache)?); + } + + let mut info = sys::GhosttyKittyGraphicsPlacementRenderInfo { + size: size_of::(), + ..Default::default() + }; + check(unsafe { + sys::ghostty_kitty_graphics_placement_render_info( + iterator.0, + raw_image, + terminal.raw(), + &mut info, + ) + })?; + + let mut rect = sys::GhosttySelection { + size: size_of::(), + ..Default::default() + }; + check(unsafe { + sys::ghostty_kitty_graphics_placement_rect( + iterator.0, + raw_image, + terminal.raw(), + &mut rect, + ) + })?; + let mut anchor = sys::GhosttyPointCoordinate::default(); + check(unsafe { + sys::ghostty_terminal_point_from_grid_ref( + terminal.raw(), + &rect.start, + sys::GHOSTTY_POINT_TAG_SCREEN, + &mut anchor, + ) + })?; + let anchor = KittyPlacementAnchor { col: anchor.x, row: anchor.y }; + + placements.push(KittyPlacement { + key: KittyPlacementKey { image_id, placement_id, ordinal: 0 }, + image_id, + placement_id, + is_internal: placement_value( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_INTERNAL, + )?, + x_offset: placement_value( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, + )?, + y_offset: placement_value( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET, + )?, + source_x: info.source_x, + source_y: info.source_y, + source_width: info.source_width, + source_height: info.source_height, + columns: placement_value( + iterator.0, + sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS, + )?, + rows: placement_value(iterator.0, sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS)?, + grid_cols: info.grid_cols, + grid_rows: info.grid_rows, + pixel_width: info.pixel_width, + pixel_height: info.pixel_height, + viewport_col: info.viewport_col, + viewport_row: info.viewport_row, + viewport_visible: info.viewport_visible, + anchor: Some(anchor), + z: placement_value(iterator.0, sys::GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z)?, + }); + } + + placements.sort_by_key(|placement| PlacementSortKey::from(placement)); + let mut ordinals = BTreeMap::<(u32, u32), u32>::new(); + let mut anchors = BTreeMap::new(); + for placement in &mut placements { + let ordinal = ordinals.entry((placement.image_id, placement.placement_id)).or_default(); + placement.key.ordinal = *ordinal; + *ordinal = ordinal.saturating_add(1); + if let Some(anchor) = placement.anchor { + anchors.insert(placement.key, anchor); + } + } + let current_generations = images.values().map(|image| image.generation).collect::>(); + pixel_cache.retain(|image_generation, _| { + #[cfg(test)] + record_pixel_cache_generation_lookup(); + current_generations.contains(image_generation) + }); + + Ok(KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot { + generation, + images: images.into_values().collect(), + placements, + }, + anchors, + }) +} + +fn graphics_generation(graphics: sys::GhosttyKittyGraphics) -> Result { + graphics_value(graphics, sys::GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION) +} + +fn graphics_value( + graphics: sys::GhosttyKittyGraphics, + data: sys::GhosttyKittyGraphicsData, +) -> Result { + let mut value = T::default(); + check(unsafe { + sys::ghostty_kitty_graphics_get(graphics, data, (&mut value as *mut T).cast()) + })?; + Ok(value) +} + +fn terminal_graphics(terminal: &Terminal) -> Result> { + let mut graphics: sys::GhosttyKittyGraphics = ptr::null_mut(); + match check(unsafe { + sys::ghostty_terminal_get( + terminal.raw(), + sys::GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + (&mut graphics as *mut sys::GhosttyKittyGraphics).cast(), + ) + }) { + Ok(()) if !graphics.is_null() => Ok(Some(graphics)), + Ok(()) | Err(Error::NoValue) => Ok(None), + Err(error) => Err(error), + } +} + +fn placement_value( + iterator: sys::GhosttyKittyGraphicsPlacementIterator, + data: sys::GhosttyKittyGraphicsPlacementData, +) -> Result { + let mut value = T::default(); + check(unsafe { + sys::ghostty_kitty_graphics_placement_get(iterator, data, (&mut value as *mut T).cast()) + })?; + Ok(value) +} + +fn image_value( + image: sys::GhosttyKittyGraphicsImage, + data: sys::GhosttyKittyGraphicsImageData, +) -> Result { + let mut value = T::default(); + check(unsafe { + sys::ghostty_kitty_graphics_image_get(image, data, (&mut value as *mut T).cast()) + })?; + Ok(value) +} + +fn snapshot_image_format(raw: sys::GhosttyKittyImageFormat) -> Result { + // Ghostty's Kitty parser accepts raw RGB/RGBA and normalizes PNGs to RGBA. + // Its grayscale enum values cannot reach image storage through that parser, + // so reject them instead of expanding bytes beyond the transport budget. + match raw { + sys::GHOSTTY_KITTY_IMAGE_FORMAT_RGB => Ok(KittyImageFormat::Rgb), + sys::GHOSTTY_KITTY_IMAGE_FORMAT_RGBA => Ok(KittyImageFormat::Rgba), + _ => Err(Error::InvalidValue), + } +} + +fn copy_image( + image: sys::GhosttyKittyGraphicsImage, + pixel_cache: &mut HashMap>, +) -> Result { + let id = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_ID)?; + let number = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_NUMBER)?; + let generation = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_GENERATION)?; + let width = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_WIDTH)?; + let height = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_HEIGHT)?; + let raw_format: sys::GhosttyKittyImageFormat = + image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_FORMAT)?; + let format = snapshot_image_format(raw_format)?; + let data_ptr: *const u8 = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR)?; + let data_len: usize = image_value(image, sys::GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN)?; + + if let Some(data) = pixel_cache.get(&generation) { + return Ok(KittyImage { + id, + number, + generation, + width, + height, + format, + data: data.clone(), + }); + } + record_pixel_cache_miss(); + if data_ptr.is_null() && data_len != 0 { + return Err(Error::InvalidValue); + } + let bytes = if data_len == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(data_ptr, data_len) } + }; + let data: Arc<[u8]> = Arc::from(bytes); + let expected = usize::try_from(width) + .ok() + .and_then(|width| usize::try_from(height).ok().and_then(|height| width.checked_mul(height))) + .and_then(|pixels| pixels.checked_mul(format.bytes_per_pixel())) + .ok_or(Error::InvalidValue)?; + if data.len() != expected { + return Err(Error::InvalidValue); + } + pixel_cache.insert(generation, data.clone()); + Ok(KittyImage { id, number, generation, width, height, format, data }) +} + +pub(crate) fn install_png_decoder() -> Result<()> { + static RESULT: OnceLock = OnceLock::new(); + check(*RESULT.get_or_init(|| unsafe { + sys::ghostty_sys_set( + sys::GHOSTTY_SYS_OPT_DECODE_PNG, + decode_png as *const () as *const c_void, + ) + })) +} + +unsafe extern "C" fn decode_png( + _userdata: *mut c_void, + allocator: *const sys::GhosttyAllocator, + data: *const u8, + data_len: usize, + out: *mut sys::GhosttySysImage, +) -> bool { + std::panic::catch_unwind(|| { + if data.is_null() || out.is_null() || data_len > MAX_KITTY_IMAGE_BYTES { + return false; + } + let encoded = unsafe { std::slice::from_raw_parts(data, data_len) }; + if !png_header_within_limits(encoded) { + return false; + } + let mut decoder = png::Decoder::new(Cursor::new(encoded)); + decoder.set_limits(png::Limits { bytes: MAX_KITTY_IMAGE_BYTES }); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = match decoder.read_info() { + Ok(reader) => reader, + Err(_) => return false, + }; + let decoded_len = reader.output_buffer_size(); + if decoded_len > MAX_KITTY_IMAGE_BYTES { + return false; + } + let mut decoded = vec![0; decoded_len]; + let info = match reader.next_frame(&mut decoded) { + Ok(info) => info, + Err(_) => return false, + }; + let pixel_count = match usize::try_from(info.width).ok().and_then(|width| { + usize::try_from(info.height).ok().and_then(|height| width.checked_mul(height)) + }) { + Some(count) => count, + None => return false, + }; + let rgba_len = match pixel_count.checked_mul(4) { + Some(len) if len <= MAX_KITTY_IMAGE_BYTES => len, + Some(_) | None => return false, + }; + let source_len = info.buffer_size(); + if source_len > decoded.len() || source_len > MAX_KITTY_IMAGE_BYTES { + return false; + } + let rgba = match info.color_type { + png::ColorType::Rgba => { + if source_len != rgba_len { + return false; + } + decoded.truncate(source_len); + decoded + } + color_type => { + let source = &decoded[..source_len]; + let mut rgba = Vec::with_capacity(rgba_len); + match color_type { + png::ColorType::Rgb => { + for pixel in source.chunks_exact(3) { + rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]); + } + } + png::ColorType::GrayscaleAlpha => { + for pixel in source.chunks_exact(2) { + rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]); + } + } + png::ColorType::Grayscale => { + for value in source { + rgba.extend_from_slice(&[*value, *value, *value, 255]); + } + } + png::ColorType::Indexed | png::ColorType::Rgba => return false, + } + rgba + } + }; + if rgba.len() != rgba_len { + return false; + } + let output = unsafe { sys::ghostty_alloc(allocator, rgba.len()) }; + if output.is_null() { + return false; + } + unsafe { + ptr::copy_nonoverlapping(rgba.as_ptr(), output, rgba.len()); + *out = sys::GhosttySysImage { + width: info.width, + height: info.height, + data: output, + data_len: rgba.len(), + }; + } + true + }) + .unwrap_or(false) +} + +fn png_header_within_limits(encoded: &[u8]) -> bool { + let Some(signature) = encoded.get(..8) else { + return false; + }; + let Some(length) = encoded.get(8..12) else { + return false; + }; + let Some(kind) = encoded.get(12..16) else { + return false; + }; + let Some(width) = encoded.get(16..20) else { + return false; + }; + let Some(height) = encoded.get(20..24) else { + return false; + }; + if signature != PNG_SIGNATURE || length != 13_u32.to_be_bytes() || kind != b"IHDR" { + return false; + } + let width = u32::from_be_bytes(width.try_into().unwrap()); + let height = u32::from_be_bytes(height.try_into().unwrap()); + width > 0 + && height > 0 + && usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height).ok().and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + .is_some_and(|bytes| bytes <= MAX_KITTY_IMAGE_BYTES) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reset_counter(counter: &'static std::thread::LocalKey>) { + counter.with(|value| value.set(0)); + } + + fn counter(counter: &'static std::thread::LocalKey>) -> usize { + counter.with(std::cell::Cell::get) + } + + #[test] + fn snapshot_format_rejects_unreachable_grayscale_storage() { + for raw in + [sys::GHOSTTY_KITTY_IMAGE_FORMAT_GRAY, sys::GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA] + { + assert!(matches!(snapshot_image_format(raw), Err(Error::InvalidValue))); + } + } + + #[test] + fn inflight_limit_covers_exact_base64_expansion_and_bounded_framing() { + assert_eq!(kitty_inflight_replay_limit_for_image_bytes(0), 0); + assert_eq!(kitty_inflight_replay_limit_for_image_bytes(1), 5); + assert_eq!(KITTY_INFLIGHT_REPLAY_MAX_BYTES, 13_595_480); + assert_eq!( + kitty_inflight_replay_limit_for_image_bytes(MAX_KITTY_IMAGE_BYTES as u64), + KITTY_INFLIGHT_REPLAY_MAX_BYTES as u64 + ); + assert_eq!( + kitty_inflight_replay_limit_for_image_bytes(u64::MAX), + KITTY_INFLIGHT_REPLAY_MAX_BYTES as u64 + ); + } + + #[test] + fn inflight_tracker_replays_completed_and_partial_chunks() { + let first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + let partial = b"\x1b_Gm=0;AA"; + let mut tracker = KittyInFlightTracker::default(); + + for bytes in first.chunks(3) { + tracker.write(bytes); + } + for bytes in partial.chunks(2) { + tracker.write(bytes); + } + + let mut expected = first.to_vec(); + expected.extend_from_slice(partial); + assert_eq!(tracker.replay_prefix(usize::MAX), expected); + } + + #[test] + fn inflight_tracker_clears_only_for_a_final_direct_transmission() { + let first = b"\x1b_Ga=T,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + let placement = b"\x1b_Ga=p,i=92,p=1,c=1,r=1\x1b\\"; + let final_chunk = b"\x1b_Gm=0;AAAA\x1b\\"; + let mut tracker = KittyInFlightTracker::default(); + + tracker.write(first); + tracker.write(placement); + assert_eq!(tracker.replay_prefix(usize::MAX), first); + + tracker.write(final_chunk); + assert!(tracker.replay_prefix(usize::MAX).is_empty()); + } + + #[test] + fn inflight_tracker_uses_numeric_final_chunk_semantics() { + let first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + let final_chunk = b"\x1b_Gm=00;AAAA\x1b\\"; + let mut tracker = KittyInFlightTracker::default(); + + tracker.write(first); + tracker.write(final_chunk); + + assert!( + tracker.replay_prefix(usize::MAX).is_empty(), + "a zero-valued numeric m parameter kept the completed transmission in flight" + ); + } + + #[test] + fn inflight_tracker_handles_c1_apc_and_terminal_reset() { + let first = b"\x9fGa=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x9c"; + let mut tracker = KittyInFlightTracker::default(); + + tracker.write(first); + assert_eq!(tracker.replay_prefix(usize::MAX), first); + + tracker.write(b"\x1bc"); + assert!(tracker.replay_prefix(usize::MAX).is_empty()); + } + + #[test] + fn inflight_tracker_cancels_only_the_active_apc_on_can_or_sub() { + for cancel in [0x18, 0x1a] { + let first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + let mut tracker = KittyInFlightTracker::default(); + tracker.write(first); + tracker.write(b"\x1b_Gm=0;cancelled"); + tracker.write(&[cancel]); + tracker.write(b"ordinary output"); + + assert_eq!( + tracker.replay_prefix(usize::MAX), + first, + "cancel byte {cancel:#x} retained the aborted APC or following text" + ); + + tracker.write(b"\x1b_Gm=0;AAAA\x1b\\"); + assert!(tracker.replay_prefix(usize::MAX).is_empty()); + } + } + + #[test] + fn completed_oversized_non_transmissions_do_not_poison_future_replay() { + for header in ["a=p,i=92,p=1,c=1,r=1", "a=d,d=i,i=92"] { + let mut tracker = KittyInFlightTracker::default(); + tracker.set_max_bytes(64); + let mut command = format!("\x1b_G{header};").into_bytes(); + command.extend(std::iter::repeat_n(b'x', 128)); + command.extend_from_slice(b"\x1b\\"); + tracker.write(&command); + + assert!( + tracker.replay_prefix_checked(usize::MAX).is_ok(), + "completed oversized {header} command poisoned replay" + ); + + let first = b"\x1b_Ga=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x1b\\"; + tracker.write(first); + assert_eq!( + tracker.replay_prefix_checked(usize::MAX).unwrap(), + first, + "completed oversized {header} command poisoned a later upload" + ); + } + } + + #[test] + fn snapshot_enumerates_each_stored_image_once() { + let mut terminal = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + for number in 1..=64 { + terminal.vt_write( + format!("\x1b_Ga=t,t=d,f=24,I={number},s=1,v=1,q=2;AAAA\x1b\\").as_bytes(), + ); + } + terminal.vt_write(b"\x1b_Ga=t,t=d,f=24,s=1,v=1,q=2;AAEA\x1b\\"); + + reset_counter(&SNAPSHOT_IMAGE_VISITS); + let graphics = snapshot(&terminal, &mut HashMap::new(), true).unwrap(); + + assert_eq!(graphics.images.len(), 65); + assert_eq!(counter(&SNAPSHOT_IMAGE_VISITS), graphics.images.len()); + assert_eq!(graphics.images.iter().filter(|image| image.number == 0).count(), 1); + } + + #[test] + fn snapshot_pixel_cache_retention_looks_up_each_generation_once() { + let mut terminal = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + for image_id in 1..=64 { + terminal.vt_write( + format!("\x1b_Ga=t,t=d,f=24,i={image_id},s=1,v=1,q=2;AAAA\x1b\\").as_bytes(), + ); + } + let mut pixel_cache = HashMap::new(); + let graphics = snapshot(&terminal, &mut pixel_cache, true).unwrap(); + assert_eq!(pixel_cache.len(), graphics.images.len()); + + reset_counter(&PIXEL_CACHE_GENERATION_LOOKUPS); + let refreshed = snapshot(&terminal, &mut pixel_cache, true).unwrap(); + + assert_eq!(refreshed.images.len(), graphics.images.len()); + assert_eq!(counter(&PIXEL_CACHE_GENERATION_LOOKUPS), graphics.images.len()); + } + + #[test] + fn repeated_vt_replay_reuses_cached_image_pixels() { + let mut terminal = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + + reset_counter(&PIXEL_CACHE_MISSES); + terminal.vt_replay().unwrap(); + terminal.vt_replay().unwrap(); + + assert_eq!( + counter(&PIXEL_CACHE_MISSES), + 1, + "an unchanged replay copied libghostty image pixels again" + ); + } + + #[test] + fn render_snapshot_skips_unchanged_graphics_but_refreshes_geometry_damage() { + let mut terminal = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + let mut pixel_cache = HashMap::new(); + let first = snapshot_for_render(&terminal, &mut pixel_cache, true) + .unwrap() + .expect("forced first render snapshot"); + assert_eq!(first.placements.len(), 1); + + reset_counter(&SNAPSHOT_PLACEMENT_VISITS); + terminal.vt_write(b"text"); + assert!( + snapshot_for_render(&terminal, &mut pixel_cache, false).unwrap().is_none(), + "ordinary text output rebuilt an unchanged Kitty scene" + ); + assert_eq!(counter(&SNAPSHOT_PLACEMENT_VISITS), 0); + + terminal.resize(21, 8, 8, 16).unwrap(); + let resized = snapshot_for_render(&terminal, &mut pixel_cache, false) + .unwrap() + .expect("resize must refresh placement geometry"); + assert_eq!(resized.placements.len(), 1); + assert!(counter(&SNAPSHOT_PLACEMENT_VISITS) > 0); + } + + #[test] + fn forced_empty_terminal_rebind_releases_cached_pixels() { + let mut populated = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + populated.vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + let mut pixel_cache = HashMap::new(); + let graphics = snapshot_for_render(&populated, &mut pixel_cache, true) + .unwrap() + .expect("populated terminal snapshot"); + assert_eq!(graphics.images.len(), 1); + assert_eq!(pixel_cache.len(), 1); + + let empty = Terminal::new(20, 8, 100, crate::Callbacks::default()).unwrap(); + let rebound = snapshot_for_render(&empty, &mut pixel_cache, true) + .unwrap() + .expect("forced empty terminal snapshot"); + + assert!(rebound.is_empty()); + assert!(pixel_cache.is_empty(), "rebound render state retained stale image pixels"); + } + + #[test] + fn png_header_rejects_dimensions_above_the_decode_bound() { + let mut header = Vec::from(*PNG_SIGNATURE); + header.extend_from_slice(&13_u32.to_be_bytes()); + header.extend_from_slice(b"IHDR"); + header.extend_from_slice(&5_000_u32.to_be_bytes()); + header.extend_from_slice(&5_000_u32.to_be_bytes()); + assert!(!png_header_within_limits(&header)); + + header[16..20].copy_from_slice(&1_u32.to_be_bytes()); + header[20..24].copy_from_slice(&1_u32.to_be_bytes()); + assert!(png_header_within_limits(&header)); + } +} diff --git a/cmux-tui/crates/ghostty-vt/src/lib.rs b/cmux-tui/crates/ghostty-vt/src/lib.rs index 5aba3cee46..42065cd685 100644 --- a/cmux-tui/crates/ghostty-vt/src/lib.rs +++ b/cmux-tui/crates/ghostty-vt/src/lib.rs @@ -5,6 +5,7 @@ //! rendered from this crate matches what a real Ghostty surface would show. mod key; +mod kitty; mod mouse; mod render; mod terminal; @@ -13,15 +14,22 @@ mod terminal; pub use ghostty_vt_sys as sys; pub use key::{KeyAction, KeyEncoder, KeyInput, Mods, key_input_from_chord}; +pub use kitty::{ + KITTY_INFLIGHT_REPLAY_MAX_BYTES, KittyGraphicsSnapshot, KittyImage, KittyImageAlias, + KittyImageFormat, KittyPlacement, KittyPlacementAnchor, KittyPlacementKey, + MAX_KITTY_IMAGE_BYTES, MAX_KITTY_IMAGES, MAX_KITTY_PLACEMENTS, + kitty_inflight_replay_limit_for_image_bytes, +}; pub use mouse::{MouseAction, MouseButton, MouseEncoder, MouseEncoders, MouseInput}; pub use render::{ ATTR_BLINK, ATTR_BOLD, ATTR_FAINT, ATTR_INVERSE, ATTR_INVISIBLE, ATTR_ITALIC, - ATTR_STRIKETHROUGH, Cell, CellWidth, ColorSpec, CursorInfo, CursorShape, Dirty, RenderFrame, - RenderState, StyledRun, UnderlineStyle, rows_to_runs, + ATTR_STRIKETHROUGH, Cell, CellWidth, ColorSpec, CursorInfo, CursorShape, Dirty, + KittyGraphicsFrameDelta, RenderFrame, RenderState, StyledRun, UnderlineStyle, rows_to_runs, }; pub use terminal::{ - Callbacks, ClearHistoryOutcome, NotifyFn, PtyWriteFn, Rgb, Screen, Scrollbar, Terminal, - TerminalColorOverrides, TerminalPointerSemanticSnapshot, parse_color, parse_palette_entry, + Callbacks, ClearHistoryOutcome, KittyGraphicsLimits, KittyImageIdCursors, KittyReplayState, + NotifyFn, PtyWriteFn, Rgb, Screen, Scrollbar, Terminal, TerminalColorOverrides, + TerminalPointerSemanticSnapshot, VtReplay, parse_color, parse_palette_entry, }; pub(crate) fn check(result: ghostty_vt_sys::GhosttyResult) -> std::result::Result<(), Error> { diff --git a/cmux-tui/crates/ghostty-vt/src/render.rs b/cmux-tui/crates/ghostty-vt/src/render.rs index e2fe04e433..4b95a768ea 100644 --- a/cmux-tui/crates/ghostty-vt/src/render.rs +++ b/cmux-tui/crates/ghostty-vt/src/render.rs @@ -1,8 +1,11 @@ +use std::collections::HashMap; use std::ffi::c_void; use std::ptr; +use std::sync::Arc; use ghostty_vt_sys as sys; +use crate::kitty::{self, KittyGraphicsSnapshot}; use crate::terminal::{Rgb, Terminal}; use crate::{Result, check}; @@ -146,9 +149,59 @@ pub struct RenderFrame { pub cursor_color: Option, pub default_colors: (Rgb, Rgb), pub dirty_rows: Vec, + pub kitty_graphics: Arc, + pub kitty_graphics_delta: Arc, rows: Vec>, } +/// Shared change metadata computed once when a terminal graphics snapshot is +/// produced, then reused by every render attachment. +#[derive(Debug, Clone, Default)] +pub struct KittyGraphicsFrameDelta { + pub snapshot_id: u64, + pub previous_snapshot_id: Option, + pub image_revision: u64, + pub placement_revision: u64, + pub image_generations: Arc<[(u32, u64)]>, + pub changed_image_ids: Arc<[u32]>, + pub removed_image_ids: Arc<[u32]>, +} + +fn image_generation_delta(previous: &[(u32, u64)], next: &[(u32, u64)]) -> (Vec, Vec) { + let mut changed = Vec::new(); + let mut removed = Vec::new(); + let (mut previous_index, mut next_index) = (0, 0); + while previous_index < previous.len() || next_index < next.len() { + match (previous.get(previous_index), next.get(next_index)) { + (Some(&(previous_id, previous_generation)), Some(&(next_id, next_generation))) => { + if previous_id < next_id { + removed.push(previous_id); + previous_index += 1; + } else if next_id < previous_id { + changed.push(next_id); + next_index += 1; + } else { + if previous_generation != next_generation { + changed.push(next_id); + } + previous_index += 1; + next_index += 1; + } + } + (Some(&(previous_id, _)), None) => { + removed.push(previous_id); + previous_index += 1; + } + (None, Some(&(next_id, _))) => { + changed.push(next_id); + next_index += 1; + } + (None, None) => break, + } + } + (changed, removed) +} + impl RenderFrame { /// All viewport rows in this immutable snapshot. pub fn styled_rows(&self) -> &[Vec] { @@ -184,6 +237,10 @@ pub struct RenderState { grapheme_buf: Vec, palette: [Rgb; 256], default_palette: [Rgb; 256], + kitty_graphics: Arc, + kitty_graphics_delta: Arc, + kitty_pixel_cache: HashMap>, + kitty_terminal_instance_id: Option, next_frame_seq: u64, } @@ -218,6 +275,10 @@ impl RenderState { grapheme_buf: Vec::new(), palette: [Rgb::default(); 256], default_palette: [Rgb::default(); 256], + kitty_graphics: Arc::new(KittyGraphicsSnapshot::default()), + kitty_graphics_delta: Arc::new(KittyGraphicsFrameDelta::default()), + kitty_pixel_cache: HashMap::new(), + kitty_terminal_instance_id: None, next_frame_seq: 0, }) } @@ -228,9 +289,77 @@ impl RenderState { self.palette = terminal_palette(terminal.raw(), sys::GHOSTTY_TERMINAL_DATA_COLOR_PALETTE)?; self.default_palette = terminal_palette(terminal.raw(), sys::GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT)?; + let terminal_instance_id = terminal.instance_id(); + if let Some(graphics) = kitty::snapshot_for_render( + terminal, + &mut self.kitty_pixel_cache, + self.kitty_terminal_instance_id != Some(terminal_instance_id), + )? { + let same_terminal = self.kitty_terminal_instance_id == Some(terminal_instance_id); + let image_generations = graphics + .images + .iter() + .map(|image| (image.id, image.generation)) + .collect::>(); + let (changed_image_ids, removed_image_ids) = if same_terminal { + image_generation_delta( + &self.kitty_graphics_delta.image_generations, + &image_generations, + ) + } else { + let (_, removed) = image_generation_delta( + &self.kitty_graphics_delta.image_generations, + &image_generations, + ); + (image_generations.iter().map(|(id, _)| *id).collect(), removed) + }; + let image_changed = !changed_image_ids.is_empty() || !removed_image_ids.is_empty(); + let placement_changed = + !same_terminal || self.kitty_graphics.placements != graphics.placements; + let snapshot_id = self.kitty_graphics_delta.snapshot_id.wrapping_add(1).max(1); + self.kitty_graphics_delta = Arc::new(KittyGraphicsFrameDelta { + snapshot_id, + previous_snapshot_id: same_terminal + .then_some(self.kitty_graphics_delta.snapshot_id), + image_revision: if image_changed { + self.kitty_graphics_delta.image_revision.wrapping_add(1) + } else { + self.kitty_graphics_delta.image_revision + }, + placement_revision: if placement_changed { + self.kitty_graphics_delta.placement_revision.wrapping_add(1) + } else { + self.kitty_graphics_delta.placement_revision + }, + image_generations: image_generations.into(), + changed_image_ids: changed_image_ids.into(), + removed_image_ids: removed_image_ids.into(), + }); + self.kitty_graphics = Arc::new(graphics); + } + self.kitty_terminal_instance_id = Some(terminal_instance_id); Ok(()) } + /// Snapshot all Kitty images while sharing this renderer's immutable pixel + /// cache with its live frames. + pub fn snapshot_kitty_graphics( + &mut self, + terminal: &Terminal, + include_unplaced: bool, + ) -> Result> { + Ok(Arc::new(kitty::snapshot(terminal, &mut self.kitty_pixel_cache, include_unplaced)?)) + } + + /// Release renderer-owned Kitty pixels after the process owner lowers a + /// terminal's native storage share. The next update rebuilds only images + /// that survived libghostty's eviction. + pub fn clear_kitty_graphics_cache(&mut self) { + self.kitty_graphics = Arc::new(KittyGraphicsSnapshot::default()); + self.kitty_pixel_cache.clear(); + self.kitty_terminal_instance_id = None; + } + fn get(&self, data: sys::GhosttyRenderStateData) -> Result { let mut out = T::default(); check(unsafe { @@ -370,6 +499,8 @@ impl RenderState { cursor_color, default_colors, dirty_rows, + kitty_graphics: self.kitty_graphics.clone(), + kitty_graphics_delta: self.kitty_graphics_delta.clone(), rows, }) } @@ -758,3 +889,19 @@ impl Drop for RenderState { } } } + +#[cfg(test)] +mod tests { + use super::image_generation_delta; + + #[test] + fn image_generation_delta_is_linear_and_reports_upserts_and_removals() { + let previous = [(1, 10), (2, 20), (4, 40)]; + let next = [(1, 11), (3, 30), (4, 40)]; + + let (changed, removed) = image_generation_delta(&previous, &next); + + assert_eq!(changed, vec![1, 3]); + assert_eq!(removed, vec![2]); + } +} diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index b227d8834e..47b1b602d8 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -1,19 +1,170 @@ use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::ffi::c_void; use std::ptr; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use base64::Engine as _; use ghostty_vt_sys as sys; +use crate::kitty::{ + self, KITTY_INFLIGHT_REPLAY_MAX_BYTES, KittyGraphicsSnapshot, KittyImage, KittyImageAlias, + KittyInFlightTracker, KittyPlacement, KittyPlacementAnchor, KittyReplaySnapshot, + MAX_KITTY_IMAGE_BYTES, MAX_KITTY_IMAGES, MAX_KITTY_PLACEMENTS, +}; use crate::mouse::{MouseModeProbe, MouseModeSignature}; use crate::render::{Cell, CursorShape, read_grid_ref_cell, terminal_palette}; -use crate::{Result, check}; +use crate::{Error, Result, check}; static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_HISTORY_EPOCH: AtomicU64 = AtomicU64::new(1); const VT_REPLAY_ESTIMATED_BYTES_PER_CELL: u64 = 32; +const DEFAULT_KITTY_IMAGE_STORAGE_LIMIT: u64 = MAX_KITTY_IMAGE_BYTES as u64; +const DEFAULT_KITTY_IMAGE_COUNT_LIMIT: u64 = MAX_KITTY_IMAGES; +const DEFAULT_KITTY_PLACEMENT_COUNT_LIMIT: u64 = MAX_KITTY_PLACEMENTS; +const KITTY_REPLAY_CHUNK: usize = 4096; +const KITTY_REPLAY_RAW_CHUNK: usize = KITTY_REPLAY_CHUNK / 4 * 3; const MAX_COLOR_OSC_BYTES: usize = 16 * 1024; const MOUSE_DEC_MODES: [u16; 8] = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; +#[cfg(test)] +thread_local! { + static KITTY_REPLAY_IMAGE_ENCODINGS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn reset_kitty_replay_image_encodings() { + KITTY_REPLAY_IMAGE_ENCODINGS.set(0); +} + +#[cfg(test)] +fn kitty_replay_image_encodings() -> usize { + KITTY_REPLAY_IMAGE_ENCODINGS.get() +} + +/// Per-terminal Kitty resource limits that every byte-stream emulator must +/// share to make admission and eviction deterministic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyGraphicsLimits { + pub image_bytes: u64, + pub inflight_bytes: u64, + pub images: u64, + pub placements: u64, +} + +impl KittyGraphicsLimits { + pub const fn disabled() -> Self { + Self { image_bytes: 0, inflight_bytes: 0, images: 0, placements: 0 } + } + + pub fn validate(self) -> Result { + if self.image_bytes > MAX_KITTY_IMAGE_BYTES as u64 + || self.inflight_bytes > KITTY_INFLIGHT_REPLAY_MAX_BYTES as u64 + || self.images > MAX_KITTY_IMAGES + || self.placements > MAX_KITTY_PLACEMENTS + { + return Err(Error::InvalidValue); + } + Ok(self) + } +} + +impl Default for KittyGraphicsLimits { + fn default() -> Self { + Self { + image_bytes: DEFAULT_KITTY_IMAGE_STORAGE_LIMIT, + inflight_bytes: KITTY_INFLIGHT_REPLAY_MAX_BYTES as u64, + images: DEFAULT_KITTY_IMAGE_COUNT_LIMIT, + placements: DEFAULT_KITTY_PLACEMENT_COUNT_LIMIT, + } + } +} + +/// Per-screen automatic Kitty image-ID cursors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyImageIdCursors { + pub primary: u32, + pub alternate: u32, +} + +impl KittyImageIdCursors { + pub const DEFAULT_NEXT_IMAGE_ID: u32 = 2_147_483_647; + + pub const fn defaults() -> Self { + Self { primary: Self::DEFAULT_NEXT_IMAGE_ID, alternate: Self::DEFAULT_NEXT_IMAGE_ID } + } + + fn validate(self) -> Result { + if self.primary == 0 || self.alternate == 0 { + return Err(Error::InvalidValue); + } + Ok(self) + } +} + +impl Default for KittyImageIdCursors { + fn default() -> Self { + Self::defaults() + } +} + +/// Kitty state that cannot be represented by protocol escape sequences. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyReplayState { + pub limits: KittyGraphicsLimits, + /// Byte offset where replay must restore `replay_next_image_ids`. + /// Prefix bytes may contain a synthetic terminal reset; suffix bytes can + /// contain an in-flight upload that allocates an automatic ID. + pub replay_cursor_offset: u32, + /// Per-screen cursors installed at `replay_cursor_offset`. A cursor may + /// point at an automatic ID already reserved by an in-flight multipart upload. + pub replay_next_image_ids: KittyImageIdCursors, + /// Per-screen steady-state cursors restored after replay bytes and aliases. + pub next_image_ids: KittyImageIdCursors, +} + +impl KittyReplayState { + pub const fn disabled() -> Self { + Self { + limits: KittyGraphicsLimits::disabled(), + replay_cursor_offset: 0, + replay_next_image_ids: KittyImageIdCursors::defaults(), + next_image_ids: KittyImageIdCursors::defaults(), + } + } + + pub fn validate(self) -> Result { + self.limits.validate()?; + self.replay_next_image_ids.validate()?; + self.next_image_ids.validate()?; + Ok(self) + } + + pub fn validate_for_replay(self, replay_len: usize) -> Result { + let state = self.validate()?; + if state.replay_cursor_offset as usize > replay_len { + return Err(Error::InvalidValue); + } + Ok(state) + } +} + +impl Default for KittyReplayState { + fn default() -> Self { + Self::disabled() + } +} + +/// Terminal state replay plus Kitty metadata that cannot share one APC command. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VtReplay { + pub bytes: Vec, + pub kitty_image_aliases: Vec, + pub kitty_state: KittyReplayState, +} + /// RGB color triple. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Rgb { @@ -617,7 +768,14 @@ impl PromptSemanticTracker { pub struct Terminal { raw: sys::GhosttyTerminal, instance_id: u64, + history_epoch: u64, + // Detect fixed-size scrollback eviction without scanning retained rows. + history_anchor: sys::GhosttyTrackedGridRef, mouse_mode_revision: u64, + kitty_inflight: Box, + // Keep the potentially long-lived replay cache behind one pointer so + // adding it does not inflate every Surface enum value. + kitty_replay_pixel_cache: Box, mouse_mode_bits: u8, mouse_mode_signature: MouseModeSignature, mouse_mode_probe: MouseModeProbe, @@ -633,6 +791,9 @@ pub struct Terminal { c1_normalizer: C1Normalizer, } +#[derive(Default)] +struct KittyReplayPixelCache(HashMap>); + /// Tracks whether Ghostty's persistent VT stream is between complete /// sequences and UTF-8 code points. /// @@ -796,8 +957,8 @@ impl VtBoundaryState { } /// Ghostty's parser intentionally treats bytes >= 0x80 as UTF-8 in ground -/// state, while PTYs can still emit the 8-bit OSC/ST forms. Normalize only -/// standalone C1 OSC/ST bytes; continuation bytes inside UTF-8 text remain +/// state, while PTYs can still emit the 8-bit OSC/APC/ST forms. Normalize only +/// standalone C1 control bytes; continuation bytes inside UTF-8 text remain /// byte-for-byte unchanged. #[derive(Default)] struct C1Normalizer { @@ -817,6 +978,7 @@ impl C1Normalizer { }; let replacement = (!continuation).then_some(byte).and_then(|byte| match byte { 0x9d => Some(b']'), + 0x9f => Some(b'_'), 0x9c => Some(b'\\'), _ => None, }); @@ -1688,16 +1850,25 @@ unsafe extern "C" fn bell_trampoline(_terminal: sys::GhosttyTerminal, userdata: impl Terminal { pub fn new(cols: u16, rows: u16, max_scrollback: usize, callbacks: Callbacks) -> Result { + kitty::install_png_decoder()?; let mouse_mode_probe = MouseModeProbe::new()?; let mut raw: sys::GhosttyTerminal = ptr::null_mut(); let opts = sys::GhosttyTerminalOptions { cols: cols.max(1), rows: rows.max(1), max_scrollback }; check(unsafe { sys::ghostty_terminal_new(ptr::null(), &mut raw, opts) })?; + if let Err(error) = configure_kitty_graphics(raw) { + unsafe { sys::ghostty_terminal_free(raw) }; + return Err(error); + } let mut term = Terminal { raw, instance_id: NEXT_TERMINAL_ID.fetch_add(1, Ordering::Relaxed), + history_epoch: NEXT_HISTORY_EPOCH.fetch_add(1, Ordering::Relaxed), + history_anchor: ptr::null_mut(), mouse_mode_revision: 0, + kitty_inflight: Box::new(KittyInFlightTracker::default()), + kitty_replay_pixel_cache: Box::default(), mouse_mode_bits: 0, mouse_mode_signature: MouseModeSignature::default(), mouse_mode_probe, @@ -1746,6 +1917,41 @@ impl Terminal { self.instance_id } + /// Monotonic token for retained-history row contents and coordinates. + /// Frontends must match this with a paged history read before projecting + /// absolute graphics anchors onto those rows. + pub fn history_epoch(&self) -> u64 { + self.history_epoch + } + + fn bump_history_epoch(&mut self) { + self.history_epoch = NEXT_HISTORY_EPOCH.fetch_add(1, Ordering::Relaxed); + } + + fn ensure_history_anchor(&mut self) { + if !self.history_anchor.is_null() || self.scrollback_rows() == 0 { + return; + } + let point = sys::GhosttyPoint { + tag: sys::GHOSTTY_POINT_TAG_HISTORY, + value: sys::GhosttyPointValue { + coordinate: sys::GhosttyPointCoordinate { x: 0, y: 0 }, + }, + }; + let mut anchor: sys::GhosttyTrackedGridRef = ptr::null_mut(); + if check(unsafe { sys::ghostty_terminal_grid_ref_track(self.raw, point, &mut anchor) }) + .is_ok() + { + self.history_anchor = anchor; + } + } + + fn reset_history_anchor(&mut self) { + unsafe { sys::ghostty_tracked_grid_ref_free(self.history_anchor) }; + self.history_anchor = ptr::null_mut(); + self.ensure_history_anchor(); + } + /// Feed VT-encoded bytes (pty output) into the terminal. pub fn vt_write(&mut self, data: &[u8]) { let _ = self.vt_write_with_normalized(data); @@ -1761,7 +1967,11 @@ impl Terminal { if data.is_empty() { return Cow::Borrowed(data); } + self.ensure_history_anchor(); + let previous_history_rows = self.scrollback_rows(); + let history_anchor_missing = previous_history_rows > 0 && self.history_anchor.is_null(); let normalized = self.c1_normalizer.normalize(data); + self.kitty_inflight.write(&normalized); self.vt_boundary.feed(&normalized); self.prompt_semantic.feed(&normalized); self.cursor_override.write(&normalized); @@ -1769,6 +1979,18 @@ impl Terminal { self.color_overrides.write(&normalized); let mouse_modes_may_have_changed = self.mouse_mode_change_detector.write(&normalized); unsafe { sys::ghostty_terminal_vt_write(self.raw, normalized.as_ptr(), normalized.len()) }; + let history_rows = self.scrollback_rows(); + let history_anchor_evicted = !self.history_anchor.is_null() + && !unsafe { sys::ghostty_tracked_grid_ref_has_value(self.history_anchor) }; + if previous_history_rows != history_rows || history_anchor_missing || history_anchor_evicted + { + self.bump_history_epoch(); + } + if history_rows == 0 || history_anchor_evicted { + self.reset_history_anchor(); + } else if self.history_anchor.is_null() { + self.ensure_history_anchor(); + } if mouse_modes_may_have_changed { self.refresh_mouse_mode_revision(); } @@ -2214,9 +2436,227 @@ impl Terminal { cell_width_px, cell_height_px, ) + })?; + self.bump_history_epoch(); + self.reset_history_anchor(); + Ok(()) + } + + /// Copy the active screen's Kitty image storage into owned Rust values. + pub fn kitty_graphics_snapshot(&self) -> Result { + kitty::snapshot(self, &mut Default::default(), true) + } + + /// Restore number aliases after replaying Kitty images by stable ID. + pub fn restore_kitty_image_aliases(&mut self, aliases: &[KittyImageAlias]) -> Result<()> { + if aliases.is_empty() { + return Ok(()); + } + + let mut graphics: sys::GhosttyKittyGraphics = ptr::null_mut(); + check(unsafe { + sys::ghostty_terminal_get( + self.raw, + sys::GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, + (&mut graphics as *mut sys::GhosttyKittyGraphics).cast(), + ) + })?; + if graphics.is_null() { + return Err(Error::NoValue); + } + + for alias in aliases { + if unsafe { sys::ghostty_kitty_graphics_image(graphics, alias.image_id) }.is_null() { + return Err(Error::NoValue); + } + } + for alias in aliases { + check(unsafe { + sys::ghostty_kitty_graphics_image_set_number( + graphics, + alias.image_id, + alias.image_number, + ) + })?; + } + Ok(()) + } + + /// Apply one complete replay sidecar and byte stream to this terminal. + pub fn apply_vt_replay(&mut self, replay: &VtReplay) -> Result<()> { + self.apply_vt_replay_parts(&replay.bytes, &replay.kitty_image_aliases, replay.kitty_state) + } + + /// Apply replay bytes and their non-VT sidecar through the same ordering + /// used by owned [`VtReplay`] values. + pub fn apply_vt_replay_parts( + &mut self, + bytes: &[u8], + kitty_image_aliases: &[KittyImageAlias], + kitty_state: KittyReplayState, + ) -> Result<()> { + let kitty_state = kitty_state.validate_for_replay(bytes.len())?; + self.set_kitty_graphics_limits(kitty_state.limits)?; + let cursor_offset = kitty_state.replay_cursor_offset as usize; + self.vt_write(&bytes[..cursor_offset]); + self.set_kitty_image_id_cursors(kitty_state.replay_next_image_ids)?; + self.vt_write(&bytes[cursor_offset..]); + // Protocol-v1/v2 peers cannot carry the replay sidecar. Their safe + // compatibility state disables graphics, so aliases for discarded + // replay images must also be discarded. + if kitty_state.limits != KittyGraphicsLimits::disabled() { + self.restore_kitty_image_aliases(kitty_image_aliases)?; + } + self.set_kitty_image_id_cursors(kitty_state.next_image_ids) + } + + fn kitty_replay_state(&self, replay_cursor_offset: u32) -> Result { + let raw: sys::GhosttyTerminalKittyImageIdCursorState = + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_ID_CURSORS)?; + Ok(KittyReplayState { + limits: self.kitty_graphics_limits()?, + replay_cursor_offset, + replay_next_image_ids: KittyImageIdCursors { + primary: raw.replay.primary, + alternate: raw.replay.alternate, + } + .validate()?, + next_image_ids: KittyImageIdCursors { + primary: raw.next.primary, + alternate: raw.next.alternate, + } + .validate()?, }) } + fn set_kitty_image_id_cursors(&mut self, cursors: KittyImageIdCursors) -> Result<()> { + let cursors = cursors.validate()?; + let raw = sys::GhosttyTerminalKittyImageIdCursors { + primary: cursors.primary, + alternate: cursors.alternate, + }; + check(unsafe { + sys::ghostty_terminal_set( + self.raw, + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_ID_CURSORS, + (&raw as *const sys::GhosttyTerminalKittyImageIdCursors).cast(), + ) + }) + } + + pub fn kitty_graphics_limits(&self) -> Result { + Ok(KittyGraphicsLimits { + image_bytes: self.kitty_image_storage_limit()?, + inflight_bytes: self.kitty_inflight_storage_limit(), + images: self.kitty_image_count_limit()?, + placements: self.kitty_placement_count_limit()?, + }) + } + + /// Apply all Kitty limits through one shared path. Returns whether native + /// image or placement content changed. + pub fn set_kitty_graphics_limits(&mut self, limits: KittyGraphicsLimits) -> Result { + let limits = limits.validate()?; + let generation = self.kitty_graphics_generation()?; + self.set_kitty_inflight_storage_limit(limits.inflight_bytes); + self.set_kitty_image_count_limit(limits.images)?; + if self.set_kitty_placement_count_limit(limits.placements).is_err() { + // Placement reductions preserve visible state by default. Under + // pressure, release the old scene before installing the bound. + self.set_kitty_image_storage_limit(0)?; + self.set_kitty_placement_count_limit(limits.placements)?; + } + // Run after count eviction even when the byte value is unchanged so + // the replay pixel cache is pruned against native ownership. + self.set_kitty_image_storage_limit(limits.image_bytes)?; + Ok(self.kitty_graphics_generation()? != generation) + } + + /// Set the active terminal's bounded Kitty image storage in bytes. + pub fn set_kitty_image_storage_limit(&mut self, bytes: u64) -> Result<()> { + check(unsafe { + sys::ghostty_terminal_set( + self.raw, + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + (&bytes as *const u64).cast(), + ) + })?; + + // Lowering libghostty's limit evicts native images immediately. Keep + // the replay-side pixel copy at the same boundary instead of waiting + // for some later replay to notice those evictions. + let mut pixel_cache = std::mem::take(&mut self.kitty_replay_pixel_cache.0); + let snapshot = kitty::snapshot(self, &mut pixel_cache, true); + if snapshot.is_err() { + pixel_cache.clear(); + } + self.kitty_replay_pixel_cache.0 = pixel_cache; + snapshot.map(|_| ()) + } + + pub fn kitty_image_storage_limit(&self) -> Result { + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT) + } + + /// Set the total bytes retained while tracking a chunked Kitty upload. + /// + /// The completed prefix and current command share this one limit. + pub fn set_kitty_inflight_storage_limit(&mut self, bytes: u64) { + let bounded = bytes.min(KITTY_INFLIGHT_REPLAY_MAX_BYTES as u64) as usize; + self.kitty_inflight.set_max_bytes(bounded); + } + + pub fn kitty_inflight_storage_limit(&self) -> u64 { + self.kitty_inflight.max_bytes() as u64 + } + + /// Content generation for the active screen's Kitty image store. + pub fn kitty_graphics_generation(&self) -> Result { + kitty::generation(self) + } + + /// Set the active terminal's maximum number of stored Kitty images. + pub fn set_kitty_image_count_limit(&mut self, count: u64) -> Result<()> { + check(unsafe { + sys::ghostty_terminal_set( + self.raw, + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_COUNT_LIMIT, + (&count as *const u64).cast(), + ) + }) + } + + pub fn kitty_image_count_limit(&self) -> Result { + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_COUNT_LIMIT) + } + + /// Set the active terminal's maximum number of Kitty placements. + pub fn set_kitty_placement_count_limit(&mut self, count: u64) -> Result<()> { + check(unsafe { + sys::ghostty_terminal_set( + self.raw, + sys::GHOSTTY_TERMINAL_OPT_KITTY_PLACEMENT_COUNT_LIMIT, + (&count as *const u64).cast(), + ) + }) + } + + pub fn kitty_placement_count_limit(&self) -> Result { + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_PLACEMENT_COUNT_LIMIT) + } + + /// Whether file, temporary-file, or shared-memory image media are enabled. + /// + /// cmux enables only direct (`t=d`) payloads, so this is `(false, false, + /// false)` for terminals created by [`Terminal::new`]. + pub fn kitty_external_image_media_enabled(&self) -> Result<(bool, bool, bool)> { + Ok(( + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE)?, + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE)?, + self.get(sys::GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM)?, + )) + } + fn get(&self, data: sys::GhosttyTerminalData) -> Result { let mut out = T::default(); check(unsafe { @@ -2456,7 +2896,7 @@ impl Terminal { false, true, ) - .ok_or(crate::Error::InvalidValue) + .ok_or(Error::InvalidValue) } /// Plain text of a selection range given in absolute screen @@ -2535,13 +2975,27 @@ impl Terminal { Ok(String::from_utf8_lossy(&self.format(opts)?).into_owned()) } - /// VT-sequence replay of the terminal's current state: feeding the - /// returned bytes into a fresh terminal of the same size reproduces + /// Replay of the terminal's current state. + /// + /// Feeding `bytes` into a fresh terminal of the same size and restoring + /// `kitty_image_aliases` reproduces /// the screen contents, styles, cursor, modes, palette, keyboard /// state, charsets, and tabstops. This is the attach primitive: a new /// frontend replays this, then follows the live pty stream. - pub fn vt_replay(&mut self) -> Result> { - self.vt_replay_with_selection(None, true) + pub fn vt_replay(&mut self) -> Result { + self.vt_replay_bounded(usize::MAX) + } + + /// Byte-only compatibility replay. This discards Kitty number aliases. + pub fn vt_replay_bytes(&mut self) -> Result> { + Ok(self.vt_replay()?.bytes) + } + + /// Reject replay state that cannot fit under `max_bytes` regardless of + /// text or completed graphics truncation. Callers can use this before a + /// destructive geometry change, then build the full replay afterward. + pub fn preflight_vt_replay_bounded(&self, max_bytes: usize) -> Result<()> { + self.kitty_inflight.replay_prefix_fits(max_bytes) } /// VT replay bounded to `max_bytes`, retaining the newest complete rows. @@ -2553,17 +3007,28 @@ impl Terminal { /// A pathological screen whose newest row alone exceeds the budget falls /// back to a terminal reset so callers can still attach and receive live /// output instead of entering a permanent overflow loop. - pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result> { + pub fn vt_replay_bounded(&mut self, max_bytes: usize) -> Result { self.vt_replay_bounded_with_palette(max_bytes, true) } /// Theme-portable replay for process-separated renderers. /// - /// This reproduces cells, styles, modes, cursor, and history but omits - /// terminal palette/default-color OSC state. Pair it with a sparse - /// [`TerminalColorOverrides`] snapshot so the receiving renderer keeps - /// its own Ghostty theme for every color the application did not set. + /// This reproduces cells, styles, modes, cursor, history, and Kitty + /// graphics but omits terminal palette/default-color OSC state. Pair it + /// with a sparse [`TerminalColorOverrides`] snapshot so the receiving + /// renderer keeps its own Ghostty theme for every color the application + /// did not set. This byte-only compatibility API discards Kitty number + /// aliases. pub fn vt_replay_bounded_theme_portable(&mut self, max_bytes: usize) -> Result> { + Ok(self.vt_replay_bounded_theme_portable_with_aliases(max_bytes)?.bytes) + } + + /// Theme-portable replay retaining aliases for the Kitty images admitted + /// by the bounded replay plan. + pub fn vt_replay_bounded_theme_portable_with_aliases( + &mut self, + max_bytes: usize, + ) -> Result { self.vt_replay_bounded_with_palette(max_bytes, false) } @@ -2571,27 +3036,122 @@ impl Terminal { &mut self, max_bytes: usize, include_palette: bool, - ) -> Result> { + ) -> Result { + let inflight = self.kitty_inflight.replay_prefix_checked(max_bytes)?; + let remaining = max_bytes.checked_sub(inflight.len()).ok_or(Error::OutOfSpace)?; + let mut pixel_cache = std::mem::take(&mut self.kitty_replay_pixel_cache.0); + let snapshot = kitty::snapshot_for_replay(self, &mut pixel_cache, true); + self.kitty_replay_pixel_cache.0 = pixel_cache; + let snapshot = snapshot?; + let catalog = + KittyReplayCatalog::new(&snapshot, self.cell_pixel_size(), self.rows().max(1)); + + let active_start = self.scrollbar().map(|scrollbar| { + let viewport_start = scrollbar.total.saturating_sub(scrollbar.len); + let visible_start = catalog.visible_anchor_start().unwrap_or(viewport_start); + viewport_start.min(visible_start) + }); + let active_text = self.vt_replay_text_layout_bounded( + remaining, + catalog.placement_rows(), + active_start, + include_palette, + )?; + let visible_cost = + active_text.range.map(|range| catalog.visible_cost(range, remaining)).unwrap_or(0); + + let text_budget = remaining.saturating_sub(visible_cost); + let text = self.vt_replay_text_layout_bounded( + text_budget, + catalog.placement_rows(), + active_start, + include_palette, + )?; + let graphics_budget = remaining.saturating_sub(text.bytes.len()); + let graphics = catalog.plan(text.range, graphics_budget, false); + let reset_before_images = text.range.is_none(); + let interleaved = text.interleave(&graphics.placements).ok_or(Error::OutOfSpace)?; + + let total = graphics + .image_bytes + .len() + .checked_add(interleaved.len()) + .and_then(|total| total.checked_add(inflight.len())) + .ok_or(Error::OutOfSpace)?; + if total > max_bytes || graphics.total_len > graphics_budget { + return Err(Error::OutOfSpace); + } + let mut bytes = Vec::with_capacity(total); + if reset_before_images { + bytes.extend_from_slice(&interleaved); + bytes.extend_from_slice(&graphics.image_bytes); + } else { + bytes.extend_from_slice(&graphics.image_bytes); + bytes.extend_from_slice(&interleaved); + } + let replay_cursor_offset = u32::try_from(bytes.len()).map_err(|_| Error::OutOfSpace)?; + bytes.extend_from_slice(&inflight); + Ok(VtReplay { + bytes, + kitty_image_aliases: graphics.aliases, + kitty_state: self.kitty_replay_state(replay_cursor_offset)?, + }) + } + + /// Bounded byte-only compatibility replay. This discards Kitty aliases. + pub fn vt_replay_bounded_bytes(&mut self, max_bytes: usize) -> Result> { + Ok(self.vt_replay_bounded(max_bytes)?.bytes) + } + + fn vt_replay_text_layout_bounded( + &mut self, + max_bytes: usize, + placement_rows: &KittyReplayRowIndex, + minimum_start: Option, + include_palette: bool, + ) -> Result { let Some(scrollbar) = self.scrollbar() else { - return Ok(minimal_vt_replay(max_bytes)); + return Ok(ReplayText::minimal(max_bytes)); }; if scrollbar.total == 0 { - return Ok(minimal_vt_replay(max_bytes)); + return Ok(ReplayText::minimal(max_bytes)); } let screen_rows = scrollbar.len.min(scrollbar.total).max(1); + let minimum_start = minimum_start + .unwrap_or_else(|| scrollbar.total.saturating_sub(screen_rows)) + .min(scrollbar.total - 1); + let minimum_rows = scrollbar.total.saturating_sub(minimum_start).max(screen_rows); let mut tail_rows = - vt_replay_row_window(scrollbar.total, screen_rows, self.cols(), max_bytes); + vt_replay_row_window(scrollbar.total, screen_rows, self.cols(), max_bytes) + .max(minimum_rows) + .min(scrollbar.total); let mut best = None; - let mut upper_failed = false; + let mut failed_start = None; loop { - if let Some(replay) = - self.vt_replay_screen_tail_bounded(tail_rows, max_bytes, include_palette)? - { - if upper_failed || tail_rows == scrollbar.total { + let range = ReplayRowRange { + start: scrollbar.total.saturating_sub(tail_rows), + end: scrollbar.total - 1, + }; + if let Some(replay) = self.vt_replay_text_range_bounded( + range, + placement_rows, + max_bytes, + include_palette, + )? { + if tail_rows == scrollbar.total { return Ok(replay); } + if let Some(failed_start) = failed_start { + return self.vt_replay_text_at_oldest_fitting_anchor( + replay, + failed_start, + placement_rows, + max_bytes, + include_palette, + ); + } best = Some(replay); let next = tail_rows.saturating_mul(2).min(scrollbar.total); if next == tail_rows { @@ -2600,83 +3160,173 @@ impl Terminal { tail_rows = next; continue; } - upper_failed = true; + failed_start = Some(range.start); if let Some(replay) = best { - return Ok(replay); + return self.vt_replay_text_at_oldest_fitting_anchor( + replay, + range.start, + placement_rows, + max_bytes, + include_palette, + ); } - if tail_rows <= 1 { + if tail_rows <= minimum_rows { break; } - tail_rows = if tail_rows > screen_rows { - screen_rows.max(tail_rows / 2) - } else { - tail_rows / 2 - }; + let next = minimum_rows.max(tail_rows / 2); + if next == tail_rows { + break; + } + tail_rows = next; } - Ok(minimal_vt_replay(max_bytes)) + Ok(ReplayText::minimal(max_bytes)) } - fn vt_replay_screen_tail_bounded( + fn vt_replay_text_at_oldest_fitting_anchor( &mut self, - rows: u64, + mut best: ReplayText, + failed_start: u64, + placement_rows: &KittyReplayRowIndex, max_bytes: usize, include_palette: bool, - ) -> Result>> { - let scrollbar = self.scrollbar().ok_or(crate::Error::InvalidValue)?; - let cols = self.cols(); - if cols == 0 || scrollbar.total == 0 || rows == 0 { - return Err(crate::Error::InvalidValue); + ) -> Result { + let Some(best_range) = best.range else { + return Ok(best); + }; + let candidates = placement_rows + .anchors + .range(failed_start..best_range.start) + .copied() + .collect::>(); + let mut low = 0; + let mut high = candidates.len(); + while low < high { + let middle = low + (high - low) / 2; + let range = ReplayRowRange { start: candidates[middle], end: best_range.end }; + if let Some(replay) = self.vt_replay_text_range_bounded( + range, + placement_rows, + max_bytes, + include_palette, + )? { + best = replay; + high = middle; + } else { + low = middle + 1; + } } - let selection = sys::GhosttySelection { + Ok(best) + } + + fn vt_replay_text_range_bounded( + &mut self, + range: ReplayRowRange, + placement_rows: &KittyReplayRowIndex, + max_bytes: usize, + include_palette: bool, + ) -> Result> { + let cols = self.cols(); + if cols == 0 || range.start > range.end { + return Err(Error::InvalidValue); + } + let suffix = self.cursor_position_escape(); + let suffix_len = suffix.as_ref().map_or(0, Vec::len); + let Some(format_max_bytes) = max_bytes.checked_sub(suffix_len) else { + return Ok(None); + }; + let insert_at_start = placement_rows.overlaps(range.start); + let mut segment_ends = + placement_rows.anchors.range(range.start..=range.end).copied().collect::>(); + if insert_at_start { + segment_ends.insert(range.start); + } + segment_ends.insert(range.end); + + let mut bytes = Vec::new(); + let mut insertion_offsets = BTreeMap::new(); + let mut segment_start = range.start; + let replay_rows = range.end - range.start + 1; + let screen_rows = u64::from(self.rows().max(1)); + let history_bearing = replay_rows > screen_rows; + let mut emitted_breaks = 0usize; + for segment_end in segment_ends { + if segment_end < segment_start { + continue; + } + let selection = self.screen_selection(segment_start, segment_end)?; + let first = segment_start == range.start; + let last = segment_end == range.end; + let remaining = format_max_bytes.saturating_sub(bytes.len()); + let Some(chunk) = self.format_bounded( + Self::vt_replay_segment_options(&selection, first, last, include_palette), + remaining, + )? + else { + return Ok(None); + }; + emitted_breaks = emitted_breaks + .saturating_add(chunk.windows(2).filter(|bytes| *bytes == b"\r\n").count()); + bytes.extend_from_slice(&chunk); + if history_bearing { + let expected_breaks = + usize::try_from(segment_end - range.start).unwrap_or(usize::MAX); + while emitted_breaks < expected_breaks { + if bytes.len().saturating_add(2) > format_max_bytes { + return Ok(None); + } + bytes.extend_from_slice(b"\r\n"); + emitted_breaks = emitted_breaks.saturating_add(1); + } + } + if placement_rows.anchors.contains(&segment_end) + || (insert_at_start && segment_end == range.start) + { + insertion_offsets.insert(segment_end, bytes.len()); + } + if !last { + if bytes.len().saturating_add(2) > format_max_bytes { + return Ok(None); + } + bytes.extend_from_slice(b"\r\n"); + emitted_breaks = emitted_breaks.saturating_add(1); + segment_start = segment_end.saturating_add(1); + } + } + if history_bearing { + // A history-bearing selection must advance once per row so the + // reconstructed scrollback keeps Kitty anchors aligned. A + // viewport-only selection may use direct cursor positioning for + // sparse rows; padding that case would scroll visible text away. + let expected_breaks = usize::try_from(replay_rows - 1).unwrap_or(usize::MAX); + for _ in emitted_breaks..expected_breaks { + if bytes.len().saturating_add(2) > format_max_bytes { + return Ok(None); + } + bytes.extend_from_slice(b"\r\n"); + } + } + if let Some(suffix) = suffix { + bytes.extend_from_slice(&suffix); + } + Ok(Some(ReplayText { bytes, range: Some(range), insertion_offsets })) + } + + fn screen_selection(&self, start_row: u64, end_row: u64) -> Result { + let cols = self.cols(); + if cols == 0 || start_row > end_row { + return Err(Error::InvalidValue); + } + Ok(sys::GhosttySelection { size: size_of::(), start: self - .grid_ref(sys::GHOSTTY_POINT_TAG_SCREEN, 0, scrollbar.total.saturating_sub(rows)) - .ok_or(crate::Error::InvalidValue)?, + .grid_ref(sys::GHOSTTY_POINT_TAG_SCREEN, 0, start_row) + .ok_or(Error::InvalidValue)?, end: self - .grid_ref( - sys::GHOSTTY_POINT_TAG_SCREEN, - cols.saturating_sub(1), - scrollbar.total - 1, - ) - .ok_or(crate::Error::InvalidValue)?, + .grid_ref(sys::GHOSTTY_POINT_TAG_SCREEN, cols.saturating_sub(1), end_row) + .ok_or(Error::InvalidValue)?, rectangle: false, - }; - self.vt_replay_with_selection_bounded(Some(&selection), max_bytes, include_palette) - } - - fn vt_replay_with_selection( - &mut self, - selection: Option<&sys::GhosttySelection>, - include_palette: bool, - ) -> Result> { - let suffix = self.cursor_position_escape(); - let mut replay = self.format(Self::vt_replay_options(selection, include_palette))?; - if let Some(suffix) = suffix { - replay.extend_from_slice(&suffix); - } - Ok(replay) - } - - fn vt_replay_with_selection_bounded( - &mut self, - selection: Option<&sys::GhosttySelection>, - max_bytes: usize, - include_palette: bool, - ) -> Result>> { - let suffix = self.cursor_position_escape().unwrap_or_default(); - let Some(format_max_bytes) = max_bytes.checked_sub(suffix.len()) else { - return Ok(None); - }; - let Some(mut replay) = self.format_bounded( - Self::vt_replay_options(selection, include_palette), - format_max_bytes, - )? - else { - return Ok(None); - }; - replay.extend_from_slice(&suffix); - Ok(Some(replay)) + }) } fn cursor_position_escape(&self) -> Option> { @@ -2684,6 +3334,28 @@ impl Terminal { Some(format!("\x1b[{};{}H", u32::from(y) + 1, u32::from(x) + 1).into_bytes()) } + fn vt_replay_segment_options( + selection: &sys::GhosttySelection, + first: bool, + last: bool, + include_palette: bool, + ) -> sys::GhosttyFormatterTerminalOptions { + let mut options = Self::vt_replay_options(Some(selection), include_palette); + options.extra.palette = include_palette && first; + options.extra.modes = first; + options.extra.scrolling_region = last; + options.extra.tabstops = last; + options.extra.pwd = last; + options.extra.keyboard = last; + options.extra.screen.cursor = last; + options.extra.screen.style = last; + options.extra.screen.hyperlink = last; + options.extra.screen.protection = last; + options.extra.screen.kitty_keyboard = last; + options.extra.screen.charsets = last; + options + } + fn vt_replay_options( selection: Option<&sys::GhosttySelection>, include_palette: bool, @@ -2715,6 +3387,14 @@ impl Terminal { } } + fn cell_pixel_size(&self) -> (u32, u32) { + let cols = u32::from(self.cols().max(1)); + let rows = u32::from(self.rows().max(1)); + let width = self.get::(sys::GHOSTTY_TERMINAL_DATA_WIDTH_PX).unwrap_or(cols); + let height = self.get::(sys::GHOSTTY_TERMINAL_DATA_HEIGHT_PX).unwrap_or(rows); + ((width / cols).max(1), (height / rows).max(1)) + } + fn grid_ref(&self, tag: sys::GhosttyPointTag, x: u16, y: u64) -> Option { let y = u32::try_from(y).ok()?; let point = sys::GhosttyPoint { @@ -2795,11 +3475,619 @@ impl Terminal { } } +fn configure_kitty_graphics(raw: sys::GhosttyTerminal) -> Result<()> { + for (option, limit) in [ + (sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, &DEFAULT_KITTY_IMAGE_STORAGE_LIMIT), + (sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_COUNT_LIMIT, &DEFAULT_KITTY_IMAGE_COUNT_LIMIT), + ( + sys::GHOSTTY_TERMINAL_OPT_KITTY_PLACEMENT_COUNT_LIMIT, + &DEFAULT_KITTY_PLACEMENT_COUNT_LIMIT, + ), + ] { + check(unsafe { sys::ghostty_terminal_set(raw, option, (limit as *const u64).cast()) })?; + } + let disabled = false; + for option in [ + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE, + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE, + sys::GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM, + ] { + check(unsafe { + sys::ghostty_terminal_set(raw, option, (&disabled as *const bool).cast()) + })?; + } + Ok(()) +} + fn minimal_vt_replay(max_bytes: usize) -> Vec { const RESET: &[u8] = b"\x1bc"; if max_bytes >= RESET.len() { RESET.to_vec() } else { Vec::new() } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ReplayRowRange { + start: u64, + end: u64, +} + +#[derive(Default)] +struct KittyReplayRowIndex { + anchors: BTreeSet, + // Merged inclusive spans avoid expanding attacker-controlled `grid_rows` + // into one entry per occupied cell. + occupied_spans: Vec, +} + +impl KittyReplayRowIndex { + fn insert(&mut self, row: u64, grid_rows: u32) { + self.anchors.insert(row); + self.occupied_spans.push(ReplayRowRange { + start: row, + end: row.saturating_add(u64::from(grid_rows.max(1)) - 1), + }); + } + + fn finish(mut self) -> Self { + self.occupied_spans.sort_by_key(|span| (span.start, span.end)); + let mut merged = Vec::::with_capacity(self.occupied_spans.len()); + for span in self.occupied_spans.drain(..) { + if let Some(previous) = merged.last_mut() + && span.start <= previous.end.saturating_add(1) + { + previous.end = previous.end.max(span.end); + } else { + merged.push(span); + } + } + self.occupied_spans = merged; + self + } + + fn overlaps(&self, row: u64) -> bool { + let insertion = self.occupied_spans.partition_point(|span| span.start <= row); + insertion > 0 && self.occupied_spans[insertion - 1].end >= row + } +} + +#[cfg(test)] +impl FromIterator for KittyReplayRowIndex { + fn from_iter>(rows: T) -> Self { + let mut index = Self::default(); + for row in rows { + index.insert(row, 1); + } + index.finish() + } +} + +struct ReplayText { + bytes: Vec, + range: Option, + insertion_offsets: BTreeMap, +} + +impl ReplayText { + fn minimal(max_bytes: usize) -> Self { + Self { + bytes: minimal_vt_replay(max_bytes), + range: None, + insertion_offsets: BTreeMap::new(), + } + } + + fn interleave(self, placements: &BTreeMap>>) -> Option> { + let placement_bytes = placements + .values() + .flatten() + .try_fold(0usize, |total, command| total.checked_add(command.len()))?; + let mut bytes = Vec::with_capacity(self.bytes.len().checked_add(placement_bytes)?); + let mut copied = 0; + for (row, offset) in self.insertion_offsets { + if offset < copied || offset > self.bytes.len() { + return None; + } + bytes.extend_from_slice(&self.bytes[copied..offset]); + if let Some(commands) = placements.get(&row) { + for command in commands { + bytes.extend_from_slice(command); + } + } + copied = offset; + } + bytes.extend_from_slice(&self.bytes[copied..]); + Some(bytes) + } +} + +struct KittyReplayPlacement<'a> { + placement: &'a KittyPlacement, + anchor: KittyPlacementAnchor, +} + +struct KittyReplayImage<'a> { + image: &'a KittyImage, + transmission_len: usize, + placements: Vec>, +} + +struct KittyReplayCatalog<'a> { + images: Vec>, + placement_rows: KittyReplayRowIndex, + cell_pixels: (u32, u32), + terminal_rows: u16, + #[cfg(test)] + placement_grouping_visits: usize, +} + +struct KittyReplayCandidate { + image_index: usize, + visible: bool, + cost: usize, + placements: Vec<(u64, Vec)>, +} + +struct KittyReplayPlan { + image_bytes: Vec, + placements: BTreeMap>>, + aliases: Vec, + total_len: usize, +} + +impl<'a> KittyReplayCatalog<'a> { + fn new(snapshot: &'a KittyReplaySnapshot, cell_pixels: (u32, u32), terminal_rows: u16) -> Self { + let mut placements_by_image = HashMap::>>::new(); + let mut placement_rows = KittyReplayRowIndex::default(); + #[cfg(test)] + let mut placement_grouping_visits = 0; + for placement in &snapshot.graphics.placements { + #[cfg(test)] + { + placement_grouping_visits += 1; + } + let Some(anchor) = snapshot.anchors.get(&placement.key).copied() else { + continue; + }; + placement_rows.insert(u64::from(anchor.row), placement.grid_rows); + placements_by_image + .entry(placement.image_id) + .or_default() + .push(KittyReplayPlacement { placement, anchor }); + } + + let mut images = snapshot.graphics.images.iter().collect::>(); + images.sort_by_key(|image| (image.generation, image.id)); + let images = images + .into_iter() + .filter_map(|image| { + Some(KittyReplayImage { + image, + transmission_len: kitty_replay_image_len(image)?, + placements: placements_by_image.remove(&image.id).unwrap_or_default(), + }) + }) + .collect(); + Self { + images, + placement_rows: placement_rows.finish(), + cell_pixels, + terminal_rows, + #[cfg(test)] + placement_grouping_visits, + } + } + + fn visible_anchor_start(&self) -> Option { + self.images + .iter() + .flat_map(|image| &image.placements) + .filter(|placement| placement.placement.viewport_visible) + .map(|placement| u64::from(placement.anchor.row)) + .min() + } + + fn placement_rows(&self) -> &KittyReplayRowIndex { + &self.placement_rows + } + + fn visible_cost(&self, range: ReplayRowRange, max_bytes: usize) -> usize { + self.plan_internal(Some(range), max_bytes, true, false).total_len + } + + fn plan( + &self, + range: Option, + max_bytes: usize, + visible_only: bool, + ) -> KittyReplayPlan { + self.plan_internal(range, max_bytes, visible_only, true) + } + + fn plan_internal( + &self, + range: Option, + max_bytes: usize, + visible_only: bool, + encode_images: bool, + ) -> KittyReplayPlan { + let mut candidates = Vec::with_capacity(self.images.len()); + for (image_index, image) in self.images.iter().enumerate() { + let mut visible = false; + let mut placements = Vec::new(); + if let Some(range) = range { + for replay_placement in &image.placements { + let row = u64::from(replay_placement.anchor.row); + let end = row + .saturating_add(u64::from(replay_placement.placement.grid_rows.max(1)) - 1); + if end < range.start || row > range.end { + continue; + } + let Some(command) = kitty_replay_placement_at( + replay_placement.placement, + replay_placement.anchor, + range.start, + self.terminal_rows, + self.cell_pixels, + ) else { + continue; + }; + visible |= replay_placement.placement.viewport_visible; + placements.push((row.max(range.start), command)); + } + } + let Some(cost) = + placements.iter().try_fold(image.transmission_len, |total, (_, command)| { + total.checked_add(command.len()) + }) + else { + continue; + }; + candidates.push(KittyReplayCandidate { image_index, visible, cost, placements }); + } + + let mut admitted = vec![false; candidates.len()]; + let mut total_len = 0usize; + for require_visible in [true, false] { + if visible_only && !require_visible { + break; + } + for (index, candidate) in candidates.iter().enumerate() { + if candidate.visible != require_visible { + continue; + } + let Some(next) = total_len.checked_add(candidate.cost) else { + continue; + }; + if next > max_bytes { + continue; + } + admitted[index] = true; + total_len = next; + } + } + + let mut numbered_history_counts = HashMap::::new(); + for image in &self.images { + if image.image.number != 0 { + numbered_history_counts.entry(image.image.number).or_default().0 += 1; + } + } + for (candidate, is_admitted) in candidates.iter().zip(&admitted) { + let image = &self.images[candidate.image_index]; + if *is_admitted && image.image.number != 0 { + numbered_history_counts.entry(image.image.number).or_default().1 += 1; + } + } + + let mut image_bytes = Vec::new(); + let mut placements = BTreeMap::>>::new(); + let mut aliases = Vec::new(); + for (candidate, admitted) in candidates.into_iter().zip(admitted) { + if !admitted { + continue; + } + let image = &self.images[candidate.image_index]; + if encode_images { + append_kitty_replay_image(&mut image_bytes, image.image); + } + for (row, command) in candidate.placements { + placements.entry(row).or_default().push(command); + } + if image.image.number != 0 + && numbered_history_counts + .get(&image.image.number) + .is_some_and(|(total, admitted)| total == admitted) + { + aliases.push(KittyImageAlias { + image_id: image.image.id, + image_number: image.image.number, + }); + } + } + KittyReplayPlan { image_bytes, placements, aliases, total_len } + } +} + +fn kitty_replay_image_len(image: &KittyImage) -> Option { + if image.data.is_empty() { + return Some(0); + } + let encoded_len = image.data.len().checked_add(2)?.checked_div(3)?.checked_mul(4)?; + let chunks = image.data.len().div_ceil(KITTY_REPLAY_RAW_CHUNK); + let first_header = format!( + "\x1b_Ga=t,t=d,f={},i={},s={},v={},q=2,m=0;", + image.format.kitty_protocol_value(), + image.id, + image.width, + image.height + ) + .len(); + let continuation_header = b"\x1b_Gq=2,m=0;".len(); + encoded_len + .checked_add(first_header)? + .checked_add(2)? + .checked_add((chunks - 1).checked_mul(continuation_header.checked_add(2)?)?) +} + +fn append_kitty_replay_image(bytes: &mut Vec, image: &KittyImage) { + if image.data.is_empty() { + return; + } + #[cfg(test)] + KITTY_REPLAY_IMAGE_ENCODINGS.set(KITTY_REPLAY_IMAGE_ENCODINGS.get() + 1); + let mut payload = [0_u8; KITTY_REPLAY_CHUNK]; + for (index, chunk) in image.data.chunks(KITTY_REPLAY_RAW_CHUNK).enumerate() { + let more = usize::from((index + 1) * KITTY_REPLAY_RAW_CHUNK < image.data.len()); + if index == 0 { + bytes.extend_from_slice( + format!( + "\x1b_Ga=t,t=d,f={},i={},s={},v={},q=2,m={more};", + image.format.kitty_protocol_value(), + image.id, + image.width, + image.height + ) + .as_bytes(), + ); + } else { + bytes.extend_from_slice(format!("\x1b_Gq=2,m={more};").as_bytes()); + } + let encoded = base64::engine::general_purpose::STANDARD + .encode_slice(chunk, &mut payload) + .expect("a 3:4-sized replay buffer must fit base64 output"); + bytes.extend_from_slice(&payload[..encoded]); + bytes.extend_from_slice(b"\x1b\\"); + } +} + +fn kitty_replay_placement_at( + placement: &KittyPlacement, + anchor: KittyPlacementAnchor, + replay_start_row: u64, + terminal_rows: u16, + cell_pixels: (u32, u32), +) -> Option> { + // The interleaved row break or final replay suffix positions the cursor + // after this command. DECSC/DECRC would overwrite the application's one + // saved-cursor slot while providing no additional replay state. + let anchor_row = u64::from(anchor.row); + if anchor_row >= replay_start_row { + if placement.pixel_width == 0 + || placement.pixel_height == 0 + || placement.source_width == 0 + || placement.source_height == 0 + { + return None; + } + let row = (anchor_row - replay_start_row) + .min(u64::from(terminal_rows.saturating_sub(1))) + .saturating_add(1); + let col = u32::from(anchor.col).saturating_add(1); + let placement_id = if placement.is_internal { 0 } else { placement.placement_id }; + let mut command = format!( + "\x1b[{row};{col}H\x1b_Ga=p,i={},p={},x={},y={},w={},h={},X={},Y={}", + placement.image_id, + placement_id, + placement.source_x, + placement.source_y, + placement.source_width, + placement.source_height, + placement.x_offset, + placement.y_offset, + ); + if placement.columns > 0 { + command.push_str(&format!(",c={}", placement.columns)); + } + if placement.rows > 0 { + command.push_str(&format!(",r={}", placement.rows)); + } + command.push_str(&format!(",z={},C=1,q=2;\x1b\\", placement.z)); + return Some(command.into_bytes()); + } + let relative_row = i64::try_from(replay_start_row - anchor_row).ok()?.checked_neg()?; + kitty_replay_placement_from_origin(placement, i64::from(anchor.col), relative_row, cell_pixels) +} + +#[cfg(test)] +fn kitty_replay_placement(placement: &KittyPlacement, cell_pixels: (u32, u32)) -> Option> { + kitty_replay_placement_from_origin( + placement, + i64::from(placement.viewport_col), + i64::from(placement.viewport_row), + cell_pixels, + ) +} + +fn kitty_replay_placement_from_origin( + placement: &KittyPlacement, + viewport_col: i64, + viewport_row: i64, + cell_pixels: (u32, u32), +) -> Option> { + if placement.pixel_width == 0 + || placement.pixel_height == 0 + || placement.source_width == 0 + || placement.source_height == 0 + { + return None; + } + + let cell_width = cell_pixels.0.max(1); + let cell_height = cell_pixels.1.max(1); + let image_left = viewport_col + .saturating_mul(i64::from(cell_width)) + .saturating_add(i64::from(placement.x_offset)); + let image_top = viewport_row + .saturating_mul(i64::from(cell_height)) + .saturating_add(i64::from(placement.y_offset)); + let image_right = image_left.saturating_add(i64::from(placement.pixel_width)); + let image_bottom = image_top.saturating_add(i64::from(placement.pixel_height)); + let visible_left = image_left.max(0); + let visible_top = image_top.max(0); + let mut visible_width = image_right.saturating_sub(visible_left); + let mut visible_height = image_bottom.saturating_sub(visible_top); + if visible_width <= 0 || visible_height <= 0 { + return None; + } + if placement.columns > 0 { + visible_width -= visible_width % i64::from(cell_width); + } + if placement.rows > 0 { + visible_height -= visible_height % i64::from(cell_height); + } + if visible_width <= 0 || visible_height <= 0 { + return None; + } + + let source_left = replay_proportional_boundary( + placement.source_width, + u32::try_from(visible_left.saturating_sub(image_left)).ok()?, + placement.pixel_width, + ); + let source_right = replay_proportional_boundary( + placement.source_width, + u32::try_from(visible_left.saturating_add(visible_width).saturating_sub(image_left)) + .ok()?, + placement.pixel_width, + ); + let source_top = replay_proportional_boundary( + placement.source_height, + u32::try_from(visible_top.saturating_sub(image_top)).ok()?, + placement.pixel_height, + ); + let source_bottom = replay_proportional_boundary( + placement.source_height, + u32::try_from(visible_top.saturating_add(visible_height).saturating_sub(image_top)).ok()?, + placement.pixel_height, + ); + let source_x = placement.source_x.saturating_add(source_left); + let source_y = placement.source_y.saturating_add(source_top); + let mut source_width = source_right.saturating_sub(source_left); + let mut source_height = source_bottom.saturating_sub(source_top); + if source_width == 0 || source_height == 0 { + return None; + } + + let columns = if placement.columns > 0 { + Some(u32::try_from(visible_width).ok()?.checked_div(cell_width)?) + } else { + None + }; + let rows = if placement.rows > 0 { + Some(u32::try_from(visible_height).ok()?.checked_div(cell_height)?) + } else { + None + }; + if columns.is_some_and(|columns| columns == 0) || rows.is_some_and(|rows| rows == 0) { + return None; + } + if columns.is_some() && rows.is_none() { + source_height = replay_fit_inferred_source_dimension( + columns?.saturating_mul(cell_width), + source_width, + source_height, + u32::try_from(visible_height).ok()?, + ); + } else if columns.is_none() && rows.is_some() { + source_width = replay_fit_inferred_source_dimension( + rows?.saturating_mul(cell_height), + source_height, + source_width, + u32::try_from(visible_width).ok()?, + ); + } else if columns.is_none() && rows.is_none() { + source_width = source_width.min(u32::try_from(visible_width).ok()?); + source_height = source_height.min(u32::try_from(visible_height).ok()?); + } + if source_width == 0 || source_height == 0 { + return None; + } + + let col = u32::try_from(visible_left).ok()?.checked_div(cell_width)?.saturating_add(1); + let row = u32::try_from(visible_top).ok()?.checked_div(cell_height)?.saturating_add(1); + let x_offset = u32::try_from(visible_left).ok()? % cell_width; + let y_offset = u32::try_from(visible_top).ok()? % cell_height; + let placement_id = if placement.is_internal { 0 } else { placement.placement_id }; + let mut command = format!( + "\x1b[{row};{col}H\x1b_Ga=p,i={},p={},x={source_x},y={source_y},w={source_width},h={source_height},X={x_offset},Y={y_offset}", + placement.image_id, placement_id + ); + if let Some(columns) = columns { + command.push_str(&format!(",c={columns}")); + } + if let Some(rows) = rows { + command.push_str(&format!(",r={rows}")); + } + command.push_str(&format!(",z={},C=1,q=2;\x1b\\", placement.z)); + Some(command.into_bytes()) +} + +fn replay_proportional_boundary( + source_pixels: u32, + output_pixels: u32, + rendered_pixels: u32, +) -> u32 { + if rendered_pixels == 0 { + return 0; + } + u32::try_from( + u128::from(source_pixels) * u128::from(output_pixels) / u128::from(rendered_pixels), + ) + .unwrap_or(source_pixels) + .min(source_pixels) +} + +fn replay_rounded_ratio(value: u32, numerator: u32, denominator: u32) -> Option { + if denominator == 0 { + return None; + } + u32::try_from( + (u128::from(value) * u128::from(numerator) + u128::from(denominator) / 2) + / u128::from(denominator), + ) + .ok() +} + +fn replay_fit_inferred_source_dimension( + explicit_pixels: u32, + fixed_source: u32, + inferred_source: u32, + maximum_pixels: u32, +) -> u32 { + let mut low = 0; + let mut high = inferred_source; + while low < high { + let candidate = low + (high - low).div_ceil(2); + if replay_rounded_ratio(explicit_pixels, candidate, fixed_source) + .is_some_and(|pixels| pixels <= maximum_pixels) + { + low = candidate; + } else { + high = candidate - 1; + } + } + low +} + fn vt_replay_row_window(total_rows: u64, screen_rows: u64, cols: u16, max_bytes: usize) -> u64 { let estimated_row_bytes = u64::from(cols.max(1)) * VT_REPLAY_ESTIMATED_BYTES_PER_CELL; let budget_rows = u64::try_from(max_bytes).unwrap_or(u64::MAX) / estimated_row_bytes; @@ -2809,6 +4097,8 @@ fn vt_replay_row_window(total_rows: u64, screen_rows: u64, cols: u16, max_bytes: impl Drop for Terminal { fn drop(&mut self) { unsafe { + sys::ghostty_tracked_grid_ref_free(self.history_anchor); + self.history_anchor = ptr::null_mut(); // Clear callbacks first so a hypothetical late invocation can't // touch the freed Box. sys::ghostty_terminal_set(self.raw, sys::GHOSTTY_TERMINAL_OPT_WRITE_PTY, ptr::null()); @@ -2825,11 +4115,55 @@ impl Drop for Terminal { #[cfg(test)] mod tests { - use super::{ - Callbacks, ClearHistoryOutcome, MouseModeChangeDetector, PaletteOsc, PromptSemantic, - PromptSemanticTracker, PromptTrackState, Screen, Terminal, vt_replay_row_window, + use crate::kitty::{ + KittyGraphicsSnapshot, KittyImage, KittyImageAlias, KittyImageFormat, KittyPlacement, + KittyPlacementAnchor, KittyPlacementKey, KittyReplaySnapshot, }; + use super::{ + Callbacks, ClearHistoryOutcome, KittyReplayCatalog, MouseModeChangeDetector, PaletteOsc, + PromptSemantic, PromptSemanticTracker, PromptTrackState, Screen, Terminal, + kitty_replay_image_encodings, kitty_replay_image_len, kitty_replay_placement, + reset_kitty_replay_image_encodings, vt_replay_row_window, + }; + + fn replay_placement_fixture( + source: (u32, u32), + grid: (u32, u32), + pixels: (u32, u32), + sizing: (u32, u32), + viewport: (i32, i32), + offset: (u32, u32), + ) -> KittyPlacement { + KittyPlacement { + key: KittyPlacementKey { image_id: 1, placement_id: 2, ordinal: 0 }, + image_id: 1, + placement_id: 2, + is_internal: false, + x_offset: offset.0, + y_offset: offset.1, + source_x: 0, + source_y: 0, + source_width: source.0, + source_height: source.1, + columns: sizing.0, + rows: sizing.1, + grid_cols: grid.0, + grid_rows: grid.1, + pixel_width: pixels.0, + pixel_height: pixels.1, + viewport_col: viewport.0, + viewport_row: viewport.1, + viewport_visible: true, + anchor: None, + z: 3, + } + } + + fn replay_placement_command(placement: &KittyPlacement) -> String { + String::from_utf8(kitty_replay_placement(placement, (10, 20)).unwrap()).unwrap() + } + #[test] fn unrelated_osc_tracking_keeps_palette_state_out_of_line() { assert!(size_of::() <= 16); @@ -2854,6 +4188,50 @@ mod tests { assert_ne!(first.instance_id(), second.instance_id()); } + #[test] + fn history_epoch_ignores_output_that_only_mutates_the_active_screen() { + let mut terminal = Terminal::new(8, 2, 1_000, Callbacks::default()).unwrap(); + let initial = terminal.history_epoch(); + + terminal.vt_write(b"visible"); + terminal.vt_write(b"\x1b[H\x1b[2K\x1b]2;title\x07"); + + assert_eq!(terminal.history_rows(), 0); + assert_eq!(terminal.history_epoch(), initial); + + terminal.vt_write(b"first\r\nsecond\r\nthird"); + assert!(terminal.history_rows() > 0); + assert!(terminal.history_epoch() > initial); + } + + #[test] + fn history_epoch_ignores_kitty_content_updates_that_do_not_move_history() { + let mut terminal = Terminal::new(8, 2, 1_000, Callbacks::default()).unwrap(); + terminal.vt_write(b"first\r\nsecond\r\nthird"); + assert!(terminal.history_rows() > 0); + let history_epoch = terminal.history_epoch(); + + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=8,p=1,s=1,v=1,c=1,r=1,C=1,q=2;/wAA\x1b\\"); + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=8,p=1,s=1,v=1,c=1,r=1,C=1,q=2;AP8A\x1b\\"); + + assert_eq!(terminal.history_epoch(), history_epoch); + } + + #[test] + fn history_epochs_change_across_mutations_and_terminal_instances() { + let mut first = Terminal::new(8, 2, 1_000, Callbacks::default()).unwrap(); + let initial = first.history_epoch(); + first.vt_write(b"first\r\nsecond\r\nthird"); + let after_output = first.history_epoch(); + first.resize(40, 12, 8, 16).unwrap(); + let after_resize = first.history_epoch(); + let second = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); + + assert!(after_output > initial); + assert!(after_resize > after_output); + assert_ne!(second.history_epoch(), after_resize); + } + #[test] fn ordinary_output_does_not_probe_unchanged_ambiguous_mouse_modes() { let mut terminal = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); @@ -3280,10 +4658,10 @@ mod tests { } source.vt_write(b"LATEST-VISIBLE-CONTENT"); - let full = source.vt_replay().unwrap(); + let full = source.vt_replay_bytes().unwrap(); assert!(full.len() > 32 * 1024); - let bounded = source.vt_replay_bounded(32 * 1024).unwrap(); + let bounded = source.vt_replay_bounded_bytes(32 * 1024).unwrap(); assert!(bounded.len() <= 32 * 1024); let mut restored = Terminal::new(80, 24, 0, Callbacks::default()).unwrap(); @@ -3299,9 +4677,108 @@ mod tests { } source.vt_write(b"LATEST-VISIBLE-CONTENT"); - let full = source.vt_replay().unwrap(); + let full = source.vt_replay_bytes().unwrap(); assert!(full.len() < 8 * 1024 * 1024); - assert_eq!(source.vt_replay_bounded(8 * 1024 * 1024).unwrap(), full); + assert_eq!(source.vt_replay_bounded_bytes(8 * 1024 * 1024).unwrap(), full); + } + + #[test] + fn vt_replay_preserves_sparse_viewport_rows_without_scrolling_them_into_history() { + let mut source = Terminal::new(80, 24, 100, Callbacks::default()).unwrap(); + source.vt_write(b"READY\r\n"); + let expected = source.viewport_text().unwrap(); + assert!(expected.contains("READY")); + + let replay = source.vt_replay_bytes().unwrap(); + let mut target = Terminal::new(80, 24, 100, Callbacks::default()).unwrap(); + target.vt_write(&replay); + + assert_eq!(target.viewport_text().unwrap(), expected); + } + + #[test] + fn theme_portable_replay_retains_aliases_for_admitted_kitty_images() { + let mut source = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + source.vt_write(b"\x1b_Ga=T,t=d,f=24,I=77,p=0,s=1,v=1,c=1,r=1,q=2;/wAA\x1b\\"); + let image_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + + let replay = source.vt_replay_bounded_theme_portable_with_aliases(1024 * 1024).unwrap(); + assert_eq!( + replay.kitty_image_aliases, + vec![KittyImageAlias { image_id, image_number: 77 }] + ); + + let mut target = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + target.vt_write(&replay.bytes); + target.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + target.vt_write(b"\x1b_Ga=p,I=77,p=5,c=1,r=1,q=2;\x1b\\"); + assert_eq!(target.kitty_graphics_snapshot().unwrap().placements[0].image_id, image_id); + } + + #[test] + fn kitty_replay_placement_does_not_replace_the_saved_cursor_slot() { + let mut source = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + source.resize(20, 8, 10, 20).unwrap(); + source.vt_write(b"before"); + source.vt_write(b"\x1b_Ga=T,t=d,f=32,i=77,p=1,s=1,v=1,c=2,r=2,q=2;/wAAfw==\x1b\\"); + source.vt_write(b"\x1b[8;10Htail"); + let source_graphics = source.kitty_graphics_snapshot().unwrap(); + assert_eq!(source_graphics.placements.len(), 1); + assert!( + source_graphics.placements[0].pixel_width > 0, + "fixture placement: {:?}", + source_graphics.placements[0] + ); + + let replay = source.vt_replay_bounded_theme_portable_with_aliases(1024 * 1024).unwrap(); + let mut target = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + target.resize(20, 8, 10, 20).unwrap(); + target.vt_write(&replay.bytes); + assert_eq!(target.kitty_graphics_snapshot().unwrap().placements.len(), 1); + target.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + target.vt_write(b"\x1b[8;20H\x1b8"); + + assert_eq!( + target.cursor_position(), + Some((0, 0)), + "a placement replay must leave the no-save DECRC fallback unchanged" + ); + } + + #[test] + fn minimal_bounded_replay_resets_before_numbered_kitty_images() { + let mut source = Terminal::new(256, 1, 0, Callbacks::default()).unwrap(); + source.vt_write("x".repeat(255).as_bytes()); + source.vt_write(b"\x1b_Ga=t,t=d,f=24,I=77,s=1,v=1,q=2;/wAA\x1b\\"); + let snapshot = source.kitty_graphics_snapshot().unwrap(); + let image = snapshot.images.first().unwrap(); + let max_bytes = kitty_replay_image_len(image).unwrap() + b"\x1bc".len(); + let text = source + .vt_replay_text_layout_bounded( + max_bytes, + &super::KittyReplayRowIndex::default(), + None, + false, + ) + .unwrap(); + assert_eq!(text.range, None, "fixture did not reach the minimal reset fallback"); + + let replay = source.vt_replay_bounded_theme_portable_with_aliases(max_bytes).unwrap(); + assert_eq!( + replay.kitty_image_aliases, + vec![KittyImageAlias { image_id: image.id, image_number: 77 }] + ); + assert!( + replay.bytes.starts_with(b"\x1bc\x1b_G"), + "the terminal reset cleared a preceding image transmission: {:?}", + replay.bytes + ); + + let mut target = Terminal::new(256, 1, 0, Callbacks::default()).unwrap(); + target.vt_write(&replay.bytes); + target.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + target.vt_write(b"\x1b_Ga=p,I=77,p=5,c=1,r=1,q=2;\x1b\\"); + assert_eq!(target.kitty_graphics_snapshot().unwrap().placements[0].image_id, image.id); } #[test] @@ -3310,4 +4787,223 @@ mod tests { assert_eq!(rows, 3_276); } + + #[test] + fn bounded_text_replay_snaps_to_the_oldest_fitting_placement_anchor() { + let mut source = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + for row in 0..40 { + source.vt_write(format!("row-{row:02}\r\n").as_bytes()); + } + source.vt_write(b"tail"); + let scrollbar = source.scrollbar().unwrap(); + let anchor_row = scrollbar.total - 12; + let placement_rows = [anchor_row].into_iter().collect(); + let anchor_range = super::ReplayRowRange { start: anchor_row, end: scrollbar.total - 1 }; + let anchor_bytes = source + .vt_replay_text_range_bounded(anchor_range, &placement_rows, usize::MAX, true) + .unwrap() + .unwrap() + .bytes + .len(); + let older_range = + super::ReplayRowRange { start: scrollbar.total - 16, end: scrollbar.total - 1 }; + assert!( + source + .vt_replay_text_range_bounded(older_range, &placement_rows, anchor_bytes, true) + .unwrap() + .is_none(), + "fixture must put the anchor between a fitting and oversized geometric window" + ); + + let replay = source + .vt_replay_text_layout_bounded( + anchor_bytes, + &placement_rows, + Some(scrollbar.total - scrollbar.len), + true, + ) + .unwrap(); + + assert_eq!(replay.range.unwrap().start, anchor_row); + } + + #[test] + fn kitty_replay_groups_each_placement_once() { + let image_count = 64_u32; + let images = (1..=image_count) + .map(|id| KittyImage { + id, + number: 0, + generation: u64::from(id), + width: 1, + height: 1, + format: KittyImageFormat::Rgb, + data: std::sync::Arc::from([0_u8, 0, 0]), + }) + .collect::>(); + let placements = (1..=image_count) + .map(|id| { + let mut placement = + replay_placement_fixture((1, 1), (1, 1), (1, 1), (1, 1), (0, 0), (0, 0)); + placement.key.image_id = id; + placement.image_id = id; + placement + }) + .collect::>(); + let anchors = placements + .iter() + .enumerate() + .map(|(row, placement)| { + (placement.key, KittyPlacementAnchor { col: 0, row: u32::try_from(row).unwrap() }) + }) + .collect(); + let snapshot = KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot { generation: 1, images, placements }, + anchors, + }; + + let catalog = KittyReplayCatalog::new(&snapshot, (1, 1), 24); + + assert_eq!(catalog.placement_grouping_visits, snapshot.graphics.placements.len()); + } + + #[test] + fn kitty_replay_does_not_encode_images_rejected_by_the_budget() { + let snapshot = KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot { + generation: 1, + images: vec![KittyImage { + id: 1, + number: 0, + generation: 1, + width: 1, + height: 1, + format: KittyImageFormat::Rgb, + data: std::sync::Arc::from([0_u8, 0, 0]), + }], + placements: Vec::new(), + }, + anchors: Default::default(), + }; + + reset_kitty_replay_image_encodings(); + let catalog = KittyReplayCatalog::new(&snapshot, (1, 1), 24); + let replay = catalog.plan(None, 0, false); + + assert!(replay.image_bytes.is_empty()); + assert_eq!( + kitty_replay_image_encodings(), + 0, + "catalog construction encoded an image that the replay budget rejected" + ); + } + + #[test] + fn bounded_replay_clips_a_placement_overlapping_the_retained_window() { + let mut source = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + source.resize(12, 4, 10, 20).unwrap(); + for row in 0..12 { + source.vt_write(format!("row-{row:02}\r\n").as_bytes()); + } + source.vt_write(b"tail"); + let end = source.scrollbar().unwrap().total - 1; + let range = super::ReplayRowRange { start: end - 5, end }; + let anchor = KittyPlacementAnchor { col: 0, row: u32::try_from(range.start - 1).unwrap() }; + let placement = + replay_placement_fixture((10, 60), (1, 3), (10, 60), (1, 3), (0, 0), (0, 0)); + let snapshot = KittyReplaySnapshot { + graphics: KittyGraphicsSnapshot { + generation: 1, + images: vec![KittyImage { + id: 1, + number: 0, + generation: 1, + width: 10, + height: 60, + format: KittyImageFormat::Rgb, + data: std::sync::Arc::from(vec![127_u8; 10 * 60 * 3]), + }], + placements: vec![placement.clone()], + }, + anchors: [(placement.key, anchor)].into_iter().collect(), + }; + let catalog = KittyReplayCatalog::new(&snapshot, (10, 20), 4); + let text = source + .vt_replay_text_range_bounded(range, catalog.placement_rows(), usize::MAX, true) + .unwrap() + .unwrap(); + let graphics = catalog.plan(Some(range), usize::MAX, false); + let mut replay = graphics.image_bytes; + replay.extend(text.interleave(&graphics.placements).unwrap()); + + let mut restored = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + restored.resize(12, 4, 10, 20).unwrap(); + restored.vt_write(&replay); + let restored_graphics = restored.kitty_graphics_snapshot().unwrap(); + let restored_placement = + restored_graphics.placements.first().expect("overlapping placement"); + + assert_eq!(restored_placement.source_y, 20); + assert_eq!(restored_placement.source_height, 40); + assert_eq!(restored_placement.rows, 2); + assert_eq!(restored_placement.grid_rows, 2); + } + + #[test] + fn kitty_inflight_tracking_uses_the_normalized_c1_stream() { + let mut terminal = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + terminal.vt_write(&[0xe0]); + terminal.vt_write(b"\x9fGa=t,t=d,f=24,i=92,s=1,v=2,m=1;AAAA\x9c"); + + assert!( + terminal.kitty_inflight.replay_prefix(usize::MAX).is_empty(), + "a UTF-8 continuation byte that Ghostty parsed as text became a replayable Kitty APC" + ); + } + + #[test] + fn replay_native_left_clip_preserves_native_pixel_size() { + let command = replay_placement_command(&replay_placement_fixture( + (15, 10), + (2, 1), + (15, 10), + (0, 0), + (-1, 0), + (4, 0), + )); + + assert!(command.contains("x=6,y=0,w=9,h=10,X=0,Y=0"), "{command:?}"); + assert!(!command.contains(",c="), "{command:?}"); + assert!(!command.contains(",r="), "{command:?}"); + } + + #[test] + fn replay_column_only_top_clip_keeps_rows_inferred() { + let command = replay_placement_command(&replay_placement_fixture( + (20, 10), + (2, 2), + (20, 10), + (2, 0), + (0, -1), + (0, 15), + )); + + assert!(command.contains("x=0,y=5,w=20,h=5,X=0,Y=0,c=2"), "{command:?}"); + assert!(!command.contains(",r="), "{command:?}"); + } + + #[test] + fn replay_row_only_left_clip_keeps_columns_inferred() { + let command = replay_placement_command(&replay_placement_fixture( + (10, 40), + (2, 2), + (10, 40), + (0, 2), + (-1, 0), + (5, 0), + )); + + assert!(command.contains("x=5,y=0,w=5,h=40,X=0,Y=0,r=2"), "{command:?}"); + assert!(!command.contains(",c="), "{command:?}"); + } } diff --git a/cmux-tui/crates/ghostty-vt/tests/kitty_graphics.rs b/cmux-tui/crates/ghostty-vt/tests/kitty_graphics.rs new file mode 100644 index 0000000000..7f0c7bdd0e --- /dev/null +++ b/cmux-tui/crates/ghostty-vt/tests/kitty_graphics.rs @@ -0,0 +1,1031 @@ +use ghostty_vt::{Callbacks, Error, KittyImageFormat, RenderState, Terminal}; + +const PNG_1X1_RED: &str = concat!( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA", + "DUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" +); + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + terminal.resize(20, 8, 10, 20).unwrap(); + terminal +} + +fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let a = chunk[0]; + let b = chunk.get(1).copied().unwrap_or(0); + let c = chunk.get(2).copied().unwrap_or(0); + encoded.push(ALPHABET[(a >> 2) as usize] as char); + encoded.push(ALPHABET[(((a & 0x03) << 4) | (b >> 4)) as usize] as char); + encoded.push(if chunk.len() > 1 { + ALPHABET[(((b & 0x0f) << 2) | (c >> 6)) as usize] as char + } else { + '=' + }); + encoded.push(if chunk.len() > 2 { ALPHABET[(c & 0x3f) as usize] as char } else { '=' }); + } + encoded +} + +fn kitty(params: &str, payload: &str) -> Vec { + format!("\x1b_G{params};{payload}\x1b\\").into_bytes() +} + +fn assert_inflight_replay_completes( + source: &mut Terminal, + image_id: u32, + first_chunk: &[u8], + final_chunk: &[u8], +) { + source.vt_write(first_chunk); + assert!(source.kitty_graphics_snapshot().unwrap().image(image_id).is_none()); + + let replay = source.vt_replay().unwrap(); + let mut mirror = terminal(); + mirror.apply_vt_replay(&replay).unwrap(); + mirror.vt_write(final_chunk); + + assert_eq!( + &*mirror + .kitty_graphics_snapshot() + .unwrap() + .image(image_id) + .expect("replayed in-flight transmission must accept its final chunk") + .data, + &[255; 6] + ); +} + +#[test] +fn chunked_rgb_transmission_is_snapshotted_as_owned_pixels() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=t,t=d,f=24,i=7,s=1,v=2,m=1,q=2", "////")); + terminal.vt_write(&kitty("m=0,q=2", "////")); + terminal.vt_write(&kitty("a=p,i=7,p=3,c=2,r=1,q=2", "")); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + let image = snapshot.image(7).expect("image"); + assert_eq!(image.format, KittyImageFormat::Rgb); + assert_eq!((image.width, image.height), (1, 2)); + assert_eq!(&*image.data, &[255; 6]); + assert_eq!(snapshot.placements.len(), 1); + + terminal.vt_write(&kitty("a=d,d=I,i=7,q=2", "")); + assert!(terminal.kitty_graphics_snapshot().unwrap().images.is_empty()); + assert_eq!(&*image.data, &[255; 6], "snapshot pixels must outlive Ghostty's borrowed handle"); +} + +#[test] +fn snapshots_expose_absolute_anchors_for_scrollback_placements() { + let mut terminal = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + terminal.resize(12, 4, 10, 20).unwrap(); + terminal.vt_write(&kitty("a=T,t=d,f=24,i=8,p=1,s=1,v=1,c=1,r=1,C=1,q=2", "/wAA")); + for row in 0..8 { + terminal.vt_write(format!("row-{row}\r\n").as_bytes()); + } + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + let placement = snapshot.placements.first().expect("scrollback placement"); + + assert!(!placement.viewport_visible); + assert_eq!(placement.anchor.map(|anchor| (anchor.col, anchor.row)), Some((0, 0))); +} + +#[test] +fn unsupported_grayscale_transmissions_are_ignored_and_parser_recovers() { + let grayscale = [0x10, 0x80]; + let grayscale_alpha = [0x20, 0x40, 0xa0, 0xc0]; + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=t,t=d,f=8,i=70,s=2,v=1,q=2", &encode_base64(&grayscale))); + terminal.vt_write(&kitty("a=t,t=d,f=16,i=71,s=2,v=1,q=2", &encode_base64(&grayscale_alpha))); + terminal.vt_write(&kitty("a=t,t=d,f=24,i=72,s=1,v=1,q=2", "/wAA")); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + assert!(snapshot.image(70).is_none()); + assert!(snapshot.image(71).is_none()); + assert_eq!(snapshot.image(72).unwrap().format, KittyImageFormat::Rgb); + assert_eq!(&*snapshot.image(72).unwrap().data, &[255, 0, 0]); +} + +#[test] +fn failed_alias_restore_does_not_partially_reassign_images() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=t,t=d,f=24,i=7,s=1,v=1,q=2", "/wAA")); + + assert!( + terminal + .restore_kitty_image_aliases(&[ + ghostty_vt::KittyImageAlias { image_id: 7, image_number: 77 }, + ghostty_vt::KittyImageAlias { image_id: 999, image_number: 88 }, + ]) + .is_err() + ); + + assert_eq!( + terminal.kitty_graphics_snapshot().unwrap().image(7).unwrap().number, + 0, + "an alias before the invalid entry was committed" + ); +} + +#[test] +fn rgba_snapshot_preserves_alpha_crop_offsets_z_and_real_cell_geometry() { + let mut terminal = terminal(); + let pixels = [255, 0, 0, 0, 0, 255, 0, 64, 0, 0, 255, 128, 255, 255, 255, 255]; + terminal.vt_write(b"\x1b[3;4H"); + terminal.vt_write(&kitty("a=t,t=d,f=32,i=8,s=2,v=2,q=2", &encode_base64(&pixels))); + terminal.vt_write(&kitty("a=p,i=8,p=12,x=1,y=0,w=1,h=2,X=3,Y=4,c=2,r=3,z=-2,q=2", "")); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + let image = snapshot.image(8).expect("image"); + assert_eq!(image.format, KittyImageFormat::Rgba); + assert_eq!(&*image.data, &pixels); + let placement = &snapshot.placements[0]; + assert_eq!((placement.viewport_col, placement.viewport_row), (3, 2)); + assert_eq!((placement.source_x, placement.source_y), (1, 0)); + assert_eq!((placement.source_width, placement.source_height), (1, 2)); + assert_eq!((placement.x_offset, placement.y_offset), (3, 4)); + assert_eq!((placement.columns, placement.rows), (2, 3)); + assert_eq!((placement.grid_cols, placement.grid_rows), (2, 3)); + assert_eq!((placement.pixel_width, placement.pixel_height), (20, 60)); + assert_eq!(placement.z, -2); +} + +#[test] +fn png_transmission_is_decoded_to_rgba() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=T,t=d,f=100,i=9,p=1,q=2", PNG_1X1_RED)); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + let image = snapshot.image(9).expect("decoded PNG image"); + assert_eq!(image.format, KittyImageFormat::Rgba); + assert_eq!((image.width, image.height), (1, 1)); + assert_eq!(&*image.data, &[255, 0, 0, 255]); +} + +#[test] +fn oversized_png_header_is_rejected_before_decode_allocation_and_parser_recovers() { + let mut terminal = terminal(); + let mut oversized = b"\x89PNG\r\n\x1a\n".to_vec(); + oversized.extend_from_slice(&13_u32.to_be_bytes()); + oversized.extend_from_slice(b"IHDR"); + oversized.extend_from_slice(&5_000_u32.to_be_bytes()); + oversized.extend_from_slice(&5_000_u32.to_be_bytes()); + terminal.vt_write(&kitty("a=t,t=d,f=100,i=90,q=2", &encode_base64(&oversized))); + assert!(terminal.kitty_graphics_snapshot().unwrap().image(90).is_none()); + + terminal.vt_write(&kitty("a=T,t=d,f=100,i=91,p=1,q=2", PNG_1X1_RED)); + assert_eq!( + &*terminal.kitty_graphics_snapshot().unwrap().image(91).unwrap().data, + &[255, 0, 0, 255] + ); +} + +#[test] +fn retransmit_delete_and_malformed_input_recover_without_stale_pixels() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=T,t=d,f=24,i=10,p=4,s=1,v=1,q=2", "////")); + let first = terminal.kitty_graphics_snapshot().unwrap(); + let first_image = first.image(10).unwrap(); + + terminal.vt_write(&kitty("a=t,t=d,f=24,i=10,s=1,v=1,q=2", "AAAA")); + let second = terminal.kitty_graphics_snapshot().unwrap(); + let second_image = second.image(10).unwrap(); + assert!(second_image.generation > first_image.generation); + assert_eq!(&*second_image.data, &[0, 0, 0]); + + terminal.vt_write(b"\x1b_Ga=T,t=d,f=24,i=99,s=999999999,v=2;!!!!\x1b\\"); + terminal.vt_write(&kitty("a=T,t=d,f=24,i=11,p=1,s=1,v=1,q=2", "/wAA")); + let recovered = terminal.kitty_graphics_snapshot().unwrap(); + assert!(recovered.image(99).is_none()); + assert_eq!(&*recovered.image(11).unwrap().data, &[255, 0, 0]); + + terminal.vt_write(&kitty("a=d,d=i,i=10,p=4,q=2", "")); + assert!( + terminal + .kitty_graphics_snapshot() + .unwrap() + .placements + .iter() + .all(|placement| placement.image_id != 10) + ); + terminal.vt_write(&kitty("a=d,d=I,i=10,q=2", "")); + assert!(terminal.kitty_graphics_snapshot().unwrap().image(10).is_none()); +} + +#[test] +fn replay_reconstructs_preexisting_images_and_placements() { + let mut source = terminal(); + source.vt_write(b"before"); + source.vt_write(&kitty("a=T,t=d,f=32,i=21,p=2,s=1,v=1,c=2,r=2,z=4,q=2", "/wAAfw==")); + let expected = source.kitty_graphics_snapshot().unwrap(); + + let replay = source.vt_replay_bytes().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay); + let actual = mirror.kitty_graphics_snapshot().unwrap(); + + assert_eq!(actual.images.len(), 1); + assert_eq!(actual.placements.len(), 1); + assert_eq!(actual.image(21).unwrap().data, expected.image(21).unwrap().data); + assert_eq!(actual.placements[0].z, 4); + assert_eq!((actual.placements[0].grid_cols, actual.placements[0].grid_rows), (2, 2)); +} + +#[test] +fn replay_preserves_native_and_single_axis_placement_sizing() { + let mut source = terminal(); + source.vt_write(&kitty( + "a=t,t=d,f=24,i=23,s=20,v=10,q=2", + &encode_base64(&vec![255; 20 * 10 * 3]), + )); + source.vt_write(&kitty("a=p,i=23,p=1,C=1,q=2", "")); + source.vt_write(b"\x1b[2;1H"); + source.vt_write(&kitty("a=p,i=23,p=2,c=2,C=1,q=2", "")); + source.vt_write(b"\x1b[3;1H"); + source.vt_write(&kitty("a=p,i=23,p=3,r=2,C=1,q=2", "")); + + let replay = source.vt_replay_bytes().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay); + let actual = mirror.kitty_graphics_snapshot().unwrap(); + let sizing = |placement_id| { + let placement = actual + .placements + .iter() + .find(|placement| placement.placement_id == placement_id) + .unwrap(); + (placement.columns, placement.rows) + }; + assert_eq!(sizing(1), (0, 0)); + assert_eq!(sizing(2), (2, 0)); + assert_eq!(sizing(3), (0, 2)); +} + +#[test] +fn replay_keeps_an_unplaced_image_for_a_post_attach_placement() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,i=22,s=1,v=1,q=2", "/wAA")); + let before_place = source.kitty_graphics_snapshot().unwrap(); + assert!(before_place.image(22).is_some()); + assert!(before_place.placements.is_empty()); + + let replay = source.vt_replay_bytes().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay); + assert!(mirror.kitty_graphics_snapshot().unwrap().image(22).is_some()); + + let place = kitty("a=p,i=22,p=7,c=1,r=1,q=2", ""); + source.vt_write(&place); + mirror.vt_write(&place); + let mirrored = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!(mirrored.placements.len(), 1); + assert_eq!(mirrored.placements[0].placement_id, 7); +} + +#[test] +fn replay_keeps_a_number_only_image_for_a_post_attach_number_placement() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,I=77,s=1,v=1,q=2", "/wAA")); + + source.vt_write(&kitty("a=p,I=77,p=99,c=1,r=1,q=2", "")); + let assigned_id = source.kitty_graphics_snapshot().unwrap().placements[0].image_id; + source.vt_write(&kitty(&format!("a=d,d=i,i={assigned_id},p=99,q=2"), "")); + assert!(source.kitty_graphics_snapshot().unwrap().placements.is_empty()); + + let replay = source.vt_replay().unwrap(); + assert_eq!( + replay.kitty_image_aliases, + vec![ghostty_vt::KittyImageAlias { image_id: assigned_id, image_number: 77 }] + ); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let place_by_number = kitty("a=p,I=77,p=8,c=1,r=1,q=2", ""); + let place_by_id = kitty(&format!("a=p,i={assigned_id},p=9,c=1,r=1,q=2"), ""); + for place in [&place_by_number, &place_by_id] { + source.vt_write(place); + mirror.vt_write(place); + } + assert_eq!(source.kitty_graphics_snapshot().unwrap().placements.len(), 2); + let mirrored = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!(mirrored.placements.len(), 2); + assert!(mirrored.placements.iter().all(|placement| placement.image_id == assigned_id)); + assert_eq!( + mirrored.placements.iter().map(|placement| placement.placement_id).collect::>(), + vec![8, 9] + ); +} + +#[test] +fn replay_preserves_duplicate_number_history_and_source_generation_order() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,I=77,s=1,v=1,q=2", "/wAA")); + let first_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty("a=t,t=d,f=24,I=77,s=1,v=1,q=2", "AP8A")); + let source_images = source.kitty_graphics_snapshot().unwrap().images; + let newest_id = + source_images.iter().max_by_key(|image| image.generation).expect("newest image").id; + assert_ne!(first_id, newest_id); + + let replay = source.vt_replay().unwrap(); + assert_eq!(replay.kitty_image_aliases.len(), 2); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let delete_newest = kitty(&format!("a=d,d=I,i={newest_id},q=2"), ""); + source.vt_write(&delete_newest); + mirror.vt_write(&delete_newest); + let place_older = kitty("a=p,I=77,p=10,c=1,r=1,q=2", ""); + source.vt_write(&place_older); + mirror.vt_write(&place_older); + assert_eq!(source.kitty_graphics_snapshot().unwrap().placements[0].image_id, first_id); + assert_eq!(mirror.kitty_graphics_snapshot().unwrap().placements[0].image_id, first_id); +} + +#[test] +fn replay_preserves_the_next_automatic_image_id_after_eviction() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,I=1,s=1,v=1,q=2", "/wAA")); + let first_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty(&format!("a=d,d=I,i={first_id},q=2"), "")); + assert!(source.kitty_graphics_snapshot().unwrap().images.is_empty()); + + let replay = source.vt_replay().unwrap(); + let mut mirror = terminal(); + mirror.apply_vt_replay(&replay).unwrap(); + + let next = kitty("a=t,t=d,f=24,I=2,s=1,v=1,q=2", "AP8A"); + source.vt_write(&next); + mirror.vt_write(&next); + let source_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + let mirror_id = mirror.kitty_graphics_snapshot().unwrap().images[0].id; + assert_eq!( + mirror_id, source_id, + "a replacement mirror reused an automatic image ID already consumed by the source" + ); +} + +#[test] +fn replay_preserves_independent_automatic_image_ids_for_both_screens() { + let mut source = terminal(); + for image_number in [1, 2] { + source.vt_write(&kitty(&format!("a=t,t=d,f=24,I={image_number},s=1,v=1,q=2"), "/wAA")); + let image_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty(&format!("a=d,d=I,i={image_id},q=2"), "")); + } + + source.vt_write(b"\x1b[?1049h"); + source.vt_write(&kitty("a=t,t=d,f=24,I=3,s=1,v=1,q=2", "/wAA")); + let alternate_image_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty(&format!("a=d,d=I,i={alternate_image_id},q=2"), "")); + + let replay = source.vt_replay().unwrap(); + assert_ne!( + replay.kitty_state.next_image_ids.primary, replay.kitty_state.next_image_ids.alternate, + "the test requires divergent primary and alternate cursors" + ); + let mut mirror = terminal(); + mirror.apply_vt_replay(&replay).unwrap(); + + let next_alternate = kitty("a=t,t=d,f=24,I=4,s=1,v=1,q=2", "AP8A"); + source.vt_write(&next_alternate); + mirror.vt_write(&next_alternate); + assert_eq!( + mirror.kitty_graphics_snapshot().unwrap().images[0].id, + source.kitty_graphics_snapshot().unwrap().images[0].id, + "alternate-screen automatic IDs diverged after replay" + ); + + source.vt_write(b"\x1b[?1049l"); + mirror.vt_write(b"\x1b[?1049l"); + let next_primary = kitty("a=t,t=d,f=24,I=5,s=1,v=1,q=2", "AA8A"); + source.vt_write(&next_primary); + mirror.vt_write(&next_primary); + assert_eq!( + mirror.kitty_graphics_snapshot().unwrap().images[0].id, + source.kitty_graphics_snapshot().unwrap().images[0].id, + "primary-screen automatic IDs diverged after replaying the alternate screen" + ); +} + +#[test] +fn replay_preserves_the_automatic_id_of_an_inflight_upload() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,I=1,s=1,v=1,q=2", "/wAA")); + let first_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty(&format!("a=d,d=I,i={first_id},q=2"), "")); + source.vt_write(&kitty("a=t,t=d,f=24,I=2,s=1,v=2,m=1,q=2", "////")); + + let mut replay = source.vt_replay().unwrap(); + replay.bytes.splice(..0, b"\x1bc".iter().copied()); + replay.kitty_state.replay_cursor_offset += 2; + let mut mirror = terminal(); + mirror.apply_vt_replay(&replay).unwrap(); + + let final_chunk = kitty("m=0,q=2", "////"); + source.vt_write(&final_chunk); + mirror.vt_write(&final_chunk); + let source_image = source.kitty_graphics_snapshot().unwrap().images[0].clone(); + let mirror_image = mirror.kitty_graphics_snapshot().unwrap().images[0].clone(); + assert_eq!( + mirror_image.id, source_image.id, + "replaying an in-flight upload allocated a different automatic image ID" + ); + assert_eq!(mirror_image.number, source_image.number); + assert_eq!(mirror_image.data, source_image.data); +} + +#[test] +fn replay_preserves_an_occupied_probe_that_is_later_deleted() { + const FIRST_AUTOMATIC_ID: u32 = 2_147_483_647; + let mut source = terminal(); + source.vt_write(&kitty(&format!("a=t,t=d,f=24,i={FIRST_AUTOMATIC_ID},s=1,v=1,q=2"), "/wAA")); + let replay = source.vt_replay().unwrap(); + + let mut mirror = terminal(); + mirror.apply_vt_replay(&replay).unwrap(); + + let delete_probe = kitty(&format!("a=d,d=I,i={FIRST_AUTOMATIC_ID},q=2"), ""); + source.vt_write(&delete_probe); + mirror.vt_write(&delete_probe); + let next = kitty("a=t,t=d,f=24,I=2,s=1,v=1,q=2", "AP8A"); + source.vt_write(&next); + mirror.vt_write(&next); + + let source_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + let mirror_id = mirror.kitty_graphics_snapshot().unwrap().images[0].id; + assert_eq!(source_id, FIRST_AUTOMATIC_ID); + assert_eq!(mirror_id, source_id, "replay lost the occupied raw image-ID cursor"); +} + +#[test] +fn replay_aliases_follow_generation_order_and_exclude_omitted_images() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,i=90,s=1,v=1,q=2", "/wAA")); + source.vt_write(&kitty("a=t,t=d,f=24,i=10,s=1,v=1,q=2", "AP8A")); + source + .restore_kitty_image_aliases(&[ + ghostty_vt::KittyImageAlias { image_id: 90, image_number: 88 }, + ghostty_vt::KittyImageAlias { image_id: 10, image_number: 88 }, + ]) + .unwrap(); + source.vt_write(&kitty("a=t,t=d,f=24,i=91,s=100,v=1,q=2", &encode_base64(&vec![255; 300]))); + source + .restore_kitty_image_aliases(&[ghostty_vt::KittyImageAlias { + image_id: 91, + image_number: 91, + }]) + .unwrap(); + + let full = source.vt_replay().unwrap(); + assert_eq!( + full.kitty_image_aliases, + vec![ + ghostty_vt::KittyImageAlias { image_id: 90, image_number: 88 }, + ghostty_vt::KittyImageAlias { image_id: 10, image_number: 88 }, + ghostty_vt::KittyImageAlias { image_id: 91, image_number: 91 }, + ] + ); + let mut mirror = terminal(); + mirror.vt_write(&full.bytes); + mirror.restore_kitty_image_aliases(&full.kitty_image_aliases).unwrap(); + mirror.vt_write(&kitty("a=p,I=88,p=11,c=1,r=1,q=2", "")); + assert_eq!(mirror.kitty_graphics_snapshot().unwrap().placements[0].image_id, 10); + + let bounded = source.vt_replay_bounded(256).unwrap(); + assert!( + bounded.kitty_image_aliases.iter().all(|alias| alias.image_id != 91), + "alias sidecar must not name an image omitted from bounded replay" + ); + assert!(!String::from_utf8_lossy(&bounded.bytes).contains("i=91")); +} + +#[test] +fn bounded_replay_suppresses_an_incomplete_duplicate_number_alias_history() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,I=77,s=1,v=1,q=2", "/wAA")); + let first_id = source.kitty_graphics_snapshot().unwrap().images[0].id; + source.vt_write(&kitty("a=t,t=d,f=24,I=77,s=100,v=1,q=2", &encode_base64(&vec![255; 300]))); + let images = source.kitty_graphics_snapshot().unwrap().images; + let newest_id = images.iter().max_by_key(|image| image.generation).unwrap().id; + assert_ne!(first_id, newest_id); + + let bounded = source.vt_replay_bounded(256).unwrap(); + let replay_text = String::from_utf8_lossy(&bounded.bytes); + assert!(replay_text.contains(&format!("i={first_id}"))); + assert!(!replay_text.contains(&format!("i={newest_id}"))); + assert!( + bounded.kitty_image_aliases.iter().all(|alias| alias.image_number != 77), + "a partial duplicate-number history exposed an alias to the wrong image" + ); + + let mut mirror = terminal(); + mirror.apply_vt_replay(&bounded).unwrap(); + let place_by_number = kitty("a=p,I=77,p=12,c=1,r=1,q=2", ""); + source.vt_write(&place_by_number); + mirror.vt_write(&place_by_number); + assert_eq!(source.kitty_graphics_snapshot().unwrap().placements[0].image_id, newest_id); + assert!( + mirror.kitty_graphics_snapshot().unwrap().placements.is_empty(), + "a bounded mirror routed a number command to an older admitted image" + ); +} + +#[test] +fn replay_preserves_an_inflight_chunked_transmission_until_its_final_chunk() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,i=92,s=1,v=2,m=1,q=2", "////")); + assert!(source.kitty_graphics_snapshot().unwrap().image(92).is_none()); + + let replay = source.vt_replay().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let final_chunk = kitty("m=0,q=2", "////"); + source.vt_write(&final_chunk); + mirror.vt_write(&final_chunk); + assert_eq!(&*source.kitty_graphics_snapshot().unwrap().image(92).unwrap().data, &[255; 6]); + assert_eq!(&*mirror.kitty_graphics_snapshot().unwrap().image(92).unwrap().data, &[255; 6]); +} + +#[test] +fn utf8_emoji_before_chunked_transmission_does_not_hide_its_replay_prefix() { + let mut source = terminal(); + source.vt_write("😀".as_bytes()); + assert_inflight_replay_completes( + &mut source, + 192, + &kitty("a=t,t=d,f=24,i=192,s=1,v=2,m=1,q=2", "////"), + &kitty("m=0,q=2", "////"), + ); +} + +#[test] +fn split_utf8_scalar_before_chunked_transmission_does_not_hide_its_replay_prefix() { + let mut source = terminal(); + let emoji = "😀".as_bytes(); + source.vt_write(&emoji[..2]); + source.vt_write(&emoji[2..]); + assert_inflight_replay_completes( + &mut source, + 193, + &kitty("a=t,t=d,f=24,i=193,s=1,v=2,m=1,q=2", "////"), + &kitty("m=0,q=2", "////"), + ); +} + +#[test] +fn invalid_utf8_resynchronizes_before_a_chunked_transmission() { + let mut source = terminal(); + source.vt_write(&[0xf0, 0x9f]); + assert_inflight_replay_completes( + &mut source, + 194, + &kitty("a=t,t=d,f=24,i=194,s=1,v=2,m=1,q=2", "////"), + &kitty("m=0,q=2", "////"), + ); +} + +#[test] +fn bare_c1_kitty_apc_chunked_transmission_remains_replayable() { + let mut source = terminal(); + let first = b"\x9fGa=t,t=d,f=24,i=195,s=1,v=2,m=1,q=2;////\x9c"; + source.vt_write(first); + + let replay = source.vt_replay().unwrap(); + let normalized_first = kitty("a=t,t=d,f=24,i=195,s=1,v=2,m=1,q=2", "////"); + assert!( + replay.bytes.ends_with(&normalized_first), + "a genuine bare C1 Kitty APC must remain part of normalized attach replay" + ); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let final_chunk = b"\x9fGm=0,q=2;////\x9c"; + source.vt_write(final_chunk); + mirror.vt_write(final_chunk); + + assert_eq!(&*source.kitty_graphics_snapshot().unwrap().image(195).unwrap().data, &[255; 6]); + assert_eq!(&*mirror.kitty_graphics_snapshot().unwrap().image(195).unwrap().data, &[255; 6]); +} + +#[test] +fn replay_preserves_image_eviction_age_when_ids_sort_in_the_opposite_order() { + let mut source = terminal(); + source.set_kitty_image_storage_limit(6).unwrap(); + source.set_kitty_image_count_limit(2).unwrap(); + source.vt_write(&kitty("a=t,t=d,f=24,i=90,s=1,v=1,q=2", "/wAA")); + source.vt_write(&kitty("a=t,t=d,f=24,i=10,s=1,v=1,q=2", "AP8A")); + + let replay = source.vt_replay().unwrap(); + let mut mirror = terminal(); + mirror.set_kitty_image_storage_limit(6).unwrap(); + mirror.set_kitty_image_count_limit(2).unwrap(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let newest = kitty("a=t,t=d,f=24,i=50,s=1,v=1,q=2", "AAD/"); + source.vt_write(&newest); + mirror.vt_write(&newest); + let image_ids = |terminal: &mut Terminal| { + terminal + .kitty_graphics_snapshot() + .unwrap() + .images + .iter() + .map(|image| image.id) + .collect::>() + }; + + assert_eq!(image_ids(&mut source), vec![10, 50]); + assert_eq!(image_ids(&mut mirror), image_ids(&mut source)); +} + +#[test] +fn anonymous_transmission_after_replay_uses_an_unoccupied_image_id() { + const FIRST_AUTOMATIC_ID: u32 = 2_147_483_647; + let mut source = terminal(); + source.vt_write(&kitty(&format!("a=t,t=d,f=24,i={FIRST_AUTOMATIC_ID},s=1,v=1,q=2"), "/wAA")); + source.vt_write(&kitty("a=t,t=d,f=24,i=7,s=1,v=1,q=2", "AP8A")); + let expected = source + .kitty_graphics_snapshot() + .unwrap() + .images + .iter() + .map(|image| (image.id, image.data.to_vec())) + .collect::>(); + + let replay = source.vt_replay().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + mirror.vt_write(&kitty("a=t,t=d,f=24,s=1,v=1,q=2", "AAD/")); + + let snapshot = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.images.len(), 3); + for (image_id, pixels) in expected { + assert_eq!(&*snapshot.image(image_id).expect("replayed image").data, pixels); + } + let anonymous = snapshot + .images + .iter() + .find(|image| image.id != FIRST_AUTOMATIC_ID && image.id != 7) + .expect("anonymous image must use an unoccupied ID"); + assert_eq!(&*anonymous.data, &[0, 0, 255]); +} + +#[test] +fn bounded_replay_uses_the_full_cap_for_a_large_visible_image() { + let mut source = terminal(); + let pixels = vec![127; 80 * 40 * 3]; + source.vt_write(b"visible image"); + source + .vt_write(&kitty("a=T,t=d,f=24,i=93,p=1,s=80,v=40,c=10,r=4,q=2", &encode_base64(&pixels))); + let full = source.vt_replay().unwrap(); + assert!( + encode_base64(&pixels).len() > full.bytes.len() / 2, + "fixture must exceed the old half-budget" + ); + + let bounded = source.vt_replay_bounded(full.bytes.len()).unwrap(); + assert!(bounded.bytes.len() <= full.bytes.len()); + let mut mirror = terminal(); + mirror.vt_write(&bounded.bytes); + mirror.restore_kitty_image_aliases(&bounded.kitty_image_aliases).unwrap(); + + let snapshot = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!(&*snapshot.image(93).expect("visible image must fit the total cap").data, &pixels); + assert_eq!(snapshot.placements.len(), 1); +} + +#[test] +fn bounded_replay_trims_scrollback_before_a_visible_image() { + let pixels = vec![96; 80 * 40 * 3]; + + let mut visible_only = terminal(); + visible_only.vt_write(b"visible image"); + visible_only + .vt_write(&kitty("a=T,t=d,f=24,i=97,p=1,s=80,v=40,c=10,r=4,q=2", &encode_base64(&pixels))); + let budget = visible_only.vt_replay().unwrap().bytes.len(); + + let mut source = terminal(); + for row in 0..200 { + source + .vt_write(format!("history row {row:03} fills the terminal scrollback\r\n").as_bytes()); + } + source.vt_write(b"\x1b[2J\x1b[Hvisible image"); + source + .vt_write(&kitty("a=T,t=d,f=24,i=97,p=1,s=80,v=40,c=10,r=4,q=2", &encode_base64(&pixels))); + + let replay = source.vt_replay_bounded(budget).unwrap(); + assert!(replay.bytes.len() <= budget); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let snapshot = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!( + &*snapshot.image(97).expect("visible image must displace old scrollback").data, + &pixels + ); + assert_eq!(snapshot.placements.len(), 1); +} + +#[test] +fn bounded_replay_prioritizes_a_visible_image_over_an_older_unplaced_image() { + let pixels = vec![64; 600 * 3]; + let mut visible_only = terminal(); + visible_only.vt_write(b"same text"); + visible_only + .vt_write(&kitty("a=T,t=d,f=24,i=95,p=1,s=600,v=1,c=10,r=1,q=2", &encode_base64(&pixels))); + let budget = visible_only.vt_replay().unwrap().bytes.len(); + + let mut source = terminal(); + source.vt_write(b"same text"); + source.vt_write(&kitty("a=t,t=d,f=24,i=94,s=600,v=1,q=2", &encode_base64(&pixels))); + source + .vt_write(&kitty("a=T,t=d,f=24,i=95,p=1,s=600,v=1,c=10,r=1,q=2", &encode_base64(&pixels))); + + let replay = source.vt_replay_bounded(budget).unwrap(); + assert!(replay.bytes.len() <= budget); + let mut mirror = terminal(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + let snapshot = mirror.kitty_graphics_snapshot().unwrap(); + + assert!(snapshot.image(95).is_some(), "visible image was starved by an unplaced image"); + assert!(snapshot.placements.iter().any(|placement| placement.image_id == 95)); + assert!(snapshot.image(94).is_none(), "tight replay unexpectedly retained both images"); +} + +fn visible_placement_signature(terminal: &mut Terminal) -> Vec<(u32, i32, u32, u32, u32)> { + terminal + .kitty_graphics_snapshot() + .unwrap() + .placements + .iter() + .filter(|placement| placement.viewport_visible) + .map(|placement| { + ( + placement.image_id, + placement.viewport_row, + placement.grid_rows, + placement.source_y, + placement.source_height, + ) + }) + .collect() +} + +#[test] +fn replay_preserves_scrollback_placements_before_and_after_resize() { + let mut source = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + source.resize(12, 4, 10, 20).unwrap(); + source.vt_write(&kitty("a=t,t=d,f=24,i=96,s=1,v=1,q=2", "/wAA")); + source.vt_write(&kitty("a=t,t=d,f=24,i=97,s=1,v=1,q=2", "AP8A")); + source.vt_write(&kitty("a=p,i=96,p=1,c=1,r=2,q=2", "")); + source.vt_write(b"row-0\r\nrow-1\r\n"); + source.vt_write(&kitty("a=p,i=97,p=2,c=1,r=3,q=2", "")); + for row in 2..=3 { + source.vt_write(format!("row-{row}\r\n").as_bytes()); + } + source.vt_write(b"tail"); + let source_bottom = source.kitty_graphics_snapshot().unwrap(); + assert!( + source_bottom + .placements + .iter() + .any(|placement| placement.image_id == 96 && !placement.viewport_visible) + ); + assert!( + source_bottom.placements.iter().any(|placement| { + placement.image_id == 97 && placement.viewport_visible && placement.viewport_row < 0 + }), + "fixture placements: {:?}", + source_bottom.placements + ); + + let replay = source.vt_replay().unwrap(); + let mut mirror = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + mirror.resize(12, 4, 10, 20).unwrap(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + assert_eq!( + mirror + .kitty_graphics_snapshot() + .unwrap() + .placements + .iter() + .map(|placement| placement.image_id) + .collect::>(), + vec![96, 97], + "byte replay must retain fully and partially offscreen placements" + ); + + source.scroll_delta(-3); + mirror.scroll_delta(-3); + assert_eq!(visible_placement_signature(&mut mirror), visible_placement_signature(&mut source)); + + source.resize(12, 6, 10, 20).unwrap(); + mirror.resize(12, 6, 10, 20).unwrap(); + source.scroll_to_bottom(); + mirror.scroll_to_bottom(); + source.scroll_delta(-3); + mirror.scroll_delta(-3); + assert_eq!(visible_placement_signature(&mut mirror), visible_placement_signature(&mut source)); +} + +#[test] +fn replay_preserves_graphics_across_blank_only_scrollback_rows() { + let mut source = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + source.resize(12, 4, 10, 20).unwrap(); + source.vt_write(&kitty("a=t,t=d,f=24,i=98,s=1,v=1,q=2", "/wAA")); + for _ in 0..6 { + source.vt_write(b"\r\n"); + } + source.vt_write(&kitty("a=p,i=98,p=1,c=1,r=2,q=2", "")); + source.vt_write(b"\r\n\r\ntail"); + + let expected = source.kitty_graphics_snapshot().unwrap(); + assert_eq!(expected.placements.len(), 1); + let replay = source.vt_replay().unwrap(); + let mut mirror = Terminal::new(12, 4, 100, Callbacks::default()).unwrap(); + mirror.resize(12, 4, 10, 20).unwrap(); + mirror.vt_write(&replay.bytes); + mirror.restore_kitty_image_aliases(&replay.kitty_image_aliases).unwrap(); + + let actual = mirror.kitty_graphics_snapshot().unwrap(); + assert_eq!( + actual + .placements + .iter() + .map(|placement| ( + placement.image_id, + placement.viewport_row, + placement.viewport_visible, + )) + .collect::>(), + expected + .placements + .iter() + .map(|placement| ( + placement.image_id, + placement.viewport_row, + placement.viewport_visible, + )) + .collect::>() + ); + + source.scroll_delta(-4); + mirror.scroll_delta(-4); + assert_eq!(visible_placement_signature(&mut mirror), visible_placement_signature(&mut source)); +} + +#[test] +fn bounded_replay_reports_an_inflight_prefix_that_cannot_fit() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,i=98,s=1,v=2,m=1,q=2", "////")); + + assert_eq!(source.vt_replay_bounded(16), Err(Error::OutOfSpace)); +} + +#[test] +fn storage_limit_bounds_retained_pixel_data() { + let mut terminal = terminal(); + terminal.set_kitty_image_storage_limit(8).unwrap(); + assert_eq!(terminal.kitty_image_storage_limit().unwrap(), 8); + terminal.vt_write(&kitty("a=t,t=d,f=32,i=30,s=2,v=2,q=2", &encode_base64(&[255; 16]))); + let retained = terminal + .kitty_graphics_snapshot() + .unwrap() + .images + .iter() + .map(|image| image.data.len()) + .sum::(); + assert!(retained <= 8, "retained {retained} bytes despite an 8-byte limit"); +} + +#[test] +fn object_count_limits_bound_images_and_placements_across_reset() { + let mut terminal = terminal(); + assert_eq!(terminal.kitty_image_count_limit().unwrap(), 4_096); + assert_eq!(terminal.kitty_placement_count_limit().unwrap(), 16_384); + + terminal.set_kitty_image_count_limit(2).unwrap(); + terminal.set_kitty_placement_count_limit(2).unwrap(); + for image_id in 31..=33 { + terminal.vt_write(&kitty(&format!("a=t,t=d,f=24,i={image_id},s=1,v=1,q=2"), "/wAA")); + } + assert_eq!( + terminal + .kitty_graphics_snapshot() + .unwrap() + .images + .iter() + .map(|image| image.id) + .collect::>(), + vec![32, 33] + ); + + for placement_id in 1..=3 { + terminal.vt_write(&kitty(&format!("a=p,i=33,p={placement_id},c=1,r=1,q=2"), "")); + } + assert_eq!( + terminal + .kitty_graphics_snapshot() + .unwrap() + .placements + .iter() + .map(|placement| placement.placement_id) + .collect::>(), + vec![1, 2] + ); + + terminal.vt_write(b"\x1bc"); + assert_eq!(terminal.kitty_image_count_limit().unwrap(), 2); + assert_eq!(terminal.kitty_placement_count_limit().unwrap(), 2); +} + +#[test] +fn terminal_enables_direct_payloads_without_external_image_media() { + let terminal = terminal(); + assert_eq!(terminal.kitty_external_image_media_enabled().unwrap(), (false, false, false)); +} + +#[test] +fn render_frame_owns_the_same_graphics_snapshot_as_text() { + let mut terminal = terminal(); + terminal.vt_write(b"frame"); + terminal.vt_write(&kitty("a=T,t=d,f=24,i=40,p=1,s=1,v=1,q=2", "/wAA")); + let mut render = RenderState::new().unwrap(); + render.update(&mut terminal).unwrap(); + let frame = render.build_frame().unwrap(); + + let text = + frame.styled_rows().iter().flatten().map(|cell| cell.text.as_str()).collect::(); + assert!(text.contains("frame")); + assert_eq!(&*frame.kitty_graphics.image(40).unwrap().data, &[255, 0, 0]); + + terminal.vt_write(&kitty("a=d,d=A,q=2", "")); + assert_eq!(&*frame.kitty_graphics.image(40).unwrap().data, &[255, 0, 0]); +} + +#[test] +fn steady_state_render_snapshot_does_not_probe_or_copy_unplaced_images() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=t,t=d,f=24,i=41,s=1,v=1,q=2", "/wAA")); + assert!(terminal.kitty_graphics_snapshot().unwrap().image(41).is_some()); + + let mut render = RenderState::new().unwrap(); + render.update(&mut terminal).unwrap(); + let frame = render.build_frame().unwrap(); + assert!(frame.kitty_graphics.images.is_empty()); + assert!(frame.kitty_graphics.placements.is_empty()); +} + +#[test] +fn anonymous_or_reused_placement_ids_have_distinct_snapshot_keys() { + let mut terminal = terminal(); + terminal.vt_write(&kitty("a=t,t=d,f=24,i=50,s=1,v=1,q=2", "/wAA")); + terminal.vt_write(b"\x1b[1;1H"); + terminal.vt_write(&kitty("a=p,i=50,p=0,c=1,r=1,q=2", "")); + terminal.vt_write(b"\x1b[2;2H"); + terminal.vt_write(&kitty("a=p,i=50,p=0,c=1,r=1,q=2", "")); + + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + assert_eq!(snapshot.placements.len(), 2); + assert!(snapshot.placements.iter().all(|placement| placement.is_internal)); + assert_ne!(snapshot.placements[0].placement_id, snapshot.placements[1].placement_id); + assert_ne!(snapshot.placements[0].key, snapshot.placements[1].key); +} + +#[test] +fn replay_keeps_anonymous_placements_separate_from_later_explicit_ids() { + let mut source = terminal(); + source.vt_write(&kitty("a=t,t=d,f=24,i=51,s=1,v=1,q=2", "/wAA")); + source.vt_write(b"\x1b[1;1H"); + source.vt_write(&kitty("a=p,i=51,p=0,c=1,r=1,q=2", "")); + source.vt_write(b"\x1b[2;2H"); + source.vt_write(&kitty("a=p,i=51,p=0,c=1,r=1,q=2", "")); + + let replay = source.vt_replay_bytes().unwrap(); + let mut mirror = terminal(); + mirror.vt_write(&replay); + + let explicit = kitty("a=p,i=51,p=1,c=1,r=1,q=2", ""); + source.vt_write(b"\x1b[3;3H"); + mirror.vt_write(b"\x1b[3;3H"); + source.vt_write(&explicit); + mirror.vt_write(&explicit); + + assert_eq!(source.kitty_graphics_snapshot().unwrap().placements.len(), 3); + assert_eq!(mirror.kitty_graphics_snapshot().unwrap().placements.len(), 3); +} diff --git a/cmux-tui/crates/ghostty-vt/tests/png_compat.rs b/cmux-tui/crates/ghostty-vt/tests/png_compat.rs new file mode 100644 index 0000000000..96f621ecd3 --- /dev/null +++ b/cmux-tui/crates/ghostty-vt/tests/png_compat.rs @@ -0,0 +1,190 @@ +use base64::Engine as _; +use ghostty_vt::{Callbacks, KittyImageFormat, Terminal}; +use png::{BitDepth, ColorType, Compression, Encoder, FilterType}; + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 8, 100, Callbacks::default()).unwrap(); + terminal.resize(20, 8, 10, 20).unwrap(); + terminal +} + +fn encode_png( + width: u32, + height: u32, + color: ColorType, + depth: BitDepth, + pixels: &[u8], + palette: Option<&[u8]>, + transparency: Option<&[u8]>, +) -> Vec { + let mut encoded = Vec::new(); + { + let mut encoder = Encoder::new(&mut encoded, width, height); + encoder.set_color(color); + encoder.set_depth(depth); + encoder.set_compression(Compression::Fast); + encoder.set_filter(FilterType::NoFilter); + if let Some(palette) = palette { + encoder.set_palette(palette.to_vec()); + } + if let Some(transparency) = transparency { + encoder.set_trns(transparency.to_vec()); + } + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(pixels).unwrap(); + } + encoded +} + +fn kitty_png(image_id: u32, encoded: &[u8]) -> Vec { + let payload = base64::engine::general_purpose::STANDARD.encode(encoded); + format!("\x1b_Ga=t,t=d,f=100,i={image_id},q=2;{payload}\x1b\\").into_bytes() +} + +fn assert_image(terminal: &Terminal, image_id: u32, size: (u32, u32), expected_rgba: &[u8]) { + let snapshot = terminal.kitty_graphics_snapshot().unwrap(); + let image = snapshot.image(image_id).unwrap_or_else(|| panic!("missing image {image_id}")); + assert_eq!(image.format, KittyImageFormat::Rgba, "image {image_id}"); + assert_eq!((image.width, image.height), size, "image {image_id}"); + assert_eq!(&*image.data, expected_rgba, "image {image_id}"); +} + +fn chunk_offsets(encoded: &[u8], expected_kind: &[u8; 4]) -> (usize, usize) { + let mut offset = 8; + while offset + 12 <= encoded.len() { + let data_len = u32::from_be_bytes(encoded[offset..offset + 4].try_into().unwrap()) as usize; + let data_start = offset + 8; + let data_end = data_start.checked_add(data_len).expect("chunk length"); + let crc_end = data_end.checked_add(4).expect("chunk checksum"); + assert!(crc_end <= encoded.len(), "well-formed generated PNG"); + if &encoded[offset + 4..offset + 8] == expected_kind { + return (data_start, data_end); + } + offset = crc_end; + } + panic!("missing {} chunk", String::from_utf8_lossy(expected_kind)); +} + +#[test] +fn eight_bit_rgb_grayscale_and_grayscale_alpha_decode_to_rgba() { + let rgb = + encode_png(2, 1, ColorType::Rgb, BitDepth::Eight, &[1, 2, 3, 250, 251, 252], None, None); + let grayscale = + encode_png(3, 1, ColorType::Grayscale, BitDepth::Eight, &[0, 127, 255], None, None); + let grayscale_alpha = encode_png( + 2, + 1, + ColorType::GrayscaleAlpha, + BitDepth::Eight, + &[32, 64, 200, 128], + None, + None, + ); + let mut terminal = terminal(); + + terminal.vt_write(&kitty_png(1, &rgb)); + terminal.vt_write(&kitty_png(2, &grayscale)); + terminal.vt_write(&kitty_png(3, &grayscale_alpha)); + + assert_image(&terminal, 1, (2, 1), &[1, 2, 3, 255, 250, 251, 252, 255]); + assert_image(&terminal, 2, (3, 1), &[0, 0, 0, 255, 127, 127, 127, 255, 255, 255, 255, 255]); + assert_image(&terminal, 3, (2, 1), &[32, 32, 32, 64, 200, 200, 200, 128]); +} + +#[test] +fn indexed_palette_and_transparency_expand_to_rgba() { + let indexed = encode_png( + 3, + 1, + ColorType::Indexed, + BitDepth::Eight, + &[0, 1, 2], + Some(&[255, 0, 0, 0, 255, 0, 0, 0, 255]), + Some(&[255, 0, 128]), + ); + let transparent_rgb = encode_png( + 2, + 1, + ColorType::Rgb, + BitDepth::Eight, + &[255, 0, 0, 0, 0, 255], + None, + Some(&[0, 255, 0, 0, 0, 0]), + ); + let mut terminal = terminal(); + + terminal.vt_write(&kitty_png(10, &indexed)); + terminal.vt_write(&kitty_png(11, &transparent_rgb)); + + assert_image(&terminal, 10, (3, 1), &[255, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 128]); + assert_image(&terminal, 11, (2, 1), &[255, 0, 0, 0, 0, 0, 255, 255]); +} + +#[test] +fn sixteen_bit_samples_are_stripped_to_eight_bit_rgba() { + let rgb_16 = encode_png( + 1, + 1, + ColorType::Rgb, + BitDepth::Sixteen, + &[0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc], + None, + None, + ); + let mut terminal = terminal(); + + terminal.vt_write(&kitty_png(20, &rgb_16)); + + assert_image(&terminal, 20, (1, 1), &[0x12, 0x56, 0x9a, 255]); +} + +#[test] +fn truncated_idat_and_bad_checksum_are_rejected_before_parser_recovery() { + let valid = encode_png(2, 1, ColorType::Rgb, BitDepth::Eight, &[9, 8, 7, 6, 5, 4], None, None); + let (data_start, data_end) = chunk_offsets(&valid, b"IDAT"); + let mut truncated = valid.clone(); + truncated.truncate(data_start + (data_end - data_start).div_ceil(2)); + let mut bad_checksum = valid.clone(); + bad_checksum[data_end + 3] ^= 0x01; + let mut terminal = terminal(); + + terminal.vt_write(&kitty_png(30, &truncated)); + terminal.vt_write(&kitty_png(31, &bad_checksum)); + + let rejected = terminal.kitty_graphics_snapshot().unwrap(); + assert!(rejected.image(30).is_none(), "truncated IDAT must be rejected"); + assert!(rejected.image(31).is_none(), "bad IDAT checksum must be rejected"); + + terminal.vt_write(&kitty_png(32, &valid)); + assert_image(&terminal, 32, (2, 1), &[9, 8, 7, 255, 6, 5, 4, 255]); +} + +#[test] +fn valid_png_with_oversized_decoded_rgba_is_rejected_and_parser_recovers() { + const WIDTH: u32 = 2_501; + const HEIGHT: u32 = 1_000; + let oversized = encode_png( + WIDTH, + HEIGHT, + ColorType::Grayscale, + BitDepth::Eight, + &vec![0; WIDTH as usize * HEIGHT as usize], + None, + None, + ); + assert!( + oversized.len() < 100_000, + "fixture must be rejected for decoded size, not encoded payload size" + ); + let valid = encode_png(1, 1, ColorType::Rgb, BitDepth::Eight, &[4, 5, 6], None, None); + let mut terminal = terminal(); + + terminal.vt_write(&kitty_png(40, &oversized)); + assert!( + terminal.kitty_graphics_snapshot().unwrap().image(40).is_none(), + "decoded RGBA above the decoder limit must be rejected" + ); + + terminal.vt_write(&kitty_png(41, &valid)); + assert_image(&terminal, 41, (1, 1), &[4, 5, 6, 255]); +} diff --git a/cmux-tui/crates/ghostty-vt/tests/render_v7.rs b/cmux-tui/crates/ghostty-vt/tests/render_v7.rs index 3473b7b9b5..e1cba2cf50 100644 --- a/cmux-tui/crates/ghostty-vt/tests/render_v7.rs +++ b/cmux-tui/crates/ghostty-vt/tests/render_v7.rs @@ -1,4 +1,5 @@ use std::mem::size_of; +use std::sync::Arc; use ghostty_vt::{ ATTR_BLINK, ATTR_BOLD, ATTR_FAINT, ATTR_INVERSE, ATTR_INVISIBLE, ATTR_ITALIC, @@ -78,6 +79,26 @@ fn render_frame_shares_damage_and_preserves_walk_rows_path() { assert_eq!(tui_line, "B"); } +#[test] +fn text_only_frames_share_the_immutable_kitty_scene() { + let mut term = Terminal::new(12, 3, 1000, Callbacks::default()).unwrap(); + term.vt_write(b"\x1b_Ga=T,t=d,f=24,i=41,p=7,s=1,v=1,c=1,r=1,q=2;AAAA\x1b\\"); + let mut state = RenderState::new().unwrap(); + + state.update(&mut term).unwrap(); + let initial = state.build_frame().unwrap(); + assert_eq!(initial.kitty_graphics.images.len(), 1); + + term.vt_write(b"text"); + state.update(&mut term).unwrap(); + let text = state.build_frame().unwrap(); + + assert!( + Arc::ptr_eq(&initial.kitty_graphics, &text.kitty_graphics), + "text-only damage deep-copied the complete graphics scene" + ); +} + #[test] fn wide_cells_emit_each_glyph_once_and_keep_blank_gaps() { let mut term = Terminal::new(16, 2, 0, Callbacks::default()).unwrap(); diff --git a/cmux-tui/crates/ghostty-vt/tests/terminal.rs b/cmux-tui/crates/ghostty-vt/tests/terminal.rs index 921f5112b6..cc65213fd5 100644 --- a/cmux-tui/crates/ghostty-vt/tests/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/tests/terminal.rs @@ -329,8 +329,8 @@ fn vt_replay_restores_cursor_position_after_tabstops() { let expected = source.cursor_position().unwrap(); assert_eq!(expected, (16, 0)); - let full = source.vt_replay().unwrap(); - let bounded = source.vt_replay_bounded(8 * 1024 * 1024).unwrap(); + let full = source.vt_replay_bytes().unwrap(); + let bounded = source.vt_replay_bounded_bytes(8 * 1024 * 1024).unwrap(); for replay in [&full, &bounded] { let mut mirror = Terminal::new(104, 39, 0, Callbacks::default()).unwrap(); mirror.vt_write(replay); @@ -644,14 +644,21 @@ fn terminal_tracks_same_valued_osc_palette_overrides_and_resets() { #[test] fn vt_write_returns_the_exact_normalized_stream_across_split_utf8_and_c1_controls() { let mut term = Terminal::new(80, 2, 0, Callbacks::default()).unwrap(); - let chunks: &[&[u8]] = - &[b"\xc2", b"\x9dUTF8-continued ", b"\x9d4;9;#090909", b"\x9c", b"VISIBLE"]; + let chunks: &[&[u8]] = &[ + b"\xc2", + b"\x9fUTF8-continued ", + b"\x9fignored", + b"\x9c", + b"\x9d4;9;#090909", + b"\x9c", + b"VISIBLE", + ]; let mut emitted = Vec::new(); for chunk in chunks { emitted.extend_from_slice(term.vt_write_with_normalized(chunk).as_ref()); } - assert_eq!(emitted, b"\xc2\x9dUTF8-continued \x1b]4;9;#090909\x1b\\VISIBLE"); + assert_eq!(emitted, b"\xc2\x9fUTF8-continued \x1b_ignored\x1b\\\x1b]4;9;#090909\x1b\\VISIBLE"); assert!(term.palette_overridden(9)); assert!(term.viewport_text().unwrap().contains("VISIBLE")); } diff --git a/cmux-tui/docs/concepts.md b/cmux-tui/docs/concepts.md index 873a2e3957..8f772ce16c 100644 --- a/cmux-tui/docs/concepts.md +++ b/cmux-tui/docs/concepts.md @@ -52,6 +52,6 @@ Closing a pane closes all tabs in that pane. Closing a screen closes every pane ## PTY and Browser Surfaces -A PTY surface parses child-process output with libghostty-vt. Frontends render snapshots of that terminal state. Attach clients receive a VT replay first, then a base64 stream of subsequent PTY bytes, plus ordered resize frames when the surface geometry changes. +A PTY surface parses child-process output with libghostty-vt. Frontends render snapshots of that terminal state, including inline Kitty graphics with image pixels, placement geometry, cropping, transparency, and z-order. Attach clients receive a VT replay first, then a base64 stream of subsequent PTY bytes, plus ordered resize frames when the surface geometry changes. Kitty image storage, aliases, and placement anchors survive attach, remote mirroring, scrolling, and resize within the configured replay and transport byte limits; graphics beyond the replay budget may be omitted. A browser surface is a local Chrome/Chromium target controlled through the Chrome DevTools Protocol. The local TUI draws browser frames with kitty graphics and forwards keyboard, mouse, and wheel input over CDP. Protocol-v7 attach clients receive an initial `browser-state` event with the latest optional frame, followed by updated `browser-state` and base64 PNG `frame` events. diff --git a/cmux-tui/docs/getting-started.md b/cmux-tui/docs/getting-started.md index 368f29334d..6267522518 100644 --- a/cmux-tui/docs/getting-started.md +++ b/cmux-tui/docs/getting-started.md @@ -43,6 +43,8 @@ cargo run -p cmux-tui -- attach --session agents Detach from an attached TUI with prefix `d`. With default keys, that is `Ctrl-b d`. The server keeps running, and another `attach` reconnects to the same tree. PTY tabs attach with a Ghostty VT-state replay followed by a live output stream. +PTY programs may emit inline images with the Kitty graphics protocol. cmux-tui preserves those images across local, attached, and remote sessions when the outer terminal supports Kitty graphics, subject to the configured replay and transport byte limits; graphics beyond the replay budget may be omitted. + Attach one terminal without the sidebar, status bar, pane border, or other tabs: ```bash diff --git a/cmux-tui/frontends/web/src/App.tsx b/cmux-tui/frontends/web/src/App.tsx index dc3b89de05..e5432e449d 100644 --- a/cmux-tui/frontends/web/src/App.tsx +++ b/cmux-tui/frontends/web/src/App.tsx @@ -2,6 +2,7 @@ import { useReducer } from "react"; import "@xterm/xterm/css/xterm.css"; import { ConnectScreen } from "./components/ConnectScreen"; import { ClientsIndicator } from "./components/ClientsIndicator"; +import { RenderGraphicsBudgetProvider } from "./components/RenderGraphics"; import { Sidebar } from "./components/Sidebar"; import { StatusBar } from "./components/StatusBar"; import { TerminalPane } from "./components/TerminalPane"; @@ -12,6 +13,14 @@ import { t } from "./i18n"; import { drawerReducer } from "./lib/mobile"; export default function App() { + return ( + + + + ); +} + +function AppContent() { useVisualViewport(); const connection = useCmuxClient(); const [drawer, dispatchDrawer] = useReducer(drawerReducer, "closed"); diff --git a/cmux-tui/frontends/web/src/components/ByteTerminal.tsx b/cmux-tui/frontends/web/src/components/ByteTerminal.tsx index 01321fd456..3bf85b3f82 100644 --- a/cmux-tui/frontends/web/src/components/ByteTerminal.tsx +++ b/cmux-tui/frontends/web/src/components/ByteTerminal.tsx @@ -1,4 +1,4 @@ -import type { CmuxClient, Id } from "cmux/browser"; +import type { CmuxClient, Id } from "cmux/raw"; import { useAttachedTerminal } from "../hooks/useAttachedTerminal"; import { TerminalFrame } from "./TerminalFrame"; diff --git a/cmux-tui/frontends/web/src/components/ClientsIndicator.tsx b/cmux-tui/frontends/web/src/components/ClientsIndicator.tsx index d055d6c957..518dc5ab3f 100644 --- a/cmux-tui/frontends/web/src/components/ClientsIndicator.tsx +++ b/cmux-tui/frontends/web/src/components/ClientsIndicator.tsx @@ -1,5 +1,5 @@ import { useReducer, useRef, type MouseEvent } from "react"; -import type { ClientInfo, Id } from "cmux/browser"; +import type { ClientInfo, Id } from "cmux/raw"; import { t } from "../i18n"; import { contextMenuReducer } from "../lib/contextMenu"; import { MenuPopover } from "./ContextMenu"; diff --git a/cmux-tui/frontends/web/src/components/ConnectScreen.tsx b/cmux-tui/frontends/web/src/components/ConnectScreen.tsx index 43845d4e5a..7fe17137ab 100644 --- a/cmux-tui/frontends/web/src/components/ConnectScreen.tsx +++ b/cmux-tui/frontends/web/src/components/ConnectScreen.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react"; import { t } from "../i18n"; import type { ConnectionConfig } from "../hooks/useCmuxClient"; import { initialConnectionConfig, rememberWebSocketUrl } from "../lib/connectionDefaults"; -import type { PairingChallenge } from "cmux/browser"; +import type { PairingChallenge } from "cmux/raw"; interface ConnectScreenProps { connecting: boolean; diff --git a/cmux-tui/frontends/web/src/components/RenderGraphics.tsx b/cmux-tui/frontends/web/src/components/RenderGraphics.tsx new file mode 100644 index 0000000000..594bd8209a --- /dev/null +++ b/cmux-tui/frontends/web/src/components/RenderGraphics.tsx @@ -0,0 +1,617 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type CSSProperties, + type ReactNode, +} from "react"; +import type { RenderGraphicImage, RenderGraphicPlacement } from "cmux/raw"; +import { useDecodedRenderGraphicImages } from "../hooks/useDecodedRenderGraphicImages"; +import type { RenderGraphicsModel } from "../lib/renderModel"; +import { + planRenderGraphicPlacement, + RENDER_GRAPHIC_CANVAS_BACKING_BYTE_CAP, + RENDER_GRAPHIC_CANVAS_COUNT_CAP, + RENDER_GRAPHIC_DECODED_BYTE_CAP, + renderGraphicRgbaByteLength, + resolveRenderGraphicPlacementPlan, + type DecodedRenderGraphicImage, + type RenderGraphicPlacementPlan, + type ResolvedRenderGraphicPlacement, +} from "../lib/renderGraphics"; +import { RenderGraphicsDecodeScheduler } from "../lib/renderGraphicsDecodeScheduler"; + +interface RenderGraphicsProps { + backgroundChildren?: ReactNode; + children: ReactNode; + graphics?: RenderGraphicsModel; + plainChildren?: ReactNode; +} + +interface RenderGraphicCanvasProps { + decoded: DecodedRenderGraphicImage; + placement: ResolvedRenderGraphicPlacement; +} + +interface CandidatePriority { + z: number; + order: number; +} + +interface RenderGraphicCandidate extends CandidatePriority { + imageId: number; + placement: RenderGraphicPlacementPlan; + decodedBytes: number; +} + +interface GraphicsSelection { + placements: ReadonlySet; + images: ReadonlySet; +} + +interface RenderedPlacement { + decoded: DecodedRenderGraphicImage; + order: number; + placement: ResolvedRenderGraphicPlacement; +} + +interface ImageAdmissionMetadata { + decodedBytes: number; + id: number; + width: number; + height: number; +} + +const EMPTY_IMAGES: readonly RenderGraphicImage[] = []; +const EMPTY_PLACEMENTS: readonly RenderGraphicPlacement[] = []; +const EMPTY_SELECTION: GraphicsSelection = { + placements: new Set(), + images: new Set(), +}; + +function compareCandidates( + left: CandidatePriority, + right: CandidatePriority, +): number { + return compareCandidateValues(left.z, left.order, right.z, right.order); +} + +function compareCandidateValues( + leftZ: number, + leftOrder: number, + rightZ: number, + rightOrder: number, +): number { + return leftZ - rightZ || leftOrder - rightOrder; +} + +function candidateBackingBytesLowerBound(placement: RenderGraphicPlacement): number { + const width = placement.source_width; + const height = placement.source_height; + if (!Number.isSafeInteger(width) || width <= 0 + || !Number.isSafeInteger(height) || height <= 0) return 0; + const pixels = width * height; + const bytes = pixels * 4; + // Every valid placement plan uses this exact byte count. Returning zero for + // malformed or overflowing dimensions makes suffix pruning conservative. + return Number.isSafeInteger(pixels) && Number.isSafeInteger(bytes) ? bytes : 0; +} + +function heapPush( + heap: T[], + value: T, + comparePriority: (left: T, right: T) => number, +): void { + heap.push(value); + let index = heap.length - 1; + while (index > 0) { + const parent = Math.floor((index - 1) / 2); + if (comparePriority(heap[parent], heap[index]) >= 0) break; + [heap[parent], heap[index]] = [heap[index], heap[parent]]; + index = parent; + } +} + +function heapPop( + heap: T[], + comparePriority: (left: T, right: T) => number, +): T | undefined { + const root = heap[0]; + const tail = heap.pop(); + if (tail === undefined || heap.length === 0) return root; + heap[0] = tail; + let index = 0; + while (true) { + const left = index * 2 + 1; + const right = left + 1; + let next = index; + if (left < heap.length && comparePriority(heap[left], heap[next]) > 0) next = left; + if (right < heap.length && comparePriority(heap[right], heap[next]) > 0) next = right; + if (next === index) break; + [heap[index], heap[next]] = [heap[next], heap[index]]; + index = next; + } + return root; +} + +class RenderGraphicCandidateSource { + private readonly candidateCache: + (RenderGraphicCandidate | null | undefined)[]; + private readonly minimumBackingBytes: number[]; + private readonly placementIndexes: number[]; + + constructor( + private readonly placements: readonly RenderGraphicPlacement[], + private readonly images: ReadonlyMap, + ) { + this.placementIndexes = []; + for (const [order, placement] of placements.entries()) { + if (!placement.viewport_visible || !Number.isSafeInteger(placement.z) + || !images.has(placement.image_id)) continue; + this.placementIndexes.push(order); + } + this.placementIndexes.sort((left, right) => + -compareCandidateValues( + placements[left]!.z, + left, + placements[right]!.z, + right, + ) + ); + this.candidateCache = new Array(this.placementIndexes.length); + + // Store only bounded protocol indexes, not geometry plans. The one-time + // ordering avoids rescanning all placements for each 512-candidate page, + // while plans remain lazy for candidates the global budget considers. + this.minimumBackingBytes = new Array(this.placementIndexes.length); + let minimum = Number.POSITIVE_INFINITY; + for (let position = this.placementIndexes.length - 1; position >= 0; position -= 1) { + const placement = placements[this.placementIndexes[position]!]!; + minimum = Math.min(minimum, candidateBackingBytesLowerBound(placement)); + this.minimumBackingBytes[position] = minimum; + } + } + + canFitBackingFrom(position: number, availableBytes: number): boolean { + return (this.minimumBackingBytes[position] ?? Number.POSITIVE_INFINITY) <= availableBytes; + } + + candidateFrom( + startPosition: number, + rejectedImages: ReadonlySet, + ): + | { candidate: RenderGraphicCandidate; nextPosition: number } + | undefined { + for (let position = startPosition; position < this.placementIndexes.length; position += 1) { + const order = this.placementIndexes[position]!; + const rawPlacement = this.placements[order]!; + const image = this.images.get(rawPlacement.image_id); + if (image === undefined) continue; + if (rejectedImages.has(image.id)) continue; + let candidate = this.candidateCache[position]; + if (candidate === undefined) { + const placement = planRenderGraphicPlacement(image, rawPlacement); + candidate = placement === null + ? null + : { + imageId: image.id, + placement, + order, + z: rawPlacement.z, + decodedBytes: image.decodedBytes, + }; + this.candidateCache[position] = candidate; + } + if (candidate === null) continue; + return { + candidate, + nextPosition: position + 1, + }; + } + return undefined; + } +} + +interface OwnerCandidates { + order: number; + source: RenderGraphicCandidateSource; +} + +interface GlobalCandidateCursor { + owner: symbol; + ownerOrder: number; + candidate: RenderGraphicCandidate; + nextPosition: number; +} + +class GraphicsBudgetRegistry { + private readonly candidates = new Map(); + private readonly selections = new Map(); + private readonly listeners = new Map void>>(); + private readonly revisions = new Map(); + private readonly pendingRemovals = new Map(); + private nextOwnerOrder = 0; + + subscribe(owner: symbol, listener: () => void): () => void { + let listeners = this.listeners.get(owner); + if (listeners === undefined) { + listeners = new Set(); + this.listeners.set(owner, listeners); + } + listeners.add(listener); + return () => { + listeners?.delete(listener); + if (listeners?.size === 0) this.listeners.delete(owner); + }; + } + + snapshot(owner: symbol): number { + return this.revisions.get(owner) ?? 0; + } + + selected(owner: symbol): GraphicsSelection { + return this.selections.get(owner) ?? EMPTY_SELECTION; + } + + update(owner: symbol, source: RenderGraphicCandidateSource): void { + this.pendingRemovals.delete(owner); + const current = this.candidates.get(owner); + this.candidates.set(owner, { + order: current?.order ?? this.nextOwnerOrder++, + source, + }); + this.recalculate(); + } + + scheduleRemove(owner: symbol): void { + const token = Symbol("graphics-budget-removal"); + this.pendingRemovals.set(owner, token); + queueMicrotask(() => { + if (this.pendingRemovals.get(owner) !== token) return; + this.pendingRemovals.delete(owner); + if (!this.candidates.delete(owner)) return; + this.selections.delete(owner); + this.revisions.delete(owner); + this.recalculate(); + }); + } + + private recalculate(): void { + const nextPlacements = new Map>(); + const nextImages = new Map>(); + const rejectedImages = new Map>(); + for (const owner of this.candidates.keys()) { + nextPlacements.set(owner, new Set()); + nextImages.set(owner, new Set()); + rejectedImages.set(owner, new Set()); + } + const compareGlobal = ( + left: GlobalCandidateCursor, + right: GlobalCandidateCursor, + ) => compareCandidates(left.candidate, right.candidate) + || right.ownerOrder - left.ownerOrder; + const cursors: GlobalCandidateCursor[] = []; + for (const [owner, state] of this.candidates) { + const next = state.source.candidateFrom(0, rejectedImages.get(owner)!); + if (next !== undefined) { + heapPush(cursors, { + owner, + ownerOrder: state.order, + candidate: next.candidate, + nextPosition: next.nextPosition, + }, compareGlobal); + } + } + let admitted = 0; + let backingBytes = 0; + let decodedBytes = 0; + while (cursors.length > 0 && admitted < RENDER_GRAPHIC_CANVAS_COUNT_CAP) { + const { owner, ownerOrder, candidate, nextPosition } = heapPop(cursors, compareGlobal)!; + if (candidate.placement.backingBytes + <= RENDER_GRAPHIC_CANVAS_BACKING_BYTE_CAP - backingBytes) { + const images = nextImages.get(owner)!; + const imageId = candidate.imageId; + if (images.has(imageId) + || candidate.decodedBytes <= RENDER_GRAPHIC_DECODED_BYTE_CAP - decodedBytes) { + nextPlacements.get(owner)!.add(candidate); + if (!images.has(imageId)) { + images.add(imageId); + decodedBytes += candidate.decodedBytes; + } + backingBytes += candidate.placement.backingBytes; + admitted += 1; + } else { + rejectedImages.get(owner)!.add(imageId); + } + } + if (admitted >= RENDER_GRAPHIC_CANVAS_COUNT_CAP) continue; + const source = this.candidates.get(owner)?.source; + if (source === undefined + || !source.canFitBackingFrom( + nextPosition, + RENDER_GRAPHIC_CANVAS_BACKING_BYTE_CAP - backingBytes, + )) continue; + const next = source.candidateFrom(nextPosition, rejectedImages.get(owner)!); + if (next !== undefined) { + heapPush(cursors, { + owner, + ownerOrder, + candidate: next.candidate, + nextPosition: next.nextPosition, + }, compareGlobal); + } + } + + const next = new Map(); + for (const [owner, placements] of nextPlacements) { + next.set(owner, { placements, images: nextImages.get(owner)! }); + } + for (const [owner, selection] of next) { + const previous = this.selections.get(owner); + const changed = previous === undefined + || previous.placements.size !== selection.placements.size + || previous.images.size !== selection.images.size + || [...selection.placements].some((candidate) => + !previous.placements.has(candidate) + ) + || [...selection.images].some((imageId) => !previous.images.has(imageId)); + if (!changed) continue; + this.selections.set(owner, selection); + this.revisions.set(owner, (this.revisions.get(owner) ?? 0) + 1); + for (const listener of this.listeners.get(owner) ?? []) listener(); + } + } +} + +interface RenderGraphicsResources { + budget: GraphicsBudgetRegistry; + decoder: RenderGraphicsDecodeScheduler; + modelBudget: object; +} + +const GraphicsResourcesContext = createContext(null); +const defaultModelBudget = {}; + +function useDecoderLifetime(decoder: RenderGraphicsDecodeScheduler): void { + useEffect(() => { + decoder.retain(); + return () => decoder.scheduleDispose(); + }, [decoder]); +} + +export function RenderGraphicsBudgetProvider({ children }: { children: ReactNode }) { + const [resources] = useState(() => ({ + budget: new GraphicsBudgetRegistry(), + decoder: new RenderGraphicsDecodeScheduler(), + modelBudget: {}, + })); + useDecoderLifetime(resources.decoder); + return ( + + {children} + + ); +} + +function useGraphicsResources(): RenderGraphicsResources { + const resources = useContext(GraphicsResourcesContext); + if (resources === null) { + throw new Error("RenderGraphics requires RenderGraphicsBudgetProvider"); + } + return resources; +} + +export function useRenderGraphicsModelBudget(): object { + return useContext(GraphicsResourcesContext)?.modelBudget ?? defaultModelBudget; +} + +function useImageAdmissionMetadata( + images: readonly RenderGraphicImage[], +): ReadonlyMap { + const previous = useRef>(new Map()); + return useMemo(() => { + const metadata = new Map(); + for (const image of images) { + const decodedBytes = renderGraphicRgbaByteLength(image); + if (decodedBytes !== null) { + metadata.set(image.id, { + decodedBytes, + id: image.id, + width: image.width, + height: image.height, + }); + } + } + const retained = previous.current; + let unchanged = retained.size === metadata.size; + if (unchanged) { + for (const [id, candidate] of metadata) { + const current = retained.get(id); + if (current === undefined + || current.decodedBytes !== candidate.decodedBytes + || current.width !== candidate.width + || current.height !== candidate.height) { + unchanged = false; + break; + } + } + } + if (unchanged) return retained; + previous.current = metadata; + return metadata; + }, [images]); +} + +function RenderGraphicCanvas({ decoded, placement }: RenderGraphicCanvasProps) { + const canvasRef = useCallback((canvas: HTMLCanvasElement | null) => { + if (canvas === null || typeof ImageData === "undefined") return; + const context = canvas.getContext("2d"); + if (context === null) return; + const source = placement.source; + canvas.width = source.width; + canvas.height = source.height; + const pixels = new ImageData( + decoded.pixels, + decoded.image.width, + decoded.image.height, + ); + context.clearRect(0, 0, canvas.width, canvas.height); + context.putImageData( + pixels, + -source.x, + -source.y, + source.x, + source.y, + source.width, + source.height, + ); + return () => { + canvas.width = 0; + canvas.height = 0; + }; + }, [decoded, placement]); + + return ( +