aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:52:35 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:53:31 +0300
commit36b4001f4526b60f1c9b67a4a34596e5ad2bcca1 (patch)
tree520ab28a0a3f3f798e6e655fc83529e8617720e9 /src
parentaca35ea76f3df15ec68fd07959d939f5e7a78a6c (diff)
feat: sync theme with host appearance
refs #675
Diffstat (limited to 'src')
-rw-r--r--src/app/config_io.rs8
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/app/mod.rs213
-rw-r--r--src/app/runtime.rs4
-rw-r--r--src/app/state.rs30
-rw-r--r--src/app/theme_sync.rs60
-rw-r--r--src/client/input.rs17
-rw-r--r--src/client/mod.rs112
-rw-r--r--src/config/theme.rs29
-rw-r--r--src/main.rs21
-rw-r--r--src/pane/osc.rs38
-rw-r--r--src/pane/terminal.rs88
-rw-r--r--src/raw_input.rs213
-rw-r--r--src/server/clients.rs48
-rw-r--r--src/server/headless.rs154
-rw-r--r--src/terminal_theme.rs38
16 files changed, 998 insertions, 83 deletions
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index 7a196008..95014b30 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -43,7 +43,13 @@ impl App {
pub(super) fn save_theme(&mut self, name: &str) {
if self.update_config_file("theme", |content| {
- crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
+ let content = crate::config::upsert_section_value(
+ content,
+ "theme",
+ "name",
+ &format!("\"{name}\""),
+ );
+ crate::config::upsert_section_bool(&content, "theme", "auto_switch", false)
}) {
self.apply_config_from_disk(false);
}
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index cc4f6d58..9651b075 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -100,7 +100,13 @@ fn preview_selected_theme(state: &mut AppState) {
use crate::app::state::Palette;
let name = THEME_NAMES[state.settings.list.selected];
- if let Some(palette) = Palette::from_name(name) {
+ if let Some(mut palette) = Palette::from_name(name) {
+ if let Some(custom) = &state.theme_runtime.custom {
+ palette = palette.with_overrides(custom);
+ }
+ if let Some(accent) = &state.theme_runtime.legacy_accent {
+ palette.accent = crate::config::parse_color(accent);
+ }
state.palette = palette;
state.theme_name = name.to_string();
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index ff1581cf..c8e3eb55 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -228,46 +228,109 @@ fn parse_cjk_ime_agents(names: &[String]) -> Vec<crate::detect::Agent> {
out
}
-/// Resolve the palette from config: base theme + optional custom overrides.
-fn resolve_palette(config: &crate::config::Config) -> state::Palette {
- resolve_palette_with_legacy_accent(config, true)
+fn normalize_theme_name(name: &str) -> String {
+ name.to_lowercase().replace([' ', '_'], "-")
}
-fn resolve_palette_with_legacy_accent(
+fn sibling_theme_names(name: &str) -> (String, String) {
+ match normalize_theme_name(name).as_str() {
+ "catppuccin" | "catppuccin-mocha" | "catppuccin-latte" | "latte" | "light" => {
+ ("catppuccin".to_string(), "catppuccin-latte".to_string())
+ }
+ "tokyo-night" | "tokyonight" | "tokyo-night-day" | "tokyo-day" | "tokyonight-day" => {
+ ("tokyo-night".to_string(), "tokyo-night-day".to_string())
+ }
+ "gruvbox" | "gruvbox-dark" | "gruvbox-light" => {
+ ("gruvbox".to_string(), "gruvbox-light".to_string())
+ }
+ "one-dark" | "onedark" | "one-light" | "onelight" => {
+ ("one-dark".to_string(), "one-light".to_string())
+ }
+ "solarized" | "solarized-dark" | "solarized-light" => {
+ ("solarized".to_string(), "solarized-light".to_string())
+ }
+ "kanagawa" | "kanagawa-lotus" | "lotus" => {
+ ("kanagawa".to_string(), "kanagawa-lotus".to_string())
+ }
+ "rose-pine" | "rosepine" | "rose-pine-dawn" | "rosepine-dawn" | "dawn" => {
+ ("rose-pine".to_string(), "rose-pine-dawn".to_string())
+ }
+ _ => (name.to_string(), name.to_string()),
+ }
+}
+
+fn theme_runtime_config(
config: &crate::config::Config,
use_legacy_ui_accent: bool,
+) -> state::ThemeRuntimeConfig {
+ let manual_name = config
+ .theme
+ .name
+ .clone()
+ .unwrap_or_else(|| "catppuccin".to_string());
+ let (default_dark, default_light) = sibling_theme_names(&manual_name);
+ state::ThemeRuntimeConfig {
+ manual_name,
+ dark_name: config.theme.dark_name.clone().unwrap_or(default_dark),
+ light_name: config.theme.light_name.clone().unwrap_or(default_light),
+ auto_switch: config.theme.auto_switch,
+ custom: config.theme.custom.clone(),
+ legacy_accent: (use_legacy_ui_accent
+ && config.ui.accent != "cyan"
+ && config
+ .theme
+ .custom
+ .as_ref()
+ .and_then(|c| c.accent.as_ref())
+ .is_none())
+ .then(|| config.ui.accent.clone()),
+ }
+}
+
+fn resolve_palette_for_theme_name(
+ name: &str,
+ fallback_name: &str,
+ runtime: &state::ThemeRuntimeConfig,
) -> state::Palette {
- // Start with the named theme (default: catppuccin)
- let base_name = config.theme.name.as_deref().unwrap_or("catppuccin");
- let mut palette = state::Palette::from_name(base_name).unwrap_or_else(|| {
+ let mut palette = state::Palette::from_name(name).unwrap_or_else(|| {
tracing::warn!(
- theme = base_name,
- "unknown theme, falling back to catppuccin"
+ theme = name,
+ fallback = fallback_name,
+ "unknown theme, falling back"
);
- state::Palette::catppuccin()
+ state::Palette::from_name(fallback_name).unwrap_or_else(state::Palette::catppuccin)
});
- // Apply custom overrides if present
- if let Some(custom) = &config.theme.custom {
+ if let Some(custom) = &runtime.custom {
palette = palette.with_overrides(custom);
}
-
- // Legacy: if ui.accent is set and no theme.custom.accent, use it for compat
- if use_legacy_ui_accent
- && config.ui.accent != "cyan"
- && config
- .theme
- .custom
- .as_ref()
- .and_then(|c| c.accent.as_ref())
- .is_none()
- {
- palette.accent = crate::config::parse_color(&config.ui.accent);
+ if let Some(accent) = &runtime.legacy_accent {
+ palette.accent = crate::config::parse_color(accent);
}
palette
}
+fn resolve_effective_theme(
+ runtime: &state::ThemeRuntimeConfig,
+ appearance: Option<crate::terminal_theme::HostAppearance>,
+) -> (state::Palette, String) {
+ let (name, fallback) = if runtime.auto_switch {
+ match appearance.unwrap_or(crate::terminal_theme::HostAppearance::Dark) {
+ crate::terminal_theme::HostAppearance::Dark => (&runtime.dark_name, "catppuccin"),
+ crate::terminal_theme::HostAppearance::Light => {
+ (&runtime.light_name, "catppuccin-latte")
+ }
+ }
+ } else {
+ (&runtime.manual_name, "catppuccin")
+ };
+ (
+ resolve_palette_for_theme_name(name, fallback, runtime),
+ name.clone(),
+ )
+}
+
impl App {
pub fn new(
config: &Config,
@@ -416,6 +479,8 @@ impl App {
};
let agent_manifest_summaries = crate::detect::manifest::reload_manifests();
+ let theme_runtime = theme_runtime_config(config, true);
+ let (theme_palette, theme_name) = resolve_effective_theme(&theme_runtime, None);
let mut state = AppState {
terminals: std::collections::HashMap::new(),
@@ -543,12 +608,11 @@ impl App {
toast_config: config.ui.toast.clone(),
keybinds: config.keybinds(),
spinner_tick: 0,
- palette: resolve_palette(config),
- theme_name: config
- .theme
- .name
- .clone()
- .unwrap_or_else(|| "catppuccin".to_string()),
+ palette: theme_palette,
+ theme_name,
+ theme_runtime,
+ host_terminal_appearance: None,
+ host_terminal_appearance_explicit: false,
settings: state::SettingsState {
section: state::SettingsSection::Theme,
list: state::SelectionListState::new(0),
@@ -1292,12 +1356,8 @@ impl App {
}
if !invalid_section("theme") {
- self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui"));
- self.state.theme_name = config
- .theme
- .name
- .clone()
- .unwrap_or_else(|| "catppuccin".to_string());
+ self.state.theme_runtime = theme_runtime_config(config, !invalid_section("ui"));
+ self.refresh_effective_app_theme();
}
let status = if diagnostics.is_empty() {
@@ -1435,6 +1495,11 @@ impl App {
self.update_host_terminal_theme(kind, color);
}
}
+ crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => {
+ if apply_host_terminal_theme {
+ self.set_host_terminal_appearance(appearance, true);
+ }
+ }
crate::raw_input::RawInputEvent::Unsupported => {}
}
self.sync_prefix_input_source(previous_mode);
@@ -2082,6 +2147,82 @@ mod tests {
}
#[test]
+ fn theme_auto_switch_is_opt_in_and_preserves_manual_default() {
+ let mut config = Config::default();
+ config.theme.name = Some("tokyo-night".to_string());
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+
+ let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+
+ assert!(!app.state.theme_runtime.auto_switch);
+ assert_eq!(app.state.theme_name, "tokyo-night");
+ assert_eq!(app.state.palette, state::Palette::tokyo_night());
+ }
+
+ #[test]
+ fn theme_auto_switch_uses_sibling_map_and_explicit_appearance() {
+ let mut config = Config::default();
+ config.theme.name = Some("tokyo-night".to_string());
+ config.theme.auto_switch = true;
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+
+ assert_eq!(app.state.theme_name, "tokyo-night");
+ assert!(
+ app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true)
+ );
+
+ assert_eq!(app.state.theme_name, "tokyo-night-day");
+ assert_eq!(app.state.palette, state::Palette::tokyo_night_day());
+ }
+
+ #[test]
+ fn theme_auto_switch_applies_custom_overrides_after_active_base() {
+ let mut config = Config::default();
+ config.theme.name = Some("gruvbox".to_string());
+ config.theme.auto_switch = true;
+ config.theme.custom = Some(crate::config::CustomThemeColors {
+ accent: Some("#010203".to_string()),
+ ..Default::default()
+ });
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+
+ app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true);
+
+ assert_eq!(app.state.theme_name, "gruvbox-light");
+ assert_eq!(
+ app.state.palette.accent,
+ ratatui::style::Color::Rgb(1, 2, 3)
+ );
+ }
+
+ #[test]
+ fn inferred_background_appearance_does_not_override_explicit_report() {
+ let mut config = Config::default();
+ config.theme.name = Some("catppuccin".to_string());
+ config.theme.auto_switch = true;
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+
+ app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true);
+ app.update_host_terminal_theme(
+ crate::terminal_theme::DefaultColorKind::Background,
+ crate::terminal_theme::RgbColor {
+ r: 0xff,
+ g: 0xff,
+ b: 0xff,
+ },
+ );
+
+ assert_eq!(
+ app.state.host_terminal_appearance,
+ Some(crate::terminal_theme::HostAppearance::Dark)
+ );
+ assert_eq!(app.state.theme_name, "catppuccin");
+ }
+
+ #[test]
fn startup_restores_preview_update_available_from_saved_notes() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("startup-preview-update-available");
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 815701b7..097f1ace 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -159,6 +159,10 @@ impl App {
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
self.update_host_terminal_theme(kind, color)
}
+ crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => {
+ self.query_host_terminal_theme();
+ self.set_host_terminal_appearance(appearance, true)
+ }
crate::raw_input::RawInputEvent::Unsupported => false,
};
self.sync_prefix_input_source(previous_mode);
diff --git a/src/app/state.rs b/src/app/state.rs
index 585bb3e4..55d41358 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -46,7 +46,7 @@ pub(crate) struct RightClickPassthroughGesture {
pub pane_info: PaneInfo,
pub modifiers: KeyModifiers,
}
-use crate::terminal_theme::TerminalTheme;
+use crate::terminal_theme::{HostAppearance, TerminalTheme};
use crate::workspace::Workspace;
// ---------------------------------------------------------------------------
@@ -55,7 +55,7 @@ use crate::workspace::Workspace;
/// All colors used by the UI. Derived from a base accent color for now,
/// but structured so a full theme system can replace it later.
-#[derive(Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] // all fields defined for theming — some used later
pub struct Palette {
/// Primary accent (highlight, active borders).
@@ -978,6 +978,16 @@ impl SelectionListState {
}
}
+#[derive(Debug, Clone)]
+pub struct ThemeRuntimeConfig {
+ pub manual_name: String,
+ pub dark_name: String,
+ pub light_name: String,
+ pub auto_switch: bool,
+ pub custom: Option<crate::config::CustomThemeColors>,
+ pub legacy_accent: Option<String>,
+}
+
pub struct SettingsState {
/// Which section tab is active.
pub section: SettingsSection,
@@ -1384,6 +1394,12 @@ pub struct AppState {
pub palette: Palette,
/// Currently applied theme name (for settings UI).
pub theme_name: String,
+ /// Runtime theme configuration used to resolve manual and auto-switch palettes.
+ pub theme_runtime: ThemeRuntimeConfig,
+ /// Last known foreground host terminal appearance.
+ pub host_terminal_appearance: Option<HostAppearance>,
+ /// True when the foreground host explicitly reported appearance via Mode 2031.
+ pub host_terminal_appearance_explicit: bool,
/// Settings panel state.
pub settings: SettingsState,
/// Cached integration recommendations for onboarding/settings UI.
@@ -1722,6 +1738,16 @@ impl AppState {
spinner_tick: 0,
palette: Palette::catppuccin(),
theme_name: "catppuccin".to_string(),
+ theme_runtime: ThemeRuntimeConfig {
+ manual_name: "catppuccin".to_string(),
+ dark_name: "catppuccin".to_string(),
+ light_name: "catppuccin-latte".to_string(),
+ auto_switch: false,
+ custom: None,
+ legacy_accent: None,
+ },
+ host_terminal_appearance: None,
+ host_terminal_appearance_explicit: false,
settings: SettingsState {
section: SettingsSection::Theme,
list: SelectionListState::new(0),
diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs
index a45818df..392d6f59 100644
--- a/src/app/theme_sync.rs
+++ b/src/app/theme_sync.rs
@@ -16,15 +16,54 @@ impl App {
kind: crate::terminal_theme::DefaultColorKind,
color: crate::terminal_theme::RgbColor,
) -> bool {
+ let mut changed = false;
+ if matches!(kind, crate::terminal_theme::DefaultColorKind::Background)
+ && !self.state.host_terminal_appearance_explicit
+ {
+ changed |= self.set_host_terminal_appearance(color.inferred_appearance(), false);
+ }
let next_theme = self.state.host_terminal_theme.with_color(kind, color);
- self.set_host_terminal_theme(next_theme)
+ changed | self.set_host_terminal_theme(next_theme)
+ }
+
+ pub(super) fn set_host_terminal_appearance(
+ &mut self,
+ appearance: crate::terminal_theme::HostAppearance,
+ explicit: bool,
+ ) -> bool {
+ if self.state.host_terminal_appearance == Some(appearance)
+ && self.state.host_terminal_appearance_explicit == explicit
+ {
+ return false;
+ }
+ if self.state.host_terminal_appearance_explicit && !explicit {
+ return false;
+ }
+ self.state.host_terminal_appearance = Some(appearance);
+ self.state.host_terminal_appearance_explicit = explicit;
+ self.refresh_effective_app_theme()
+ }
+
+ pub(crate) fn set_host_terminal_appearance_state(
+ &mut self,
+ appearance: Option<crate::terminal_theme::HostAppearance>,
+ explicit: bool,
+ ) -> bool {
+ if self.state.host_terminal_appearance == appearance
+ && self.state.host_terminal_appearance_explicit == explicit
+ {
+ return false;
+ }
+ self.state.host_terminal_appearance = appearance;
+ self.state.host_terminal_appearance_explicit = explicit;
+ self.refresh_effective_app_theme()
}
pub(crate) fn set_host_terminal_theme(
&mut self,
theme: crate::terminal_theme::TerminalTheme,
) -> bool {
- if theme.is_empty() || theme == self.state.host_terminal_theme {
+ if theme == self.state.host_terminal_theme {
return false;
}
self.state.host_terminal_theme = theme;
@@ -32,11 +71,22 @@ impl App {
true
}
- fn apply_host_terminal_theme_to_panes(&self) {
- if self.state.host_terminal_theme.is_empty() {
- return;
+ pub(super) fn refresh_effective_app_theme(&mut self) -> bool {
+ let (palette, theme_name) = super::resolve_effective_theme(
+ &self.state.theme_runtime,
+ self.state.host_terminal_appearance,
+ );
+ if self.state.theme_name == theme_name && self.state.palette == palette {
+ return false;
}
+ self.state.theme_name = theme_name;
+ self.state.palette = palette;
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ true
+ }
+ fn apply_host_terminal_theme_to_panes(&self) {
for runtime in self.terminal_runtimes.values() {
runtime.apply_host_terminal_theme(self.state.host_terminal_theme);
}
diff --git a/src/client/input.rs b/src/client/input.rs
index add4990c..ba1e353f 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -59,6 +59,7 @@ fn unix_stdin_reader_loop(
let mut framer = crate::raw_input::RawInputByteFramer::default();
if host_color_query_sent {
framer.host_color_query_sent();
+ framer.enable_host_color_scheme_change_tracking();
}
while !should_quit.load(Ordering::Acquire) {
@@ -75,7 +76,10 @@ fn unix_stdin_reader_loop(
}
if stdin_read_ready(&reader, 10) == Some(false) {
- for data in framer.flush_timeout() {
+ let had_pending = framer.has_pending_input();
+ let chunks = framer.flush_timeout();
+ let held_escape = had_pending && chunks.is_empty();
+ for data in chunks {
if event_tx
.blocking_send(ClientLoopEvent::StdinInput(data))
.is_err()
@@ -83,6 +87,16 @@ fn unix_stdin_reader_loop(
return;
}
}
+ if held_escape && stdin_read_ready(&reader, 10) == Some(false) {
+ for data in framer.flush_timeout() {
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinInput(data))
+ .is_err()
+ {
+ return;
+ }
+ }
+ }
}
}
Err(err) => {
@@ -268,6 +282,7 @@ fn windows_client_input_event_from_raw(
Some(crate::protocol::ClientInputEvent::FocusLost)
}
crate::raw_input::RawInputEvent::HostDefaultColor { .. }
+ | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_)
| crate::raw_input::RawInputEvent::Unsupported => None,
}
}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 70d39559..834edd2b 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -329,6 +329,8 @@ fn setup_terminal_with_capabilities(
mouse_capture: bool,
) -> io::Result<TerminalGuard> {
ratatui::init();
+ let host_color_scheme_reports =
+ should_enable_host_color_scheme_reports(enable_client_protocols);
if enable_client_protocols {
if mouse_capture {
@@ -337,11 +339,19 @@ fn setup_terminal_with_capabilities(
set_mouse_capture(false)?;
}
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
+ if host_color_scheme_reports {
+ write_host_color_scheme_report_mode(&mut io::stdout(), true)?;
+ }
push_keyboard_enhancement_flags()?;
- } else if mouse_capture {
- set_mouse_capture(true)?;
} else {
- set_mouse_capture(false)?;
+ if should_query_host_terminal_theme() {
+ write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
+ }
+ if mouse_capture {
+ set_mouse_capture(true)?;
+ } else {
+ set_mouse_capture(false)?;
+ }
}
let modify_other_keys_mode = enable_client_protocols
@@ -360,15 +370,42 @@ fn setup_terminal_with_capabilities(
Ok(TerminalGuard {
reset_modify_other_keys: modify_other_keys_mode.is_some(),
+ reset_host_color_scheme_reports: host_color_scheme_reports,
})
}
+fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool {
+ enable_client_protocols && should_query_host_terminal_theme()
+}
+
/// Guard that restores the terminal when dropped.
struct TerminalGuard {
reset_modify_other_keys: bool,
+ reset_host_color_scheme_reports: bool,
+}
+
+fn write_host_color_scheme_report_mode(
+ writer: &mut impl io::Write,
+ enabled: bool,
+) -> io::Result<()> {
+ let sequence = if enabled {
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE
+ } else {
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE
+ };
+ writer.write_all(sequence.as_bytes())?;
+ writer.flush()
}
-fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<()> {
+fn write_terminal_restore_postlude(
+ writer: &mut impl io::Write,
+ reset_host_color_scheme_reports: bool,
+) -> io::Result<()> {
+ if reset_host_color_scheme_reports {
+ writer.write_all(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
+ )?;
+ }
// Restore a visible cursor and reset DECSCUSR back to the terminal default.
writer.write_all(b"\x1b[?25h\x1b[0 q")?;
writer.flush()
@@ -387,7 +424,7 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> {
}
}
-fn restore_terminal_state(reset_modify_other_keys: bool) {
+fn restore_terminal_state(reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool) {
let _ = clear_received_kitty_graphics(&mut io::stdout());
// Reset modifyOtherKeys if we enabled it.
@@ -404,7 +441,7 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
DisableMouseCapture
);
ratatui::restore();
- let _ = write_terminal_restore_postlude(&mut io::stdout());
+ let _ = write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports);
}
#[cfg(not(windows))]
@@ -432,7 +469,10 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> {
impl Drop for TerminalGuard {
fn drop(&mut self) {
- restore_terminal_state(self.reset_modify_other_keys);
+ restore_terminal_state(
+ self.reset_modify_other_keys,
+ self.reset_host_color_scheme_reports,
+ );
}
}
@@ -704,9 +744,13 @@ fn run_client_with_mode(
// Install a panic hook to restore the terminal on panic (same as monolithic).
let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
+ let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
- restore_terminal_state(panic_resets_modify_other_keys);
+ restore_terminal_state(
+ panic_resets_modify_other_keys,
+ panic_resets_host_color_scheme_reports,
+ );
original_hook(info);
}));
@@ -898,6 +942,9 @@ async fn run_client_loop(
) {
state.request_full_redraw();
}
+ if crate::raw_input::events_require_host_terminal_theme_query(&events) {
+ query_host_terminal_theme();
+ }
data
};
if should_bridge_clipboard_image_paste(&data, state.remote_image_paste_key) {
@@ -1601,17 +1648,64 @@ mod tests {
}
#[test]
+ fn write_host_color_scheme_report_mode_emits_mode_sequences() {
+ let mut output = Vec::new();
+ write_host_color_scheme_report_mode(&mut output, true).unwrap();
+ write_host_color_scheme_report_mode(&mut output, false).unwrap();
+
+ let mut expected = Vec::new();
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE.as_bytes(),
+ );
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
+ );
+ assert_eq!(output, expected);
+ }
+
+ #[test]
+ fn color_scheme_change_event_requests_host_theme_query() {
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
+
+ assert!(crate::raw_input::events_require_host_terminal_theme_query(
+ &events
+ ));
+ }
+
+ #[test]
fn host_terminal_theme_query_is_disabled_on_windows() {
assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
}
#[test]
+ fn color_scheme_reports_are_enabled_only_for_full_clients() {
+ assert_eq!(
+ should_enable_host_color_scheme_reports(true),
+ !cfg!(windows)
+ );
+ assert!(!should_enable_host_color_scheme_reports(false));
+ }
+
+ #[test]
fn terminal_restore_postlude_restores_visible_default_cursor() {
let mut output = Vec::new();
- write_terminal_restore_postlude(&mut output).unwrap();
+ write_terminal_restore_postlude(&mut output, false).unwrap();
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
}
+ #[test]
+ fn terminal_restore_postlude_disables_color_scheme_reports_when_enabled() {
+ let mut output = Vec::new();
+ write_terminal_restore_postlude(&mut output, true).unwrap();
+
+ let mut expected = Vec::new();
+ expected.extend_from_slice(
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE.as_bytes(),
+ );
+ expected.extend_from_slice(b"\x1b[?25h\x1b[0 q");
+ assert_eq!(output, expected);
+ }
+
#[cfg(unix)]
#[test]
fn attach_escape_detaches_on_prefix_q() {
diff --git a/src/config/theme.rs b/src/config/theme.rs
index 87722b6e..fc601d3f 100644
--- a/src/config/theme.rs
+++ b/src/config/theme.rs
@@ -11,17 +11,23 @@ use tracing::warn;
/// accent = "#f5c2e7"
/// red = "#ff6188"
/// ```
-#[derive(Debug, Default, Deserialize)]
+#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
/// Built-in theme name. Default: "catppuccin".
pub name: Option<String>,
+ /// Follow host terminal light/dark appearance and switch between theme names.
+ pub auto_switch: bool,
+ /// Theme name used when `auto_switch` selects a dark appearance.
+ pub dark_name: Option<String>,
+ /// Theme name used when `auto_switch` selects a light appearance.
+ pub light_name: Option<String>,
/// Custom overrides — applied on top of the selected base theme.
pub custom: Option<CustomThemeColors>,
}
/// Per-token color overrides. All fields optional — only set what you want to change.
-#[derive(Debug, Default, Deserialize)]
+#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct CustomThemeColors {
pub accent: Option<String>,
@@ -135,6 +141,22 @@ name = "dracula"
}
#[test]
+ fn theme_auto_switch_fields_parse() {
+ let toml = r#"
+[theme]
+name = "catppuccin"
+auto_switch = true
+dark_name = "tokyo-night"
+light_name = "catppuccin-latte"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.theme.name.as_deref(), Some("catppuccin"));
+ assert!(config.theme.auto_switch);
+ assert_eq!(config.theme.dark_name.as_deref(), Some("tokyo-night"));
+ assert_eq!(config.theme.light_name.as_deref(), Some("catppuccin-latte"));
+ }
+
+ #[test]
fn theme_custom_overrides_parse() {
let toml = r##"
[theme]
@@ -158,6 +180,9 @@ red = "rgb(255, 85, 85)"
fn theme_defaults_when_missing() {
let config: Config = toml::from_str("").unwrap();
assert!(config.theme.name.is_none());
+ assert!(!config.theme.auto_switch);
+ assert!(config.theme.dark_name.is_none());
+ assert!(config.theme.light_name.is_none());
assert!(config.theme.custom.is_none());
}
}
diff --git a/src/main.rs b/src/main.rs
index 2217bef2..3c66ff1d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,6 +42,18 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
+fn set_host_color_scheme_reports(enabled: bool) -> io::Result<()> {
+ use std::io::Write;
+
+ let sequence = if enabled {
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE
+ } else {
+ crate::terminal_theme::HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE
+ };
+ io::stdout().write_all(sequence.as_bytes())?;
+ io::stdout().flush()
+}
+
mod agent_resume;
mod api;
mod app;
@@ -102,6 +114,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# vesper
# name = "catppuccin"
+# Follow host terminal light/dark appearance and switch Herdr UI themes.
+# Existing manual behavior is unchanged unless this is true.
+# auto_switch = false
+# dark_name = "catppuccin"
+# light_name = "catppuccin-latte"
+
# Override individual color tokens on top of the base theme.
# Accepts: hex (#rrggbb), named colors, rgb(r,g,b), or panel_bg = "reset"
# [theme.custom]
@@ -665,6 +683,7 @@ fn main() -> io::Result<()> {
DisableBracketedPaste,
DisableMouseCapture
);
+ let _ = set_host_color_scheme_reports(false);
let _ = pop_keyboard_enhancement_flags();
ratatui::restore();
original_hook(info);
@@ -691,6 +710,7 @@ fn main() -> io::Result<()> {
execute!(io::stdout(), DisableMouseCapture)?;
}
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
+ set_host_color_scheme_reports(true)?;
push_keyboard_enhancement_flags()?;
// Some hosts do not honor Kitty keyboard enhancement pushes for
@@ -728,6 +748,7 @@ fn main() -> io::Result<()> {
DisableBracketedPaste,
DisableMouseCapture
)?;
+ set_host_color_scheme_reports(false)?;
ratatui::restore();
// Drop app (and all workspaces/panes) before runtime shuts down
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index e7b97a79..83cb47ae 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -885,22 +885,44 @@ pub(super) fn write_host_terminal_theme(
terminal: &mut crate::ghostty::Terminal,
theme: crate::terminal_theme::TerminalTheme,
) {
- if let Some(color) = theme.foreground {
- let sequence = crate::terminal_theme::osc_set_default_color_sequence(
+ write_host_terminal_theme_selective(terminal, theme, true, true);
+}
+
+pub(super) fn write_host_terminal_theme_selective(
+ terminal: &mut crate::ghostty::Terminal,
+ theme: crate::terminal_theme::TerminalTheme,
+ foreground: bool,
+ background: bool,
+) {
+ if foreground {
+ write_host_default_color(
+ terminal,
crate::terminal_theme::DefaultColorKind::Foreground,
- color,
+ theme.foreground,
);
- terminal.write(sequence.as_bytes());
}
- if let Some(color) = theme.background {
- let sequence = crate::terminal_theme::osc_set_default_color_sequence(
+ if background {
+ write_host_default_color(
+ terminal,
crate::terminal_theme::DefaultColorKind::Background,
- color,
+ theme.background,
);
- terminal.write(sequence.as_bytes());
}
}
+fn write_host_default_color(
+ terminal: &mut crate::ghostty::Terminal,
+ kind: crate::terminal_theme::DefaultColorKind,
+ color: Option<crate::terminal_theme::RgbColor>,
+) {
+ let sequence = if let Some(color) = color {
+ crate::terminal_theme::osc_set_default_color_sequence(kind, color)
+ } else {
+ crate::terminal_theme::osc_reset_default_color_sequence(kind).to_string()
+ };
+ terminal.write(sequence.as_bytes());
+}
+
pub(super) fn restore_host_terminal_theme_if_needed(
core: &mut GhosttyPaneCore,
pane_id: PaneId,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index eca56975..a87b9622 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -24,8 +24,8 @@ use super::{
osc::{
contains_scrollback_clear_sequence, current_transient_default_color_owner,
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
- write_host_terminal_theme, AgentOscStateTracker, CwdOscTracker, DefaultColorEvent,
- DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery,
+ write_host_terminal_theme_selective, AgentOscStateTracker, CwdOscTracker,
+ DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery,
DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
},
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
@@ -399,11 +399,18 @@ impl GhosttyPaneTerminal {
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
if let Ok(mut core) = self.core.lock() {
+ let foreground_unowned = !core.child_default_foreground_changed;
+ let background_unowned = !core.child_default_background_changed;
core.host_terminal_theme = theme;
- core.transient_default_color_owner_pgid = None;
- core.child_default_foreground_changed = false;
- core.child_default_background_changed = false;
- write_host_terminal_theme(&mut core.terminal, theme);
+ if foreground_unowned && background_unowned {
+ core.transient_default_color_owner_pgid = None;
+ }
+ write_host_terminal_theme_selective(
+ &mut core.terminal,
+ theme,
+ foreground_unowned,
+ background_unowned,
+ );
}
}
@@ -646,6 +653,10 @@ impl GhosttyPaneTerminal {
core.terminal.write(&bytes[written..]);
terminal_responses.extend(self.drain_pending_pty_responses());
}
+
+ if !core.child_default_foreground_changed && !core.child_default_background_changed {
+ core.transient_default_color_owner_pgid = None;
+ }
}
fn drain_pending_pty_responses(&self) -> Vec<Bytes> {
@@ -1929,7 +1940,10 @@ fn respond_to_default_color_event(
}
}
DefaultColorEvent::Set(query) => mark_child_default_color_changed(core, query, true),
- DefaultColorEvent::Reset(query) => mark_child_default_color_changed(core, query, false),
+ DefaultColorEvent::Reset(query) => {
+ mark_child_default_color_changed(core, query, false);
+ apply_cached_host_default_color(core, query);
+ }
}
}
@@ -1975,6 +1989,15 @@ fn osc_rgb_response(command: &str, r: u8, g: u8, b: u8) -> Bytes {
Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"))
}
+fn apply_cached_host_default_color(core: &mut GhosttyPaneCore, query: DefaultColorQuery) {
+ write_host_terminal_theme_selective(
+ &mut core.terminal,
+ core.host_terminal_theme,
+ matches!(query, DefaultColorQuery::Foreground),
+ matches!(query, DefaultColorQuery::Background),
+ );
+}
+
fn mark_child_default_color_changed(
core: &mut GhosttyPaneCore,
query: DefaultColorQuery,
@@ -3386,6 +3409,57 @@ mod tests {
}
#[test]
+ fn host_theme_update_preserves_child_default_color_override() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx);
+ assert!(result.terminal_responses.is_empty());
+
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ });
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx);
+ assert!(result.terminal_responses.is_empty());
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn child_default_color_reset_restores_cached_host_color() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]11;#112233\x07", &tx);
+ pane.apply_host_terminal_theme(crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ });
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]111\x07", &tx);
+ assert!(!pane.has_transient_default_color_override());
+
+ let result = pane.process_pty_bytes(pane_id, 0, b"\x1b]11;?\x07", &tx);
+ assert_eq!(
+ result.terminal_responses,
+ vec![Bytes::from_static(b"\x1b]11;rgb:aaaa/bbbb/cccc\x1b\\")]
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[test]
fn process_pty_bytes_recovers_xtgettcap_after_osc_bel_terminator() {
let (tx, mut rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 57ab6274..9978cc0c 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -95,9 +95,13 @@ use std::os::fd::AsRawFd;
use tokio::sync::mpsc;
use crate::input::{parse_terminal_key_sequence, TerminalKey};
-use crate::terminal_theme::{parse_default_color_response, DefaultColorKind, RgbColor};
+use crate::terminal_theme::{
+ parse_default_color_response, DefaultColorKind, HostAppearance, RgbColor,
+};
const ESC: u8 = 0x1b;
+pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
+pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
@@ -112,6 +116,7 @@ pub enum RawInputEvent {
kind: DefaultColorKind,
color: RgbColor,
},
+ HostColorSchemeChanged(HostAppearance),
Unsupported,
}
@@ -129,6 +134,14 @@ impl RawInputFramer {
self.byte_framer.host_color_query_sent();
}
+ pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
+ self.byte_framer.enable_host_color_scheme_change_tracking();
+ }
+
+ pub(crate) fn has_pending_input(&self) -> bool {
+ self.byte_framer.has_pending_input()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
@@ -159,6 +172,7 @@ pub(crate) struct RawInputByteFramer {
discarded_tail_bytes: usize,
host_color_replies_awaited: u8,
held_pending_color_esc: bool,
+ host_color_scheme_change_tracking: bool,
}
const HOST_COLOR_QUERY_REPLIES: u8 = 2;
@@ -176,6 +190,14 @@ impl RawInputByteFramer {
self.held_pending_color_esc = false;
}
+ pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
+ self.host_color_scheme_change_tracking = true;
+ }
+
+ pub(crate) fn has_pending_input(&self) -> bool {
+ !self.buffer.is_empty()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
let mut chunks = self.drain_available_chunks();
@@ -217,6 +239,17 @@ impl RawInputByteFramer {
return chunks;
}
+ if starts_with_incomplete_host_color_scheme_report(&self.buffer) {
+ tracing::debug!(
+ len = self.buffer.len(),
+ "discarding incomplete host color scheme report after input timeout"
+ );
+ self.discard_until = Some(ControlStringFamily::HostColorSchemeCsi);
+ self.discarded_tail_bytes = 0;
+ self.buffer.clear();
+ return chunks;
+ }
+
if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) {
tracing::debug!(
len = self.buffer.len(),
@@ -291,6 +324,10 @@ impl RawInputByteFramer {
};
if matches!(event, RawInputEvent::HostDefaultColor { .. }) {
self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_sub(1);
+ } else if self.host_color_scheme_change_tracking
+ && matches!(event, RawInputEvent::HostColorSchemeChanged(_))
+ {
+ self.host_color_query_sent();
}
self.held_pending_color_esc = false;
chunks.push(self.buffer[..consumed].to_vec());
@@ -327,6 +364,9 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
)
}),
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
+ ControlStringFamily::HostColorSchemeCsi => buffer
+ .iter()
+ .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')),
}
}
@@ -340,6 +380,12 @@ pub(crate) fn events_require_host_surface_redraw(
.any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
+pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool {
+ events
+ .iter()
+ .any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_)))
+}
+
pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
let (tx, rx) = mpsc::channel(256);
@@ -349,6 +395,7 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
let mut scratch = [0u8; 1024];
let mut framer = RawInputFramer::default();
framer.host_color_query_sent();
+ framer.enable_host_color_scheme_change_tracking();
loop {
match reader.read(&mut scratch) {
@@ -357,7 +404,13 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
send_raw_input_events(framer.push(&scratch[..n]), &tx);
if stdin_read_ready(&reader, 10) == Some(false) {
- send_raw_input_events(framer.flush_timeout(), &tx);
+ let had_pending = framer.has_pending_input();
+ let events = framer.flush_timeout();
+ let held_escape = had_pending && events.is_empty();
+ send_raw_input_events(events, &tx);
+ if held_escape && stdin_read_ready(&reader, 10) == Some(false) {
+ send_raw_input_events(framer.flush_timeout(), &tx);
+ }
}
}
Err(_) => break,
@@ -497,6 +550,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
_ => {}
}
+ if let Some(appearance) = parse_host_color_scheme_report(&buffer[..seq_len]) {
+ return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len));
+ }
+
if let Some(mouse) = parse_sgr_mouse(seq) {
return Some((RawInputEvent::Mouse(mouse), seq_len));
}
@@ -519,6 +576,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
enum ControlStringFamily {
Osc,
StTerminated,
+ HostColorSchemeCsi,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -532,6 +590,14 @@ enum ControlString {
},
}
+fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> {
+ match buffer {
+ GHOSTTY_COLOR_SCHEME_DARK_REPORT => Some(HostAppearance::Dark),
+ GHOSTTY_COLOR_SCHEME_LIGHT_REPORT => Some(HostAppearance::Light),
+ _ => None,
+ }
+}
+
fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
matches!(
control_string(buffer),
@@ -541,6 +607,13 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;"))
}
+fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool {
+ buffer.starts_with(b"\x1b[?")
+ && (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer)
+ || GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.starts_with(buffer))
+ && buffer.len() < GHOSTTY_COLOR_SCHEME_DARK_REPORT.len()
+}
+
fn control_string(buffer: &[u8]) -> Option<ControlString> {
let family = match buffer.get(..2)? {
b"\x1b]" => ControlStringFamily::Osc,
@@ -650,6 +723,10 @@ fn control_string_terminator_for_family(
match family {
ControlStringFamily::Osc => osc_string_terminator(buffer),
ControlStringFamily::StTerminated => st_string_terminator(buffer),
+ ControlStringFamily::HostColorSchemeCsi => buffer
+ .iter()
+ .position(|byte| *byte == b'n')
+ .map(|idx| idx + 1),
}
}
@@ -937,6 +1014,69 @@ mod tests {
}
#[test]
+ fn parses_ghostty_color_scheme_reports() {
+ for bytes in [
+ GHOSTTY_COLOR_SCHEME_DARK_REPORT,
+ GHOSTTY_COLOR_SCHEME_LIGHT_REPORT,
+ ] {
+ let events = parse_raw_input_bytes_sync(bytes);
+ assert_eq!(events.len(), 1, "bytes: {bytes:?}");
+ assert!(matches!(
+ events[0],
+ RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark | HostAppearance::Light)
+ ));
+ assert!(events_require_host_terminal_theme_query(&events));
+ }
+ }
+
+ #[test]
+ fn ghostty_color_scheme_report_parser_is_exact() {
+ for bytes in [
+ b"\x1b[?997;0n".as_slice(),
+ b"\x1b[?997;3n".as_slice(),
+ b"\x1b[?998;1n".as_slice(),
+ ] {
+ let events = parse_raw_input_bytes_sync(bytes);
+ assert_eq!(events.len(), 1, "bytes: {bytes:?}");
+ assert!(matches!(events[0], RawInputEvent::Unsupported));
+ assert!(!events_require_host_terminal_theme_query(&events));
+ }
+ }
+
+ #[test]
+ fn raw_input_framer_reassembles_split_color_scheme_report() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b[?997;").is_empty());
+ let events = framer.push(b"1n");
+
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ events[0],
+ RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark)
+ ));
+ }
+
+ #[test]
+ fn split_color_scheme_timeout_does_not_swallow_legacy_alt_bracket() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert!(framer.push(b"\x1b[").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b[".to_vec()]);
+ }
+
+ #[test]
+ fn raw_input_byte_framer_discards_timed_out_split_color_scheme_report_tail() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert!(framer.push(b"\x1b[?997;").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert!(framer.push(b"1n").is_empty());
+ assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
+ assert!(framer.flush_timeout().is_empty());
+ }
+
+ #[test]
fn parses_xterm_alt_up_arrow() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else {
panic!("expected key");
@@ -1800,6 +1940,75 @@ mod tests {
}
#[test]
+ fn default_byte_framer_does_not_rearm_after_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert_eq!(
+ framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
+ vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
+ );
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
+ fn opt_in_does_not_delay_plain_escape_without_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+ framer.enable_host_color_scheme_change_tracking();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
+ fn opted_in_byte_framer_rearms_after_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+ framer.enable_host_color_scheme_change_tracking();
+
+ assert_eq!(
+ framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
+ vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
+ );
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ let chunks = framer.push(b"]10;#abcdef\x07");
+ assert_eq!(chunks.len(), 1);
+ let (event, _) = extract_one_event(&chunks[0]).unwrap();
+ assert!(matches!(
+ event,
+ RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Foreground,
+ color: RgbColor {
+ r: 0xab,
+ g: 0xcd,
+ b: 0xef
+ }
+ }
+ ));
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ let chunks = framer.push(b"]11;#123456\x07");
+ assert_eq!(chunks.len(), 1);
+ let (event, _) = extract_one_event(&chunks[0]).unwrap();
+ assert!(matches!(
+ event,
+ RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Background,
+ color: RgbColor {
+ r: 0x12,
+ g: 0x34,
+ b: 0x56
+ }
+ }
+ ));
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
fn flushes_lone_escape_when_not_awaiting_host_color_reply() {
let mut framer = RawInputByteFramer::default();
diff --git a/src/server/clients.rs b/src/server/clients.rs
index 611d87eb..eb823cfc 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -33,6 +33,10 @@ pub(crate) struct ClientConnection {
pub(crate) cell_size: crate::kitty_graphics::HostCellSize,
/// Last known host terminal default colors for this client.
pub(crate) host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ /// Last known host terminal appearance for this client.
+ pub(crate) host_terminal_appearance: Option<crate::terminal_theme::HostAppearance>,
+ /// True when appearance came from an explicit host color-scheme report.
+ pub(crate) host_terminal_appearance_explicit: bool,
/// Last reported focus state for this client's outer terminal.
pub(crate) outer_terminal_focus: Option<bool>,
/// Stateful parser for app-client input split across transport reads.
@@ -98,6 +102,10 @@ impl ClientConnection {
keybindings,
terminal_size,
cell_size,
+ host_terminal_appearance: host_terminal_theme
+ .background
+ .map(crate::terminal_theme::RgbColor::inferred_appearance),
+ host_terminal_appearance_explicit: false,
host_terminal_theme,
outer_terminal_focus,
raw_input: crate::raw_input::RawInputFramer::default(),
@@ -130,17 +138,47 @@ impl ClientConnection {
events: &[crate::raw_input::RawInputEvent],
) -> bool {
let mut next_theme = self.host_terminal_theme;
+ let mut changed = false;
for event in events {
- if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event {
- next_theme = next_theme.with_color(*kind, *color);
+ match event {
+ crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
+ next_theme = next_theme.with_color(*kind, *color);
+ if matches!(kind, crate::terminal_theme::DefaultColorKind::Background)
+ && !self.host_terminal_appearance_explicit
+ {
+ changed |=
+ self.set_host_appearance(Some(color.inferred_appearance()), false);
+ }
+ }
+ crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => {
+ changed |= self.set_host_appearance(Some(*appearance), true);
+ }
+ _ => {}
}
}
- if next_theme == self.host_terminal_theme {
- return false;
+ if next_theme != self.host_terminal_theme {
+ self.host_terminal_theme = next_theme;
+ changed = true;
}
+ changed
+ }
- self.host_terminal_theme = next_theme;
+ fn set_host_appearance(
+ &mut self,
+ appearance: Option<crate::terminal_theme::HostAppearance>,
+ explicit: bool,
+ ) -> bool {
+ if self.host_terminal_appearance_explicit && !explicit {
+ return false;
+ }
+ if self.host_terminal_appearance == appearance
+ && self.host_terminal_appearance_explicit == explicit
+ {
+ return false;
+ }
+ self.host_terminal_appearance = appearance;
+ self.host_terminal_appearance_explicit = explicit;
true
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 67a876f9..b162b8fe 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -778,6 +778,8 @@ impl HeadlessServer {
let terminal_size = client.terminal_size;
let outer_terminal_focus = client.outer_terminal_focus;
let host_terminal_theme = client.host_terminal_theme;
+ let host_terminal_appearance = client.host_terminal_appearance;
+ let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit;
let uses_local_keybindings = client.keybindings.is_some();
let keybindings = client
.keybindings
@@ -792,9 +794,11 @@ impl HeadlessServer {
if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
- if !host_terminal_theme.is_empty() {
- self.app.set_host_terminal_theme(host_terminal_theme);
- }
+ self.app.set_host_terminal_appearance_state(
+ host_terminal_appearance,
+ host_terminal_appearance_explicit,
+ );
+ self.app.set_host_terminal_theme(host_terminal_theme);
}
#[cfg(unix)]
@@ -1229,7 +1233,11 @@ impl HeadlessServer {
}
if self.foreground_client_id == Some(client_id) {
- let changed = self.app.set_host_terminal_theme(client.host_terminal_theme);
+ let mut changed = self.app.set_host_terminal_appearance_state(
+ client.host_terminal_appearance,
+ client.host_terminal_appearance_explicit,
+ );
+ changed |= self.app.set_host_terminal_theme(client.host_terminal_theme);
if changed {
self.resize_shared_runtime_to_effective_size_before_input();
}
@@ -5490,6 +5498,144 @@ next_tab = ""
}
#[test]
+ fn foreground_client_without_host_theme_clears_previous_host_theme() {
+ let mut server = test_headless_server();
+ let known_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0x10,
+ g: 0x20,
+ b: 0x30,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x40,
+ g: 0x50,
+ b: 0x60,
+ }),
+ };
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ known_theme,
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+
+ assert!(server.promote_client_to_foreground(1));
+ assert_eq!(server.app.state.host_terminal_theme, known_theme);
+
+ assert!(server.promote_client_to_foreground(2));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ crate::terminal_theme::TerminalTheme::default()
+ );
+ }
+
+ #[test]
+ fn foreground_client_appearance_controls_auto_theme() {
+ let mut server = test_headless_server();
+ server.app.state.theme_runtime.auto_switch = true;
+ server.app.state.theme_runtime.dark_name = "catppuccin".to_string();
+ server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }),
+ },
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme {
+ foreground: None,
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 255,
+ g: 255,
+ b: 255,
+ }),
+ },
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+
+ assert!(server.promote_client_to_foreground(1));
+ assert_eq!(server.app.state.theme_name, "catppuccin");
+
+ assert!(server.promote_client_to_foreground(2));
+ assert_eq!(server.app.state.theme_name, "catppuccin-latte");
+ }
+
+ #[test]
+ fn color_scheme_change_event_is_inert_on_server() {
+ let mut server = test_headless_server();
+ let initial_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0x10,
+ g: 0x20,
+ b: 0x30,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x40,
+ g: 0x50,
+ b: 0x60,
+ }),
+ };
+ server.app.state.host_terminal_theme = initial_theme;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
+ initial_theme,
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+
+ let changed = server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(),
+ });
+
+ assert!(!changed);
+ assert_eq!(server.foreground_client_id, None);
+ assert_eq!(server.clients[&1].host_terminal_theme, initial_theme);
+ assert_eq!(server.app.state.host_terminal_theme, initial_theme);
+ }
+
+ #[test]
fn focus_lost_updates_client_without_promoting_foreground() {
let mut server = test_headless_server();
diff --git a/src/terminal_theme.rs b/src/terminal_theme.rs
index 523e3e38..5627ab1b 100644
--- a/src/terminal_theme.rs
+++ b/src/terminal_theme.rs
@@ -5,6 +5,23 @@ pub struct RgbColor {
pub b: u8,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HostAppearance {
+ Dark,
+ Light,
+}
+
+impl RgbColor {
+ pub fn inferred_appearance(self) -> HostAppearance {
+ let luminance = u32::from(self.r) * 299 + u32::from(self.g) * 587 + u32::from(self.b) * 114;
+ if luminance >= 128_000 {
+ HostAppearance::Light
+ } else {
+ HostAppearance::Dark
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TerminalTheme {
pub foreground: Option<RgbColor>,
@@ -18,6 +35,8 @@ pub enum DefaultColorKind {
}
pub const HOST_COLOR_QUERY_SEQUENCE: &str = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\";
+pub const HOST_COLOR_SCHEME_REPORT_ENABLE_SEQUENCE: &str = "\x1b[?2031h";
+pub const HOST_COLOR_SCHEME_REPORT_DISABLE_SEQUENCE: &str = "\x1b[?2031l";
impl TerminalTheme {
pub fn with_color(mut self, kind: DefaultColorKind, color: RgbColor) -> Self {
@@ -58,6 +77,13 @@ pub fn osc_set_default_color_sequence(kind: DefaultColorKind, color: RgbColor) -
)
}
+pub fn osc_reset_default_color_sequence(kind: DefaultColorKind) -> &'static str {
+ match kind {
+ DefaultColorKind::Foreground => "\x1b]110\x1b\\",
+ DefaultColorKind::Background => "\x1b]111\x1b\\",
+ }
+}
+
fn parse_rgb_color(value: &str) -> Option<RgbColor> {
if let Some(rgb) = value.strip_prefix("rgb:") {
let mut parts = rgb.split('/');
@@ -133,6 +159,18 @@ mod tests {
}
#[test]
+ fn default_color_reset_sequences_use_xterm_osc_numbers() {
+ assert_eq!(
+ osc_reset_default_color_sequence(DefaultColorKind::Foreground),
+ "\x1b]110\x1b\\"
+ );
+ assert_eq!(
+ osc_reset_default_color_sequence(DefaultColorKind::Background),
+ "\x1b]111\x1b\\"
+ );
+ }
+
+ #[test]
fn scales_short_hex_components() {
assert_eq!(parse_hex_component("f"), Some(255));
assert_eq!(parse_hex_component("80"), Some(128));