aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-utils
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-04-28 17:14:59 +0200
committerGitHub <noreply@github.com>2026-04-28 17:14:59 +0200
commit227142dd567ee72bcc137c116bc1ce2b818975e7 (patch)
tree8958a994dddb80bf20ce1e6bf2694687736b0b19 /zellij-utils
parentupgrade rustls-webpki (#5106) (diff)
downloadsl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.tar.gz
sl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.tar.xz
sl0ppij-227142dd567ee72bcc137c116bc1ce2b818975e7.zip
Support CSI2031 (dark/light theme switching) + remove STDIN cache to avoid stale color queries (#5105)
* initial phase 1 implementation * various fies * some fixes * fix fragmented stdin cases * some fixes * renaming * guard against old clients * implement csi 2031 * allow toggling theme with actions * rustfmt * fix: finalize trailing characters in ansi parser as well * add pr
Diffstat (limited to 'zellij-utils')
-rw-r--r--zellij-utils/assets/prost/api.action.rs9
-rw-r--r--zellij-utils/assets/prost/api.event.rs39
-rw-r--r--zellij-utils/assets/prost_ipc/client_server_contract.rs82
-rw-r--r--zellij-utils/src/cli.rs6
-rw-r--r--zellij-utils/src/client_server_contract/client_to_server.proto16
-rw-r--r--zellij-utils/src/client_server_contract/common_types.proto8
-rw-r--r--zellij-utils/src/client_server_contract/server_to_client.proto6
-rw-r--r--zellij-utils/src/consts.rs2
-rw-r--r--zellij-utils/src/data.rs8
-rw-r--r--zellij-utils/src/errors.rs10
-rw-r--r--zellij-utils/src/input/actions.rs45
-rw-r--r--zellij-utils/src/input/options.rs18
-rw-r--r--zellij-utils/src/ipc.rs25
-rw-r--r--zellij-utils/src/ipc/protobuf_conversion.rs95
-rw-r--r--zellij-utils/src/ipc/tests/roundtrip_tests.rs49
-rw-r--r--zellij-utils/src/kdl/mod.rs71
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap11
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap11
-rw-r--r--zellij-utils/src/plugin_api/action.proto3
-rw-r--r--zellij-utils/src/plugin_api/action.rs24
-rw-r--r--zellij-utils/src/plugin_api/event.proto11
-rw-r--r--zellij-utils/src/plugin_api/event.rs50
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap2
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap2
-rw-r--r--zellij-utils/src/vendored/termwiz/input.rs198
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");
+ }
}