aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-client/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-05-11 10:25:11 +0200
committerGitHub <noreply@github.com>2026-05-11 10:25:11 +0200
commit0f6cf0664e05feab497778fbdddbf80492c252f2 (patch)
tree2db990623a70baa826bd0de353202bab57753d73 /zellij-client/src
parentfix: deadlock with blocking panes (#5152) (diff)
downloadsl0ppij-0f6cf0664e05feab497778fbdddbf80492c252f2.tar.gz
sl0ppij-0f6cf0664e05feab497778fbdddbf80492c252f2.tar.xz
sl0ppij-0f6cf0664e05feab497778fbdddbf80492c252f2.zip
fix(webclient): handle host query responses for pixel dimensions and colors (#5156)
* fix(webclient): handle host query responses for pixel dimensions and colors * rustfmt * add pr
Diffstat (limited to 'zellij-client/src')
-rw-r--r--zellij-client/src/unit/terminal_loop_tests.rs5
-rw-r--r--zellij-client/src/web_client/control_message.rs9
-rw-r--r--zellij-client/src/web_client/host_query_seed.rs452
-rw-r--r--zellij-client/src/web_client/mod.rs1
-rw-r--r--zellij-client/src/web_client/server_listener.rs38
-rw-r--r--zellij-client/src/web_client/unit/web_client_tests.rs193
-rw-r--r--zellij-client/src/web_client/websocket_handlers.rs108
7 files changed, 798 insertions, 8 deletions
diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs
index 87890193..6dfe96bf 100644
--- a/zellij-client/src/unit/terminal_loop_tests.rs
+++ b/zellij-client/src/unit/terminal_loop_tests.rs
@@ -746,7 +746,10 @@ async fn test_control_message_handling() {
Message::Text(text) => {
let parsed: WebClientToWebServerControlMessage =
serde_json::from_str(&text).expect("Failed to parse");
- let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload;
+ let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload
+ else {
+ panic!("Expected TerminalResize, got: {:?}", parsed.payload);
+ };
assert_eq!(size, terminal_size);
},
_ => panic!("Expected Text message, got: {:?}", received),
diff --git a/zellij-client/src/web_client/control_message.rs b/zellij-client/src/web_client/control_message.rs
index 01960ef1..e6c46444 100644
--- a/zellij-client/src/web_client/control_message.rs
+++ b/zellij-client/src/web_client/control_message.rs
@@ -12,6 +12,15 @@ pub struct WebClientToWebServerControlMessage {
#[serde(tag = "type")]
pub enum WebClientToWebServerControlMessagePayload {
TerminalResize(Size),
+ TerminalMetrics(TerminalMetricsPayload),
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct TerminalMetricsPayload {
+ pub cell_pixel_width: usize,
+ pub cell_pixel_height: usize,
+ pub text_area_pixel_width: usize,
+ pub text_area_pixel_height: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
diff --git a/zellij-client/src/web_client/host_query_seed.rs b/zellij-client/src/web_client/host_query_seed.rs
new file mode 100644
index 00000000..8b505943
--- /dev/null
+++ b/zellij-client/src/web_client/host_query_seed.rs
@@ -0,0 +1,452 @@
+//! Builds the IPC messages that seed Screen's host-terminal-query cache
+//! with web-client data on attach (and on subsequent config reloads).
+//!
+//! The native client populates `Screen.terminal_emulator_colors`,
+//! `Screen.pixel_dimensions` and `Screen.terminal_emulator_color_codes`
+//! by forwarding what its real terminal reports. The web client has no
+//! "real terminal" — its display surface is xterm.js in the browser —
+//! so we synthesize the equivalent state from two sources:
+//!
+//! 1. The same `Config` that drives `SetConfigPayload`, which the
+//! browser ultimately renders with. fg/bg are guaranteed to match
+//! what xterm.js paints.
+//! 2. The canonical xterm-256 palette formula for indices 16-255 (not
+//! overridable in xterm.js's configurable theme, so the formula is
+//! authoritative).
+//!
+//! Pixel dimensions are not handled here — they require browser-side
+//! measurement and flow through the `TerminalMetrics` control message.
+
+use zellij_utils::{
+ data::PaletteColor,
+ input::{config::Config, options::Options},
+ ipc::{ClientToServerMsg, ColorRegister},
+};
+
+/// Build the seed messages to send to the server immediately after attach
+/// (and on every `SetConfig` update). May return an empty vec if no theme
+/// information is resolvable from the config — in which case the server's
+/// cache stays at its defaults and `synthesize_cached_reply` returns empty
+/// bytes, matching pre-existing behavior.
+pub fn build_host_query_seed_msgs(
+ config: &Config,
+ config_options: &Options,
+) -> Vec<ClientToServerMsg> {
+ let mut msgs = Vec::new();
+ let resolved = resolve_theme(config, config_options);
+
+ if let Some(fg) = resolved
+ .foreground
+ .as_ref()
+ .and_then(|s| css_rgb_to_xparse(s))
+ {
+ msgs.push(ClientToServerMsg::ForegroundColor { color: fg });
+ }
+ if let Some(bg) = resolved
+ .background
+ .as_ref()
+ .and_then(|s| css_rgb_to_xparse(s))
+ {
+ msgs.push(ClientToServerMsg::BackgroundColor { color: bg });
+ }
+
+ let registers = build_color_registers(&resolved);
+ if !registers.is_empty() {
+ msgs.push(ClientToServerMsg::ColorRegisters {
+ color_registers: registers,
+ });
+ }
+
+ msgs
+}
+
+#[derive(Default, Debug, Clone)]
+struct ResolvedTheme {
+ foreground: Option<String>,
+ background: Option<String>,
+ indexed: [Option<String>; 16],
+}
+
+/// Resolve the web-client theme using the same precedence as
+/// `SetConfigPayload::from(&Config)` so server-side synthesis matches
+/// the colors xterm.js paints in the browser.
+fn resolve_theme(config: &Config, config_options: &Options) -> ResolvedTheme {
+ let mut out = ResolvedTheme::default();
+
+ let palette = config.theme_config(config_options.theme.as_ref());
+ let web_client_theme = config.web_client.theme.as_ref();
+
+ out.foreground = web_client_theme
+ .and_then(|t| t.foreground.clone())
+ .or_else(|| palette.map(|p| p.text_unselected.base.as_rgb_str()));
+ out.background = web_client_theme
+ .and_then(|t| t.background.clone())
+ .or_else(|| palette.map(|p| p.text_unselected.background.as_rgb_str()));
+
+ if let Some(t) = web_client_theme {
+ out.indexed[0] = t.black.clone();
+ out.indexed[1] = t.red.clone();
+ out.indexed[2] = t.green.clone();
+ out.indexed[3] = t.yellow.clone();
+ out.indexed[4] = t.blue.clone();
+ out.indexed[5] = t.magenta.clone();
+ out.indexed[6] = t.cyan.clone();
+ out.indexed[7] = t.white.clone();
+ out.indexed[8] = t.bright_black.clone();
+ out.indexed[9] = t.bright_red.clone();
+ out.indexed[10] = t.bright_green.clone();
+ out.indexed[11] = t.bright_yellow.clone();
+ out.indexed[12] = t.bright_blue.clone();
+ out.indexed[13] = t.bright_magenta.clone();
+ out.indexed[14] = t.bright_cyan.clone();
+ out.indexed[15] = t.bright_white.clone();
+ }
+
+ out
+}
+
+fn build_color_registers(resolved: &ResolvedTheme) -> Vec<ColorRegister> {
+ let mut registers = Vec::with_capacity(256);
+
+ // Indices 0-15: only seed entries that have an explicit override in
+ // `web_client.theme.*`. Unseeded indices fall through to empty
+ // synthesis (existing semantics) — apps will use their own defaults.
+ for (i, slot) in resolved.indexed.iter().enumerate() {
+ if let Some(css) = slot {
+ if let Some(color) = css_rgb_to_xparse_color(css) {
+ registers.push(ColorRegister { index: i, color });
+ }
+ }
+ }
+
+ // Indices 16-255: canonical xterm-256 palette. xterm.js does not let
+ // these be overridden via its theme options, and Zellij's config does
+ // not expose them, so the formula is the source of truth.
+ for index in 16u8..=255 {
+ let (r, g, b) = xterm_256_rgb(index);
+ registers.push(ColorRegister {
+ index: index as usize,
+ color: rgb_to_xparse_color(r, g, b),
+ });
+ }
+
+ registers
+}
+
+/// Convert a CSS `rgb(R, G, B)` string (the format `PaletteColor::as_rgb_str`
+/// produces, which is what `SetConfigPayload` ships to xterm.js) into the
+/// `rgb:RRRR/GGGG/BBBB` form `xparse_color` accepts, prefixed for
+/// `ClientToServerMsg::{Foreground,Background}Color`'s consumer.
+fn css_rgb_to_xparse(css: &str) -> Option<String> {
+ css_rgb_to_xparse_color(css)
+}
+
+/// Same as `css_rgb_to_xparse`. Kept as a separate alias to make the
+/// difference between OSC 10/11 (xparse-format string going into screen's
+/// terminal_emulator_colors) and OSC 4 (color string going into
+/// terminal_emulator_color_codes) explicit at call sites — currently both
+/// consumers want the same `rgb:RRRR/GGGG/BBBB` shape.
+fn css_rgb_to_xparse_color(css: &str) -> Option<String> {
+ let PaletteColor::Rgb((r, g, b)) = PaletteColor::from_rgb_str(css) else {
+ return None;
+ };
+ Some(rgb_to_xparse_color(r, g, b))
+}
+
+fn rgb_to_xparse_color(r: u8, g: u8, b: u8) -> String {
+ format!(
+ "rgb:{:04x}/{:04x}/{:04x}",
+ (r as u16) * 0x0101,
+ (g as u16) * 0x0101,
+ (b as u16) * 0x0101,
+ )
+}
+
+/// Canonical xterm-256 palette for index 16..=255. Indices 0..=15 are
+/// intentionally not handled here — those are theme-dependent and seeded
+/// separately from `WebClientTheme` when overrides are present.
+fn xterm_256_rgb(index: u8) -> (u8, u8, u8) {
+ match index {
+ 0..=15 => (0, 0, 0), // not used; caller restricts to 16..=255
+ 16..=231 => {
+ let idx = (index - 16) as u32;
+ let level = |x: u32| if x == 0 { 0u8 } else { (55 + x * 40) as u8 };
+ (level(idx / 36), level((idx / 6) % 6), level(idx % 6))
+ },
+ 232..=255 => {
+ let v = 8u8 + 10 * (index - 232);
+ (v, v, v)
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use zellij_utils::input::web_client::{WebClientConfig, WebClientTheme};
+
+ fn config_with_web_theme(theme: WebClientTheme) -> Config {
+ let mut config = Config::default();
+ config.web_client = WebClientConfig {
+ theme: Some(theme),
+ ..WebClientConfig::default()
+ };
+ config
+ }
+
+ /// Locate the single `ColorRegister` matching `index` in the
+ /// `ColorRegisters` message produced by the seed builder.
+ fn find_register(msgs: &[ClientToServerMsg], index: usize) -> Option<String> {
+ for msg in msgs {
+ if let ClientToServerMsg::ColorRegisters { color_registers } = msg {
+ if let Some(reg) = color_registers.iter().find(|r| r.index == index) {
+ return Some(reg.color.clone());
+ }
+ }
+ }
+ None
+ }
+
+ fn fg_color(msgs: &[ClientToServerMsg]) -> Option<String> {
+ msgs.iter().find_map(|m| match m {
+ ClientToServerMsg::ForegroundColor { color } => Some(color.clone()),
+ _ => None,
+ })
+ }
+
+ fn bg_color(msgs: &[ClientToServerMsg]) -> Option<String> {
+ msgs.iter().find_map(|m| match m {
+ ClientToServerMsg::BackgroundColor { color } => Some(color.clone()),
+ _ => None,
+ })
+ }
+
+ fn registers_msg(msgs: &[ClientToServerMsg]) -> Option<usize> {
+ msgs.iter().find_map(|m| match m {
+ ClientToServerMsg::ColorRegisters { color_registers } => Some(color_registers.len()),
+ _ => None,
+ })
+ }
+
+ #[test]
+ fn xterm_256_first_cube_entry_is_black() {
+ assert_eq!(xterm_256_rgb(16), (0, 0, 0));
+ }
+
+ #[test]
+ fn xterm_256_last_cube_entry_is_white() {
+ // 5,5,5 — corners at maximal level
+ assert_eq!(xterm_256_rgb(231), (255, 255, 255));
+ }
+
+ #[test]
+ fn xterm_256_greyscale_endpoints() {
+ assert_eq!(xterm_256_rgb(232), (8, 8, 8));
+ assert_eq!(xterm_256_rgb(255), (238, 238, 238));
+ }
+
+ #[test]
+ fn cube_level_formula_matches_xterm_table() {
+ // index 17: r=0, g=0, b=1 => (0,0,95)
+ assert_eq!(xterm_256_rgb(17), (0, 0, 95));
+ // index 21: r=0, g=0, b=5 => (0,0,255)
+ assert_eq!(xterm_256_rgb(21), (0, 0, 255));
+ // index 196: 196-16=180; r=180/36=5, g=(180/6)%6=0, b=180%6=0 => (255,0,0)
+ assert_eq!(xterm_256_rgb(196), (255, 0, 0));
+ }
+
+ #[test]
+ fn rgb_to_xparse_round_trip() {
+ assert_eq!(rgb_to_xparse_color(0x12, 0x34, 0x56), "rgb:1212/3434/5656");
+ assert_eq!(rgb_to_xparse_color(0, 0, 0), "rgb:0000/0000/0000");
+ assert_eq!(rgb_to_xparse_color(255, 255, 255), "rgb:ffff/ffff/ffff");
+ }
+
+ #[test]
+ fn css_rgb_str_parses_through_palette_color() {
+ // PaletteColor::from_rgb_str accepts "rgb(R, G, B)".
+ assert_eq!(
+ css_rgb_to_xparse_color("rgb(255, 128, 0)").as_deref(),
+ Some("rgb:ffff/8080/0000"),
+ );
+ }
+
+ #[test]
+ fn invalid_css_rgb_yields_no_conversion() {
+ // PaletteColor::from_rgb_str returns the default variant for
+ // strings it cannot parse; the bridge must reject those rather
+ // than emit a garbage xparse string.
+ assert!(css_rgb_to_xparse_color("#ff8800").is_none());
+ assert!(css_rgb_to_xparse_color("not a color").is_none());
+ assert!(css_rgb_to_xparse_color("").is_none());
+ }
+
+ #[test]
+ fn seed_with_explicit_fg_bg_emits_both_messages() {
+ // User has set web_client.theme.foreground and .background
+ // explicitly — the seed builder must propagate them as
+ // ForegroundColor / BackgroundColor messages in xparse format.
+ let mut theme = WebClientTheme::default();
+ theme.foreground = Some("rgb(200, 200, 200)".to_string());
+ theme.background = Some("rgb(20, 20, 20)".to_string());
+ let config = config_with_web_theme(theme);
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ assert_eq!(fg_color(&msgs).as_deref(), Some("rgb:c8c8/c8c8/c8c8"));
+ assert_eq!(bg_color(&msgs).as_deref(), Some("rgb:1414/1414/1414"));
+ }
+
+ #[test]
+ fn seed_with_indexed_overrides_populates_those_registers() {
+ // Per-index overrides in WebClientTheme must show up in the
+ // ColorRegisters payload at the correct ANSI index.
+ let mut theme = WebClientTheme::default();
+ theme.red = Some("rgb(204, 0, 0)".to_string()); // index 1
+ theme.bright_blue = Some("rgb(50, 100, 200)".to_string()); // index 12
+ let config = config_with_web_theme(theme);
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ assert_eq!(
+ find_register(&msgs, 1).as_deref(),
+ Some("rgb:cccc/0000/0000"),
+ );
+ assert_eq!(
+ find_register(&msgs, 12).as_deref(),
+ Some("rgb:3232/6464/c8c8"),
+ );
+ }
+
+ #[test]
+ fn seed_omits_unset_low_indices() {
+ // Indices 0-15 without explicit overrides should be absent from
+ // the ColorRegisters payload — apps will fall back to their own
+ // defaults rather than reading a wrong value we made up.
+ let mut theme = WebClientTheme::default();
+ theme.red = Some("rgb(255, 0, 0)".to_string());
+ let config = config_with_web_theme(theme);
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ // Only `red` (index 1) was set.
+ assert_eq!(find_register(&msgs, 0), None);
+ assert_eq!(find_register(&msgs, 2), None);
+ assert!(find_register(&msgs, 1).is_some());
+ }
+
+ #[test]
+ fn seed_always_populates_extended_palette_16_to_255() {
+ // The 16-255 range is the canonical xterm-256 palette, which is
+ // independent of theme config. Verify both the count (240
+ // entries) and a couple of spot-check values.
+ let config = Config::default();
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ // Default config has no per-index overrides, so the ColorRegisters
+ // payload contains exactly 240 entries (16..=255).
+ assert_eq!(registers_msg(&msgs), Some(240));
+
+ // Index 196 is canonical pure red in the 6x6x6 cube.
+ assert_eq!(
+ find_register(&msgs, 196).as_deref(),
+ Some("rgb:ffff/0000/0000"),
+ );
+ // Index 232 is the first greyscale step (rgb 8/8/8).
+ assert_eq!(
+ find_register(&msgs, 232).as_deref(),
+ Some("rgb:0808/0808/0808"),
+ );
+ // Index 255 is the last greyscale step (rgb 238/238/238).
+ assert_eq!(
+ find_register(&msgs, 255).as_deref(),
+ Some("rgb:eeee/eeee/eeee"),
+ );
+ }
+
+ #[test]
+ fn seed_with_overrides_sums_to_240_plus_overrides() {
+ // Adding per-index overrides should grow the ColorRegisters
+ // payload — 240 for the extended range plus one entry per
+ // override in 0..=15.
+ let mut theme = WebClientTheme::default();
+ theme.red = Some("rgb(255, 0, 0)".to_string());
+ theme.green = Some("rgb(0, 255, 0)".to_string());
+ theme.blue = Some("rgb(0, 0, 255)".to_string());
+ let config = config_with_web_theme(theme);
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ assert_eq!(registers_msg(&msgs), Some(240 + 3));
+ }
+
+ #[test]
+ fn seed_skips_color_messages_when_no_theme_resolvable() {
+ // A Config whose theme resolution yields nothing (no
+ // web_client.theme, no main theme matching) should produce no
+ // ForegroundColor / BackgroundColor messages — the server's
+ // cache keeps its defaults instead of being clobbered.
+ let mut config = Config::default();
+ // Force theme resolution to fail by clearing the theme name as
+ // well. The exact internals depend on Config::theme_config(),
+ // so this test just asserts: whatever the resolver decides,
+ // the seed builder must not emit "Some(empty string)" garbage.
+ config.web_client = WebClientConfig::default();
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ // Whatever fg/bg the resolver picks, the bridge must produce
+ // a well-formed xparse string or skip the message entirely.
+ for msg in &msgs {
+ match msg {
+ ClientToServerMsg::ForegroundColor { color }
+ | ClientToServerMsg::BackgroundColor { color } => {
+ assert!(
+ color.starts_with("rgb:"),
+ "expected xparse-format string, got: {:?}",
+ color
+ );
+ },
+ _ => {},
+ }
+ }
+ }
+
+ #[test]
+ fn seed_color_registers_always_emit_xparse_format() {
+ // Every entry in the ColorRegisters payload must use the same
+ // `rgb:RRRR/GGGG/BBBB` shape that synthesize_cached_reply
+ // re-emits verbatim — no `rgb(R, G, B)` CSS strings leaking
+ // through.
+ let mut theme = WebClientTheme::default();
+ theme.black = Some("rgb(0, 0, 0)".to_string());
+ theme.bright_white = Some("rgb(255, 255, 255)".to_string());
+ let config = config_with_web_theme(theme);
+ let opts = Options::default();
+
+ let msgs = build_host_query_seed_msgs(&config, &opts);
+
+ let registers = msgs
+ .iter()
+ .find_map(|m| match m {
+ ClientToServerMsg::ColorRegisters { color_registers } => Some(color_registers),
+ _ => None,
+ })
+ .expect("ColorRegisters message missing");
+ for reg in registers {
+ assert!(
+ reg.color.starts_with("rgb:") && reg.color.len() == 4 + 4 + 1 + 4 + 1 + 4,
+ "register {} has malformed color string: {:?}",
+ reg.index,
+ reg.color
+ );
+ }
+ }
+}
diff --git a/zellij-client/src/web_client/mod.rs b/zellij-client/src/web_client/mod.rs
index ac9ca4ec..0ce4ed0f 100644
--- a/zellij-client/src/web_client/mod.rs
+++ b/zellij-client/src/web_client/mod.rs
@@ -2,6 +2,7 @@ pub mod control_message;
pub(crate) mod authentication;
mod connection_manager;
+mod host_query_seed;
mod http_handlers;
mod ipc_listener;
mod message_handlers;
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index c8f1c64d..a3b419ef 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -1,5 +1,6 @@
use crate::os_input_output::ClientOsApi;
use crate::web_client::control_message::{SetConfigPayload, WebServerToWebClientControlMessage};
+use crate::web_client::host_query_seed::build_host_query_seed_msgs;
use crate::web_client::session_management::{
build_initial_connection, create_first_message, create_ipc_pipe,
};
@@ -119,6 +120,17 @@ pub fn zellij_server_listener(
first_message,
);
+ // Seed the server's host-terminal-query cache with web
+ // client state derived from Config (fg/bg/palette).
+ // Without this, OSC 10/11/4 queries from apps
+ // stall on the 1s server forward timeout and then come
+ // back empty. Pixel dimensions are seeded separately
+ // via the TerminalMetrics control message once the
+ // browser has reported them.
+ for seed in build_host_query_seed_msgs(&config, &config_options) {
+ os_input.send_to_server(seed);
+ }
+
if let Some(tx) = attachment_complete_tx.take() {
let _ = tx.send(());
}
@@ -187,6 +199,11 @@ pub fn zellij_server_listener(
if let Some(config_file_path) = &config_file_path {
if let Ok(new_config) = Config::from_path(&config_file_path, Some(config.clone())) {
+ // Re-seed host-query cache for this client
+ // so OSC 10/11/4 replies follow the new theme.
+ for seed in build_host_query_seed_msgs(&new_config, &config_options) {
+ os_input.send_to_server(seed);
+ }
let set_config_payload = SetConfigPayload::from(&new_config);
let client_ids: Vec<String> = {
@@ -233,11 +250,22 @@ pub fn zellij_server_listener(
// Subscribe-only messages — not relevant for web clients
Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {},
Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {},
- Some(ServerToClientMsg::ForwardQueryToHost { .. }) => {
- // Web clients do not currently participate in
- // host-terminal query forwarding — the browser
- // side has no stdin writer wired for the
- // barrier-based protocol. Silently drop.
+ Some(ServerToClientMsg::ForwardQueryToHost { token, .. }) => {
+ // Reply immediately with empty reply_bytes.
+ // This is the existing convention that signals
+ // "no host reply available — please synthesize
+ // from cached state". The server's
+ // synthesize_cached_reply path will use the
+ // pixel dimensions and colors we have already
+ // seeded from the browser/config, returning a
+ // real answer rather than waiting for the
+ // 1000ms forward timeout.
+ os_input.send_to_server(
+ ClientToServerMsg::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ },
+ );
},
None => {
if unknown_message_count >= 1000 {
diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs
index c2b80948..2fa82cd2 100644
--- a/zellij-client/src/web_client/unit/web_client_tests.rs
+++ b/zellij-client/src/web_client/unit/web_client_tests.rs
@@ -464,6 +464,199 @@ mod web_client_tests {
#[tokio::test]
#[serial]
+ async fn test_terminal_metrics_translates_to_pixel_dimensions() {
+ // Simulates a browser sending a TerminalMetrics control message
+ // (the payload our websockets.js sendTerminalMetrics() helper
+ // produces) and verifies the web server translates it into
+ // ClientToServerMsg::TerminalPixelDimensions with field-for-field
+ // accuracy. This guards the wire-format contract end-to-end
+ // through serde + the match arm in websocket_handlers.rs.
+ let _ = delete_db();
+
+ let test_token_name = "test_token_terminal_metrics";
+ let read_only = false;
+ let (auth_token, _) = create_token(Some(test_token_name.to_string()), read_only)
+ .expect("Failed to create test token");
+
+ let session_manager = Arc::new(MockSessionManager::new());
+ let client_os_api_factory = Arc::new(MockClientOsApiFactory::new());
+ let factory_for_verification = client_os_api_factory.clone();
+
+ let config = Config::default();
+ let options = Options::default();
+
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let port = addr.port();
+
+ let temp_config_path = std::env::temp_dir().join("test_config.kdl");
+ let server_handle = tokio::spawn(async move {
+ serve_web_client(
+ config,
+ options,
+ Some(temp_config_path),
+ listener,
+ None,
+ Some(session_manager),
+ Some(client_os_api_factory),
+ addr.ip(),
+ port,
+ )
+ .await;
+ });
+
+ wait_for_server(port, Duration::from_secs(5))
+ .await
+ .expect("Server failed to start");
+
+ let login_url = format!("http://127.0.0.1:{}/command/login", port);
+ let login_payload = serde_json::json!({
+ "auth_token": auth_token,
+ "remember_me": true,
+ });
+ let login_response = timeout(
+ Duration::from_secs(5),
+ tokio::task::spawn_blocking(move || {
+ isahc::Request::post(&login_url)
+ .header("Content-Type", "application/json")
+ .body(login_payload.to_string())
+ .unwrap()
+ .send()
+ }),
+ )
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert!(login_response.status().is_success());
+
+ let set_cookie_header = login_response.headers().get("set-cookie");
+ let cookie_value = set_cookie_header.unwrap().to_str().unwrap();
+ let session_token = cookie_value
+ .split(';')
+ .next()
+ .and_then(|part| part.split('=').nth(1))
+ .unwrap();
+
+ let session_url = format!("http://127.0.0.1:{}/session", port);
+ let mut client_response = timeout(
+ Duration::from_secs(5),
+ tokio::task::spawn_blocking({
+ let session_token = session_token.to_string();
+ move || {
+ isahc::Request::post(&session_url)
+ .header("Cookie", format!("session_token={}", session_token))
+ .header("Content-Type", "application/json")
+ .body("{}")
+ .unwrap()
+ .send()
+ }
+ }),
+ )
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert!(client_response.status().is_success());
+
+ let client_data: serde_json::Value =
+ serde_json::from_str(&client_response.text().unwrap()).unwrap();
+ let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
+
+ // Open the control WebSocket and consume the initial SetConfig.
+ let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
+ let (control_ws, _) = timeout(
+ Duration::from_secs(5),
+ connect_async_with_cookie(&control_ws_url, session_token),
+ )
+ .await
+ .expect("Control WebSocket connection timed out")
+ .expect("Failed to connect to control WebSocket");
+ let (mut control_sink, mut control_stream) = control_ws.split();
+ let _ = timeout(Duration::from_secs(2), control_stream.next())
+ .await
+ .expect("Timeout waiting for initial control message");
+
+ // The control channel only registers the client_id after the
+ // first inbound message. Send a TerminalResize first to mirror
+ // what the browser does at startup, then the TerminalMetrics.
+ let resize_msg = WebClientToWebServerControlMessage {
+ web_client_id: web_client_id.clone(),
+ payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
+ rows: 24,
+ cols: 80,
+ }),
+ };
+ control_sink
+ .send(Message::Text(
+ serde_json::to_string(&resize_msg).unwrap().into(),
+ ))
+ .await
+ .expect("Failed to send TerminalResize");
+
+ // Send the actual TerminalMetrics payload — this is what the
+ // browser-side sendTerminalMetrics() helper writes onto the wire.
+ // Hand-construct the JSON to lock in the exact field names the
+ // browser uses, rather than going through the serde Serialize
+ // impl (which would mask any rename mismatch).
+ let metrics_json = serde_json::json!({
+ "web_client_id": web_client_id,
+ "payload": {
+ "type": "TerminalMetrics",
+ "cell_pixel_width": 9,
+ "cell_pixel_height": 18,
+ "text_area_pixel_width": 720,
+ "text_area_pixel_height": 432,
+ },
+ });
+ control_sink
+ .send(Message::Text(metrics_json.to_string().into()))
+ .await
+ .expect("Failed to send TerminalMetrics");
+
+ // Give the server a moment to process both messages.
+ tokio::time::sleep(Duration::from_millis(500)).await;
+
+ // Inspect the captured ClientToServerMsg traffic on the mock OS
+ // API for this web client.
+ let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
+ let mut found_pixel_dims: Option<ClientToServerMsg> = None;
+ for (_, mock_api) in mock_apis.iter() {
+ for msg in mock_api.get_sent_messages() {
+ if matches!(msg, ClientToServerMsg::TerminalPixelDimensions { .. }) {
+ found_pixel_dims = Some(msg);
+ break;
+ }
+ }
+ }
+ let pixel_dims = found_pixel_dims
+ .expect("TerminalMetrics did not reach the server as TerminalPixelDimensions");
+ match pixel_dims {
+ ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
+ let cell = pixel_dimensions
+ .character_cell_size
+ .expect("character_cell_size missing");
+ let area = pixel_dimensions
+ .text_area_size
+ .expect("text_area_size missing");
+ assert_eq!(cell.width, 9);
+ assert_eq!(cell.height, 18);
+ assert_eq!(area.width, 720);
+ assert_eq!(area.height, 432);
+ },
+ other => panic!("unexpected msg variant: {:?}", other),
+ }
+
+ drop(mock_apis);
+ let _ = control_sink.close().await;
+ server_handle.abort();
+
+ revoke_token(test_token_name).expect("Failed to revoke test token");
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+
+ #[tokio::test]
+ #[serial]
async fn test_unauthorized_access_without_session() {
let _ = delete_db();
diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs
index 86b9dead..969911cc 100644
--- a/zellij-client/src/web_client/websocket_handlers.rs
+++ b/zellij-client/src/web_client/websocket_handlers.rs
@@ -1,6 +1,6 @@
use crate::web_client::authentication::SessionTokenHash;
use crate::web_client::control_message::{
- SetConfigPayload, WebClientToWebServerControlMessage,
+ SetConfigPayload, TerminalMetricsPayload, WebClientToWebServerControlMessage,
WebClientToWebServerControlMessagePayload, WebServerToWebClientControlMessage,
};
use crate::web_client::message_handlers::{
@@ -19,7 +19,11 @@ use axum::{
use futures::StreamExt;
use std::sync::{atomic::AtomicBool, Arc};
use tokio_util::sync::CancellationToken;
-use zellij_utils::{input::mouse::MouseEvent, ipc::ClientToServerMsg};
+use zellij_utils::{
+ input::mouse::MouseEvent,
+ ipc::{ClientToServerMsg, PixelDimensions},
+ pane_size::SizeInPixels,
+};
pub async fn ws_handler_control(
ws: WebSocketUpgrade,
@@ -74,6 +78,9 @@ async fn handle_ws_control(
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
ClientToServerMsg::TerminalResize { new_size: size }
},
+ WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
+ terminal_metrics_to_ipc(metrics)
+ },
};
let _ = client_connection.send_to_server(client_msg);
@@ -314,3 +321,100 @@ async fn handle_ws_terminal(
}
os_input.send_to_server(ClientToServerMsg::ClientExited);
}
+
+fn terminal_metrics_to_ipc(metrics: TerminalMetricsPayload) -> ClientToServerMsg {
+ ClientToServerMsg::TerminalPixelDimensions {
+ pixel_dimensions: PixelDimensions {
+ text_area_size: Some(SizeInPixels {
+ width: metrics.text_area_pixel_width,
+ height: metrics.text_area_pixel_height,
+ }),
+ character_cell_size: Some(SizeInPixels {
+ width: metrics.cell_pixel_width,
+ height: metrics.cell_pixel_height,
+ }),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn terminal_metrics_to_ipc_preserves_all_dimensions() {
+ let metrics = TerminalMetricsPayload {
+ cell_pixel_width: 9,
+ cell_pixel_height: 18,
+ text_area_pixel_width: 80 * 9,
+ text_area_pixel_height: 24 * 18,
+ };
+ let msg = terminal_metrics_to_ipc(metrics);
+ match msg {
+ ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
+ let cell = pixel_dimensions
+ .character_cell_size
+ .expect("cell size missing");
+ let area = pixel_dimensions
+ .text_area_size
+ .expect("text area size missing");
+ assert_eq!(cell.width, 9);
+ assert_eq!(cell.height, 18);
+ assert_eq!(area.width, 720);
+ assert_eq!(area.height, 432);
+ },
+ other => panic!("expected TerminalPixelDimensions, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn terminal_metrics_round_trips_through_json_payload() {
+ // The browser sends this message as JSON over the control
+ // socket. Verify that the on-wire shape deserializes into the
+ // variant we route into terminal_metrics_to_ipc.
+ let raw = serde_json::json!({
+ "web_client_id": "abc",
+ "payload": {
+ "type": "TerminalMetrics",
+ "cell_pixel_width": 7,
+ "cell_pixel_height": 14,
+ "text_area_pixel_width": 560,
+ "text_area_pixel_height": 336,
+ }
+ });
+ let parsed: WebClientToWebServerControlMessage =
+ serde_json::from_value(raw).expect("parse");
+ let metrics = match parsed.payload {
+ WebClientToWebServerControlMessagePayload::TerminalMetrics(m) => m,
+ other => panic!("expected TerminalMetrics, got {:?}", other),
+ };
+ assert_eq!(metrics.cell_pixel_width, 7);
+ assert_eq!(metrics.cell_pixel_height, 14);
+ assert_eq!(metrics.text_area_pixel_width, 560);
+ assert_eq!(metrics.text_area_pixel_height, 336);
+ }
+
+ #[test]
+ fn terminal_resize_still_deserializes_after_adding_variant() {
+ // Regression guard for the new enum variant: the existing
+ // TerminalResize wire shape must continue to parse unchanged
+ // (no `type` rename, no required-field changes).
+ let raw = serde_json::json!({
+ "web_client_id": "abc",
+ "payload": {
+ "type": "TerminalResize",
+ "rows": 24,
+ "cols": 80,
+ }
+ });
+ let parsed: WebClientToWebServerControlMessage =
+ serde_json::from_value(raw).expect("parse");
+ match parsed.payload {
+ WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
+ assert_eq!(size.rows, 24);
+ assert_eq!(size.cols, 80);
+ },
+ other => panic!("expected TerminalResize, got {:?}", other),
+ }
+ }
+}