aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-04-28 17:14:59 +0200
committerGitHub <noreply@github.com>2026-04-28 17:14:59 +0200
commit227142dd567ee72bcc137c116bc1ce2b818975e7 (patch)
tree8958a994dddb80bf20ce1e6bf2694687736b0b19 /zellij-server/src
parentupgrade rustls-webpki (#5106) (diff)
downloadsl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.tar.gz
sl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.tar.xz
sl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.zip
Support CSI2031 (dark/light theme switching) + remove STDIN cache to avoid stale color queries (#5105)
* initial phase 1 implementation * various fies * some fixes * fix fragmented stdin cases * some fixes * renaming * guard against old clients * implement csi 2031 * allow toggling theme with actions * rustfmt * fix: finalize trailing characters in ansi parser as well * add pr
Diffstat (limited to 'zellij-server/src')
-rw-r--r--zellij-server/src/host_query.rs180
-rw-r--r--zellij-server/src/lib.rs292
-rw-r--r--zellij-server/src/panes/grid.rs302
-rw-r--r--zellij-server/src/panes/terminal_pane.rs8
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs477
-rw-r--r--zellij-server/src/route.rs52
-rw-r--r--zellij-server/src/screen.rs570
-rw-r--r--zellij-server/src/tab/mod.rs27
-rw-r--r--zellij-server/src/unit/screen_tests.rs870
9 files changed, 2603 insertions, 175 deletions
diff --git a/zellij-server/src/host_query.rs b/zellij-server/src/host_query.rs
new file mode 100644
index 00000000..cf35c9f4
--- /dev/null
+++ b/zellij-server/src/host_query.rs
@@ -0,0 +1,180 @@
+//! Classified representation of the host-terminal queries Zellij
+//! forwards on behalf of apps running inside panes.
+//!
+//! The grid dispatcher already parses incoming OSC / CSI sequences
+//! via `vte`; rather than stashing the raw bytes and re-parsing them
+//! at the reply-synthesis stage, we capture the classification at
+//! intercept time into a [`HostQuery`]. The pipeline from Grid →
+//! Tab → Screen carries this enum; Screen's cache-fallback synthesis
+//! matches on the variants directly (no byte-level regex). When we
+//! have to actually send bytes on the wire (server → client → host
+//! terminal), [`HostQuery::to_query_bytes`] re-derives them from the
+//! enum.
+
+/// The two OSC string terminators in common use. Apps that phrase
+/// their query with one expect the reply to mirror it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OscTerminator {
+ /// `ESC \` (String Terminator, xterm-style).
+ St,
+ /// `BEL` (`\x07`).
+ Bel,
+}
+
+impl OscTerminator {
+ pub fn from_bell_terminated(bell_terminated: bool) -> Self {
+ if bell_terminated {
+ OscTerminator::Bel
+ } else {
+ OscTerminator::St
+ }
+ }
+
+ pub fn as_bytes(self) -> &'static [u8] {
+ match self {
+ OscTerminator::St => b"\x1b\\",
+ OscTerminator::Bel => b"\x07",
+ }
+ }
+}
+
+/// A whitelisted host-terminal query. Variants correspond one-to-one
+/// with the entries Grid intercepts and pushes on
+/// `pending_forwarded_queries`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum HostQuery {
+ /// `CSI 14 t` — text-area pixel dimensions.
+ TextAreaPixelSize,
+ /// `CSI 16 t` — character-cell pixel dimensions.
+ CharacterCellPixelSize,
+ /// `OSC 10 ; ? <term>` — default foreground colour.
+ DefaultForeground { terminator: OscTerminator },
+ /// `OSC 11 ; ? <term>` — default background colour.
+ DefaultBackground { terminator: OscTerminator },
+ /// `OSC 4 ; N ; ? <term>` — palette register `N`.
+ PaletteRegister {
+ index: u8,
+ terminator: OscTerminator,
+ },
+ /// `CSI ? 996 n` — query the host terminal's color-palette theme
+ /// mode (light or dark). NOT forwarded to the host. Zellij has
+ /// already queried the host once at startup (via the client's
+ /// `\e[?996n` write) and tracks unsolicited DSR 997 updates while
+ /// `\e[?2031h` is enabled, so it can answer this from cache. The
+ /// Screen handler short-circuits this variant: rather than writing
+ /// to the wire it synthesises `\e[?997;{0|1|2}n` directly into the
+ /// originating pane's pty.
+ ColorPaletteMode,
+}
+
+impl HostQuery {
+ /// Re-serialize this query as the byte sequence the host terminal
+ /// expects. Used by the client when it writes the query to its
+ /// stdout, and by tests that assert on wire shape.
+ pub fn to_query_bytes(&self) -> Vec<u8> {
+ match self {
+ HostQuery::TextAreaPixelSize => b"\x1b[14t".to_vec(),
+ HostQuery::CharacterCellPixelSize => b"\x1b[16t".to_vec(),
+ HostQuery::DefaultForeground { terminator } => {
+ let mut v = b"\x1b]10;?".to_vec();
+ v.extend_from_slice(terminator.as_bytes());
+ v
+ },
+ HostQuery::DefaultBackground { terminator } => {
+ let mut v = b"\x1b]11;?".to_vec();
+ v.extend_from_slice(terminator.as_bytes());
+ v
+ },
+ HostQuery::PaletteRegister { index, terminator } => {
+ let mut v = format!("\x1b]4;{};?", index).into_bytes();
+ v.extend_from_slice(terminator.as_bytes());
+ v
+ },
+ // `ColorPaletteMode` is answered locally by Zellij and never
+ // sent on the wire. Returning empty bytes keeps the call
+ // total without dirtying the wire format. Callers that
+ // bypass `Screen::forward_host_query` for this variant
+ // shouldn't be calling `to_query_bytes` on it at all.
+ HostQuery::ColorPaletteMode => Vec::new(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn text_area_pixel_size_serializes_as_csi_14t() {
+ assert_eq!(HostQuery::TextAreaPixelSize.to_query_bytes(), b"\x1b[14t");
+ }
+
+ #[test]
+ fn character_cell_pixel_size_serializes_as_csi_16t() {
+ assert_eq!(
+ HostQuery::CharacterCellPixelSize.to_query_bytes(),
+ b"\x1b[16t"
+ );
+ }
+
+ #[test]
+ fn default_foreground_mirrors_terminator() {
+ assert_eq!(
+ HostQuery::DefaultForeground {
+ terminator: OscTerminator::St,
+ }
+ .to_query_bytes(),
+ b"\x1b]10;?\x1b\\",
+ );
+ assert_eq!(
+ HostQuery::DefaultForeground {
+ terminator: OscTerminator::Bel,
+ }
+ .to_query_bytes(),
+ b"\x1b]10;?\x07",
+ );
+ }
+
+ #[test]
+ fn default_background_serializes_correctly() {
+ assert_eq!(
+ HostQuery::DefaultBackground {
+ terminator: OscTerminator::St,
+ }
+ .to_query_bytes(),
+ b"\x1b]11;?\x1b\\",
+ );
+ }
+
+ #[test]
+ fn palette_register_carries_index_and_terminator() {
+ assert_eq!(
+ HostQuery::PaletteRegister {
+ index: 5,
+ terminator: OscTerminator::Bel,
+ }
+ .to_query_bytes(),
+ b"\x1b]4;5;?\x07",
+ );
+ assert_eq!(
+ HostQuery::PaletteRegister {
+ index: 255,
+ terminator: OscTerminator::St,
+ }
+ .to_query_bytes(),
+ b"\x1b]4;255;?\x1b\\",
+ );
+ }
+
+ #[test]
+ fn from_bell_terminated_maps_bool_to_variant() {
+ assert_eq!(
+ OscTerminator::from_bell_terminated(true),
+ OscTerminator::Bel
+ );
+ assert_eq!(
+ OscTerminator::from_bell_terminated(false),
+ OscTerminator::St
+ );
+ }
+}
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 1f2b4d14..dea778f4 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -5,6 +5,7 @@ mod os_input_output_unix;
#[path = "os_input_output_windows.rs"]
mod os_input_output_windows;
+pub mod host_query;
pub mod os_input_output;
pub mod output;
pub mod panes;
@@ -132,6 +133,10 @@ pub enum ServerInstruction {
WebServerStarted(String), // String -> base_url
FailedToStartWebServer(String),
ClearMouseHelpText(ClientId),
+ /// Relay a forwarded-query dispatch from Screen to the server main
+ /// loop. The main loop writes `ServerToClientMsg::ForwardQueryToHost`
+ /// to any connected regular client.
+ ForwardQueryToHost(u32, Vec<u8>),
}
impl From<&ServerInstruction> for ServerContext {
@@ -180,6 +185,7 @@ impl From<&ServerInstruction> for ServerContext {
ServerContext::SendWebClientsForbidden
},
ServerInstruction::ClearMouseHelpText(..) => ServerContext::ClearMouseHelpText,
+ ServerInstruction::ForwardQueryToHost(..) => ServerContext::ForwardQueryToHost,
}
}
}
@@ -377,6 +383,28 @@ impl SessionMetaData {
..Default::default()
})
});
+ let host_theme_dark = new_config
+ .options
+ .theme_dark
+ .as_ref()
+ .and_then(|name| new_config.theme_config(Some(name)));
+ let host_theme_light = new_config
+ .options
+ .theme_light
+ .as_ref()
+ .and_then(|name| new_config.theme_config(Some(name)));
+ if new_config.options.theme_dark.is_some() && host_theme_dark.is_none() {
+ log::warn!(
+ "theme_dark='{}' not found in themes; auto-theme switch disabled for dark.",
+ new_config.options.theme_dark.as_deref().unwrap_or("?")
+ );
+ }
+ if new_config.options.theme_light.is_some() && host_theme_light.is_none() {
+ log::warn!(
+ "theme_light='{}' not found in themes; auto-theme switch disabled for light.",
+ new_config.options.theme_light.as_deref().unwrap_or("?")
+ );
+ }
self.senders
.send_to_screen(ScreenInstruction::Reconfigure {
client_id,
@@ -388,6 +416,8 @@ impl SessionMetaData {
theme: new_config
.theme_config(new_config.options.theme.as_ref())
.unwrap_or_else(|| default_palette().into()),
+ host_theme_dark,
+ host_theme_light,
simplified_ui: new_config.options.simplified_ui.unwrap_or(false),
default_shell: new_config.options.default_shell,
pane_frames: new_config.options.pane_frames.unwrap_or(true),
@@ -464,10 +494,43 @@ impl Drop for SessionMetaData {
}
}
+/// Remove a client from session state and synthesize empty
+/// `ForwardedReplyFromHost` instructions for any host-query forwards
+/// that had been dispatched to it. Without this, a client that goes
+/// away while holding an in-flight forward leaves `Screen`'s
+/// `forward_in_flight` flag stuck — the flag is only released by
+/// replies, and no reply will ever come.
+fn remove_client_and_flush_forwards(
+ client_id: ClientId,
+ os_input: &mut Box<dyn ServerOsApi>,
+ session_state: &Arc<RwLock<SessionState>>,
+ session_data: &Arc<RwLock<Option<SessionMetaData>>>,
+) {
+ let _ = os_input.remove_client(client_id);
+ let stuck_tokens = session_state.write().unwrap().remove_client(client_id);
+ if stuck_tokens.is_empty() {
+ return;
+ }
+ if let Some(session) = session_data.read().unwrap().as_ref() {
+ for token in stuck_tokens {
+ let _ = session
+ .senders
+ .send_to_screen(ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ });
+ }
+ }
+}
+
macro_rules! remove_client {
- ($client_id:expr, $os_input:expr, $session_state:expr) => {
- $os_input.remove_client($client_id).unwrap();
- $session_state.write().unwrap().remove_client($client_id);
+ ($client_id:expr, $os_input:expr, $session_state:expr, $session_data:expr) => {
+ $crate::remove_client_and_flush_forwards(
+ $client_id,
+ &mut $os_input,
+ &$session_state,
+ &$session_data,
+ );
};
}
@@ -479,7 +542,7 @@ macro_rules! remove_watcher {
}
macro_rules! send_to_client {
- ($client_id:expr, $os_input:expr, $msg:expr, $session_state:expr) => {
+ ($client_id:expr, $os_input:expr, $msg:expr, $session_state:expr, $session_data:expr) => {
let send_to_client_res = $os_input.send_to_client($client_id, $msg);
if let Err(e) = send_to_client_res {
// Try to recover the message
@@ -497,7 +560,7 @@ macro_rules! send_to_client {
// Log it so it isn't lost
Err::<(), _>(e).context(context).non_fatal();
// failed to send to client, remove it
- remove_client!($client_id, $os_input, $session_state);
+ remove_client!($client_id, $os_input, $session_state, $session_data);
}
};
}
@@ -508,6 +571,13 @@ pub(crate) struct SessionState {
pipes: HashMap<String, ClientId>, // String => pipe_id
watchers: HashMap<ClientId, bool>, // watcher clients (read-only observers) bool -> is_web_client
last_active_client: Option<ClientId>, // last client that sent a Key message
+ /// Host-query forward tokens that have been dispatched to a
+ /// specific client and are waiting for a reply. Used to clean up
+ /// when that client disconnects (or when there's no client to
+ /// dispatch to in the first place) — each stuck token gets an
+ /// empty synthetic reply so `Screen`'s `forward_in_flight` slot
+ /// releases and the queued forwards keep moving.
+ forwards_in_flight: HashMap<u32, ClientId>,
}
impl SessionState {
@@ -517,6 +587,7 @@ impl SessionState {
pipes: HashMap::new(),
watchers: HashMap::new(),
last_active_client: None,
+ forwards_in_flight: HashMap::new(),
}
}
pub fn new_client(&mut self) -> ClientId {
@@ -541,10 +612,25 @@ impl SessionState {
pub fn associate_pipe_with_client(&mut self, pipe_id: String, client_id: ClientId) {
self.pipes.insert(pipe_id, client_id);
}
- pub fn remove_client(&mut self, client_id: ClientId) {
+ /// Remove a client and return any host-query tokens that had
+ /// been dispatched to this client and were still awaiting a
+ /// reply. Callers must synthesize an empty reply for each token
+ /// (via `ScreenInstruction::ForwardedReplyFromHost`) so
+ /// `Screen`'s in-flight slot releases and any queued forwards
+ /// can proceed.
+ pub fn remove_client(&mut self, client_id: ClientId) -> Vec<u32> {
self.clients.remove(&client_id);
self.pipes.retain(|_p_id, c_id| c_id != &client_id);
self.clear_last_active_client(client_id);
+ let stuck: Vec<u32> = self
+ .forwards_in_flight
+ .iter()
+ .filter_map(|(token, owner)| (*owner == client_id).then_some(*token))
+ .collect();
+ for token in &stuck {
+ self.forwards_in_flight.remove(token);
+ }
+ stuck
}
pub fn set_client_size(&mut self, client_id: ClientId, size: Size) {
self.clients
@@ -648,6 +734,96 @@ impl SessionState {
self.last_active_client = None;
}
}
+ pub fn mark_forward_in_flight(&mut self, token: u32, client_id: ClientId) {
+ self.forwards_in_flight.insert(token, client_id);
+ }
+ pub fn clear_forward_in_flight(&mut self, token: u32) {
+ self.forwards_in_flight.remove(&token);
+ }
+ /// Client to route a host-query forward to. Prefers whichever
+ /// client most recently interacted with the session; falls back
+ /// to any currently-connected non-watcher client. Returns `None`
+ /// only when no regular client is connected.
+ pub fn pick_forward_target(&self) -> Option<ClientId> {
+ if let Some(candidate) = self.last_active_client {
+ if self.clients.contains_key(&candidate) {
+ return Some(candidate);
+ }
+ }
+ self.clients.keys().copied().next()
+ }
+}
+
+#[cfg(test)]
+mod session_state_tests {
+ use super::*;
+
+ fn with_client(id: ClientId) -> SessionState {
+ let mut s = SessionState::new();
+ s.clients.insert(id, None);
+ s
+ }
+
+ #[test]
+ fn pick_forward_target_prefers_last_active_when_still_connected() {
+ let mut s = SessionState::new();
+ s.clients.insert(1, None);
+ s.clients.insert(2, None);
+ s.set_last_active_client(2);
+ assert_eq!(s.pick_forward_target(), Some(2));
+ }
+
+ #[test]
+ fn pick_forward_target_falls_back_when_last_active_disconnected() {
+ let mut s = SessionState::new();
+ s.clients.insert(1, None);
+ s.clients.insert(2, None);
+ // Client 3 was last active but has since disconnected — not in
+ // `clients` map anymore. Must fall through to any connected
+ // client rather than returning None.
+ s.last_active_client = Some(3);
+ let picked = s
+ .pick_forward_target()
+ .expect("some client still connected");
+ assert!(picked == 1 || picked == 2);
+ }
+
+ #[test]
+ fn pick_forward_target_none_when_no_clients() {
+ let s = SessionState::new();
+ assert_eq!(s.pick_forward_target(), None);
+ }
+
+ #[test]
+ fn remove_client_returns_stuck_forward_tokens() {
+ let mut s = with_client(1);
+ s.mark_forward_in_flight(10, 1);
+ s.mark_forward_in_flight(11, 1);
+ // A forward dispatched to a *different* client — must not be
+ // returned when we remove client 1.
+ s.clients.insert(2, None);
+ s.mark_forward_in_flight(12, 2);
+
+ let mut stuck = s.remove_client(1);
+ stuck.sort_unstable();
+ assert_eq!(stuck, vec![10, 11]);
+ assert_eq!(s.forwards_in_flight.get(&12), Some(&2));
+ }
+
+ #[test]
+ fn remove_client_returns_empty_when_no_forwards_in_flight() {
+ let mut s = with_client(1);
+ assert!(s.remove_client(1).is_empty());
+ }
+
+ #[test]
+ fn clear_forward_in_flight_removes_entry() {
+ let mut s = with_client(1);
+ s.mark_forward_in_flight(42, 1);
+ s.clear_forward_in_flight(42);
+ // After clear, removing the client yields no stuck tokens.
+ assert!(s.remove_client(1).is_empty());
+ }
}
pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
@@ -1048,7 +1224,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_id,
os_input,
ServerToClientMsg::UnblockInputThread,
- session_state
+ session_state,
+ session_data
);
}
},
@@ -1062,7 +1239,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
ServerToClientMsg::UnblockCliPipeInput {
pipe_name: pipe_name.clone()
},
- session_state
+ session_state,
+ session_data
);
},
None => {
@@ -1075,7 +1253,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
ServerToClientMsg::UnblockCliPipeInput {
pipe_name: pipe_name.clone()
},
- session_state
+ session_state,
+ session_data
);
}
},
@@ -1092,7 +1271,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
pipe_name: pipe_name.clone(),
output: output.clone()
},
- session_state
+ session_state,
+ session_data
);
},
None => {
@@ -1106,7 +1286,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
pipe_name: pipe_name.clone(),
output: output.clone()
},
- session_state
+ session_state,
+ session_data
);
}
},
@@ -1136,7 +1317,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
os_input.remove_client(client_id).unwrap();
} else {
// Handle regular client removal
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
drop(completion_tx); // prevent deadlock with route thread
if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size()
{
@@ -1176,7 +1357,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.collect();
// these are just the pipes
for client_id in client_ids_to_cleanup {
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
let watcher_client_ids: Vec<ClientId> =
@@ -1211,7 +1392,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
os_input.remove_client(client_id).unwrap();
} else {
// Handle regular client removal
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size()
{
session_data
@@ -1248,7 +1429,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::WebClientsForbidden,
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() {
session_data
.write()
@@ -1269,7 +1450,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::Normal,
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
break;
},
@@ -1289,7 +1470,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::KickedByHost,
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
},
ServerInstruction::DetachSession(client_ids, completion_tx) => {
@@ -1300,7 +1481,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::Normal,
},
);
- remove_client!(*client_id, os_input, session_state);
+ remove_client!(*client_id, os_input, session_state, session_data);
}
drop(completion_tx); // we do this here explicitly to signal that the clients have
// already disconnected and to prevent a deadlock below caused
@@ -1348,7 +1529,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
ServerToClientMsg::Render {
content: client_render_instruction.clone()
},
- session_state
+ session_state,
+ session_data
);
}
} else {
@@ -1360,7 +1542,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::Normal,
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
// Also disconnect all watchers
@@ -1378,7 +1560,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::Normal,
},
);
- remove_client!(watcher_id, os_input, session_state);
+ remove_client!(watcher_id, os_input, session_state, session_data);
}
break;
}
@@ -1392,13 +1574,13 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::Error(backtrace.clone()),
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
break;
},
ServerInstruction::ConnStatus(client_id) => {
let _ = os_input.send_to_client(client_id, ServerToClientMsg::Connected);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
},
ServerInstruction::Log(
lines_to_log,
@@ -1412,7 +1594,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
ServerToClientMsg::Log {
lines: lines_to_log
},
- session_state
+ session_state,
+ session_data
);
},
ServerInstruction::LogError(
@@ -1427,7 +1610,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
ServerToClientMsg::LogError {
lines: lines_to_log
},
- session_state
+ session_state,
+ session_data
);
},
ServerInstruction::SwitchSession(mut connect_to_session, client_id, completion_tx) => {
@@ -1453,9 +1637,10 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_id,
os_input,
ServerToClientMsg::SwitchSession { connect_to_session },
- session_state
+ session_state,
+ session_data
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
drop(completion_tx); // do not deadlock with route thread
if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size()
@@ -1551,7 +1736,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_id,
os_input,
ServerToClientMsg::ConfigFileUpdated,
- session_state
+ session_state,
+ session_data
);
}
},
@@ -1593,7 +1779,8 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_id,
os_input,
ServerToClientMsg::StartWebServer,
- session_state
+ session_state,
+ session_data
);
} else {
// TODO: test this
@@ -1644,7 +1831,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
exit_reason: ExitReason::WebClientsForbidden,
},
);
- remove_client!(client_id, os_input, session_state);
+ remove_client!(client_id, os_input, session_state, session_data);
}
let web_watcher_client_ids: Vec<ClientId> = session_state
.read()
@@ -1707,6 +1894,51 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.send_to_screen(ScreenInstruction::ClearMouseHelpText(client_id))
.unwrap();
},
+ ServerInstruction::ForwardQueryToHost(token, query_bytes) => {
+ // Pick a regular (non-watcher) client to carry the
+ // forward. Preference is the most recently active
+ // client (whichever last sent input); falls back to
+ // any connected client. When the host terminals of
+ // attached clients differ, the recently-active one
+ // is the best proxy for "what the user is currently
+ // looking at".
+ let target_client_id = {
+ let mut session = session_state.write().unwrap();
+ let picked = session.pick_forward_target();
+ if let Some(cid) = picked {
+ session.mark_forward_in_flight(token, cid);
+ }
+ picked
+ };
+ if let Some(client_id) = target_client_id {
+ send_to_client!(
+ client_id,
+ os_input,
+ ServerToClientMsg::ForwardQueryToHost { token, query_bytes },
+ session_state,
+ session_data
+ );
+ } else {
+ // No client to ask — synthesize an empty reply
+ // so `Screen`'s in-flight slot releases. Without
+ // this, a detached session (apps still running)
+ // accumulates forwards in `forward_queue` with
+ // no way for them to drain until someone
+ // reattaches.
+ log::warn!(
+ "No connected client to forward host query (token={}); returning empty reply",
+ token
+ );
+ if let Some(session) = session_data.read().unwrap().as_ref() {
+ let _ = session.senders.send_to_screen(
+ ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ },
+ );
+ }
+ }
+ },
}
}
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index c3e5a588..ee468e7b 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -5,7 +5,9 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::rc::Rc;
use unicode_width::UnicodeWidthChar;
-use zellij_utils::data::{HighlightLayer, HighlightStyle, RegexHighlight, Style};
+use zellij_utils::data::{
+ HighlightLayer, HighlightStyle, HostTerminalThemeMode, RegexHighlight, Style,
+};
use zellij_utils::errors::prelude::*;
use std::{
@@ -601,7 +603,9 @@ pub struct Grid {
scroll_region: (usize, usize),
active_charset: CharsetIndex,
preceding_char: Option<TerminalCharacter>,
+ #[allow(dead_code)]
terminal_emulator_colors: Rc<RefCell<Palette>>,
+ #[allow(dead_code)]
terminal_emulator_color_codes: Rc<RefCell<HashMap<usize, String>>>,
pub(crate) output_buffer: OutputBuffer,
title_stack: Vec<String>,
@@ -630,12 +634,24 @@ pub struct Grid {
pub mouse_mode: MouseMode,
pub mouse_tracking: MouseTracking,
pub focus_event_tracking: bool,
+ /// Has the app in this pane subscribed to host color-palette theme
+ /// notifications via `CSI ? 2031 h`? When true, host-emitted DSR 997
+ /// notifications (received by the client and forwarded as
+ /// `ScreenInstruction::HostTerminalThemeChanged`) are pushed onto
+ /// `pending_messages_to_pty` for this pane.
+ pub color_palette_notification_enabled: bool,
pub search_results: SearchResult,
pub pending_clipboard_update: Option<String>,
pub pending_osc7_cwd: Option<std::path::PathBuf>,
/// Pending desktop notifications: (payload, terminator)
/// Payload is the semicolon-joined params after "99", terminator is "\x07" or "\x1b\\"
pub pending_desktop_notifications: Vec<(String, String)>,
+ /// Whitelisted host-terminal queries intercepted from the app running
+ /// in this pane (CSI 14t / 16t pixel-dim queries, OSC 10;? / 11;? /
+ /// 4;N;? color queries). Each entry is the raw byte sequence that
+ /// Zellij should forward to the host terminal; the host's reply is
+ /// later routed back to this pane's pty.
+ pub pending_forwarded_queries: Vec<crate::host_query::HostQuery>,
ui_component_bytes: Option<Vec<u8>>,
style: Style,
debug: bool,
@@ -647,8 +663,15 @@ pub struct Grid {
// disabled by user config?
click: Click,
hyperlink_tracker: HyperlinkTracker,
- pub pane_default_fg: Option<AnsiCode>,
- pub pane_default_bg: Option<AnsiCode>,
+ /// Pane-scoped override for the default foreground colour. Narrow
+ /// to literal RGB by construction — the setters below refuse
+ /// palette-indexed / named variants silently, so this field can be
+ /// converted to an `OSC 10` reply without fallibility and the
+ /// render path can wrap it unconditionally.
+ pub pane_default_fg: Option<(u8, u8, u8)>,
+ /// Pane-scoped override for the default background colour. Same
+ /// invariant as `pane_default_fg`.
+ pub pane_default_bg: Option<(u8, u8, u8)>,
pub plugin_highlights: HashMap<u32, Vec<(String, CompiledHighlight)>>,
// key: plugin_id (u32), inner vec: (pattern, compiled) pairs
pub hover_position: Option<Position>, // pane-relative cursor cell; None when outside pane
@@ -657,27 +680,51 @@ pub struct Grid {
impl Grid {
pub fn set_pane_default_colors(&mut self, fg: Option<String>, bg: Option<String>) {
- self.pane_default_fg = fg.as_ref().and_then(|s| xparse_color(s.as_bytes()));
- self.pane_default_bg = bg.as_ref().and_then(|s| xparse_color(s.as_bytes()));
+ // Parse inputs; anything that isn't literal RGB (palette
+ // index / named colour / parse failure) is silently dropped so
+ // the invariant on `pane_default_{fg,bg}` is preserved.
+ self.pane_default_fg = fg
+ .as_ref()
+ .and_then(|s| xparse_color(s.as_bytes()))
+ .and_then(rgb_of_ansi_code);
+ self.pane_default_bg = bg
+ .as_ref()
+ .and_then(|s| xparse_color(s.as_bytes()))
+ .and_then(rgb_of_ansi_code);
self.output_buffer.update_all_lines();
}
pub fn get_pane_default_color_strings(&self) -> (Option<String>, Option<String>) {
(
- self.pane_default_fg.and_then(ansi_code_to_color_string),
- self.pane_default_bg.and_then(ansi_code_to_color_string),
+ self.pane_default_fg.map(rgb_to_hex_string),
+ self.pane_default_bg.map(rgb_to_hex_string),
)
}
}
-fn ansi_code_to_color_string(code: AnsiCode) -> Option<String> {
+/// Extract the RGB triple from an `AnsiCode`, or `None` for any
+/// other variant. Used at the ingress points that populate
+/// `Grid::pane_default_{fg,bg}` to keep those fields narrow to
+/// literal RGB.
+fn rgb_of_ansi_code(code: AnsiCode) -> Option<(u8, u8, u8)> {
match code {
- AnsiCode::RgbCode((r, g, b)) => Some(format!("#{:02x}{:02x}{:02x}", r, g, b)),
- AnsiCode::ColorIndex(idx) => Some(format!("{}", idx)),
- AnsiCode::NamedColor(named) => Some(format!("{:?}", named).to_lowercase()),
+ AnsiCode::RgbCode(rgb) => Some(rgb),
_ => None,
}
}
+fn rgb_to_hex_string((r, g, b): (u8, u8, u8)) -> String {
+ format!("#{:02x}{:02x}{:02x}", r, g, b)
+}
+
+/// Format an RGB triple as the body of an OSC 10/11 reply —
+/// `rgb:RRRR/GGGG/BBBB` per xterm's ctlseqs, where each 8-bit
+/// component is widened to the 16-bit hex form by repetition
+/// (`0xAB` → `0xABAB`).
+fn osc_color_reply_body((r, g, b): (u8, u8, u8)) -> String {
+ let expand = |c: u8| (c as u16) * 0x0101;
+ format!("rgb:{:04x}/{:04x}/{:04x}", expand(r), expand(g), expand(b))
+}
+
/// A compiled highlight entry for one plugin/pattern combination.
#[derive(Clone)]
pub struct CompiledHighlight {
@@ -916,12 +963,14 @@ impl Grid {
mouse_mode: MouseMode::default(),
mouse_tracking: MouseTracking::default(),
focus_event_tracking: false,
+ color_palette_notification_enabled: false,
character_cell_size,
search_results: Default::default(),
sixel_grid,
pending_clipboard_update: None,
pending_osc7_cwd: None,
pending_desktop_notifications: Vec::new(),
+ pending_forwarded_queries: Vec::new(),
ui_component_bytes: None,
style,
debug,
@@ -1521,7 +1570,15 @@ impl Grid {
for character_chunk in character_chunks.iter_mut() {
character_chunk.add_changed_colors(self.changed_colors);
- character_chunk.add_pane_defaults(self.pane_default_fg, self.pane_default_bg);
+ // CharacterChunk still carries `Option<AnsiCode>` because
+ // `adjust_styles_for_custom_bg_fg` assigns the value into
+ // `CharacterStyles.{foreground,background}` (themselves
+ // `Option<AnsiCode>`). Re-wrap the narrow RGB at the
+ // boundary so the downstream pipeline stays uniform.
+ character_chunk.add_pane_defaults(
+ self.pane_default_fg.map(AnsiCode::RgbCode),
+ self.pane_default_bg.map(AnsiCode::RgbCode),
+ );
if self
.selection
.contains_row(character_chunk.y.saturating_sub(content_y))
@@ -3229,6 +3286,20 @@ impl Grid {
pub fn reset_cursor_position(&mut self) {
self.cursor = Cursor::new(0, 0, self.styled_underlines);
}
+ /// Queue a CSI ?997;{1|2}n DSR notification of host color-palette
+ /// theme mode onto this grid's pty-write queue. No-op when the app
+ /// has not opted in via `CSI ? 2031 h`.
+ pub fn push_color_palette_dsr(&mut self, mode: HostTerminalThemeMode) {
+ if !self.color_palette_notification_enabled {
+ return;
+ }
+ let code = match mode {
+ HostTerminalThemeMode::Dark => 1,
+ HostTerminalThemeMode::Light => 2,
+ };
+ self.pending_messages_to_pty
+ .push(format!("\u{1b}[?997;{}n", code).into_bytes());
+ }
pub fn lock_renders(&mut self) {
self.lock_renders = true;
}
@@ -3489,15 +3560,20 @@ impl Perform for Grid {
return;
} else if chunk.get(1).as_ref().and_then(|c| c.get(0)) == Some(&b'?') {
if let Some(index) = index {
- let terminal_emulator_color_codes =
- self.terminal_emulator_color_codes.borrow();
- let color = terminal_emulator_color_codes.get(&(index as usize));
- if let Some(color) = color {
- let color_response_message =
- format!("\u{1b}]4;{};{}{}", index, color, terminator);
- self.pending_messages_to_pty
- .push(color_response_message.as_bytes().to_vec());
- }
+ // Forward palette-register queries to the
+ // host — apps want the actual host palette,
+ // not Zellij's cached copy. (Zellij's cache
+ // still auto-refreshes via double-dispatch
+ // when the host's reply comes back.)
+ self.pending_forwarded_queries.push(
+ crate::host_query::HostQuery::PaletteRegister {
+ index,
+ terminator:
+ crate::host_query::OscTerminator::from_bell_terminated(
+ bell_terminated,
+ ),
+ },
+ );
}
}
}
@@ -3519,40 +3595,69 @@ impl Perform for Grid {
if let Some(mut dynamic_code) = parse_number(params[0]) {
for param in &params[1..] {
if param == b"?" {
- // Query: respond with pane default if set, else terminal default
- let color_rgb = if dynamic_code == 10 {
- match self.pane_default_fg {
- Some(AnsiCode::RgbCode((r, g, b))) => Some((r, g, b)),
- _ => match self.terminal_emulator_colors.borrow().fg {
- PaletteColor::Rgb((r, g, b)) => Some((r, g, b)),
- _ => None,
+ // If this pane has a local override for
+ // the channel being queried (set via
+ // `zellij action set-pane-color` or via
+ // a prior OSC 10;<rgb> / 11;<rgb> from
+ // inside the pane), answer with that
+ // override directly instead of
+ // forwarding to the host. Apps inside
+ // the pane must see the colors Zellij
+ // is actually rendering for them, not
+ // the host terminal's background.
+ let local_override = match dynamic_code {
+ 10 => self.pane_default_fg,
+ 11 => self.pane_default_bg,
+ _ => None,
+ };
+ if let Some(rgb) = local_override {
+ let reply = format!(
+ "\u{1b}]{};{}{}",
+ dynamic_code,
+ osc_color_reply_body(rgb),
+ terminator
+ );
+ self.pending_messages_to_pty.push(reply.as_bytes().to_vec());
+ } else {
+ // No local override — forward to
+ // the host so the app observes the
+ // terminal's actual color. Zellij's
+ // cached copy is refreshed via the
+ // double-dispatch on the reply.
+ let term =
+ crate::host_query::OscTerminator::from_bell_terminated(
+ bell_terminated,
+ );
+ let query = match dynamic_code {
+ 10 => crate::host_query::HostQuery::DefaultForeground {
+ terminator: term,
},
- }
- } else if dynamic_code == 11 {
- match self.pane_default_bg {
- Some(AnsiCode::RgbCode((r, g, b))) => Some((r, g, b)),
- _ => match self.terminal_emulator_colors.borrow().bg {
- PaletteColor::Rgb((r, g, b)) => Some((r, g, b)),
- _ => None,
+ 11 => crate::host_query::HostQuery::DefaultBackground {
+ terminator: term,
},
- }
- } else {
- None
- };
- let (r, g, b) = color_rgb.unwrap_or((0, 0, 0));
- let color_response_message = format!(
- "\u{1b}]{};rgb:{1:02x}{1:02x}/{2:02x}{2:02x}/{3:02x}{3:02x}{4}",
- dynamic_code, r, g, b, terminator
- );
- self.pending_messages_to_pty
- .push(color_response_message.as_bytes().to_vec());
+ _ => {
+ // Out-of-range dynamic_code
+ // (shouldn't happen since
+ // the outer match pins it to
+ // 10 or 11): skip.
+ dynamic_code += 1;
+ continue;
+ },
+ };
+ self.pending_forwarded_queries.push(query);
+ }
} else {
- // Set: parse color and store as pane default
- if let Some(color) = xparse_color(param) {
+ // Set: parse color and store as pane
+ // default. Only literal RGB is stored;
+ // palette-indexed / named variants (or
+ // a parse failure) are silently dropped
+ // to keep the pane-default fields
+ // narrow.
+ if let Some(rgb) = xparse_color(param).and_then(rgb_of_ansi_code) {
if dynamic_code == 10 {
- self.pane_default_fg = Some(color);
+ self.pane_default_fg = Some(rgb);
} else if dynamic_code == 11 {
- self.pane_default_bg = Some(color);
+ self.pane_default_bg = Some(rgb);
}
self.output_buffer.update_all_lines();
}
@@ -3838,6 +3943,9 @@ impl Perform for Grid {
1006 => {
self.mouse_mode = MouseMode::NoEncoding;
},
+ 2031 => {
+ self.color_palette_notification_enabled = false;
+ },
_ => {},
};
}
@@ -3943,6 +4051,9 @@ impl Perform for Grid {
1006 => {
self.mouse_mode = MouseMode::Sgr;
},
+ 2031 => {
+ self.color_palette_notification_enabled = true;
+ },
_ => {},
}
}
@@ -4200,27 +4311,51 @@ impl Perform for Grid {
} else if c == 'n' {
// DSR - device status report
// https://vt100.net/docs/vt510-rm/DSR.html
- match next_param_or(0) {
- 5 => {
- // report terminal status
- let all_good = "\u{1b}[0n";
- self.pending_messages_to_pty
- .push(all_good.as_bytes().to_vec());
- },
- 6 => {
- // CPR - cursor position report
+ let first_intermediate_is_questionmark = match intermediates.get(0) {
+ Some(b'?') => true,
+ None => false,
+ _ => false,
+ };
+ if first_intermediate_is_questionmark {
+ // CSI ? 996 n — query host terminal color-palette mode
+ // (Contour spec; see contour-terminal.org). Zellij
+ // short-circuits this query: we know the host's mode
+ // from our own startup `\e[?996n` plus unsolicited DSR
+ // 997 updates while `\e[?2031h` is enabled, so the
+ // pane gets answered locally without a host round-trip.
+ // Pushing onto `pending_forwarded_queries` enrols the
+ // query in the existing Grid → Tab → Screen pipeline;
+ // Screen recognises the variant and writes the reply
+ // straight to the pane's pty.
+ for param in params_iter.map(|param| param[0]) {
+ if param == 996 {
+ self.pending_forwarded_queries
+ .push(crate::host_query::HostQuery::ColorPaletteMode);
+ }
+ }
+ } else {
+ match next_param_or(0) {
+ 5 => {
+ // report terminal status
+ let all_good = "\u{1b}[0n";
+ self.pending_messages_to_pty
+ .push(all_good.as_bytes().to_vec());
+ },
+ 6 => {
+ // CPR - cursor position report
- // Note that this is relative to scrolling region.
- let offset = self.scroll_region.0; // scroll_region_top
- let position_report = format!(
- "\u{1b}[{};{}R",
- self.cursor.y + 1 - offset,
- self.cursor.x + 1
- );
- self.pending_messages_to_pty
- .push(position_report.as_bytes().to_vec());
- },
- _ => {},
+ // Note that this is relative to scrolling region.
+ let offset = self.scroll_region.0; // scroll_region_top
+ let position_report = format!(
+ "\u{1b}[{};{}R",
+ self.cursor.y + 1 - offset,
+ self.cursor.x + 1
+ );
+ self.pending_messages_to_pty
+ .push(position_report.as_bytes().to_vec());
+ },
+ _ => {},
+ }
}
} else if c == 'x' {
// DECREQTPARM - Request Terminal Parameters
@@ -4244,25 +4379,18 @@ impl Perform for Grid {
} else if c == 't' {
match next_param_or(1) as usize {
14 => {
- if let Some(character_cell_size) = *self.character_cell_size.borrow() {
- let text_area_pixel_size_report = format!(
- "\x1b[4;{};{}t",
- character_cell_size.height * self.height,
- character_cell_size.width * self.width
- );
- self.pending_messages_to_pty
- .push(text_area_pixel_size_report.as_bytes().to_vec());
- }
+ // Forward to host: apps asking for text-area pixels
+ // want the real window size, not Zellij's synthesised
+ // (cell_size * grid_size) value. The host's reply will
+ // be written back to this pane by the forwarding
+ // infrastructure on Screen.
+ self.pending_forwarded_queries
+ .push(crate::host_query::HostQuery::TextAreaPixelSize);
},
16 => {
- if let Some(character_cell_size) = *self.character_cell_size.borrow() {
- let character_cell_size_report = format!(
- "\x1b[6;{};{}t",
- character_cell_size.height, character_cell_size.width
- );
- self.pending_messages_to_pty
- .push(character_cell_size_report.as_bytes().to_vec());
- }
+ // Forward to host: character-cell pixel size.
+ self.pending_forwarded_queries
+ .push(crate::host_query::HostQuery::CharacterCellPixelSize);
},
18 => {
// report text area
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index 5aeef4f2..eeec67d9 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -586,6 +586,14 @@ impl Pane for TerminalPane {
self.grid.pending_messages_to_pty.drain(..).collect()
}
+ fn drain_forwarded_queries(&mut self) -> Vec<crate::host_query::HostQuery> {
+ self.grid.pending_forwarded_queries.drain(..).collect()
+ }
+
+ fn push_color_palette_dsr(&mut self, mode: zellij_utils::data::HostTerminalThemeMode) {
+ self.grid.push_color_palette_dsr(mode);
+ }
+
fn drain_clipboard_update(&mut self) -> Option<String> {
self.grid.pending_clipboard_update.take()
}
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index ed3bc3ac..8e8618b6 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -3260,12 +3260,16 @@ fn terminal_pixel_size_reports() {
for byte in content {
vte_parser.advance(&mut grid, byte);
}
+ // CSI 14t and CSI 16t are forwarded to the host; Zellij no longer
+ // synthesises local replies from character_cell_size for these.
+ assert!(grid.pending_messages_to_pty.is_empty());
+ use crate::host_query::HostQuery;
assert_eq!(
- grid.pending_messages_to_pty
- .iter()
- .map(|bytes| String::from_utf8(bytes.clone()).unwrap())
- .collect::<Vec<String>>(),
- vec!["\x1b[4;1071;776t", "\x1b[6;21;8t"]
+ grid.pending_forwarded_queries,
+ vec![
+ HostQuery::TextAreaPixelSize,
+ HostQuery::CharacterCellPixelSize
+ ],
);
}
@@ -3299,13 +3303,17 @@ fn terminal_pixel_size_reports_in_unsupported_terminals() {
for byte in content {
vte_parser.advance(&mut grid, byte);
}
- let expected: Vec<String> = vec![];
+ // Forwarding is independent of character_cell_size availability —
+ // the host terminal is authoritative for these queries regardless
+ // of what Zellij knows locally.
+ assert!(grid.pending_messages_to_pty.is_empty());
+ use crate::host_query::HostQuery;
assert_eq!(
- grid.pending_messages_to_pty
- .iter()
- .map(|bytes| String::from_utf8(bytes.clone()).unwrap())
- .collect::<Vec<String>>(),
- expected,
+ grid.pending_forwarded_queries,
+ vec![
+ HostQuery::TextAreaPixelSize,
+ HostQuery::CharacterCellPixelSize
+ ],
);
}
@@ -3586,15 +3594,16 @@ pub fn osc_4_background_query() {
for byte in content.as_bytes() {
vte_parser.advance(&mut grid, *byte);
}
- let message_string = grid
- .pending_messages_to_pty
+ // Post-refactor: OSC 10;? is forwarded to the host, not answered
+ // from Zellij's cached palette. pending_messages_to_pty must stay
+ // empty.
+ assert!(grid.pending_messages_to_pty.is_empty());
+ let forwarded_string: String = grid
+ .pending_forwarded_queries
.iter()
- .map(|m| String::from_utf8_lossy(m))
- .fold(String::new(), |mut acc, s| {
- acc.push_str(&s);
- acc
- });
- assert_eq!(message_string, "\u{1b}]10;rgb:0000/0000/0000\u{1b}\\");
+ .map(|q| String::from_utf8(q.to_query_bytes()).unwrap())
+ .collect();
+ assert_eq!(forwarded_string, "\u{1b}]10;?\u{1b}\\");
}
#[test]
@@ -3626,15 +3635,13 @@ pub fn osc_4_foreground_query() {
for byte in content.as_bytes() {
vte_parser.advance(&mut grid, *byte);
}
- let message_string = grid
- .pending_messages_to_pty
+ assert!(grid.pending_messages_to_pty.is_empty());
+ let forwarded_string: String = grid
+ .pending_forwarded_queries
.iter()
- .map(|m| String::from_utf8_lossy(m))
- .fold(String::new(), |mut acc, s| {
- acc.push_str(&s);
- acc
- });
- assert_eq!(message_string, "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\");
+ .map(|q| String::from_utf8(q.to_query_bytes()).unwrap())
+ .collect();
+ assert_eq!(forwarded_string, "\u{1b}]11;?\u{1b}\\");
}
#[test]
@@ -3668,15 +3675,14 @@ pub fn osc_4_color_query() {
for byte in content.as_bytes() {
vte_parser.advance(&mut grid, *byte);
}
- let message_string = grid
- .pending_messages_to_pty
+ // OSC 4;N;? is forwarded to the host for the real palette value.
+ assert!(grid.pending_messages_to_pty.is_empty());
+ let forwarded_string: String = grid
+ .pending_forwarded_queries
.iter()
- .map(|m| String::from_utf8_lossy(m))
- .fold(String::new(), |mut acc, s| {
- acc.push_str(&s);
- acc
- });
- assert_eq!(message_string, "\u{1b}]4;222;rgb:ffff/d7d7/8787\u{1b}\\");
+ .map(|q| String::from_utf8(q.to_query_bytes()).unwrap())
+ .collect();
+ assert_eq!(forwarded_string, "\u{1b}]4;222;?\u{1b}\\");
}
#[test]
@@ -4397,21 +4403,26 @@ fn osc_11_set_and_query_pane_default_bg() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pane_default_bg, Some(AnsiCode::RgbCode((0, 26, 58))));
+ assert_eq!(grid.pane_default_bg, Some((0, 26, 58)));
- // Query background via OSC 11
+ // Query background via OSC 11 — because a pane-scoped override is
+ // in place, the query is short-circuited: apps inside the pane
+ // must see what Zellij is actually rendering, not the host
+ // terminal's bg. The reply uses xterm's canonical
+ // `rgb:RRRR/GGGG/BBBB` form with each 8-bit channel widened by
+ // repetition (0x00 → 0x0000, 0x1a → 0x1a1a, 0x3a → 0x3a3a).
let query_bg = b"\x1b]11;?\x07";
for byte in query_bg.iter() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pending_messages_to_pty.len(), 1);
- let response = String::from_utf8(grid.pending_messages_to_pty[0].clone()).unwrap();
assert!(
- response.contains("11;rgb:0000/1a1a/3a3a"),
- "Response was: {}",
- response
+ grid.pending_forwarded_queries.is_empty(),
+ "OSC 11 query must not be forwarded when a pane override is set"
);
+ assert_eq!(grid.pending_messages_to_pty.len(), 1);
+ let reply = String::from_utf8(grid.pending_messages_to_pty[0].clone()).unwrap();
+ assert_eq!(reply, "\u{1b}]11;rgb:0000/1a1a/3a3a\u{7}",);
}
#[test]
@@ -4443,21 +4454,23 @@ fn osc_10_set_and_query_pane_default_fg() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pane_default_fg, Some(AnsiCode::RgbCode((0, 224, 0))));
+ assert_eq!(grid.pane_default_fg, Some((0, 224, 0)));
- // Query foreground via OSC 10
+ // Query foreground via OSC 10 — pane-scoped override is in place,
+ // so the query is answered locally (see OSC 11 equivalent test for
+ // the short-circuit rationale).
let query_fg = b"\x1b]10;?\x07";
for byte in query_fg.iter() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pending_messages_to_pty.len(), 1);
- let response = String::from_utf8(grid.pending_messages_to_pty[0].clone()).unwrap();
assert!(
- response.contains("10;rgb:0000/e0e0/0000"),
- "Response was: {}",
- response
+ grid.pending_forwarded_queries.is_empty(),
+ "OSC 10 query must not be forwarded when a pane override is set"
);
+ assert_eq!(grid.pending_messages_to_pty.len(), 1);
+ let reply = String::from_utf8(grid.pending_messages_to_pty[0].clone()).unwrap();
+ assert_eq!(reply, "\u{1b}]10;rgb:0000/e0e0/0000\u{7}",);
}
#[test]
@@ -4493,8 +4506,8 @@ fn osc_110_111_reset_pane_default_colors() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pane_default_fg, Some(AnsiCode::RgbCode((0, 224, 0))));
- assert_eq!(grid.pane_default_bg, Some(AnsiCode::RgbCode((0, 26, 58))));
+ assert_eq!(grid.pane_default_fg, Some((0, 224, 0)));
+ assert_eq!(grid.pane_default_bg, Some((0, 26, 58)));
// Reset foreground via OSC 110
let reset_fg = b"\x1b]110\x07";
@@ -4502,7 +4515,7 @@ fn osc_110_111_reset_pane_default_colors() {
vte_parser.advance(&mut grid, *byte);
}
assert_eq!(grid.pane_default_fg, None);
- assert_eq!(grid.pane_default_bg, Some(AnsiCode::RgbCode((0, 26, 58))));
+ assert_eq!(grid.pane_default_bg, Some((0, 26, 58)));
// Reset background via OSC 111
let reset_bg = b"\x1b]111\x07";
@@ -4542,7 +4555,7 @@ fn osc_11_set_bg_produces_ansi_in_render_output() {
vte_parser.advance(&mut grid, *byte);
}
- assert_eq!(grid.pane_default_bg, Some(AnsiCode::RgbCode((0, 26, 58))));
+ assert_eq!(grid.pane_default_bg, Some((0, 26, 58)));
// Render the grid and check that the pane defaults are stamped on chunks
let style = Style::default();
@@ -5690,3 +5703,361 @@ fn row_without_scroll_has_no_bg_color() {
"rows not created by scroll should have no bg_color"
);
}
+
+fn new_grid_for_forwarding_test() -> Grid {
+ Grid::new(
+ 10,
+ 20,
+ Rc::new(RefCell::new(Palette::default())),
+ Rc::new(RefCell::new(HashMap::new())),
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(Some(SizeInPixels {
+ width: 8,
+ height: 16,
+ }))),
+ Rc::new(RefCell::new(SixelImageStore::default())),
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ )
+}
+
+#[test]
+fn csi_14t_forwards_to_host_not_local() {
+ // CSI 14t used to synthesize a local "\x1b[4;H;Wt" reply; after the
+ // refactor it must be forwarded to the host instead so apps observe
+ // the terminal's real window pixel dimensions.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b[14t" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "local reply path must not fire for 14t"
+ );
+ assert_eq!(grid.pending_forwarded_queries.len(), 1);
+ assert_eq!(
+ grid.pending_forwarded_queries[0],
+ crate::host_query::HostQuery::TextAreaPixelSize,
+ "forwarded classification must be TextAreaPixelSize"
+ );
+}
+
+#[test]
+fn csi_16t_forwards_to_host_not_local() {
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b[16t" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_messages_to_pty.is_empty());
+ assert_eq!(grid.pending_forwarded_queries.len(), 1);
+ assert_eq!(
+ grid.pending_forwarded_queries[0],
+ crate::host_query::HostQuery::CharacterCellPixelSize,
+ );
+}
+
+#[test]
+fn csi_18t_still_answered_locally() {
+ // 18 reports Zellij's own text-area size in cells — Zellij is
+ // authoritative for this, do NOT forward.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b[18t" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert_eq!(grid.pending_messages_to_pty.len(), 1);
+ assert!(grid.pending_forwarded_queries.is_empty());
+}
+
+#[test]
+fn osc_11_set_stays_local() {
+ // OSC 11;<rgb> (set pane default bg) must stay local — Zellij needs
+ // to track it for its own rendering.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b]11;rgb:ffff/ffff/ffff\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_messages_to_pty.is_empty());
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "set (not query) must not forward"
+ );
+ assert!(grid.pane_default_bg.is_some());
+}
+
+#[test]
+fn osc_11_query_without_override_forwards_to_host() {
+ // When no pane-local override is in place the query must still be
+ // forwarded — the host's actual bg is what the app asked for.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ assert!(grid.pane_default_bg.is_none());
+ for byte in b"\x1b]11;?\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "no override → no local reply"
+ );
+ assert_eq!(grid.pending_forwarded_queries.len(), 1);
+ assert!(matches!(
+ grid.pending_forwarded_queries[0],
+ crate::host_query::HostQuery::DefaultBackground { .. }
+ ));
+}
+
+#[test]
+fn osc_10_query_without_override_forwards_to_host() {
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ assert!(grid.pane_default_fg.is_none());
+ for byte in b"\x1b]10;?\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pending_messages_to_pty.is_empty());
+ assert_eq!(grid.pending_forwarded_queries.len(), 1);
+ assert!(matches!(
+ grid.pending_forwarded_queries[0],
+ crate::host_query::HostQuery::DefaultForeground { .. }
+ ));
+}
+
+#[test]
+fn set_pane_default_colors_short_circuits_osc_queries() {
+ // The CLI path (`zellij action set-pane-color`) lands on
+ // `Grid::set_pane_default_colors`. A later OSC 10/11 query must
+ // read that override, not be forwarded — the entire point of the
+ // CLI override is that apps see the color Zellij is painting, not
+ // the underlying host's.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ grid.set_pane_default_colors(Some("#ff8040".to_string()), Some("#102030".to_string()));
+
+ for byte in b"\x1b]10;?\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ for byte in b"\x1b]11;?\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "overrides set via set_pane_default_colors must suppress forwarding"
+ );
+ assert_eq!(grid.pending_messages_to_pty.len(), 2);
+ let fg_reply = String::from_utf8(grid.pending_messages_to_pty[0].clone()).unwrap();
+ let bg_reply = String::from_utf8(grid.pending_messages_to_pty[1].clone()).unwrap();
+ assert_eq!(fg_reply, "\u{1b}]10;rgb:ffff/8080/4040\u{7}");
+ assert_eq!(bg_reply, "\u{1b}]11;rgb:1010/2020/3030\u{7}");
+}
+
+#[test]
+fn osc_11_override_short_circuits_only_the_overridden_channel() {
+ // Setting only the bg must not short-circuit fg queries — each
+ // channel's override is independent. If only `pane_default_bg` is
+ // populated, an OSC 10 query (fg) still goes to the host.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b]11;rgb:1010/2020/3030\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(grid.pane_default_bg.is_some());
+ assert!(grid.pane_default_fg.is_none());
+
+ for byte in b"\x1b]10;?\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "fg has no override → must forward"
+ );
+ assert_eq!(grid.pending_forwarded_queries.len(), 1);
+}
+
+#[test]
+fn csi_2026_dollar_p_stays_local() {
+ // DECRQM mode 2026 (synchronised output) is emulated by Zellij
+ // itself — never forwarded. The response is the DECRPM form
+ // `\x1b[?2026;2$y` (2 = "reset but recognised"; Zellij brackets its
+ // own frames, so individual panes are treated as "not enabled").
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b[?2026$p" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "CSI ?2026$p is emulated locally, must not forward"
+ );
+ assert_eq!(grid.pending_messages_to_pty.len(), 1);
+ assert_eq!(
+ grid.pending_messages_to_pty[0], b"\x1b[?2026;2$y",
+ "DECRPM reply must use the `$y` suffix per spec"
+ );
+}
+
+#[test]
+fn csi_22t_and_23t_stay_local() {
+ // CSI 22t / 23t manipulate the pane's title stack — Zellij owns
+ // that state, so forwarding would route an app's push/pop to the
+ // host's unrelated title stack instead of the visible pane title.
+ // They produce no reply; both queues must stay empty after each.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ grid.set_title("hello".to_string());
+
+ for byte in b"\x1b[22;0t" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "22t is a stack op, not a query"
+ );
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "22t produces no reply"
+ );
+
+ // The push actually landed on Zellij's per-pane stack: restore it.
+ grid.set_title("different".to_string());
+ for byte in b"\x1b[23;0t" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "23t is a stack op, not a query"
+ );
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "23t produces no reply"
+ );
+ assert_eq!(
+ grid.title.as_deref(),
+ Some("hello"),
+ "pop should restore the previously-pushed title"
+ );
+}
+
+#[test]
+fn osc_4_set_stays_local() {
+ // OSC 4;<index>;<rgb> writes to Zellij's in-memory palette; only
+ // the query form (`OSC 4;N;?`) ever forwards to the host.
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b]4;5;rgb:ffff/0000/0000\x07" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "OSC 4 set must not forward"
+ );
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "OSC 4 set produces no reply"
+ );
+ let changed = grid
+ .changed_colors
+ .expect("changed_colors should be populated by OSC 4 set");
+ assert!(changed[5].is_some(), "index 5 should have been written");
+}
+
+#[test]
+fn decset_2031_enables_color_palette_notification() {
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ assert!(
+ !grid.color_palette_notification_enabled,
+ "default state must be disabled"
+ );
+ for byte in b"\x1b[?2031h" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.color_palette_notification_enabled,
+ "DECSET 2031 must enable the flag"
+ );
+ for byte in b"\x1b[?2031l" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ !grid.color_palette_notification_enabled,
+ "DECRST 2031 must disable the flag"
+ );
+}
+
+#[test]
+fn push_color_palette_dsr_emits_when_enabled() {
+ let mut grid = new_grid_for_forwarding_test();
+ grid.color_palette_notification_enabled = true;
+ grid.push_color_palette_dsr(zellij_utils::data::HostTerminalThemeMode::Dark);
+ assert_eq!(grid.pending_messages_to_pty.len(), 1);
+ assert_eq!(
+ grid.pending_messages_to_pty[0],
+ b"\x1b[?997;1n".to_vec(),
+ "Dark mode emits ?997;1n"
+ );
+ grid.push_color_palette_dsr(zellij_utils::data::HostTerminalThemeMode::Light);
+ assert_eq!(grid.pending_messages_to_pty.len(), 2);
+ assert_eq!(
+ grid.pending_messages_to_pty[1],
+ b"\x1b[?997;2n".to_vec(),
+ "Light mode emits ?997;2n"
+ );
+}
+
+#[test]
+fn push_color_palette_dsr_noop_when_disabled() {
+ let mut grid = new_grid_for_forwarding_test();
+ assert!(!grid.color_palette_notification_enabled);
+ grid.push_color_palette_dsr(zellij_utils::data::HostTerminalThemeMode::Dark);
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "no DSR is queued when the app has not opted in via CSI ?2031h"
+ );
+}
+
+#[test]
+fn csi_996n_pushes_color_palette_mode_query_to_forwarded_queries() {
+ use crate::host_query::HostQuery;
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ for byte in b"\x1b[?996n" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert_eq!(
+ grid.pending_forwarded_queries,
+ vec![HostQuery::ColorPaletteMode],
+ "CSI ?996n must push HostQuery::ColorPaletteMode for Screen to short-circuit"
+ );
+ assert!(
+ grid.pending_messages_to_pty.is_empty(),
+ "Grid must NOT answer locally — Screen owns the host_terminal_theme_mode cache"
+ );
+}
+
+#[test]
+fn csi_5n_status_query_still_handled_locally() {
+ let mut parser = vte::Parser::new();
+ let mut grid = new_grid_for_forwarding_test();
+ // Plain DSR 5 (no `?` intermediate) is not the new theme query and
+ // must continue to receive its `\e[0n` "all good" reply locally.
+ for byte in b"\x1b[5n" {
+ parser.advance(&mut grid, *byte);
+ }
+ assert!(
+ grid.pending_forwarded_queries.is_empty(),
+ "DSR 5 is not a forwarded query"
+ );
+ assert_eq!(
+ grid.pending_messages_to_pty,
+ vec![b"\x1b[0n".to_vec()],
+ "DSR 5 must still produce its local 'all good' reply"
+ );
+}
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index 155997f9..849e3405 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -1149,6 +1149,27 @@ pub(crate) fn route_action(
.with_context(err_context)?;
should_break = true;
},
+ Action::SetDarkTheme => {
+ senders
+ .send_to_screen(ScreenInstruction::SetDarkTheme(Some(NotificationEnd::new(
+ completion_tx,
+ ))))
+ .with_context(err_context)?;
+ },
+ Action::SetLightTheme => {
+ senders
+ .send_to_screen(ScreenInstruction::SetLightTheme(Some(
+ NotificationEnd::new(completion_tx),
+ )))
+ .with_context(err_context)?;
+ },
+ Action::ToggleTheme => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleTheme(Some(NotificationEnd::new(
+ completion_tx,
+ ))))
+ .with_context(err_context)?;
+ },
Action::SwitchSession {
name,
tab_position,
@@ -2598,6 +2619,37 @@ pub(crate) fn route_thread_main(
retry_queue
);
},
+ ClientToServerMsg::ForwardedReplyFromHost {
+ token,
+ ref reply_bytes,
+ } => {
+ // The client that owns this forward
+ // answered — drop the in-flight entry
+ // first so a later disconnect of that
+ // client can't synthesize a spurious
+ // empty reply for the same token.
+ session_state
+ .write()
+ .unwrap()
+ .clear_forward_in_flight(token);
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: reply_bytes.clone(),
+ },
+ instruction,
+ retry_queue
+ );
+ },
+ ClientToServerMsg::HostTerminalThemeChanged { mode } => {
+ let _ = send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::HostTerminalThemeChanged(mode),
+ instruction,
+ retry_queue
+ );
+ },
ClientToServerMsg::SubscribeToPaneRenders {
ref pane_ids,
ref scrollback,
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index f05b0823..f3d130ec 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -29,7 +29,7 @@
//! - `tab_history: BTreeMap<ClientId, Vec<usize>>`: History of tab IDs per client
use std::cell::RefCell;
-use std::collections::{BTreeMap, HashMap, HashSet};
+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;
use std::rc::Rc;
@@ -41,10 +41,10 @@ use crate::route::NotificationEnd;
use log::{debug, warn};
use zellij_utils::data::{
CommandOrPlugin, Direction, EventType, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
- KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse,
- NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneRenderReport,
- PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo,
- Styling, TabInfo, WebSharing,
+ HostTerminalThemeMode, KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse,
+ ListTabsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest,
+ PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize,
+ ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -80,6 +80,7 @@ use crate::{
panes::PaneId,
plugins::{DumpSessionLayoutResponse, PluginId, PluginInstruction, PluginRenderAsset},
pty::{get_default_shell, ClientTabIndexOrPaneId, PtyInstruction, VteBytes},
+ pty_writer::PtyWriteInstruction,
tab::{SuppressedPanes, Tab},
thread_bus::Bus,
ui::loading_indication::LoadingIndication,
@@ -500,6 +501,37 @@ pub enum ScreenInstruction {
TerminalBackgroundColor(String),
TerminalForegroundColor(String),
TerminalColorRegisters(Vec<(usize, String)>),
+ /// A pane's Grid intercepted an app-in-pane whitelisted query; Screen
+ /// assigns a token, queues the forward, and dispatches to the client.
+ /// `query` carries the classified form so Screen can match on it
+ /// directly (for cache-fallback synthesis) without re-parsing bytes.
+ ForwardHostQuery {
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ },
+ /// The client observed the host's reply to a previously forwarded
+ /// query (closed by the Primary-DA barrier or the 500 ms timeout).
+ /// The reply bytes are written verbatim to the originating pane.
+ ForwardedReplyFromHost {
+ token: u32,
+ reply_bytes: Vec<u8>,
+ },
+ /// The host terminal reported a color-palette theme mode (DSR 997 reply
+ /// to `CSI ? 996 n`, or unsolicited notification while `CSI ? 2031 h`
+ /// is enabled). Triggers auto-theme switch (when configured), the
+ /// `HostTerminalThemeChanged` plugin event, and per-pane DSR forwarding
+ /// for panes that opted in via `CSI ? 2031 h`.
+ HostTerminalThemeChanged(HostTerminalThemeMode),
+ /// Manual theme actions issued via the CLI (e.g. `zellij action set-dark-theme`)
+ /// or a keybinding. They share the same convergence point as
+ /// `HostTerminalThemeChanged`, but additionally surface a CLI-friendly error
+ /// via `NotificationEnd` if `theme_dark` and `theme_light` are not both set
+ /// (the auto-switch gate). "Last one wins": these compete naively with
+ /// terminal-driven notifications via the dedupe in
+ /// `update_host_terminal_theme_mode`.
+ SetDarkTheme(Option<NotificationEnd>),
+ SetLightTheme(Option<NotificationEnd>),
+ ToggleTheme(Option<NotificationEnd>),
ChangeMode(ModeInfo, ClientId, Option<NotificationEnd>),
ChangeModeForAllClients(ModeInfo, Option<NotificationEnd>),
MouseEvent(MouseEvent, ClientId, Option<NotificationEnd>),
@@ -672,6 +704,12 @@ pub enum ScreenInstruction {
keybinds: Keybinds,
default_mode: InputMode,
theme: Styling,
+ /// Resolved styling for `theme_dark`. When both this and
+ /// `host_theme_light` are `Some`, Screen auto-switches the active
+ /// palette in response to host CSI 2031 / DSR 997 notifications.
+ host_theme_dark: Option<Styling>,
+ /// Resolved styling for `theme_light`. See `host_theme_dark`.
+ host_theme_light: Option<Styling>,
simplified_ui: bool,
default_shell: Option<PathBuf>,
pane_frames: bool,
@@ -935,6 +973,16 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenContext::TerminalForegroundColor
},
ScreenInstruction::TerminalColorRegisters(..) => ScreenContext::TerminalColorRegisters,
+ ScreenInstruction::ForwardHostQuery { .. } => ScreenContext::ForwardHostQuery,
+ ScreenInstruction::ForwardedReplyFromHost { .. } => {
+ ScreenContext::ForwardedReplyFromHost
+ },
+ ScreenInstruction::HostTerminalThemeChanged(..) => {
+ ScreenContext::HostTerminalThemeChanged
+ },
+ ScreenInstruction::SetDarkTheme(..) => ScreenContext::SetDarkTheme,
+ ScreenInstruction::SetLightTheme(..) => ScreenContext::SetLightTheme,
+ ScreenInstruction::ToggleTheme(..) => ScreenContext::ToggleTheme,
ScreenInstruction::ChangeMode(..) => ScreenContext::ChangeMode,
ScreenInstruction::ChangeModeForAllClients(..) => {
ScreenContext::ChangeModeForAllClients
@@ -1356,8 +1404,89 @@ pub(crate) struct Screen {
pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>,
plugins_need_ansi_pane_contents: bool,
background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet<EventType>>,
+ /// Monotonic counter used to tag each forwarded host-terminal query
+ /// with a unique token. 0 is reserved as a sentinel (see
+ /// `STARTUP_SENTINEL_TOKEN`); real forwards start at 1.
+ next_forward_token: u32,
+ /// Map of forwarded-query token → the originating pane plus the
+ /// raw query bytes. When the client sends back the reply for
+ /// `token`, the server looks up the pane here and writes the
+ /// reply bytes to its pty. The retained `query_bytes` feed the
+ /// cache-fallback synthesis: if the reply comes back empty
+ /// (detached session, client crash, host timeout), we use the
+ /// cached bg/fg/pixel/palette state to answer in the host's
+ /// stead.
+ pending_forwarded_queries: HashMap<u32, PendingForwardEntry>,
+ /// Serialization queue for forwarded queries. Invariant: at most one
+ /// forward is in flight to the client at a time (enforced by
+ /// `forward_in_flight`). When the reply (or timeout) closes the
+ /// active slot, the next queued forward is dispatched.
+ forward_queue: VecDeque<PendingForward>,
+ /// Token of the forward currently in flight to the (single)
+ /// connected client, if any. Used to:
+ /// * serialize forwarded queries globally (only one at a time);
+ /// * gate slot release on `handle_forwarded_reply_from_host` to a
+ /// token-equality match, so a late server-side timeout for an
+ /// already-answered forward cannot clobber a queued forward that
+ /// the real reply just dispatched.
+ forward_in_flight_token: Option<u32>,
+ /// Last-known host terminal color-palette theme mode (CSI 2031 /
+ /// DSR 997). `None` until the host first reports. Used both for
+ /// auto-theme switching and to dedupe duplicate notifications.
+ host_terminal_theme_mode: Option<HostTerminalThemeMode>,
+ /// Resolved styling to apply when `host_terminal_theme_mode == Dark`.
+ /// `None` disables auto-switch. Refreshed on each reconfigure.
+ host_theme_dark_styling: Option<Styling>,
+ /// Resolved styling to apply when `host_terminal_theme_mode == Light`.
+ /// `None` disables auto-switch. Refreshed on each reconfigure.
+ host_theme_light_styling: Option<Styling>,
+}
+
+/// A pending forward waiting to be dispatched once the current in-flight
+/// forward's barrier reply (or timeout) arrives.
+#[derive(Debug, Clone)]
+struct PendingForward {
+ token: u32,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+}
+
+/// A forward currently in flight (dispatched to the client, waiting
+/// for a reply). Retains the `HostQuery` classification so we can
+/// fall back to cache-synthesized replies when the live reply is
+/// empty (no client attached, client-side timeout, etc.) without
+/// re-parsing byte strings.
+#[derive(Debug, Clone)]
+struct PendingForwardEntry {
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
}
+/// Reserved sentinel token for Zellij's own startup batch of host
+/// queries (pixel dims, bg/fg, sync-output, palette registers). The
+/// existing fire-and-forget startup plumbing is unchanged today; the
+/// sentinel exists so a future migration can route the startup batch
+/// through the same forwarded-query mechanism as per-pane queries
+/// while keeping its replies routable to `Screen`'s cached state
+/// rather than to a pane's pty. Reserved value 0 is excluded from
+/// `next_forward_token` allocation by the wrap-skip in
+/// `forward_host_query`.
+const STARTUP_SENTINEL_TOKEN: u32 = 0;
+
+/// Server-side deadline for a forwarded host query. If no
+/// `ForwardedReplyFromHost` arrives within this window, the server
+/// synthesizes an empty reply for itself so the in-flight slot
+/// releases and queued forwards continue to drain.
+///
+/// This recovers the worst-case where the connected client doesn't
+/// understand `ServerToClientMsg::ForwardQueryToHost` (an old client
+/// against a new server) — without it the slot would deadlock and
+/// every app-issued host query would hang. The window is generous
+/// relative to the 500 ms client-side deadline so a well-behaved new
+/// client always replies first; only the old-client and
+/// network-pathological cases ever see this fire.
+const SERVER_FORWARD_TIMEOUT_MS: u64 = 1000;
+
impl Screen {
/// Creates and returns a new [`Screen`].
pub fn new(
@@ -1457,6 +1586,13 @@ impl Screen {
pane_render_subscribers: HashMap::new(),
plugins_need_ansi_pane_contents: false,
background_plugin_subscriptions: HashMap::new(),
+ next_forward_token: 1, // 0 is reserved as the startup sentinel
+ pending_forwarded_queries: HashMap::new(),
+ forward_queue: VecDeque::new(),
+ forward_in_flight_token: None,
+ host_terminal_theme_mode: None,
+ host_theme_dark_styling: None,
+ host_theme_light_styling: None,
}
}
@@ -1953,6 +2089,237 @@ impl Screen {
}
}
+ /// Enqueue a whitelisted host-terminal query from pane `pane_id`.
+ /// Queries are serialized globally: at most one is in flight to the
+ /// client at a time; the rest wait in `forward_queue`. Returns the
+ /// allocated token so callers (tests) can observe it if useful.
+ pub fn forward_host_query(
+ &mut self,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ ) -> u32 {
+ // ColorPaletteMode is answered locally — Zellij already knows
+ // the host's mode from its own startup query and from
+ // unsolicited DSR 997 updates. Skip the entire forwarding
+ // machinery (no token, no in-flight slot, no host round-trip)
+ // and write the reply straight to the originating pane's pty.
+ if let crate::host_query::HostQuery::ColorPaletteMode = query {
+ self.answer_color_palette_mode_query_locally(pane_id);
+ return STARTUP_SENTINEL_TOKEN; // sentinel: no real forward happened
+ }
+ let token = self.next_forward_token;
+ // Skip over the reserved sentinel (0) on wrap; allocate a fresh
+ // u32 for every forward.
+ self.next_forward_token = self.next_forward_token.wrapping_add(1);
+ if self.next_forward_token == STARTUP_SENTINEL_TOKEN {
+ self.next_forward_token = 1;
+ }
+ if self.forward_in_flight_token.is_some() {
+ self.forward_queue.push_back(PendingForward {
+ token,
+ pane_id,
+ query,
+ });
+ } else {
+ self.dispatch_forward(token, pane_id, query);
+ }
+ token
+ }
+
+ /// Synthesise the DSR 997 reply to a `CSI ? 996 n` query from
+ /// `host_terminal_theme_mode` and write it directly to the
+ /// originating pane's pty. Plugin panes are skipped (they receive
+ /// `Event::HostTerminalThemeChanged` and have no notion of
+ /// VT-protocol queries). When Zellij has not yet learned the host
+ /// mode, the pane receives no reply — matching what a host that
+ /// does not implement CSI 2031 would do. The Contour spec only
+ /// defines `;1` (dark) and `;2` (light); fabricating any other
+ /// code (e.g. `;0`) would be non-conformant.
+ fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) {
+ let terminal_id = match pane_id {
+ PaneId::Terminal(id) => id,
+ PaneId::Plugin(_) => return,
+ };
+ let code: u8 = match self.host_terminal_theme_mode {
+ Some(HostTerminalThemeMode::Dark) => 1,
+ Some(HostTerminalThemeMode::Light) => 2,
+ None => {
+ log::debug!(
+ "CSI ?996n received but host_terminal_theme_mode is unknown; \
+ dropping (spec defines only 1=dark / 2=light)"
+ );
+ return;
+ },
+ };
+ let reply = format!("\u{1b}[?997;{}n", code).into_bytes();
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(reply, terminal_id, None));
+ }
+
+ /// Dispatch a forward to the client and mark the slot as in-flight.
+ /// Spawns a one-shot timeout task on the global tokio runtime that
+ /// synthesizes an empty reply for `token` after
+ /// `SERVER_FORWARD_TIMEOUT_MS`. The handler's token-equality guard
+ /// makes the timeout a no-op if a real reply arrived first, so no
+ /// explicit cancellation is needed.
+ fn dispatch_forward(
+ &mut self,
+ token: u32,
+ pane_id: PaneId,
+ query: crate::host_query::HostQuery,
+ ) {
+ let query_bytes = query.to_query_bytes();
+ self.pending_forwarded_queries
+ .insert(token, PendingForwardEntry { pane_id, query });
+ self.forward_in_flight_token = Some(token);
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::ForwardQueryToHost(token, query_bytes));
+ let senders = self.bus.senders.clone();
+ crate::global_async_runtime::get_tokio_runtime().spawn(async move {
+ tokio::time::sleep(std::time::Duration::from_millis(SERVER_FORWARD_TIMEOUT_MS)).await;
+ let _ = senders.send_to_screen(ScreenInstruction::ForwardedReplyFromHost {
+ token,
+ reply_bytes: Vec::new(),
+ });
+ });
+ }
+
+ /// Handle a host-reply observed by the client for token `token`.
+ /// Writes the bytes to the originating pane's pty (if still present)
+ /// and releases the in-flight slot so the next queued forward can
+ /// dispatch.
+ ///
+ /// An empty `reply_bytes` is treated as "nobody answered" — either
+ /// because no client was attached to forward the query, or the
+ /// chosen client's host timed out. In that case we try to answer
+ /// from Zellij's cached view of the host (pixel dims, bg/fg,
+ /// palette) so the pane still gets a well-formed reply instead of
+ /// zero bytes. If the cache has nothing relevant either, the pane
+ /// receives an empty write and the app decides what to do.
+ pub fn handle_forwarded_reply_from_host(
+ &mut self,
+ token: u32,
+ reply_bytes: Vec<u8>,
+ ) -> Result<()> {
+ // Stale-reply guard. Both the real `ForwardedReplyFromHost`
+ // path and the server-side timeout path land here. If a real
+ // reply landed first (releasing the slot AND dispatching the
+ // next queued forward), the late timeout's empty reply for the
+ // already-cleared token must be a no-op — releasing the slot
+ // again would clobber the new in-flight forward. The same
+ // logic also rejects duplicate replies and replies for tokens
+ // belonging to a previously-closed pane.
+ if self.forward_in_flight_token != Some(token) {
+ log::debug!(
+ "Dropping stale forwarded reply (token={}, in-flight={:?}, len={} bytes)",
+ token,
+ self.forward_in_flight_token,
+ reply_bytes.len(),
+ );
+ return Ok(());
+ }
+ if let Some(entry) = self.pending_forwarded_queries.remove(&token) {
+ let PendingForwardEntry { pane_id, query } = entry;
+ match pane_id {
+ PaneId::Terminal(terminal_id) => {
+ let payload = if reply_bytes.is_empty() {
+ self.synthesize_cached_reply(&query)
+ } else {
+ reply_bytes
+ };
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(payload, terminal_id, None));
+ },
+ PaneId::Plugin(_) => {
+ // Plugin panes do not issue whitelisted host queries;
+ // if we reach here the mapping was populated
+ // erroneously. Drop the reply.
+ log::warn!(
+ "Discarding host reply for plugin pane (token={}); plugins do not forward CSI/OSC queries",
+ token
+ );
+ },
+ }
+ }
+ // Release the slot and dispatch the next queued forward, if any.
+ self.forward_in_flight_token = None;
+ if let Some(next) = self.forward_queue.pop_front() {
+ self.dispatch_forward(next.token, next.pane_id, next.query);
+ }
+ Ok(())
+ }
+
+ /// Build a reply for `query` from whatever host state Zellij has
+ /// cached, or an empty `Vec` if nothing relevant is cached. The
+ /// classification happened at intercept time (Grid produces a
+ /// [`HostQuery`]), so this method is pure structural dispatch —
+ /// no re-parsing of bytes.
+ fn synthesize_cached_reply(&self, query: &crate::host_query::HostQuery) -> Vec<u8> {
+ use crate::host_query::HostQuery;
+ match query {
+ HostQuery::TextAreaPixelSize => self
+ .pixel_dimensions
+ .text_area_size
+ .map(|dims| format!("\x1b[4;{};{}t", dims.height, dims.width).into_bytes())
+ .unwrap_or_default(),
+ HostQuery::CharacterCellPixelSize => self
+ .pixel_dimensions
+ .character_cell_size
+ .map(|dims| format!("\x1b[6;{};{}t", dims.height, dims.width).into_bytes())
+ .unwrap_or_default(),
+ HostQuery::DefaultForeground { terminator }
+ | HostQuery::DefaultBackground { terminator } => {
+ let palette = self.terminal_emulator_colors.borrow();
+ let (channel, color) = match query {
+ HostQuery::DefaultForeground { .. } => (10u32, palette.fg),
+ HostQuery::DefaultBackground { .. } => (11u32, palette.bg),
+ _ => unreachable!(),
+ };
+ if let PaletteColor::Rgb((r, g, b)) = color {
+ let mut out = format!(
+ "\x1b]{};rgb:{:04x}/{:04x}/{:04x}",
+ channel,
+ (r as u16) * 0x0101,
+ (g as u16) * 0x0101,
+ (b as u16) * 0x0101,
+ )
+ .into_bytes();
+ out.extend_from_slice(terminator.as_bytes());
+ out
+ } else {
+ Vec::new()
+ }
+ },
+ HostQuery::PaletteRegister { index, terminator } => {
+ let codes = self.terminal_emulator_color_codes.borrow();
+ if let Some(color) = codes.get(&(*index as usize)) {
+ let mut out = format!("\x1b]4;{};{}", index, color).into_bytes();
+ out.extend_from_slice(terminator.as_bytes());
+ out
+ } else {
+ Vec::new()
+ }
+ },
+ // Should not reach here: ColorPaletteMode short-circuits in
+ // `forward_host_query` before any cache-fallback path runs.
+ // The Contour spec only defines `;1` and `;2`; if the host
+ // mode is unknown there is no compliant reply, so return
+ // empty bytes (the existing convention for "no synthesis
+ // possible").
+ HostQuery::ColorPaletteMode => match self.host_terminal_theme_mode {
+ Some(HostTerminalThemeMode::Dark) => b"\x1b[?997;1n".to_vec(),
+ Some(HostTerminalThemeMode::Light) => b"\x1b[?997;2n".to_vec(),
+ None => Vec::new(),
+ },
+ }
+ }
+
pub fn render(&mut self, plugin_render_assets: Option<Vec<PluginRenderAsset>>) -> Result<()> {
// here we schedule the RenderToClients background job which debounces renders every 10ms
// rather than actually rendering
@@ -4103,6 +4470,136 @@ impl Screen {
}
Ok(())
}
+ /// Apply a host-reported color-palette theme mode (CSI 2031 / DSR 997).
+ ///
+ /// This is the Phase 2 entry-point: it
+ /// 1. de-duplicates against the last-known mode,
+ /// 2. swaps the active palette to the configured `theme_dark` / `theme_light`
+ /// (when both are configured) by reusing the reconfigure propagation,
+ /// 3. fans out an `Event::HostTerminalThemeChanged` plugin event,
+ /// 4. forwards a `CSI ?997;{1|2}n` DSR onto the pty of every terminal pane
+ /// whose app opted in via `CSI ? 2031 h`.
+ pub fn update_host_terminal_theme_mode(&mut self, mode: HostTerminalThemeMode) -> Result<()> {
+ let err_context = || "Failed to update host terminal theme mode".to_string();
+
+ // dedupe
+ if self.host_terminal_theme_mode == Some(mode) {
+ return Ok(());
+ }
+ self.host_terminal_theme_mode = Some(mode);
+
+ // resolve target styling
+ let resolved = match mode {
+ HostTerminalThemeMode::Dark => self.host_theme_dark_styling,
+ HostTerminalThemeMode::Light => self.host_theme_light_styling,
+ };
+
+ // theme propagation when both keys configured and the resolved
+ // styling exists. (If only one of theme_dark/theme_light is set,
+ // skip auto-switch; the static `theme` stays authoritative.)
+ let auto_switch_enabled =
+ self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some();
+ if auto_switch_enabled {
+ if let Some(theme) = resolved {
+ self.default_mode_info.update_theme(theme);
+ for tab in self.tabs.values_mut() {
+ tab.update_theme(theme);
+ }
+ // Iterate every connected client (active_tab_ids is the
+ // canonical "who is connected" map). Iterating
+ // self.mode_info.keys() would skip any client that has
+ // never manually changed mode
+ let client_ids: Vec<ClientId> = self.active_tab_ids.keys().copied().collect();
+ let default_for_new = self.default_mode_info.clone();
+ for client_id in client_ids {
+ let mode_info = self
+ .mode_info
+ .entry(client_id)
+ .or_insert_with(|| default_for_new.clone());
+ mode_info.update_theme(theme);
+ let mode_info_clone = mode_info.clone();
+ // Push the freshly-themed mode_info into every tab's
+ // per-client mode_info map. `update_input_modes` below
+ // reads from THAT map (not Screen's) when fanning out
+ // ModeUpdate to plugins, so without this step plugins
+ // receive ModeUpdate carrying the *old* style and the
+ // status-bar / tab-bar do not repaint. Also mark the
+ // active pane for rerender so terminal panes refresh
+ // their borders/title using the new palette.
+ for tab in self.tabs.values_mut() {
+ tab.change_mode_info(mode_info_clone.clone(), client_id);
+ tab.mark_active_pane_for_rerender(client_id);
+ }
+ }
+ for tab in self.tabs.values_mut() {
+ tab.update_input_modes().with_context(err_context)?;
+ }
+ } else {
+ log::warn!(
+ "host theme auto-switch enabled but resolved styling missing for {:?}",
+ mode
+ );
+ }
+ }
+
+ // fan out the plugin event
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ None,
+ Event::HostTerminalThemeChanged(mode),
+ )]))
+ .with_context(err_context)?;
+
+ // forward DSR to opted-in terminal panes
+ let mut pty_writes: Vec<(Vec<u8>, u32)> = vec![];
+ for tab in self.tabs.values_mut() {
+ for pane_id in tab.get_all_pane_ids() {
+ if let PaneId::Terminal(terminal_id) = pane_id {
+ if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
+ pane.push_color_palette_dsr(mode);
+ for bytes in pane.drain_messages_to_pty() {
+ pty_writes.push((bytes, terminal_id));
+ }
+ }
+ }
+ }
+ }
+ for (bytes, terminal_id) in pty_writes {
+ let _ = self
+ .bus
+ .senders
+ .send_to_pty_writer(PtyWriteInstruction::Write(bytes, terminal_id, None));
+ }
+
+ self.render(None)?;
+ Ok(())
+ }
+ /// Apply a manual host-terminal theme mode change requested via the CLI or a
+ /// keybinding. Surfaces a clear error to the CLI if the auto-switch gate
+ /// (both `theme_dark` and `theme_light` configured) is not satisfied;
+ /// otherwise delegates to `update_host_terminal_theme_mode`, which dedupes,
+ /// repaints, fans out the plugin event, and forwards DSR to opted-in panes.
+ pub fn apply_manual_host_terminal_theme_mode(
+ &mut self,
+ mode: HostTerminalThemeMode,
+ completion_tx: &mut Option<NotificationEnd>,
+ ) -> Result<()> {
+ let auto_switch_enabled =
+ self.host_theme_dark_styling.is_some() && self.host_theme_light_styling.is_some();
+ if !auto_switch_enabled {
+ if let Some(c) = completion_tx.as_mut() {
+ c.set_exit_status(1);
+ c.set_error_message(
+ "Manual theme switching requires both `theme_dark` and `theme_light` to be configured."
+ .to_string(),
+ );
+ }
+ return Ok(());
+ }
+ self.update_host_terminal_theme_mode(mode)
+ }
pub fn toggle_pane_pinned(&mut self, client_id: ClientId) {
active_tab_and_connected_client_id!(
self,
@@ -4960,6 +5457,33 @@ pub(crate) fn screen_thread_main(
debug: bool,
default_layout: Box<Layout>,
) -> Result<()> {
+ // Resolve `theme_dark` / `theme_light` to concrete `Styling` from the
+ // bundled themes BEFORE `config.options` is moved out below. These
+ // populate Screen's auto-switch state at startup; runtime updates
+ // continue to flow through `propagate_configuration_changes`.
+ let host_theme_dark_styling = config
+ .options
+ .theme_dark
+ .as_ref()
+ .and_then(|name| config.themes.get_theme(name).map(|t| t.palette));
+ let host_theme_light_styling = config
+ .options
+ .theme_light
+ .as_ref()
+ .and_then(|name| config.themes.get_theme(name).map(|t| t.palette));
+ if config.options.theme_dark.is_some() && host_theme_dark_styling.is_none() {
+ log::warn!(
+ "theme_dark='{}' not found in themes; auto-theme switch disabled for dark.",
+ config.options.theme_dark.as_deref().unwrap_or("?")
+ );
+ }
+ if config.options.theme_light.is_some() && host_theme_light_styling.is_none() {
+ log::warn!(
+ "theme_light='{}' not found in themes; auto-theme switch disabled for light.",
+ config.options.theme_light.as_deref().unwrap_or("?")
+ );
+ }
+
let config_options = config.options;
let arrow_fonts = !config_options.simplified_ui.unwrap_or_default();
let draw_pane_frames = config_options.pane_frames.unwrap_or(true);
@@ -5059,6 +5583,8 @@ pub(crate) fn screen_thread_main(
web_server_ip,
web_server_port,
);
+ screen.host_theme_dark_styling = host_theme_dark_styling;
+ screen.host_theme_light_styling = host_theme_light_styling;
let mut pending_tab_ids: HashSet<usize> = HashSet::new();
let mut pending_tab_switches: HashSet<(usize, ClientId)> = HashSet::new(); // usize is the
@@ -6628,6 +7154,36 @@ pub(crate) fn screen_thread_main(
ScreenInstruction::TerminalColorRegisters(color_registers) => {
screen.update_terminal_color_registers(color_registers);
},
+ ScreenInstruction::ForwardHostQuery { pane_id, query } => {
+ screen.forward_host_query(pane_id, query);
+ },
+ ScreenInstruction::ForwardedReplyFromHost { token, reply_bytes } => {
+ screen.handle_forwarded_reply_from_host(token, reply_bytes)?;
+ },
+ ScreenInstruction::HostTerminalThemeChanged(mode) => {
+ screen.update_host_terminal_theme_mode(mode)?;
+ },
+ ScreenInstruction::SetDarkTheme(mut completion_tx) => {
+ screen.apply_manual_host_terminal_theme_mode(
+ HostTerminalThemeMode::Dark,
+ &mut completion_tx,
+ )?;
+ },
+ ScreenInstruction::SetLightTheme(mut completion_tx) => {
+ screen.apply_manual_host_terminal_theme_mode(
+ HostTerminalThemeMode::Light,
+ &mut completion_tx,
+ )?;
+ },
+ ScreenInstruction::ToggleTheme(mut completion_tx) => {
+ let next = match screen.host_terminal_theme_mode {
+ Some(HostTerminalThemeMode::Dark) => HostTerminalThemeMode::Light,
+ Some(HostTerminalThemeMode::Light) => HostTerminalThemeMode::Dark,
+ // No prior mode: treat as Dark and toggle to Light.
+ None => HostTerminalThemeMode::Light,
+ };
+ screen.apply_manual_host_terminal_theme_mode(next, &mut completion_tx)?;
+ },
ScreenInstruction::ChangeMode(
mode_info,
client_id,
@@ -8026,6 +8582,8 @@ pub(crate) fn screen_thread_main(
keybinds,
default_mode,
theme,
+ host_theme_dark,
+ host_theme_light,
simplified_ui,
default_shell,
pane_frames,
@@ -8043,6 +8601,8 @@ pub(crate) fn screen_thread_main(
focus_follows_mouse,
mouse_click_through,
} => {
+ screen.host_theme_dark_styling = host_theme_dark;
+ screen.host_theme_light_styling = host_theme_light;
screen
.reconfigure(
keybinds,
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 928f7c79..36744b96 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -409,6 +409,17 @@ pub trait Pane {
// we should probably refactor away from this trait at some point
vec![]
}
+ fn drain_forwarded_queries(&mut self) -> Vec<crate::host_query::HostQuery> {
+ // Only terminal panes forward whitelisted queries to the host;
+ // plugin panes have no such concept.
+ vec![]
+ }
+ /// Push a CSI ?997 DSR notification (host color-palette theme mode)
+ /// onto this pane's pending pty-write queue, but only if the pane's
+ /// underlying app opted in via `CSI ? 2031 h`.
+ /// Only relevant to terminal panes, plugin panes receive this through
+ /// `Event::HostTerminalThemeChanged`
+ fn push_color_palette_dsr(&mut self, _mode: zellij_utils::data::HostTerminalThemeMode) {}
fn drain_clipboard_update(&mut self) -> Option<String> {
None
}
@@ -2723,6 +2734,7 @@ impl Tab {
}
terminal_output.handle_pty_bytes(bytes);
let messages_to_pty = terminal_output.drain_messages_to_pty();
+ let forwarded_queries = terminal_output.drain_forwarded_queries();
let clipboard_update = terminal_output.drain_clipboard_update();
let desktop_notifications = terminal_output.drain_desktop_notifications();
let osc7_cwd = terminal_output.drain_osc7_cwd();
@@ -2730,6 +2742,14 @@ impl Tab {
self.write_to_pane_id_without_preprocessing(message, PaneId::Terminal(pid))
.with_context(err_context)?;
}
+ for query in forwarded_queries {
+ let _ = self
+ .senders
+ .send_to_screen(ScreenInstruction::ForwardHostQuery {
+ pane_id: PaneId::Terminal(pid),
+ query,
+ });
+ }
if let Some(string) = clipboard_update {
self.write_selection_to_clipboard(&string)
.with_context(err_context)?;
@@ -5529,6 +5549,13 @@ impl Tab {
}
pub fn update_theme(&mut self, theme: Styling) {
self.style.colors = theme;
+ // The tab's `default_mode_info` is what `update_input_modes`
+ // falls back to when no per-client entry exists in
+ // `self.mode_info`. Without this update, plugins on a
+ // freshly-connected client (one that has never manually
+ // changed mode) receive `Event::ModeUpdate` carrying the old
+ // style and skip their re-render.
+ self.default_mode_info.update_theme(theme);
self.floating_panes.update_pane_themes(theme);
self.tiled_panes.update_pane_themes(theme);
for (_, pane) in self.suppressed_panes.values_mut() {
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 27304131..ef2da903 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -8322,3 +8322,873 @@ pub fn pty_bytes_and_hold_pane_buffered_before_new_pane() {
dumped
);
}
+
+// =====================================================================
+// Host-reply forwarding (CSI 2031)
+//
+// These tests exercise the token-lifecycle API on `Screen` directly —
+// no route.rs, no thread spawn, no client. The harness plugs real
+// `to_server` / `to_pty_writer` channels into the Bus so the forward
+// dispatch (→ `ServerInstruction::ForwardQueryToHost`) and the reply
+// write (→ `PtyWriteInstruction::Write`) can be asserted.
+// =====================================================================
+
+struct ForwardCapture {
+ server_rx: Receiver<(ServerInstruction, ErrorContext)>,
+ pty_writer_rx: Receiver<(PtyWriteInstruction, ErrorContext)>,
+}
+
+impl ForwardCapture {
+ /// Drain every pending `ServerInstruction::ForwardQueryToHost` and
+ /// return them as `(token, query_bytes)` pairs. Other variants are
+ /// dropped — the forward path only ever emits this one.
+ fn drain_forward_queries(&self) -> Vec<(u32, Vec<u8>)> {
+ let mut out = Vec::new();
+ while let Ok((instr, _ctx)) = self.server_rx.try_recv() {
+ if let ServerInstruction::ForwardQueryToHost(token, bytes) = instr {
+ out.push((token, bytes));
+ }
+ }
+ out
+ }
+
+ /// Drain every pending `PtyWriteInstruction::Write`, returning
+ /// `(bytes, terminal_id)` — the two fields the reply path sets.
+ fn drain_pty_writes(&self) -> Vec<(Vec<u8>, u32)> {
+ let mut out = Vec::new();
+ while let Ok((instr, _ctx)) = self.pty_writer_rx.try_recv() {
+ if let PtyWriteInstruction::Write(bytes, terminal_id, _) = instr {
+ out.push((bytes, terminal_id));
+ }
+ }
+ out
+ }
+}
+
+fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture) {
+ let (server_tx, server_rx) = channels::unbounded::<(ServerInstruction, ErrorContext)>();
+ let (pty_writer_tx, pty_writer_rx) =
+ channels::unbounded::<(PtyWriteInstruction, ErrorContext)>();
+
+ let mut bus: Bus<ScreenInstruction> = Bus::empty();
+ bus.senders.to_server = Some(SenderWithContext::new(server_tx));
+ bus.senders.to_pty_writer = Some(SenderWithContext::new(pty_writer_tx));
+ let fake_os_input = FakeInputOutput::default();
+ bus.os_input = Some(Box::new(fake_os_input));
+
+ let client_attributes = ClientAttributes {
+ size,
+ ..Default::default()
+ };
+ let max_panes = None;
+ let mut mode_info = ModeInfo::default();
+ mode_info.session_name = Some("zellij-test".into());
+ let draw_pane_frames = false;
+ let auto_layout = true;
+ let session_is_mirrored = true;
+ let copy_options = CopyOptions::default();
+ let default_layout = Box::new(Layout::default());
+ let default_layout_name = None;
+ let default_shell = PathBuf::from("my_default_shell");
+ let session_serialization = true;
+ let serialize_pane_viewport = false;
+ let scrollback_lines_to_serialize = None;
+ let layout_dir = None;
+ let debug = false;
+ let styled_underlines = true;
+ let osc8_hyperlinks = true;
+ let arrow_fonts = true;
+ let explicitly_disable_kitty_keyboard_protocol = false;
+ let stacked_resize = true;
+ let web_sharing = WebSharing::Off;
+ let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+ let web_server_port = 8080;
+ let visual_bell = true;
+ let screen = Screen::new(
+ bus,
+ &client_attributes,
+ max_panes,
+ mode_info,
+ draw_pane_frames,
+ auto_layout,
+ session_is_mirrored,
+ copy_options,
+ debug,
+ default_layout,
+ default_layout_name,
+ default_shell,
+ session_serialization,
+ serialize_pane_viewport,
+ scrollback_lines_to_serialize,
+ styled_underlines,
+ osc8_hyperlinks,
+ arrow_fonts,
+ layout_dir,
+ explicitly_disable_kitty_keyboard_protocol,
+ stacked_resize,
+ None,
+ false,
+ web_sharing,
+ true,
+ true,
+ visual_bell,
+ false, // focus_follows_mouse
+ false, // mouse_click_through
+ web_server_ip,
+ web_server_port,
+ );
+ (
+ screen,
+ ForwardCapture {
+ server_rx,
+ pty_writer_rx,
+ },
+ )
+}
+
+// Convenience constructors for the forwarding tests — all callers
+// want a fresh `HostQuery` value with the default terminator.
+use crate::host_query::{HostQuery, OscTerminator};
+
+fn bg_query() -> HostQuery {
+ HostQuery::DefaultBackground {
+ terminator: OscTerminator::St,
+ }
+}
+fn fg_query() -> HostQuery {
+ HostQuery::DefaultForeground {
+ terminator: OscTerminator::St,
+ }
+}
+fn fg_query_bel() -> HostQuery {
+ HostQuery::DefaultForeground {
+ terminator: OscTerminator::Bel,
+ }
+}
+fn palette_query(index: u8) -> HostQuery {
+ HostQuery::PaletteRegister {
+ index,
+ terminator: OscTerminator::St,
+ }
+}
+
+#[test]
+fn forward_host_query_when_idle_dispatches_immediately() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(42);
+ let query = bg_query();
+
+ let token = screen.forward_host_query(pane_id, query.clone());
+
+ assert_eq!(
+ screen.forward_in_flight_token,
+ Some(token),
+ "slot must flip to in-flight for the dispatched token"
+ );
+ assert_eq!(
+ screen
+ .pending_forwarded_queries
+ .get(&token)
+ .map(|e| e.pane_id),
+ Some(pane_id),
+ "token→pane mapping must be populated"
+ );
+ let forwards = capture.drain_forward_queries();
+ assert_eq!(forwards.len(), 1, "exactly one forward dispatched");
+ assert_eq!(
+ forwards[0],
+ (token, query.to_query_bytes()),
+ "wire bytes must be derived from the HostQuery"
+ );
+}
+
+#[test]
+fn forward_host_query_when_busy_queues_instead_of_dispatching() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let first_pane = PaneId::Terminal(1);
+ let second_pane = PaneId::Terminal(2);
+
+ let first_token = screen.forward_host_query(first_pane, bg_query());
+ let second_token = screen.forward_host_query(second_pane, fg_query());
+
+ // The first call dispatched; the second waits in the queue. Only
+ // the first token should be in the map; the second lives in
+ // `forward_queue`.
+ let forwards = capture.drain_forward_queries();
+ assert_eq!(forwards.len(), 1, "second call must not dispatch yet");
+ assert_eq!(forwards[0].0, first_token);
+ assert_eq!(screen.forward_queue.len(), 1);
+ assert_eq!(screen.forward_queue[0].token, second_token);
+ assert_eq!(screen.forward_queue[0].pane_id, second_pane);
+}
+
+#[test]
+fn handle_reply_writes_to_pane_pty_and_releases_slot() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane_id = PaneId::Terminal(7);
+ let token = screen.forward_host_query(pane_id, bg_query());
+ let _ = capture.drain_forward_queries(); // discard the dispatch
+
+ let reply = b"\x1b]11;rgb:1111/2222/3333\x1b\\".to_vec();
+ screen
+ .handle_forwarded_reply_from_host(token, reply.clone())
+ .expect("handler must not fail");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "one pty write expected");
+ assert_eq!(writes[0], (reply, 7));
+ assert!(
+ screen.forward_in_flight_token.is_none(),
+ "slot released so the next queued forward can dispatch"
+ );
+ assert!(
+ screen.pending_forwarded_queries.get(&token).is_none(),
+ "token entry must have been removed from the map"
+ );
+}
+
+#[test]
+fn handle_reply_dispatches_next_queued_forward() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let first_pane = PaneId::Terminal(1);
+ let second_pane = PaneId::Terminal(2);
+ let first_token = screen.forward_host_query(first_pane, bg_query());
+ let second_token = screen.forward_host_query(second_pane, fg_query());
+ // First dispatch already emitted; drop it.
+ let _ = capture.drain_forward_queries();
+
+ screen
+ .handle_forwarded_reply_from_host(first_token, b"reply".to_vec())
+ .expect("ok");
+
+ // The queued second forward must now dispatch, and the map now
+ // carries the second token → second pane.
+ let forwards = capture.drain_forward_queries();
+ assert_eq!(forwards.len(), 1, "next queued forward must dispatch");
+ assert_eq!(forwards[0].0, second_token);
+ assert!(screen.forward_queue.is_empty());
+ assert_eq!(screen.forward_in_flight_token, Some(second_token));
+ assert_eq!(
+ screen
+ .pending_forwarded_queries
+ .get(&second_token)
+ .map(|e| e.pane_id),
+ Some(second_pane)
+ );
+}
+
+#[test]
+fn handle_reply_with_unknown_token_is_silent_noop() {
+ // Token not in the map AND not the in-flight token: the handler
+ // must not panic, must not write any bytes, and crucially must
+ // NOT release the slot — the actually-in-flight forward still
+ // owns it. (See `late_timeout_after_real_reply_does_not_clobber`
+ // for the race scenario this guard prevents.)
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let first_pane = PaneId::Terminal(1);
+ let second_pane = PaneId::Terminal(2);
+ let first_token = screen.forward_host_query(first_pane, bg_query());
+ let _second_token = screen.forward_host_query(second_pane, fg_query());
+ let _ = capture.drain_forward_queries();
+
+ // Reply for a stale / unknown token (neither first nor second).
+ screen
+ .handle_forwarded_reply_from_host(9999, b"dropped".to_vec())
+ .expect("unknown tokens must not error");
+
+ assert!(
+ capture.drain_pty_writes().is_empty(),
+ "unknown token must not produce any pty write"
+ );
+ assert_eq!(
+ screen.forward_in_flight_token,
+ Some(first_token),
+ "in-flight token must still be the original"
+ );
+ assert!(
+ capture.drain_forward_queries().is_empty(),
+ "stale reply must not advance the queue"
+ );
+}
+
+#[test]
+fn late_timeout_after_real_reply_does_not_clobber_next_in_flight() {
+ // The race the token-equality guard exists to prevent:
+ // 1. dispatch token A (slot in-flight = A; A's timer is sleeping).
+ // 2. real reply for A arrives → handler releases slot, dispatches
+ // queued token B → slot in-flight = B; B's timer is sleeping.
+ // 3. A's server-side timeout fires after the real reply, sending
+ // an empty `ForwardedReplyFromHost { token: A, reply_bytes: [] }`.
+ //
+ // Without the guard, step 3 would clear the slot for token B and
+ // pop the next queued forward, clobbering an actively-in-flight
+ // request. The guard makes the late timeout a no-op.
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane_a = PaneId::Terminal(1);
+ let pane_b = PaneId::Terminal(2);
+ let pane_c = PaneId::Terminal(3);
+ let token_a = screen.forward_host_query(pane_a, bg_query());
+ let token_b = screen.forward_host_query(pane_b, fg_query());
+ let token_c = screen.forward_host_query(pane_c, palette_query(5));
+ let _ = capture.drain_forward_queries();
+
+ // Real reply for A arrives → slot moves to B.
+ screen
+ .handle_forwarded_reply_from_host(token_a, b"real-A".to_vec())
+ .expect("ok");
+ let dispatched = capture.drain_forward_queries();
+ assert_eq!(dispatched.len(), 1, "B must dispatch on A's release");
+ assert_eq!(dispatched[0].0, token_b);
+ assert_eq!(screen.forward_in_flight_token, Some(token_b));
+ // Drain the real reply's pty write so the late-timeout assertion
+ // below only sees writes (or absence thereof) caused by step 3.
+ let real_writes = capture.drain_pty_writes();
+ assert_eq!(real_writes.len(), 1, "real reply should write once");
+
+ // Late timeout for A fires (server-side timer woke up after the
+ // real reply already advanced the queue).
+ screen
+ .handle_forwarded_reply_from_host(token_a, Vec::new())
+ .expect("late timeout must be a no-op, not an error");
+
+ assert_eq!(
+ screen.forward_in_flight_token,
+ Some(token_b),
+ "B's slot must NOT be released by A's late timeout"
+ );
+ assert_eq!(
+ screen.forward_queue.front().map(|p| p.token),
+ Some(token_c),
+ "C must still be queued — A's late timeout must not have popped it"
+ );
+ assert!(
+ capture.drain_forward_queries().is_empty(),
+ "no spurious dispatch from a late timeout"
+ );
+ assert!(
+ capture.drain_pty_writes().is_empty(),
+ "no synthetic write to any pane from a late timeout"
+ );
+}
+
+#[test]
+fn timeout_for_in_flight_token_releases_slot_with_cache_fallback() {
+ // The non-racing case: server-side timeout fires while the token
+ // is still in flight (no client reply ever arrived — the old-client
+ // compatibility path). The handler must synthesize a cache-derived
+ // reply for the pane, release the slot, and dispatch the next
+ // queued forward. (Identical externally to the empty-reply
+ // cache-fallback case, since the timer fires by sending an empty
+ // reply for the in-flight token.)
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.update_terminal_background_color("rgb:1010/2020/3030".to_string());
+ let pane = PaneId::Terminal(11);
+ let queued_pane = PaneId::Terminal(12);
+ let token = screen.forward_host_query(pane, bg_query());
+ let queued_token = screen.forward_host_query(queued_pane, fg_query());
+ let _ = capture.drain_forward_queries();
+
+ // Simulate the timeout firing: empty reply for the in-flight token.
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "cache fallback writes one reply");
+ assert_eq!(
+ std::str::from_utf8(&writes[0].0).unwrap(),
+ "\u{1b}]11;rgb:1010/2020/3030\u{1b}\\",
+ );
+ assert_eq!(
+ screen.forward_in_flight_token,
+ Some(queued_token),
+ "queued forward dispatched on slot release"
+ );
+}
+
+#[test]
+fn token_counter_wraps_skipping_sentinel() {
+ // `next_forward_token == u32::MAX` → first allocation yields
+ // `u32::MAX`, the counter then wraps to 0 which is the reserved
+ // sentinel and is skipped, so the next allocation yields 1.
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.next_forward_token = u32::MAX;
+
+ let t1 = screen.forward_host_query(PaneId::Terminal(1), bg_query());
+ // Clear the in-flight slot so the next call actually allocates
+ // (the queueing branch would still allocate, but we want the
+ // dispatch branch here).
+ screen
+ .handle_forwarded_reply_from_host(t1, b"r".to_vec())
+ .expect("ok");
+ let _ = capture.drain_forward_queries();
+ let _ = capture.drain_pty_writes();
+
+ let t2 = screen.forward_host_query(PaneId::Terminal(2), fg_query());
+ assert_eq!(t1, u32::MAX, "first token should land on u32::MAX");
+ assert_eq!(
+ t2, 1,
+ "sentinel 0 must be skipped; next allocation wraps to 1"
+ );
+}
+
+#[test]
+fn plugin_pane_reply_is_dropped_without_write() {
+ // Plugin panes never emit whitelisted host queries in production
+ // code, but if a token → PaneId::Plugin mapping ever lands in the
+ // map (via tests or future misuse), the handler must drop the
+ // reply rather than routing it to the pty writer (plugin panes
+ // don't have a pty).
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.pending_forwarded_queries.insert(
+ 77,
+ super::PendingForwardEntry {
+ pane_id: PaneId::Plugin(42),
+ query: bg_query(),
+ },
+ );
+ screen.forward_in_flight_token = Some(77);
+
+ screen
+ .handle_forwarded_reply_from_host(77, b"\x1b]11;rgb:0/0/0\x1b\\".to_vec())
+ .expect("ok");
+
+ assert!(
+ capture.drain_pty_writes().is_empty(),
+ "plugin-pane token must not produce a pty write"
+ );
+ assert!(
+ screen.pending_forwarded_queries.get(&77).is_none(),
+ "map entry must still be cleared even when the reply is dropped"
+ );
+ assert!(
+ screen.forward_in_flight_token.is_none(),
+ "slot still released"
+ );
+}
+
+// =====================================================================
+// Cache-fallback synthesis: empty reply → answer from Screen's caches
+// =====================================================================
+
+#[test]
+fn empty_reply_falls_back_to_cached_background() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(9);
+ screen.update_terminal_background_color("rgb:1010/2020/3030".to_string());
+ let token = screen.forward_host_query(pane, bg_query());
+ let _ = capture.drain_forward_queries();
+
+ // Empty reply — the client couldn't or didn't answer.
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "synthesis must write exactly one reply");
+ let (bytes, terminal_id) = &writes[0];
+ assert_eq!(*terminal_id, 9);
+ assert_eq!(
+ std::str::from_utf8(bytes).unwrap(),
+ "\u{1b}]11;rgb:1010/2020/3030\u{1b}\\",
+ );
+}
+
+#[test]
+fn empty_reply_falls_back_to_cached_foreground_with_bel_terminator() {
+ // Query used BEL; reply must mirror the same terminator.
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(1);
+ screen.update_terminal_foreground_color("rgb:dcdc/dcdc/dcdc".to_string());
+ let token = screen.forward_host_query(pane, fg_query_bel());
+ let _ = capture.drain_forward_queries();
+
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(
+ std::str::from_utf8(&writes[0].0).unwrap(),
+ "\u{1b}]10;rgb:dcdc/dcdc/dcdc\u{7}",
+ );
+}
+
+#[test]
+fn empty_reply_falls_back_to_cached_pixel_dimensions() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(1);
+ screen.update_pixel_dimensions(PixelDimensions {
+ character_cell_size: Some(SizeInPixels {
+ height: 19,
+ width: 9,
+ }),
+ text_area_size: Some(SizeInPixels {
+ height: 608,
+ width: 931,
+ }),
+ });
+
+ // CSI 14t — text-area pixels.
+ let token = screen.forward_host_query(pane, HostQuery::TextAreaPixelSize);
+ let _ = capture.drain_forward_queries();
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+ let writes = capture.drain_pty_writes();
+ assert_eq!(
+ std::str::from_utf8(&writes[0].0).unwrap(),
+ "\u{1b}[4;608;931t"
+ );
+
+ // CSI 16t — cell size.
+ let token = screen.forward_host_query(pane, HostQuery::CharacterCellPixelSize);
+ let _ = capture.drain_forward_queries();
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+ let writes = capture.drain_pty_writes();
+ assert_eq!(std::str::from_utf8(&writes[0].0).unwrap(), "\u{1b}[6;19;9t");
+}
+
+#[test]
+fn empty_reply_falls_back_to_cached_palette_register() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(1);
+ screen.update_terminal_color_registers(vec![(42, "rgb:abab/cdcd/efef".to_string())]);
+ let token = screen.forward_host_query(pane, palette_query(42));
+ let _ = capture.drain_forward_queries();
+
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(
+ std::str::from_utf8(&writes[0].0).unwrap(),
+ "\u{1b}]4;42;rgb:abab/cdcd/efef\u{1b}\\",
+ );
+}
+
+#[test]
+fn empty_reply_with_no_cache_writes_empty() {
+ // No background override, no palette, no pixel dims: synthesis
+ // returns empty and the pane receives an empty write — the app
+ // decides what to do with "host declined".
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(1);
+
+ // Default Palette::fg is EightBit(0), not Rgb — synthesis refuses.
+ let token = screen.forward_host_query(pane, fg_query());
+ let _ = capture.drain_forward_queries();
+ screen
+ .handle_forwarded_reply_from_host(token, Vec::new())
+ .expect("ok");
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "still writes — the write is empty bytes");
+ assert!(
+ writes[0].0.is_empty(),
+ "no rgb cache → synthesis returns empty"
+ );
+}
+
+#[test]
+fn non_empty_reply_bypasses_synthesis() {
+ // A real reply from the host must be passed through verbatim —
+ // we must not second-guess it with cached state.
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ let pane = PaneId::Terminal(1);
+ screen.update_terminal_background_color("rgb:0000/0000/0000".to_string());
+ let token = screen.forward_host_query(pane, bg_query());
+ let _ = capture.drain_forward_queries();
+
+ let real = b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\".to_vec();
+ screen
+ .handle_forwarded_reply_from_host(token, real.clone())
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(
+ writes[0].0, real,
+ "real reply must be forwarded verbatim, not replaced by cached bg"
+ );
+}
+
+// =====================================================================
+// (CSI 2031 / DSR 997) (dark/light theme changes)
+// =====================================================================
+
+struct ThemeCapture {
+ plugin_rx: Receiver<(PluginInstruction, ErrorContext)>,
+ pty_writer_rx: Receiver<(PtyWriteInstruction, ErrorContext)>,
+}
+
+impl ThemeCapture {
+ fn drain_plugin_events(&self) -> Vec<Event> {
+ let mut out = Vec::new();
+ while let Ok((instr, _ctx)) = self.plugin_rx.try_recv() {
+ if let PluginInstruction::Update(updates) = instr {
+ for (_pid, _cid, ev) in updates {
+ out.push(ev);
+ }
+ }
+ }
+ out
+ }
+ fn drain_pty_writes(&self) -> Vec<(Vec<u8>, u32)> {
+ let mut out = Vec::new();
+ while let Ok((instr, _ctx)) = self.pty_writer_rx.try_recv() {
+ if let PtyWriteInstruction::Write(bytes, terminal_id, _) = instr {
+ out.push((bytes, terminal_id));
+ }
+ }
+ out
+ }
+}
+
+fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) {
+ let (plugin_tx, plugin_rx) = channels::unbounded::<(PluginInstruction, ErrorContext)>();
+ let (pty_writer_tx, pty_writer_rx) =
+ channels::unbounded::<(PtyWriteInstruction, ErrorContext)>();
+
+ let mut bus: Bus<ScreenInstruction> = Bus::empty();
+ bus.senders.to_plugin = Some(SenderWithContext::new(plugin_tx));
+ bus.senders.to_pty_writer = Some(SenderWithContext::new(pty_writer_tx));
+ let fake_os_input = FakeInputOutput::default();
+ bus.os_input = Some(Box::new(fake_os_input));
+
+ let client_attributes = ClientAttributes {
+ size,
+ ..Default::default()
+ };
+ let mut mode_info = ModeInfo::default();
+ mode_info.session_name = Some("zellij-test".into());
+ let copy_options = CopyOptions::default();
+ let default_layout = Box::new(Layout::default());
+ let default_shell = PathBuf::from("my_default_shell");
+ let web_sharing = WebSharing::Off;
+ let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+ let web_server_port = 8080;
+ let screen = Screen::new(
+ bus,
+ &client_attributes,
+ None,
+ mode_info,
+ false,
+ true,
+ true,
+ copy_options,
+ false,
+ default_layout,
+ None,
+ default_shell,
+ true,
+ false,
+ None,
+ true,
+ true,
+ true,
+ None,
+ false,
+ true,
+ None,
+ false,
+ web_sharing,
+ true,
+ true,
+ true,
+ false,
+ false,
+ web_server_ip,
+ web_server_port,
+ );
+ (
+ screen,
+ ThemeCapture {
+ plugin_rx,
+ pty_writer_rx,
+ },
+ )
+}
+
+#[test]
+fn host_theme_first_update_emits_plugin_event() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_theme_capture(size);
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Dark)
+ .expect("update ok");
+
+ let events = capture.drain_plugin_events();
+ assert!(
+ events.iter().any(|e| matches!(
+ e,
+ Event::HostTerminalThemeChanged(zellij_utils::data::HostTerminalThemeMode::Dark)
+ )),
+ "Event::HostTerminalThemeChanged(Dark) must be fanned out, got: {:?}",
+ events
+ );
+ assert_eq!(
+ screen.host_terminal_theme_mode,
+ Some(zellij_utils::data::HostTerminalThemeMode::Dark),
+ "stored mode must be updated"
+ );
+}
+
+#[test]
+fn host_theme_dedupes_duplicate_mode() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_theme_capture(size);
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
+ .expect("first ok");
+ let _ = capture.drain_plugin_events();
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
+ .expect("second ok");
+
+ let events = capture.drain_plugin_events();
+ assert!(
+ events.is_empty(),
+ "duplicate mode must not re-emit any plugin events, got: {:?}",
+ events
+ );
+}
+
+#[test]
+fn host_theme_emits_again_on_mode_flip() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_theme_capture(size);
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Dark)
+ .expect("first ok");
+ let _ = capture.drain_plugin_events();
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Light)
+ .expect("flip ok");
+
+ let events = capture.drain_plugin_events();
+ assert!(
+ events.iter().any(|e| matches!(
+ e,
+ Event::HostTerminalThemeChanged(zellij_utils::data::HostTerminalThemeMode::Light)
+ )),
+ "mode flip must re-emit the plugin event, got: {:?}",
+ events
+ );
+}
+
+#[test]
+fn color_palette_mode_query_short_circuits_to_dark_reply() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.host_terminal_theme_mode = Some(zellij_utils::data::HostTerminalThemeMode::Dark);
+
+ let token = screen.forward_host_query(PaneId::Terminal(13), HostQuery::ColorPaletteMode);
+
+ assert_eq!(
+ token, 0,
+ "ColorPaletteMode must return the sentinel token; no real forward was queued"
+ );
+ assert!(
+ capture.drain_forward_queries().is_empty(),
+ "must NOT forward to host — Zellij answers from cache"
+ );
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1, "exactly one pty reply expected");
+ assert_eq!(writes[0], (b"\x1b[?997;1n".to_vec(), 13));
+ assert!(
+ screen.forward_in_flight_token.is_none(),
+ "slot must remain free; short-circuit does not occupy the queue"
+ );
+}
+
+#[test]
+fn color_palette_mode_query_short_circuits_to_light_reply() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.host_terminal_theme_mode = Some(zellij_utils::data::HostTerminalThemeMode::Light);
+
+ let _ = screen.forward_host_query(PaneId::Terminal(4), HostQuery::ColorPaletteMode);
+
+ let writes = capture.drain_pty_writes();
+ assert_eq!(writes.len(), 1);
+ assert_eq!(writes[0], (b"\x1b[?997;2n".to_vec(), 4));
+}
+
+#[test]
+fn color_palette_mode_query_stays_silent_when_host_mode_unknown() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ assert!(
+ screen.host_terminal_theme_mode.is_none(),
+ "precondition: no host mode learned yet"
+ );
+
+ let _ = screen.forward_host_query(PaneId::Terminal(1), HostQuery::ColorPaletteMode);
+
+ assert!(
+ capture.drain_pty_writes().is_empty(),
+ "Contour spec defines only ;1 (dark) and ;2 (light); when Zellij has \
+ not learned the host's mode it must stay silent rather than fabricate \
+ a non-conformant reply (e.g. ;0)"
+ );
+}
+
+#[test]
+fn color_palette_mode_query_skips_plugin_panes() {
+ use crate::host_query::HostQuery;
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_forward_capture(size);
+ screen.host_terminal_theme_mode = Some(zellij_utils::data::HostTerminalThemeMode::Dark);
+
+ let _ = screen.forward_host_query(PaneId::Plugin(99), HostQuery::ColorPaletteMode);
+
+ assert!(
+ capture.drain_pty_writes().is_empty(),
+ "plugin panes have no VT pty — they get Event::HostTerminalThemeChanged instead"
+ );
+}
+
+#[test]
+fn host_theme_no_pty_writes_when_no_panes_subscribed() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, capture) = create_new_screen_with_theme_capture(size);
+
+ screen
+ .update_host_terminal_theme_mode(zellij_utils::data::HostTerminalThemeMode::Dark)
+ .expect("ok");
+
+ let writes = capture.drain_pty_writes();
+ assert!(
+ writes.is_empty(),
+ "no panes exist (or none subscribed via CSI ?2031h), so no DSR forward is queued, got: {:?}",
+ writes
+ );
+}