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-utils | |
| 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-utils')
32 files changed, 790 insertions, 25 deletions
diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs index 267b900c..17bdc9c0 100644 --- a/zellij-utils/assets/prost/api.action.rs +++ b/zellij-utils/assets/prost/api.action.rs @@ -1117,6 +1117,9 @@ pub enum ActionName { ShowFloatingPanes = 98, HideFloatingPanes = 99, AreFloatingPanesVisible = 100, + SetDarkTheme = 101, + SetLightTheme = 102, + ToggleTheme = 103, } impl ActionName { /// String value of the enum field names used in the ProtoBuf definition. @@ -1223,6 +1226,9 @@ impl ActionName { ActionName::ShowFloatingPanes => "ShowFloatingPanes", ActionName::HideFloatingPanes => "HideFloatingPanes", ActionName::AreFloatingPanesVisible => "AreFloatingPanesVisible", + ActionName::SetDarkTheme => "SetDarkTheme", + ActionName::SetLightTheme => "SetLightTheme", + ActionName::ToggleTheme => "ToggleTheme", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -1326,6 +1332,9 @@ impl ActionName { "ShowFloatingPanes" => Some(Self::ShowFloatingPanes), "HideFloatingPanes" => Some(Self::HideFloatingPanes), "AreFloatingPanesVisible" => Some(Self::AreFloatingPanesVisible), + "SetDarkTheme" => Some(Self::SetDarkTheme), + "SetLightTheme" => Some(Self::SetLightTheme), + "ToggleTheme" => Some(Self::ToggleTheme), _ => None, } } diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs index 2ec490b0..38976384 100644 --- a/zellij-utils/assets/prost/api.event.rs +++ b/zellij-utils/assets/prost/api.event.rs @@ -9,7 +9,7 @@ pub struct EventNameList { pub struct Event { #[prost(enumeration="EventType", tag="1")] pub name: i32, - #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39")] + #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40")] pub payload: ::core::option::Option<event::Payload>, } /// Nested message and enum types in `Event`. @@ -93,10 +93,18 @@ pub mod event { InitialKeybindsPayload(super::InitialKeybindsPayload), #[prost(message, tag="39")] CommandChangedPayload(super::CommandChangedPayload), + #[prost(message, tag="40")] + HostTerminalThemeChangedPayload(super::HostTerminalThemeChangedPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct HostTerminalThemeChangedPayload { + #[prost(enumeration="HostTerminalThemeIndication", tag="1")] + pub mode: i32, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct CwdChangedPayload { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option<PaneId>, @@ -830,6 +838,7 @@ pub enum EventType { PaneRenderReportWithAnsi = 43, InitialKeybinds = 44, CommandChanged = 45, + HostTerminalThemeChanged = 46, } impl EventType { /// String value of the enum field names used in the ProtoBuf definition. @@ -883,6 +892,7 @@ impl EventType { EventType::PaneRenderReportWithAnsi => "PaneRenderReportWithAnsi", EventType::InitialKeybinds => "InitialKeybinds", EventType::CommandChanged => "CommandChanged", + EventType::HostTerminalThemeChanged => "HostTerminalThemeChanged", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -933,6 +943,33 @@ impl EventType { "PaneRenderReportWithAnsi" => Some(Self::PaneRenderReportWithAnsi), "InitialKeybinds" => Some(Self::InitialKeybinds), "CommandChanged" => Some(Self::CommandChanged), + "HostTerminalThemeChanged" => Some(Self::HostTerminalThemeChanged), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum HostTerminalThemeIndication { + Dark = 0, + Light = 1, +} +impl HostTerminalThemeIndication { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + HostTerminalThemeIndication::Dark => "Dark", + HostTerminalThemeIndication::Light => "Light", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "Dark" => Some(Self::Dark), + "Light" => Some(Self::Light), _ => None, } } diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs index 5ae8460b..142609f7 100644 --- a/zellij-utils/assets/prost_ipc/client_server_contract.rs +++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs @@ -119,7 +119,7 @@ pub struct RgbColor { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Action { - #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136")] + #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139")] pub action_type: ::core::option::Option<action::ActionType>, } /// Nested message and enum types in `Action`. @@ -401,6 +401,12 @@ pub mod action { FocusPaneByPaneId(super::FocusPaneByPaneIdAction), #[prost(message, tag="136")] AreFloatingPanesVisible(super::AreFloatingPanesVisibleAction), + #[prost(message, tag="137")] + SetDarkTheme(super::SetDarkThemeAction), + #[prost(message, tag="138")] + SetLightTheme(super::SetLightThemeAction), + #[prost(message, tag="139")] + ToggleTheme(super::ToggleThemeAction), } } // Action message definitions (all 92 variants) @@ -552,6 +558,18 @@ pub struct DetachAction { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetDarkThemeAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetLightThemeAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ToggleThemeAction { +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct CopyAction { } #[allow(clippy::derive_partial_eq_without_eq)] @@ -1927,6 +1945,10 @@ pub struct Options { pub focus_follows_mouse: ::core::option::Option<bool>, #[prost(bool, optional, tag="45")] pub mouse_click_through: ::core::option::Option<bool>, + #[prost(string, optional, tag="46")] + pub theme_dark: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="47")] + pub theme_light: ::core::option::Option<::prost::alloc::string::String>, } /// Pane-targeting action messages #[allow(clippy::derive_partial_eq_without_eq)] @@ -2857,7 +2879,7 @@ impl WebSharing { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClientToServerMsg { - #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18")] + #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20")] pub message: ::core::option::Option<client_to_server_msg::Message>, } /// Nested message and enum types in `ClientToServerMsg`. @@ -2901,6 +2923,10 @@ pub mod client_to_server_msg { SubscribeToPaneRenders(super::SubscribeToPaneRendersMsg), #[prost(message, tag="18")] DesktopNotificationResponse(super::DesktopNotificationResponseMsg), + #[prost(message, tag="19")] + ForwardedReplyFromHost(super::ForwardedReplyFromHostMsg), + #[prost(message, tag="20")] + HostTerminalThemeChanged(super::HostTerminalThemeChangedMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3034,8 +3060,48 @@ pub struct DesktopNotificationResponseMsg { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct ForwardedReplyFromHostMsg { + #[prost(uint32, tag="1")] + pub token: u32, + #[prost(bytes="vec", tag="2")] + pub reply_bytes: ::prost::alloc::vec::Vec<u8>, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct HostTerminalThemeChangedMsg { + #[prost(enumeration="HostTerminalThemeIndication", tag="1")] + pub mode: i32, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum HostTerminalThemeIndication { + Dark = 0, + Light = 1, +} +impl HostTerminalThemeIndication { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + HostTerminalThemeIndication::Dark => "HOST_TERMINAL_THEME_INDICATION_DARK", + HostTerminalThemeIndication::Light => "HOST_TERMINAL_THEME_INDICATION_LIGHT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option<Self> { + match value { + "HOST_TERMINAL_THEME_INDICATION_DARK" => Some(Self::Dark), + "HOST_TERMINAL_THEME_INDICATION_LIGHT" => Some(Self::Light), + _ => None, + } + } +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct ServerToClientMsg { - #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15")] + #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16")] pub message: ::core::option::Option<server_to_client_msg::Message>, } /// Nested message and enum types in `ServerToClientMsg`. @@ -3073,6 +3139,8 @@ pub mod server_to_client_msg { PaneRenderUpdate(super::PaneRenderUpdateMsg), #[prost(message, tag="15")] SubscribedPaneClosed(super::SubscribedPaneClosedMsg), + #[prost(message, tag="16")] + ForwardQueryToHost(super::ForwardQueryToHostMsg), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3172,3 +3240,11 @@ pub struct SubscribedPaneClosedMsg { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option<PaneId>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ForwardQueryToHostMsg { + #[prost(uint32, tag="1")] + pub token: u32, + #[prost(bytes="vec", tag="2")] + pub query_bytes: ::prost::alloc::vec::Vec<u8>, +} diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index f02ace98..5b169170 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -1621,6 +1621,12 @@ tail -f /tmp/my-live-logfile | zellij action pipe --name logs --plugin https://e }, /// Detach from the current session Detach, + /// Switch the theme to dark (uses configured `theme_dark`). + SetDarkTheme, + /// Switch the theme to light (uses configured `theme_light`). + SetLightTheme, + /// Toggle between dark and light themes (used configured `theme_dark` and `theme_light`) + ToggleTheme, /// Switch to a different session SwitchSession { /// Name of the session to switch to diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto index 2e783f3d..834feccd 100644 --- a/zellij-utils/src/client_server_contract/client_to_server.proto +++ b/zellij-utils/src/client_server_contract/client_to_server.proto @@ -23,6 +23,8 @@ message ClientToServerMsg { AttachWatcherClientMsg attach_watcher_client = 16; SubscribeToPaneRendersMsg subscribe_to_pane_renders = 17; DesktopNotificationResponseMsg desktop_notification_response = 18; + ForwardedReplyFromHostMsg forwarded_reply_from_host = 19; + HostTerminalThemeChangedMsg host_terminal_theme_changed = 20; } } @@ -109,3 +111,17 @@ message SubscribeToPaneRendersMsg { message DesktopNotificationResponseMsg { bytes raw_bytes = 1; } + +message ForwardedReplyFromHostMsg { + uint32 token = 1; + bytes reply_bytes = 2; +} + +message HostTerminalThemeChangedMsg { + HostTerminalThemeIndication mode = 1; +} + +enum HostTerminalThemeIndication { + HOST_TERMINAL_THEME_INDICATION_DARK = 0; + HOST_TERMINAL_THEME_INDICATION_LIGHT = 1; +} diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto index e610eb2a..b592eb8d 100644 --- a/zellij-utils/src/client_server_contract/common_types.proto +++ b/zellij-utils/src/client_server_contract/common_types.proto @@ -274,6 +274,9 @@ message Action { MoveTabByTabIdAction move_tab_by_tab_id = 134; FocusPaneByPaneIdAction focus_pane_by_pane_id = 135; AreFloatingPanesVisibleAction are_floating_panes_visible = 136; + SetDarkThemeAction set_dark_theme = 137; + SetLightThemeAction set_light_theme = 138; + ToggleThemeAction toggle_theme = 139; } } @@ -322,6 +325,9 @@ message CloseTabAction {} message ToggleTabAction {} message UndoRenameTabAction {} message DetachAction {} +message SetDarkThemeAction {} +message SetLightThemeAction {} +message ToggleThemeAction {} message CopyAction {} message ConfirmAction {} message DenyAction {} @@ -1166,6 +1172,8 @@ message Options { optional bool visual_bell = 43; optional bool focus_follows_mouse = 44; optional bool mouse_click_through = 45; + optional string theme_dark = 46; + optional string theme_light = 47; } enum OnForceClose { diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto index e0b8b529..b2fc3bd6 100644 --- a/zellij-utils/src/client_server_contract/server_to_client.proto +++ b/zellij-utils/src/client_server_contract/server_to_client.proto @@ -20,6 +20,7 @@ message ServerToClientMsg { ConfigFileUpdatedMsg config_file_updated = 13; PaneRenderUpdateMsg pane_render_update = 14; SubscribedPaneClosedMsg subscribed_pane_closed = 15; + ForwardQueryToHostMsg forward_query_to_host = 16; } } @@ -88,3 +89,8 @@ message PaneRenderUpdateMsg { message SubscribedPaneClosedMsg { PaneId pane_id = 1; } + +message ForwardQueryToHostMsg { + uint32 token = 1; + bytes query_bytes = 2; +} diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs index 32bb8153..0e8b5112 100644 --- a/zellij-utils/src/consts.rs +++ b/zellij-utils/src/consts.rs @@ -106,8 +106,6 @@ lazy_static! { pub static ref ZELLIJ_SESSION_INFO_CACHE_DIR: PathBuf = ZELLIJ_CACHE_DIR .join(CLIENT_SERVER_CONTRACT_DIR.clone()) .join("session_info"); - pub static ref ZELLIJ_STDIN_CACHE_FILE: PathBuf = - ZELLIJ_CACHE_DIR.join(VERSION).join("stdin_cache"); pub static ref ZELLIJ_PLUGIN_ARTIFACT_DIR: PathBuf = ZELLIJ_CACHE_DIR.join(VERSION); pub static ref ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE: PathBuf = ZELLIJ_CACHE_DIR.join(VERSION).join("seen_release_notes"); diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index cfcc3692..7b8ebf3f 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -1025,6 +1025,14 @@ pub enum Event { /// Plugins that subscribe to this event signal they cache keybindings /// and can handle lightweight ModeUpdate events without keybindings. InitialKeybinds(KeybindsVec), + /// The host terminal indicated its color palette theme mode (CSI 2031 / DSR 997). + HostTerminalThemeChanged(HostTerminalThemeMode), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum HostTerminalThemeMode { + Dark, + Light, } #[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, Display, Serialize, Deserialize)] diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index 5bb8e955..484b4dfb 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -310,6 +310,12 @@ pub enum ScreenContext { TerminalBackgroundColor, TerminalForegroundColor, TerminalColorRegisters, + ForwardHostQuery, + ForwardedReplyFromHost, + HostTerminalThemeChanged, + SetDarkTheme, + SetLightTheme, + ToggleTheme, ChangeMode, ChangeModeForAllClients, LeftClick, @@ -553,8 +559,6 @@ pub enum ClientContext { Log, LogError, OwnClientId, - StartedParsingStdinQuery, - DoneParsingStdinQuery, SwitchSession, SetSynchronisedOutput, UnblockCliPipeInput, @@ -564,6 +568,7 @@ pub enum ClientContext { StartWebServer, RenamedSession, ConfigFileUpdated, + ForwardQueryToHost, } /// Stack call representations corresponding to the different types of [`ServerInstruction`]s. @@ -599,6 +604,7 @@ pub enum ServerContext { FailedToStartWebServer, SendWebClientsForbidden, ClearMouseHelpText, + ForwardQueryToHost, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index ab4c9ae3..7f6fabfe 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -353,6 +353,12 @@ pub enum Action { }, /// Detach session and exit Detach, + /// Switch the host-terminal theme mode to dark (uses configured `theme_dark`). + SetDarkTheme, + /// Switch the host-terminal theme mode to light (uses configured `theme_light`). + SetLightTheme, + /// Toggle between dark and light host-terminal theme modes. + ToggleTheme, /// Switch to a different session SwitchSession { name: String, @@ -2052,6 +2058,9 @@ impl Action { } }, CliAction::Detach => Ok(vec![Action::Detach]), + CliAction::SetDarkTheme => Ok(vec![Action::SetDarkTheme]), + CliAction::SetLightTheme => Ok(vec![Action::SetLightTheme]), + CliAction::ToggleTheme => Ok(vec![Action::ToggleTheme]), CliAction::SwitchSession { name, tab_position, @@ -3053,6 +3062,42 @@ mod tests { assert!(matches!(actions[0], Action::CloseTab)); } + #[test] + fn test_set_dark_theme_cli_to_action() { + let result = Action::actions_from_cli( + CliAction::SetDarkTheme, + Box::new(|| PathBuf::from("/tmp")), + None, + ); + let actions = result.expect("SetDarkTheme conversion should succeed"); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::SetDarkTheme)); + } + + #[test] + fn test_set_light_theme_cli_to_action() { + let result = Action::actions_from_cli( + CliAction::SetLightTheme, + Box::new(|| PathBuf::from("/tmp")), + None, + ); + let actions = result.expect("SetLightTheme conversion should succeed"); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::SetLightTheme)); + } + + #[test] + fn test_toggle_theme_cli_to_action() { + let result = Action::actions_from_cli( + CliAction::ToggleTheme, + Box::new(|| PathBuf::from("/tmp")), + None, + ); + let actions = result.expect("ToggleTheme conversion should succeed"); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], Action::ToggleTheme)); + } + // 21. RenameTab #[test] fn test_rename_tab_with_tab_id() { diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs index bb90b7b1..854ef271 100644 --- a/zellij-utils/src/input/options.rs +++ b/zellij-utils/src/input/options.rs @@ -48,6 +48,16 @@ pub struct Options { /// Set the default theme #[clap(long, value_parser)] pub theme: Option<String>, + /// Theme name to apply when the host terminal reports a dark color palette + /// (CSI 2031 / DSR 997). Requires `theme_light` to also be set; if either + /// is missing the static `theme` remains authoritative. + #[clap(long, value_parser)] + pub theme_dark: Option<String>, + /// Theme name to apply when the host terminal reports a light color palette + /// (CSI 2031 / DSR 997). Requires `theme_dark` to also be set; if either + /// is missing the static `theme` remains authoritative. + #[clap(long, value_parser)] + pub theme_light: Option<String>, /// Set the default mode #[clap(long, arg_enum, hide_possible_values = true, value_parser)] pub default_mode: Option<InputMode>, @@ -319,6 +329,8 @@ impl Options { let layout_dir = other.layout_dir.or_else(|| self.layout_dir.clone()); let theme_dir = other.theme_dir.or_else(|| self.theme_dir.clone()); let theme = other.theme.or_else(|| self.theme.clone()); + let theme_dark = other.theme_dark.or_else(|| self.theme_dark.clone()); + let theme_light = other.theme_light.or_else(|| self.theme_light.clone()); let on_force_close = other.on_force_close.or(self.on_force_close); let scroll_buffer_size = other.scroll_buffer_size.or(self.scroll_buffer_size); let copy_command = other.copy_command.or_else(|| self.copy_command.clone()); @@ -376,6 +388,8 @@ impl Options { Options { simplified_ui, theme, + theme_dark, + theme_light, default_mode, default_shell, default_cwd, @@ -454,6 +468,8 @@ impl Options { let layout_dir = other.layout_dir.or_else(|| self.layout_dir.clone()); let theme_dir = other.theme_dir.or_else(|| self.theme_dir.clone()); let theme = other.theme.or_else(|| self.theme.clone()); + let theme_dark = other.theme_dark.or_else(|| self.theme_dark.clone()); + let theme_light = other.theme_light.or_else(|| self.theme_light.clone()); let on_force_close = other.on_force_close.or(self.on_force_close); let scroll_buffer_size = other.scroll_buffer_size.or(self.scroll_buffer_size); let copy_command = other.copy_command.or_else(|| self.copy_command.clone()); @@ -507,6 +523,8 @@ impl Options { Options { simplified_ui, theme, + theme_dark, + theme_light, default_mode, default_shell, default_cwd, diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs index 854ef0b9..5f54478d 100644 --- a/zellij-utils/src/ipc.rs +++ b/zellij-utils/src/ipc.rs @@ -1,6 +1,6 @@ //! IPC stuff for starting to split things into a client and server model. use crate::{ - data::{ClientId, ConnectToSession, KeyWithModifier, PaneId, Style}, + data::{ClientId, ConnectToSession, HostTerminalThemeMode, KeyWithModifier, PaneId, Style}, errors::{prelude::*, ErrorContext}, input::{actions::Action, cli_assets::CliAssets}, pane_size::{Size, SizeInPixels}, @@ -156,6 +156,21 @@ pub enum ClientToServerMsg { DesktopNotificationResponse { raw_bytes: Vec<u8>, }, + /// Raw reply bytes observed by the client inside a forwarding window + /// closed by the Primary-DA barrier. The server routes these bytes + /// verbatim to the pane whose app issued the whitelisted query + /// (tracked on the server by `token`). + 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). The server propagates this to the active configured + /// theme and to subscribed plugins/panes. + HostTerminalThemeChanged { + mode: HostTerminalThemeMode, + }, } // Types of messages sent from the server to the client @@ -200,6 +215,14 @@ pub enum ServerToClientMsg { SubscribedPaneClosed { pane_id: PaneId, }, + /// Instruct the client to write `query_bytes` followed by the + /// Primary-DA barrier to stdout, open a forwarding window keyed by + /// `token`, and reply with a `ForwardedReplyFromHost` once the + /// barrier closes or the window times out. + ForwardQueryToHost { + token: u32, + query_bytes: Vec<u8>, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs index 656ef54c..8025d66f 100644 --- a/zellij-utils/src/ipc/protobuf_conversion.rs +++ b/zellij-utils/src/ipc/protobuf_conversion.rs @@ -5,15 +5,18 @@ use crate::{ ClientToServerMsg as ProtoClientToServerMsg, ColorRegistersMsg, ConfigFileUpdatedMsg, ConnStatusMsg, ConnectedMsg, DesktopNotificationResponseMsg, DetachSessionMsg, ExitMsg, ExitReason as ProtoExitReason, FailedToStartWebServerMsg, FirstClientConnectedMsg, - ForegroundColorMsg, InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, - LayoutMetadata as ProtoLayoutMetadata, LogErrorMsg, LogMsg, - PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg, QueryTerminalSizeMsg, - RenamedSessionMsg, RenderMsg, ServerToClientMsg as ProtoServerToClientMsg, - StartWebServerMsg, SubscribeToPaneRendersMsg, SubscribedPaneClosedMsg, SwitchSessionMsg, - TabMetadata as ProtoTabMetadata, TerminalPixelDimensionsMsg, TerminalResizeMsg, - UnblockCliPipeInputMsg, UnblockInputThreadMsg, WebServerStartedMsg, + ForegroundColorMsg, ForwardQueryToHostMsg, ForwardedReplyFromHostMsg, + HostTerminalThemeChangedMsg, + HostTerminalThemeIndication as ProtoHostTerminalThemeIndication, + InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata, + LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg, + QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, + ServerToClientMsg as ProtoServerToClientMsg, StartWebServerMsg, SubscribeToPaneRendersMsg, + SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata, + TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg, + UnblockInputThreadMsg, WebServerStartedMsg, }, - data::{InputMode, PaneId}, + data::{HostTerminalThemeMode, InputMode, PaneId}, errors::prelude::*, ipc::{ ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, @@ -129,6 +132,20 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg { DesktopNotificationResponseMsg { raw_bytes }, ) }, + ClientToServerMsg::ForwardedReplyFromHost { token, reply_bytes } => { + client_to_server_msg::Message::ForwardedReplyFromHost(ForwardedReplyFromHostMsg { + token, + reply_bytes, + }) + }, + ClientToServerMsg::HostTerminalThemeChanged { mode } => { + let proto_mode: ProtoHostTerminalThemeIndication = mode.into(); + client_to_server_msg::Message::HostTerminalThemeChanged( + HostTerminalThemeChangedMsg { + mode: proto_mode as i32, + }, + ) + }, }; ProtoClientToServerMsg { @@ -257,6 +274,19 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg { raw_bytes: msg.raw_bytes, }) }, + Some(client_to_server_msg::Message::ForwardedReplyFromHost(msg)) => { + Ok(ClientToServerMsg::ForwardedReplyFromHost { + token: msg.token, + reply_bytes: msg.reply_bytes, + }) + }, + Some(client_to_server_msg::Message::HostTerminalThemeChanged(msg)) => { + let proto_mode = ProtoHostTerminalThemeIndication::from_i32(msg.mode) + .ok_or_else(|| anyhow!("Unknown HostTerminalThemeIndication: {}", msg.mode))?; + Ok(ClientToServerMsg::HostTerminalThemeChanged { + mode: proto_mode.into(), + }) + }, None => Err(anyhow!("Empty ClientToServerMsg message")), } } @@ -336,6 +366,12 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg { pane_id: Some(pane_id.into()), }) }, + ServerToClientMsg::ForwardQueryToHost { token, query_bytes } => { + server_to_client_msg::Message::ForwardQueryToHost(ForwardQueryToHostMsg { + token, + query_bytes, + }) + }, }; ProtoServerToClientMsg { @@ -442,6 +478,12 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg { .try_into()?; Ok(ServerToClientMsg::SubscribedPaneClosed { pane_id }) }, + Some(server_to_client_msg::Message::ForwardQueryToHost(msg)) => { + Ok(ServerToClientMsg::ForwardQueryToHost { + token: msg.token, + query_bytes: msg.query_bytes, + }) + }, None => Err(anyhow!("Empty ServerToClientMsg message")), } } @@ -625,6 +667,8 @@ impl From<crate::input::options::Options> Self { simplified_ui: options.simplified_ui, theme: options.theme, + theme_dark: options.theme_dark, + theme_light: options.theme_light, default_mode: options.default_mode.map(|m| input_mode_to_proto_i32(m)), default_shell: options .default_shell @@ -707,6 +751,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options> Ok(Self { simplified_ui: options.simplified_ui, theme: options.theme, + theme_dark: options.theme_dark, + theme_light: options.theme_light, default_mode: options .default_mode .map(|m| proto_i32_to_input_mode(m)) @@ -897,6 +943,8 @@ impl From<crate::input::actions::Action> SearchAction, SearchInputAction, SearchToggleOptionAction, + SetDarkThemeAction, + SetLightThemeAction, SetPaneBorderlessAction, SetPaneColorAction, ShowFloatingPanesAction, @@ -924,6 +972,7 @@ impl From<crate::input::actions::Action> TogglePanePinnedAction, TogglePanePinnedByPaneIdAction, ToggleTabAction, + ToggleThemeAction, UndoRenamePaneAction, UndoRenamePaneByPaneIdAction, UndoRenameTabAction, @@ -1278,6 +1327,15 @@ impl From<crate::input::actions::Action> near_current_pane, }), crate::input::actions::Action::Detach => ActionType::Detach(DetachAction {}), + crate::input::actions::Action::SetDarkTheme => { + ActionType::SetDarkTheme(SetDarkThemeAction {}) + }, + crate::input::actions::Action::SetLightTheme => { + ActionType::SetLightTheme(SetLightThemeAction {}) + }, + crate::input::actions::Action::ToggleTheme => { + ActionType::ToggleTheme(ToggleThemeAction {}) + }, crate::input::actions::Action::SwitchSession { name, tab_position, @@ -2139,6 +2197,9 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> near_current_pane: run_action.near_current_pane, }), ActionType::Detach(_) => Ok(crate::input::actions::Action::Detach), + ActionType::SetDarkTheme(_) => Ok(crate::input::actions::Action::SetDarkTheme), + ActionType::SetLightTheme(_) => Ok(crate::input::actions::Action::SetLightTheme), + ActionType::ToggleTheme(_) => Ok(crate::input::actions::Action::ToggleTheme), ActionType::SwitchSession(switch_session_action) => { Ok(crate::input::actions::Action::SwitchSession { name: switch_session_action.name.clone(), @@ -2952,6 +3013,24 @@ impl TryFrom<ProtoExitReason> for ExitReason { } } +impl From<HostTerminalThemeMode> for ProtoHostTerminalThemeIndication { + fn from(mode: HostTerminalThemeMode) -> Self { + match mode { + HostTerminalThemeMode::Dark => ProtoHostTerminalThemeIndication::Dark, + HostTerminalThemeMode::Light => ProtoHostTerminalThemeIndication::Light, + } + } +} + +impl From<ProtoHostTerminalThemeIndication> for HostTerminalThemeMode { + fn from(mode: ProtoHostTerminalThemeIndication) -> Self { + match mode { + ProtoHostTerminalThemeIndication::Dark => HostTerminalThemeMode::Dark, + ProtoHostTerminalThemeIndication::Light => HostTerminalThemeMode::Light, + } + } +} + // InputMode conversion helper functions fn input_mode_to_proto_i32(mode: InputMode) -> i32 { match mode { diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs index 23b8c4ac..4847b679 100644 --- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs +++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs @@ -1,8 +1,8 @@ use super::test_framework::*; use crate::data::{ - BareKey, CommandOrPlugin, ConnectToSession, Direction, FloatingPaneCoordinates, InputMode, - KeyModifier, KeyWithModifier, LayoutInfo, LayoutMetadata, NewPanePlacement, OriginatingPlugin, - PaneId, PluginTag, Resize, WebSharing, + BareKey, CommandOrPlugin, ConnectToSession, Direction, FloatingPaneCoordinates, + HostTerminalThemeMode, InputMode, KeyModifier, KeyWithModifier, LayoutInfo, LayoutMetadata, + NewPanePlacement, OriginatingPlugin, PaneId, PluginTag, Resize, WebSharing, }; use crate::input::actions::{Action, SearchDirection, SearchOption}; use crate::input::cli_assets::CliAssets; @@ -433,6 +433,8 @@ fn test_client_messages() { configuration_options: Some(Options { simplified_ui: Some(true), theme: Some("theme".to_owned()), + theme_dark: Some("theme_dark".to_owned()), + theme_light: Some("theme_light".to_owned()), default_mode: Some(InputMode::Normal), default_shell: Some(PathBuf::from("default_shell")), default_cwd: Some(PathBuf::from("default_cwd")), @@ -3538,6 +3540,35 @@ fn test_client_messages() { client_id: Some(100), is_cli_client: true, }); + test_client_roundtrip!(ClientToServerMsg::ForwardedReplyFromHost { + token: 0, + reply_bytes: vec![], + }); + test_client_roundtrip!(ClientToServerMsg::ForwardedReplyFromHost { + token: 42, + reply_bytes: b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\".to_vec(), + }); + test_client_roundtrip!(ClientToServerMsg::ForwardedReplyFromHost { + token: u32::MAX, + reply_bytes: (0u8..=255u8).collect(), + }); + // Guard against protobuf string-vs-bytes encoding regressions: a + // payload mixing NULs, ESC, and CSI framing must round-trip + // byte-for-byte or the pane will receive corrupt terminal data. + test_client_roundtrip!(ClientToServerMsg::ForwardedReplyFromHost { + token: 17, + reply_bytes: vec![ + 0x00, 0x1b, b'[', b'?', b'6', b'2', b';', b'1', b';', b'6', b'c', 0x00, 0x1b, b']', + b'1', b'1', b';', b'r', b'g', b'b', b':', b'f', b'f', b'f', b'f', b'/', b'f', b'f', + b'f', b'f', b'/', b'f', b'f', b'f', b'f', 0x07, 0xff, 0x00, + ], + }); + test_client_roundtrip!(ClientToServerMsg::HostTerminalThemeChanged { + mode: HostTerminalThemeMode::Dark, + }); + test_client_roundtrip!(ClientToServerMsg::HostTerminalThemeChanged { + mode: HostTerminalThemeMode::Light, + }); } fn test_server_messages() { @@ -3673,6 +3704,18 @@ fn test_server_messages() { test_server_roundtrip!(ServerToClientMsg::SubscribedPaneClosed { pane_id: PaneId::Plugin(3), }); + test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost { + token: 0, + query_bytes: vec![], + }); + test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost { + token: 7, + query_bytes: b"\x1b[14t".to_vec(), + }); + test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost { + token: u32::MAX, + query_bytes: (0u8..=255u8).collect(), + }); } #[test] diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index 2b009e1a..c7cc5366 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -74,6 +74,9 @@ macro_rules! parse_kdl_action_arguments { "ToggleTab" => Ok(Action::ToggleTab), "UndoRenameTab" => Ok(Action::UndoRenameTab), "Detach" => Ok(Action::Detach), + "SetDarkTheme" => Ok(Action::SetDarkTheme), + "SetLightTheme" => Ok(Action::SetLightTheme), + "ToggleTheme" => Ok(Action::ToggleTheme), "Copy" => Ok(Action::Copy), "Confirm" => Ok(Action::Confirm), "Deny" => Ok(Action::Deny), @@ -2674,6 +2677,10 @@ impl Options { kdl_property_first_arg_as_bool_or_error!(kdl_options, "auto_layout").map(|(v, _)| v); let theme = kdl_property_first_arg_as_string_or_error!(kdl_options, "theme") .map(|(theme, _entry)| theme.to_string()); + let theme_dark = kdl_property_first_arg_as_string_or_error!(kdl_options, "theme_dark") + .map(|(theme, _entry)| theme.to_string()); + let theme_light = kdl_property_first_arg_as_string_or_error!(kdl_options, "theme_light") + .map(|(theme, _entry)| theme.to_string()); let default_mode = match kdl_property_first_arg_as_string_or_error!(kdl_options, "default_mode") { Some((string, entry)) => Some(InputMode::from_str(string).map_err(|_| { @@ -2821,6 +2828,8 @@ impl Options { Ok(Options { simplified_ui, theme, + theme_dark, + theme_light, default_mode, default_shell, default_cwd, @@ -2958,6 +2967,62 @@ impl Options { None } } + fn theme_dark_to_kdl(&self, add_comments: bool) -> Option<KdlNode> { + let comment_text = format!( + "{}\n{}\n{}\n{}", + " ", + "// Theme to use when the host terminal reports a dark color palette.", + "// Requires `theme_light` to also be set; otherwise `theme` is used.", + "// ", + ); + + let create_node = |node_value: &str| -> KdlNode { + let mut node = KdlNode::new("theme_dark"); + node.push(node_value.to_owned()); + node + }; + if let Some(theme) = &self.theme_dark { + let mut node = create_node(theme); + if add_comments { + node.set_leading(format!("{}\n", comment_text)); + } + Some(node) + } else if add_comments { + let mut node = create_node("dracula"); + node.set_leading(format!("{}\n// ", comment_text)); + Some(node) + } else { + None + } + } + fn theme_light_to_kdl(&self, add_comments: bool) -> Option<KdlNode> { + let comment_text = format!( + "{}\n{}\n{}\n{}", + " ", + "// Theme to use when the host terminal reports a light color palette.", + "// Requires `theme_dark` to also be set; otherwise `theme` is used.", + "// ", + ); + + let create_node = |node_value: &str| -> KdlNode { + let mut node = KdlNode::new("theme_light"); + node.push(node_value.to_owned()); + node + }; + if let Some(theme) = &self.theme_light { + let mut node = create_node(theme); + if add_comments { + node.set_leading(format!("{}\n", comment_text)); + } + Some(node) + } else if add_comments { + let mut node = create_node("solarized-light"); + node.set_leading(format!("{}\n// ", comment_text)); + Some(node) + } else { + None + } + } fn default_mode_to_kdl(&self, add_comments: bool) -> Option<KdlNode> { let comment_text = format!( "{}\n{}\n{}\n{}", @@ -4190,6 +4255,12 @@ impl Options { if let Some(theme_node) = self.theme_to_kdl(add_comments) { nodes.push(theme_node); } + if let Some(theme_dark_node) = self.theme_dark_to_kdl(add_comments) { + nodes.push(theme_dark_node); + } + if let Some(theme_light_node) = self.theme_light_to_kdl(add_comments) { + nodes.push(theme_light_node); + } if let Some(default_mode) = self.default_mode_to_kdl(add_comments) { nodes.push(default_mode); } diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap index 6dad8aea..c49fa8f9 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap @@ -303,6 +303,16 @@ web_client { // // theme "dracula" +// Theme to use when the host terminal reports a dark color palette. +// Requires `theme_light` to also be set; otherwise `theme` is used. +// +// theme_dark "dracula" + +// Theme to use when the host terminal reports a light color palette. +// Requires `theme_dark` to also be set; otherwise `theme` is used. +// +// theme_light "solarized-light" + // Choose the base input mode of zellij. // Default: normal // @@ -572,3 +582,4 @@ web_client { // typically work best. Set to 0 to use the number of (physical) CPU cores. // Note: This only applies to web clients at the moment. // client_async_worker_tasks 4 + diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap index e1f47782..eda40849 100644 --- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap +++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap @@ -22,6 +22,16 @@ simplified_ui true // theme "dracula" +// Theme to use when the host terminal reports a dark color palette. +// Requires `theme_light` to also be set; otherwise `theme` is used. +// +// theme_dark "dracula" + +// Theme to use when the host terminal reports a light color palette. +// Requires `theme_dark` to also be set; otherwise `theme` is used. +// +// theme_light "solarized-light" + // Choose the base input mode of zellij. // Default: normal // @@ -291,3 +301,4 @@ web_sharing "disabled" // typically work best. Set to 0 to use the number of (physical) CPU cores. // Note: This only applies to web clients at the moment. // client_async_worker_tasks 4 + diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto index ea043d56..28a8337e 100644 --- a/zellij-utils/src/plugin_api/action.proto +++ b/zellij-utils/src/plugin_api/action.proto @@ -490,6 +490,9 @@ enum ActionName { ShowFloatingPanes = 98; HideFloatingPanes = 99; AreFloatingPanesVisible = 100; + SetDarkTheme = 101; + SetLightTheme = 102; + ToggleTheme = 103; } message Position { diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs index cbd62e5b..cd3e1522 100644 --- a/zellij-utils/src/plugin_api/action.rs +++ b/zellij-utils/src/plugin_api/action.rs @@ -611,6 +611,18 @@ impl TryFrom<ProtobufAction> for Action { Some(_) => Err("Detach should not have a payload"), None => Ok(Action::Detach), }, + Some(ProtobufActionName::SetDarkTheme) => match protobuf_action.optional_payload { + Some(_) => Err("SetDarkTheme should not have a payload"), + None => Ok(Action::SetDarkTheme), + }, + Some(ProtobufActionName::SetLightTheme) => match protobuf_action.optional_payload { + Some(_) => Err("SetLightTheme should not have a payload"), + None => Ok(Action::SetLightTheme), + }, + Some(ProtobufActionName::ToggleTheme) => match protobuf_action.optional_payload { + Some(_) => Err("ToggleTheme should not have a payload"), + None => Ok(Action::ToggleTheme), + }, Some(ProtobufActionName::LeftClick) => match protobuf_action.optional_payload { Some(OptionalPayload::LeftClickPayload(payload)) => { let position = payload.try_into()?; @@ -1567,6 +1579,18 @@ impl TryFrom<Action> for ProtobufAction { name: ProtobufActionName::Detach as i32, optional_payload: None, }), + Action::SetDarkTheme => Ok(ProtobufAction { + name: ProtobufActionName::SetDarkTheme as i32, + optional_payload: None, + }), + Action::SetLightTheme => Ok(ProtobufAction { + name: ProtobufActionName::SetLightTheme as i32, + optional_payload: None, + }), + Action::ToggleTheme => Ok(ProtobufAction { + name: ProtobufActionName::ToggleTheme as i32, + optional_payload: None, + }), Action::LaunchOrFocusPlugin { plugin: run_plugin_or_alias, should_float, diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto index 3c3a8490..5334f434 100644 --- a/zellij-utils/src/plugin_api/event.proto +++ b/zellij-utils/src/plugin_api/event.proto @@ -68,6 +68,7 @@ enum EventType { PaneRenderReportWithAnsi = 43; InitialKeybinds = 44; CommandChanged = 45; + HostTerminalThemeChanged = 46; } message EventNameList { @@ -115,9 +116,19 @@ message Event { PaneRenderReportPayload pane_render_report_with_ansi_payload = 37; InitialKeybindsPayload initial_keybinds_payload = 38; CommandChangedPayload command_changed_payload = 39; + HostTerminalThemeChangedPayload host_terminal_theme_changed_payload = 40; } } +message HostTerminalThemeChangedPayload { + HostTerminalThemeIndication mode = 1; +} + +enum HostTerminalThemeIndication { + Dark = 0; + Light = 1; +} + message CwdChangedPayload { PaneId pane_id = 1; string new_cwd = 2; diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 6f138742..af421fac 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -11,6 +11,8 @@ pub use super::generated_api::api::{ CopyDestination as ProtobufCopyDestination, CwdChangedPayload as ProtobufCwdChangedPayload, Event as ProtobufEvent, EventNameList as ProtobufEventNameList, EventType as ProtobufEventType, FileMetadata as ProtobufFileMetadata, + HostTerminalThemeChangedPayload as ProtobufHostTerminalThemeChangedPayload, + HostTerminalThemeIndication as ProtobufHostTerminalThemeIndication, InputModeKeybinds as ProtobufInputModeKeybinds, KdlError as ProtobufKdlError, KdlErrorVariant as ProtobufKdlErrorVariant, KeyBind as ProtobufKeyBind, LayoutInfo as ProtobufLayoutInfo, LayoutMetadata as ProtobufLayoutMetadata, @@ -35,11 +37,11 @@ pub use super::generated_api::api::{ }; #[allow(hidden_glob_reexports)] use crate::data::{ - ClientId, ClientInfo, CopyDestination, Event, EventType, FileMetadata, InputMode, - KeyWithModifier, LayoutInfo, LayoutMetadata, ModeInfo, Mouse, PaneContents, PaneId, PaneInfo, - PaneManifest, PaneMetadata, PaneScrollbackResponse, PermissionStatus, PluginCapabilities, - PluginInfo, SelectedText, SessionInfo, Style, TabInfo, TabMetadata, WebServerStatus, - WebSharing, + ClientId, ClientInfo, CopyDestination, Event, EventType, FileMetadata, HostTerminalThemeMode, + InputMode, KeyWithModifier, LayoutInfo, LayoutMetadata, ModeInfo, Mouse, PaneContents, PaneId, + PaneInfo, PaneManifest, PaneMetadata, PaneScrollbackResponse, PermissionStatus, + PluginCapabilities, PluginInfo, SelectedText, SessionInfo, Style, TabInfo, TabMetadata, + WebServerStatus, WebSharing, }; use crate::errors::prelude::*; @@ -578,6 +580,14 @@ impl TryFrom<ProtobufEvent> for Event { }, _ => Err("Malformed payload for InitialKeybinds Event"), }, + Some(ProtobufEventType::HostTerminalThemeChanged) => match protobuf_event.payload { + Some(ProtobufEventPayload::HostTerminalThemeChangedPayload(p)) => { + let mode = ProtobufHostTerminalThemeIndication::from_i32(p.mode) + .ok_or("Unknown HostTerminalThemeIndication")?; + Ok(Event::HostTerminalThemeChanged(mode.into())) + }, + _ => Err("Malformed payload for HostTerminalThemeChanged Event"), + }, None => Err("Unknown Protobuf Event"), } } @@ -1109,6 +1119,16 @@ impl TryFrom<Event> for ProtobufEvent { }, )), }), + Event::HostTerminalThemeChanged(mode) => { + let proto_mode: ProtobufHostTerminalThemeIndication = mode.into(); + let payload = ProtobufHostTerminalThemeChangedPayload { + mode: proto_mode as i32, + }; + Ok(ProtobufEvent { + name: ProtobufEventType::HostTerminalThemeChanged as i32, + payload: Some(event::Payload::HostTerminalThemeChangedPayload(payload)), + }) + }, Event::InitialKeybinds(keybinds) => { let mut protobuf_keybinds: Vec<ProtobufInputModeKeybinds> = vec![]; for (input_mode, input_mode_keybinds) in keybinds { @@ -2057,6 +2077,7 @@ impl TryFrom<ProtobufEventType> for EventType { ProtobufEventType::PluginConfigurationChanged => EventType::PluginConfigurationChanged, ProtobufEventType::HighlightClicked => EventType::HighlightClicked, ProtobufEventType::InitialKeybinds => EventType::InitialKeybinds, + ProtobufEventType::HostTerminalThemeChanged => EventType::HostTerminalThemeChanged, }) } } @@ -2110,10 +2131,29 @@ impl TryFrom<EventType> for ProtobufEventType { EventType::PluginConfigurationChanged => ProtobufEventType::PluginConfigurationChanged, EventType::HighlightClicked => ProtobufEventType::HighlightClicked, EventType::InitialKeybinds => ProtobufEventType::InitialKeybinds, + EventType::HostTerminalThemeChanged => ProtobufEventType::HostTerminalThemeChanged, }) } } +impl From<HostTerminalThemeMode> for ProtobufHostTerminalThemeIndication { + fn from(mode: HostTerminalThemeMode) -> Self { + match mode { + HostTerminalThemeMode::Dark => ProtobufHostTerminalThemeIndication::Dark, + HostTerminalThemeMode::Light => ProtobufHostTerminalThemeIndication::Light, + } + } +} + +impl From<ProtobufHostTerminalThemeIndication> for HostTerminalThemeMode { + fn from(mode: ProtobufHostTerminalThemeIndication) -> Self { + match mode { + ProtobufHostTerminalThemeIndication::Dark => HostTerminalThemeMode::Dark, + ProtobufHostTerminalThemeIndication::Light => HostTerminalThemeMode::Light, + } + } +} + impl From<ProtobufResurrectableSession> for (String, Duration) { fn from(protobuf_resurrectable_session: ProtobufResurrectableSession) -> (String, Duration) { ( diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap index 1b86c6f4..a13669e5 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap @@ -7,6 +7,8 @@ Options { true, ), theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap index aacf3843..9a0e7eda 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap @@ -5,6 +5,8 @@ expression: "format!(\"{:#?}\", options)" Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap index 47949964..52734174 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap @@ -5,6 +5,8 @@ expression: "format!(\"{:#?}\", options)" Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap index 243ea7ba..2b17d5a3 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap @@ -5983,6 +5983,8 @@ Config { options: Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap index 6c352256..1f1e8268 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap @@ -5983,6 +5983,8 @@ Config { options: Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap index c5f742db..8ad32dc7 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap @@ -92,6 +92,8 @@ Config { options: Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap index 9f4a1e40..74927ae6 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap @@ -5,6 +5,8 @@ expression: "format!(\"{:#?}\", options)" Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap index 027cee69..28c7b32d 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap @@ -5983,6 +5983,8 @@ Config { options: Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap index 70b23c18..43f29c48 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap @@ -5983,6 +5983,8 @@ Config { options: Options { simplified_ui: None, theme: None, + theme_dark: None, + theme_light: None, default_mode: None, default_shell: None, default_cwd: None, diff --git a/zellij-utils/src/vendored/termwiz/input.rs b/zellij-utils/src/vendored/termwiz/input.rs index 42ebf736..c25f712b 100644 --- a/zellij-utils/src/vendored/termwiz/input.rs +++ b/zellij-utils/src/vendored/termwiz/input.rs @@ -139,6 +139,19 @@ pub enum InputEvent { /// An Operating System Command sequence was received. /// Contains the raw payload between \x1b] and the terminator. OperatingSystemCommand(Vec<u8>), + /// A CSI-based device control / status report reply emitted by the + /// host terminal (not a keyboard event). This variant is only produced + /// for a deliberately narrow whitelist of final bytes — `t` (pixel + /// dimensions reply), `y` (DECRPM reply), `c` (Primary-DA reply), and + /// `n` (DSR reply). The raw field contains the exact byte sequence of + /// the original report (including the leading ESC) so it can be + /// forwarded verbatim without re-serialization. + DeviceControlReply { + intermediates: Vec<u8>, + params: Vec<u8>, + final_byte: u8, + raw: Vec<u8>, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -811,6 +824,73 @@ fn parse_sgr_mouse(buf: &[u8]) -> Option<(InputEvent, usize)> { /// OSC sequence is found, where `payload` is the bytes between `\x1b]` and the /// terminator, and `len` is the total number of bytes consumed. /// Returns `None` if the buffer does not start with `\x1b]` or the sequence is incomplete. +/// Attempt to parse a CSI-based host-terminal report (device-attribute +/// responses, DSR replies, DECRPM, pixel-dims reply, etc.) from the start +/// of `buf`. +/// +/// Only a narrow whitelist of final bytes is recognised: `t`, `y`, `c`, +/// `n`. Any other final byte returns `None` so the bytes fall through to +/// the regular CSI key-mapping machinery. +/// +/// Returns `Some((event, len))` on a full match, `None` if the bytes do +/// not look like a whitelisted CSI report (caller should try the next +/// parser) and reserves returning None with the buffer starting with +/// `ESC [` for two distinct cases — not currently disambiguated here: +/// - truly malformed / unsupported sequence, or +/// - incomplete input; caller handles incompleteness via `maybe_more`. +fn parse_csi_report(buf: &[u8]) -> Option<(InputEvent, usize)> { + if buf.get(0) != Some(&0x1b) || buf.get(1) != Some(&b'[') { + return None; + } + // Scan forward looking for a final byte in the whitelist, or bail if + // we hit something that clearly is not a CSI report (a non-printable + // byte other than the known final bytes). + let mut i = 2; + let mut intermediates: Vec<u8> = Vec::new(); + let mut params: Vec<u8> = Vec::new(); + // Parameters (0x30..=0x3F) come first, then intermediates (0x20..=0x2F), + // then a final byte (0x40..=0x7E). We only scan up to a reasonable + // length to avoid pathological buffers. + let max_scan = buf.len().min(256); + while i < max_scan { + let b = buf[i]; + match b { + // Parameters: digits, `;`, `:`, `?`, `<`, `=`, `>` + 0x30..=0x3F => { + params.push(b); + i += 1; + }, + // Intermediates: space, `!`, `"`, ... `/` + 0x20..=0x2F => { + intermediates.push(b); + i += 1; + }, + // Final byte (0x40..=0x7E): must be one of the whitelisted bytes. + b't' | b'y' | b'c' | b'n' => { + let raw = buf[0..=i].to_vec(); + return Some(( + InputEvent::DeviceControlReply { + intermediates, + params, + final_byte: b, + raw, + }, + i + 1, + )); + }, + 0x40..=0x7E => { + // Final byte outside the whitelist — not ours. + return None; + }, + _ => { + // Something unexpected inside the CSI — give up. + return None; + }, + } + } + None +} + fn parse_osc(buf: &[u8]) -> Option<(InputEvent, usize)> { // OSC sequences start with ESC ] (0x1b 0x5d) if buf.get(0) != Some(&0x1b) || buf.get(1) != Some(&b']') { @@ -1573,6 +1653,25 @@ impl InputParser { if maybe_more && self.buf.as_slice().starts_with(b"\x1b[<") { return; } + + // CSI-based host-terminal report (pixel-dims reply, + // DECRPM, DSR, Primary-DA). Must come before the + // regular CSI key-mapping machinery, which would + // otherwise match "\x1b[" as an escape prefix and + // pass the bytes through as keyboard input. + if let Some((event, len)) = parse_csi_report(self.buf.as_slice()) { + self.buf.advance(len); + callback(event); + continue; + } + + // Incomplete CSI ?... report (DECRPM, DSR 997, etc.) — + // wait for more data so the report-classification path + // can match the full sequence rather than letting the + // keymap dispatch the leading bytes as separate keys. + if maybe_more && self.buf.as_slice().starts_with(b"\x1b[?") { + return; + } } match ( @@ -2479,4 +2578,103 @@ mod test { inputs ); } + + // ===================================================================== + // parse_csi_report (CSI report whitelist for host-reply forwarding) + // ===================================================================== + + fn csi_reply(intermediates: &[u8], params: &[u8], final_byte: u8, raw: &[u8]) -> InputEvent { + InputEvent::DeviceControlReply { + intermediates: intermediates.to_vec(), + params: params.to_vec(), + final_byte, + raw: raw.to_vec(), + } + } + + #[test] + fn csi_report_recognises_each_whitelisted_final_byte() { + // `t` — pixel-dimension reply form `\x1b[4;H;Wt`. + let bytes = b"\x1b[4;600;800t"; + let (evt, consumed) = parse_csi_report(bytes).expect("t accepted"); + assert_eq!(consumed, bytes.len()); + assert_eq!(evt, csi_reply(b"", b"4;600;800", b't', bytes)); + + // `y` — DECRPM, e.g. sync-output support. Intermediate `$`. + let bytes = b"\x1b[?2026;1$y"; + let (evt, consumed) = parse_csi_report(bytes).expect("y accepted"); + assert_eq!(consumed, bytes.len()); + assert_eq!(evt, csi_reply(b"$", b"?2026;1", b'y', bytes)); + + // `c` — Primary-DA reply (barrier). + let bytes = b"\x1b[?62;1;6c"; + let (evt, consumed) = parse_csi_report(bytes).expect("c accepted"); + assert_eq!(consumed, bytes.len()); + assert_eq!(evt, csi_reply(b"", b"?62;1;6", b'c', bytes)); + + // `n` — DSR reply (used for theme notifications). + let bytes = b"\x1b[?997;1n"; + let (evt, consumed) = parse_csi_report(bytes).expect("n accepted"); + assert_eq!(consumed, bytes.len()); + assert_eq!(evt, csi_reply(b"", b"?997;1", b'n', bytes)); + } + + #[test] + fn csi_report_preserves_intermediates() { + // DECRPM uses `$` as its intermediate byte — it must land in + // `intermediates`, not `params`. + let bytes = b"\x1b[?2026;2$y"; + let (evt, _len) = parse_csi_report(bytes).expect("DECRPM accepted"); + let InputEvent::DeviceControlReply { + intermediates, + params, + final_byte, + raw, + } = evt + else { + panic!("expected DeviceControlReply, got {:?}", evt); + }; + assert_eq!(intermediates, b"$"); + assert_eq!(params, b"?2026;2"); + assert_eq!(final_byte, b'y'); + assert_eq!(raw, bytes); + } + + #[test] + fn csi_report_rejects_non_whitelisted_final_bytes() { + // `A` = cursor-up (keyboard input, not a report). + assert!(parse_csi_report(b"\x1b[A").is_none()); + // `R` = cursor-position report — not whitelisted; must pass + // through to the keyboard path. + assert!(parse_csi_report(b"\x1b[24;80R").is_none()); + // `m` = SGR; appears in render streams but should never reach + // stdin as a report. + assert!(parse_csi_report(b"\x1b[0m").is_none()); + } + + #[test] + fn csi_report_returns_none_on_truncated_input() { + // No final byte within the supplied slice → caller should wait + // for more bytes; `parse_csi_report` must not "commit" to a + // partial parse. + assert!(parse_csi_report(b"\x1b[4;600;800").is_none()); + // Only the lead-in; parameters haven't started. + assert!(parse_csi_report(b"\x1b[").is_none()); + // Empty input — zero bytes to consume. + assert!(parse_csi_report(b"").is_none()); + } + + #[test] + fn csi_report_raw_preserves_input_byte_for_byte() { + // `raw` must include the leading ESC through the final byte + // inclusive, without adding or dropping any byte — the + // forwarding path writes it verbatim to the pane's pty. + let bytes = b"\x1b[4;16;8t"; + let (evt, consumed) = parse_csi_report(bytes).expect("accepted"); + assert_eq!(consumed, bytes.len()); + let InputEvent::DeviceControlReply { raw, .. } = evt else { + panic!("wrong variant"); + }; + assert_eq!(&raw[..], bytes, "raw must be byte-identical to input"); + } } |
