fix: send pane appearance changes only after the matching background

refs #4183
This commit is contained in:
Can Celik
2026-10-23 18:22:55 +03:00
committed by Ogulcan Celik
parent 6b526626f6
commit babb09bd53
17 changed files with 390 additions and 34 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ impl App {
cwd,
self.state.pane_scrollback_limit_bytes,
host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
&launch_env,
self.event_tx.clone(),
+1 -1
View File
@@ -614,7 +614,7 @@ impl App {
cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
&launch_env,
self.event_tx.clone(),
+2 -2
View File
@@ -99,7 +99,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) {
Ok(env) => env,
Err((code, message)) => return encode_error(id, &code, message),
@@ -415,7 +415,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let cwd = pane
.cwd
.as_ref()
+1 -1
View File
@@ -71,7 +71,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let previous_focus = self.state.current_pane_focus_target();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "pane_not_found", "pane not found");
+4 -2
View File
@@ -127,6 +127,7 @@ impl App {
};
let (rows, cols) = self.state.new_pane_size(new_pane_placement);
let previous_focus = self.state.current_pane_focus_target();
let host_terminal_appearance = self.pane_host_terminal_appearance();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "workspace_not_found", "workspace not found");
};
@@ -140,7 +141,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
params.focus || placement == PluginPanePlacement::Zoomed,
);
let (tab_idx, new_pane) = match result {
@@ -206,6 +207,7 @@ impl App {
Err((code, message)) => return encode_error(id, &code, message),
};
let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone);
let host_terminal_appearance = self.pane_host_terminal_appearance();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "workspace_not_found", "workspace not found");
};
@@ -217,7 +219,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
) {
Ok(result) => result,
Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+1 -1
View File
@@ -69,7 +69,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let extra_env = match super::env::normalize_launch_env(env) {
Ok(env) => env,
Err((code, message)) => return encode_error(id, &code, message),
+1 -1
View File
@@ -136,7 +136,7 @@ impl App {
cols,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
self.event_tx.clone(),
self.render_notify.clone(),
+4 -2
View File
@@ -373,6 +373,7 @@ impl App {
.state
.new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay);
let (env, _) = self.custom_command_env();
let host_terminal_appearance = self.pane_host_terminal_appearance();
let ws = self
.state
@@ -400,7 +401,7 @@ impl App {
env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
)?;
let new_pane_id = new_pane.pane_id;
self.terminal_runtimes
@@ -470,6 +471,7 @@ impl App {
})
});
let host_terminal_appearance = self.pane_host_terminal_appearance();
let (tab_idx, new_pane, workspace_id) = {
let ws = self
.state
@@ -487,7 +489,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
true,
);
let (tab_idx, new_pane) = match result {
+2
View File
@@ -140,6 +140,7 @@ pub struct App {
pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) pending_host_appearance_deadline: Option<Instant>,
startup_per_agent_delay: Duration,
next_agent_resume_at: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
@@ -652,6 +653,7 @@ impl App {
loaded_host_cursor: config.ui.host_cursor,
agent_metadata_deadline: None,
pending_agent_resume_deadline: None,
pending_host_appearance_deadline: None,
startup_per_agent_delay: Duration::from_millis(
config.session.startup_per_agent_delay_ms.into(),
),
+2 -2
View File
@@ -54,7 +54,7 @@ impl App {
crate::pane::AgentDetection::Disabled,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
app.state.host_terminal_appearance,
app.pane_host_terminal_appearance(),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
@@ -86,7 +86,7 @@ impl App {
crate::pane::AgentDetection::Disabled,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
app.state.host_terminal_appearance,
app.pane_host_terminal_appearance(),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
+1
View File
@@ -167,6 +167,7 @@ impl App {
self.next_agent_manifest_update_check,
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.pending_host_appearance_deadline,
self.session_save_deadline,
self.next_tab_bar_status_deadline(),
render_deadline,
+79 -13
View File
@@ -1,31 +1,97 @@
use std::time::{Duration, Instant};
use super::App;
use crate::terminal_theme::HostAppearance;
#[cfg(test)]
mod tests;
impl App {
#[cfg(test)]
pub(crate) fn set_host_terminal_appearance_state(
&mut self,
appearance: Option<crate::terminal_theme::HostAppearance>,
appearance: Option<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.apply_host_terminal_appearance_to_panes();
self.refresh_effective_app_theme()
self.set_host_terminal_state(self.state.host_terminal_theme, appearance, explicit)
}
#[cfg(test)]
pub(crate) fn set_host_terminal_theme(
&mut self,
theme: crate::terminal_theme::TerminalTheme,
) -> bool {
if theme == self.state.host_terminal_theme {
self.set_host_terminal_state(
theme,
self.state.host_terminal_appearance,
self.state.host_terminal_appearance_explicit,
)
}
pub(crate) fn set_host_terminal_state(
&mut self,
theme: crate::terminal_theme::TerminalTheme,
appearance: Option<HostAppearance>,
explicit: bool,
) -> bool {
let theme_changed = theme != self.state.host_terminal_theme;
if theme_changed {
self.state.host_terminal_theme = theme;
self.apply_host_terminal_theme_to_panes();
}
let previous = self.state.host_terminal_appearance;
let appearance_changed =
previous != appearance || self.state.host_terminal_appearance_explicit != explicit;
if appearance_changed {
self.state.host_terminal_appearance = appearance;
self.state.host_terminal_appearance_explicit = explicit;
if previous.is_some() && appearance.is_some() {
if previous != appearance {
// The matching OSC 11 arrives after the appearance report's round trip.
self.pending_host_appearance_deadline
.get_or_insert(Instant::now() + Duration::from_millis(750));
}
} else {
self.pending_host_appearance_deadline = None;
self.apply_host_terminal_appearance_to_panes();
}
}
// A foreground switch must install both facts before releasing the outgoing hold.
let released = self.release_host_appearance_if_ready(Instant::now());
let repainted = appearance_changed && self.refresh_effective_app_theme();
theme_changed || released || repainted
}
pub(crate) fn pane_host_terminal_appearance(&self) -> Option<HostAppearance> {
if self.pending_host_appearance_deadline.is_some() {
// New panes must describe the background available during the hold.
self.state
.host_terminal_theme
.background
.map(|color| color.inferred_appearance())
} else {
self.state.host_terminal_appearance
}
}
pub(crate) fn release_host_appearance_if_ready(&mut self, now: Instant) -> bool {
let Some(deadline) = self.pending_host_appearance_deadline else {
return false;
};
let matches = self
.state
.host_terminal_theme
.background
.is_some_and(|color| {
Some(color.inferred_appearance()) == self.state.host_terminal_appearance
});
if !matches && now < deadline {
return false;
}
self.state.host_terminal_theme = theme;
self.apply_host_terminal_theme_to_panes();
self.pending_host_appearance_deadline = None;
self.apply_host_terminal_appearance_to_panes();
true
}
+186
View File
@@ -0,0 +1,186 @@
use super::*;
use crate::terminal::{TerminalId, TerminalRuntime};
use crate::terminal_theme::{RgbColor, TerminalTheme};
use bytes::Bytes;
use tokio::sync::mpsc;
const DARK: RgbColor = RgbColor {
r: 17,
g: 17,
b: 17,
};
const LIGHT: RgbColor = RgbColor {
r: 255,
g: 255,
b: 255,
};
fn app() -> App {
let (_, api_rx) = mpsc::unbounded_channel();
App::new(
&crate::config::Config::default(),
crate::app::AppPolicy::TEST,
None,
api_rx,
crate::api::EventHub::default(),
)
}
fn child(app: &mut App) -> (TerminalId, mpsc::Receiver<Bytes>) {
let (runtime, rx) = TerminalRuntime::test_with_channel(80, 24);
runtime.apply_host_terminal_theme(app.state.host_terminal_theme);
runtime.apply_host_terminal_appearance(app.pane_host_terminal_appearance());
runtime.test_process_pty_bytes(b"\x1b[?2031h");
let id = TerminalId::alloc();
app.terminal_runtimes.insert(id.clone(), runtime);
(id, rx)
}
fn background(app: &mut App, color: RgbColor) {
app.set_host_terminal_theme(TerminalTheme {
background: Some(color),
..app.state.host_terminal_theme
});
}
fn query(app: &App, id: &TerminalId, request: &[u8]) -> Vec<Bytes> {
app.terminal_runtimes
.get(id)
.unwrap()
.test_process_pty_bytes(request)
}
#[tokio::test]
async fn appearance_waits_for_background_not_foreground_or_palette() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (id, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert!(rx.try_recv().is_err(), "notification must wait for OSC 11");
let mut theme = app.state.host_terminal_theme;
theme.foreground = Some(DARK);
theme.palette[1] = Some(LIGHT);
app.set_host_terminal_theme(theme);
assert!(
rx.try_recv().is_err(),
"OSC 10/palette must not release the hold"
);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
background(&mut app, LIGHT);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
);
}
#[tokio::test]
async fn appearance_with_matching_background_is_immediate() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
background(&mut app, LIGHT);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(app.pending_host_appearance_deadline.is_none());
}
#[tokio::test]
async fn appearance_deadline_bounds_missing_or_inconsistent_background() {
for missing in [false, true] {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
if missing {
app.set_host_terminal_theme(TerminalTheme::default());
}
let deadline = app.pending_host_appearance_deadline.unwrap();
assert!(deadline <= Instant::now() + Duration::from_millis(750));
assert!(!app.release_host_appearance_if_ready(deadline - Duration::from_nanos(1)));
assert!(rx.try_recv().is_err());
assert!(app.release_host_appearance_if_ready(deadline));
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(!app.release_host_appearance_if_ready(deadline));
}
}
#[tokio::test]
async fn appearance_coalesces_without_extending_deadline() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
app.set_host_terminal_theme(TerminalTheme::default());
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let deadline = app.pending_host_appearance_deadline;
// A provenance-only update must not cancel a pending flip either.
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), false);
assert_eq!(app.pending_host_appearance_deadline, deadline);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert_eq!(app.pending_host_appearance_deadline, deadline);
assert!(rx.try_recv().is_err());
background(&mut app, LIGHT);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn appearance_baseline_establish_and_clear_are_immediate() {
let mut app = app();
let (id, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
assert!(app.pending_host_appearance_deadline.is_none());
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
app.set_host_terminal_appearance_state(None, false);
assert!(app.pending_host_appearance_deadline.is_none());
assert!(query(&app, &id, b"\x1b[?996n").is_empty());
assert!(rx.try_recv().is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn pane_created_during_appearance_hold_gets_consistent_pair() {
let mut app = app();
app.state.default_shell = "/bin/sh".into();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let index = app
.create_workspace_with_options(std::env::temp_dir(), false)
.unwrap();
let ws = &app.state.workspaces[index];
let id = ws.terminal_id(ws.tabs[0].root_pane).unwrap().clone();
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:1111/1111/1111\x1b\\")]
);
background(&mut app, LIGHT);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;2n")]
);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
);
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
}
}
+3 -2
View File
@@ -4019,16 +4019,17 @@ impl PaneRuntime {
(release_tx, writer)
}
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
let _content_write_guard = match self.content_write_lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
self.content_seq.fetch_add(1, Ordering::AcqRel);
let (tx, _rx) = mpsc::channel(1);
let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
let result = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
self.content_seq.fetch_add(1, Ordering::Release);
self.compression.wake();
result.terminal_responses
}
pub(crate) fn test_with_scrollback_bytes(
+5 -4
View File
@@ -845,11 +845,11 @@ impl HeadlessServer {
if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
self.app.set_host_terminal_appearance_state(
self.app.set_host_terminal_state(
host_terminal_theme,
host_terminal_appearance,
host_terminal_appearance_explicit,
);
self.app.set_host_terminal_theme(host_terminal_theme);
}
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
@@ -2270,11 +2270,11 @@ impl HeadlessServer {
if !client.shell_surface_active || self.foreground_client_id != Some(client_id) {
return false;
}
let mut changed = self.app.set_host_terminal_appearance_state(
let changed = self.app.set_host_terminal_state(
client.host_terminal_theme,
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();
}
@@ -3365,6 +3365,7 @@ impl HeadlessServer {
changed = true;
}
changed |= self.app.release_host_appearance_if_ready(now);
changed |= self.app.handle_tab_bar_status_tasks(now);
if geometry_dirty {
+95
View File
@@ -4019,6 +4019,101 @@ fn client_shell_host_theme_follows_foreground_client() {
assert!(!server.app.state.host_terminal_appearance_explicit);
}
#[tokio::test]
async fn foreground_switch_coalesces_pending_appearance_before_releasing_colors() {
use crate::terminal::{TerminalId, TerminalRuntime};
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
for incoming in [Some(HostAppearance::Dark), None] {
let mut server = test_headless_server();
server.app.set_host_terminal_theme(TerminalTheme {
background: Some(RgbColor {
r: 17,
g: 17,
b: 17,
}),
..Default::default()
});
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
runtime.apply_host_terminal_theme(server.app.state.host_terminal_theme);
runtime.apply_host_terminal_appearance(Some(HostAppearance::Dark));
runtime.test_process_pty_bytes(b"\x1b[?2031h");
let id = TerminalId::alloc();
server.app.terminal_runtimes.insert(id.clone(), runtime);
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert!(rx.try_recv().is_err());
let mut client = ClientConnection::new(
(80, 24),
crate::kitty_graphics::HostCellSize::default(),
2,
RenderEncoding::SemanticFrame,
None,
);
client.host_terminal_theme.background = Some(RgbColor {
r: 255,
g: 255,
b: 255,
});
client.host_terminal_appearance = incoming;
client.host_terminal_appearance_explicit = incoming.is_some();
server.clients.insert(2, client);
server.foreground_client_id = Some(2);
server.sync_foreground_client_state();
assert!(
rx.try_recv().is_err(),
"must not announce outgoing client A Light"
);
let expected =
incoming.map(|appearance| bytes::Bytes::from_static(appearance.color_scheme_report()));
assert_eq!(
server
.app
.terminal_runtimes
.get(&id)
.unwrap()
.test_process_pty_bytes(b"\x1b[?996n"),
expected.into_iter().collect::<Vec<_>>()
);
}
}
#[test]
fn pending_host_appearance_is_released_by_headless_deadline() {
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
let mut server = test_headless_server();
server.app.set_host_terminal_theme(TerminalTheme {
background: Some(RgbColor {
r: 17,
g: 17,
b: 17,
}),
..Default::default()
});
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let deadline = server.app.pending_host_appearance_deadline.unwrap();
assert!(
server
.app
.next_headless_loop_deadline_with_git_refresh(Instant::now(), false, false)
.unwrap()
<= deadline
);
server.handle_scheduled_tasks_headless(deadline, false);
assert!(server.app.pending_host_appearance_deadline.is_none());
}
#[test]
fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() {
let mut server = test_headless_server();
+2 -2
View File
@@ -657,8 +657,8 @@ impl TerminalRuntime {
))
}
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
self.0.test_process_pty_bytes(bytes);
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
self.0.test_process_pty_bytes(bytes)
}
pub(crate) fn test_with_scrollback_bytes(