diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-04-28 17:14:59 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 17:14:59 +0200 |
| commit | 227142dd567ee72bcc137c116bc1ce2b818975e7 (patch) | |
| tree | 8958a994dddb80bf20ce1e6bf2694687736b0b19 /zellij-server/src | |
| parent | upgrade rustls-webpki (#5106) (diff) | |
| download | sl0ppij-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.rs | 180 | ||||
| -rw-r--r-- | zellij-server/src/lib.rs | 292 | ||||
| -rw-r--r-- | zellij-server/src/panes/grid.rs | 302 | ||||
| -rw-r--r-- | zellij-server/src/panes/terminal_pane.rs | 8 | ||||
| -rw-r--r-- | zellij-server/src/panes/unit/grid_tests.rs | 477 | ||||
| -rw-r--r-- | zellij-server/src/route.rs | 52 | ||||
| -rw-r--r-- | zellij-server/src/screen.rs | 570 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 27 | ||||
| -rw-r--r-- | zellij-server/src/unit/screen_tests.rs | 870 |
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 ¶ms[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 + ); +} |
