fix(windows): advertise terminal background to TUI apps (#332)

* fix(windows): advertise terminal background to TUI apps

* refactor(windows): keep the background hint out of config.json

The daemon needs to know whether the window is light or dark when it
spawns a Windows pane, because ConPTY drops the child's OSC 11 query
before tty7's emulator can answer it. It was reading that from
`Config::theme` — a field nothing had written since it went dead — which
meant the GUI had to rewrite the user's `config.json` every time the
effective preset changed sides.

Move the hint to `appearance.json`, beside `machine.json` in the data
dir, and leave `Config::theme` exactly as it was. It is derived state:
written by the process that paints the window, read by the process that
has to describe it, and of no interest to the user. A file of its own
rather than a field on `Machine`, because the machine tree is owned by
the daemon and flushed on a timer, so a second writer would clobber the
workspaces and panes it had not seen. Absent, unreadable, and unparsable
all read as light — what the default preset is — so a daemon that starts
before the GUI has ever applied a theme describes the default window
instead of guessing.

Also silence the `unused variable` warning the hint parameter raised on
every non-Windows build, where the `COLORFGBG` block it feeds is
compiled out.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yinghao Zhu
2026-08-04 19:40:20 +08:00
committed by GitHub
co-authored by Claude Opus 5 l0ng-ai
parent 0f3d176e98
commit 5d14603722
3 changed files with 164 additions and 9 deletions
+99
View File
@@ -12,6 +12,8 @@ use crate::daemon::protocol::NativeSshSpec;
pub const MACHINE_FILE: &str = "machine.json";
pub const APPEARANCE_FILE: &str = "appearance.json";
pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR";
pub const MAX_WORKSPACES: usize = 1024;
@@ -1233,6 +1235,78 @@ pub fn default_machine_path() -> io::Result<PathBuf> {
Ok(data_dir()?.join(MACHINE_FILE))
}
pub fn appearance_path() -> io::Result<PathBuf> {
Ok(data_dir()?.join(APPEARANCE_FILE))
}
/// The light/dark mode the GUI last applied, cached beside the machine tree.
///
/// The daemon needs it when it spawns a pane on Windows, where ConPTY drops an
/// OSC 11 background query before tty7's emulator can answer it (see
/// `daemon::pane::pane_environment`), and the daemon is a separate process from
/// the GUI that owns the theme. This is derived state, not a setting: it is
/// written by whichever process paints the window and read by whoever needs to
/// describe that window, so it lives here rather than in `config.json` — the
/// user's file, which nothing should rewrite behind their back.
///
/// A file of its own rather than a field on [`Machine`]: the machine tree is
/// owned by the daemon, held in memory and flushed on a timer, so a second
/// writer would clobber the workspaces and panes it had not seen.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Appearance {
#[serde(default)]
pub dark: bool,
}
/// Read the cached appearance, or the default when there is none to read.
///
/// Absent, unreadable, and unparsable all answer `dark: false`, which is what
/// tty7's default preset is: a daemon that spawns a pane before the GUI has
/// ever applied a theme describes the default window rather than guessing.
pub fn appearance() -> Appearance {
match appearance_path() {
Ok(path) => read_appearance(&path),
Err(e) => {
log::debug!("no appearance hint ({e}); assuming the default preset");
Appearance::default()
}
}
}
/// Record the appearance the GUI just applied. A no-op when it has not changed,
/// so repainting the same theme does not touch the disk.
pub fn note_appearance(dark: bool) {
let Ok(path) = appearance_path() else { return };
let next = Appearance { dark };
if read_appearance(&path) == next && path.exists() {
return;
}
if let Err(e) = write_appearance(&path, next) {
log::warn!("could not write {}: {e}", path.display());
}
}
fn read_appearance(path: &Path) -> Appearance {
let Ok(text) = std::fs::read_to_string(path) else {
return Appearance::default();
};
serde_json::from_str::<Appearance>(crate::core::config::strip_bom(&text)).unwrap_or_else(|e| {
log::warn!(
"{} does not parse ({e}); assuming the default preset",
path.display()
);
Appearance::default()
})
}
fn write_appearance(path: &Path, appearance: Appearance) -> io::Result<()> {
let bytes = serde_json::to_vec_pretty(&appearance).map_err(io::Error::other)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::core::config::write_atomic_private(path, &bytes)
}
fn data_dir() -> io::Result<PathBuf> {
if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(explicit));
@@ -1273,6 +1347,31 @@ mod tests {
(MachineStore::open(dir.path().join(MACHINE_FILE)), dir)
}
#[test]
fn the_appearance_hint_round_trips_and_defaults_to_light() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join(APPEARANCE_FILE);
assert_eq!(
read_appearance(&path),
Appearance { dark: false },
"a hint nobody has written yet reads as the default preset"
);
write_appearance(&path, Appearance { dark: true }).unwrap();
assert_eq!(read_appearance(&path), Appearance { dark: true });
write_appearance(&path, Appearance { dark: false }).unwrap();
assert_eq!(read_appearance(&path), Appearance { dark: false });
std::fs::write(&path, "not json").unwrap();
assert_eq!(
read_appearance(&path),
Appearance { dark: false },
"a corrupt hint must not decide the background either"
);
}
fn seed(pane: u64, cwd: &str) -> PaneSeed {
PaneSeed {
pane,
+60 -9
View File
@@ -311,6 +311,8 @@ fn names_capability_env(key: &str) -> bool {
fn pane_environment(
extra_env: &std::collections::HashMap<String, String>,
// Only Windows has a use for it — see the `COLORFGBG` block below.
#[cfg_attr(not(windows), allow(unused_variables))] dark: bool,
pane: u64,
workspace: Option<&str>,
) -> Vec<(String, String)> {
@@ -326,6 +328,17 @@ fn pane_environment(
("TERM_PROGRAM_VERSION".to_string(), version.to_string()),
(TTY7_PANE_ENV.to_string(), pane.to_string()),
];
#[cfg(windows)]
if !extra_env
.keys()
.any(|key| key.eq_ignore_ascii_case("COLORFGBG"))
{
// ConPTY consumes OSC 11 queries before tty7's emulator can answer
// them. Give TUI applications the conventional fallback hint while
// preserving an explicit user override below.
let colorfgbg = if dark { "15;0" } else { "0;15" };
env.push(("COLORFGBG".to_string(), colorfgbg.to_string()));
}
if let Some(ws) = workspace {
env.push((TTY7_WS_ENV.to_string(), ws.to_string()));
}
@@ -351,7 +364,8 @@ fn apply_common_command_setup(
cmd.cwd(dir);
}
let extra_env = crate::core::config::extra_env();
for (k, v) in pane_environment(&extra_env, pane, workspace) {
let dark = crate::core::machine::appearance().dark;
for (k, v) in pane_environment(&extra_env, dark, pane, workspace) {
cmd.env(k, v);
}
@@ -4084,7 +4098,7 @@ mod tests {
#[test]
fn pane_environment_advertises_the_terminal_under_the_standard_names() {
let env: std::collections::HashMap<_, _> =
pane_environment(&std::collections::HashMap::new(), 7, Some("ws-main"))
pane_environment(&std::collections::HashMap::new(), false, 7, Some("ws-main"))
.into_iter()
.collect();
let version = env!("CARGO_PKG_VERSION");
@@ -4108,10 +4122,14 @@ mod tests {
#[test]
fn pane_environment_hands_the_shell_its_own_address() {
let env: std::collections::HashMap<_, _> =
pane_environment(&std::collections::HashMap::new(), 42, Some("ws-main"))
.into_iter()
.collect();
let env: std::collections::HashMap<_, _> = pane_environment(
&std::collections::HashMap::new(),
false,
42,
Some("ws-main"),
)
.into_iter()
.collect();
assert_eq!(
env.get(TTY7_PANE_ENV).map(String::as_str),
@@ -4137,7 +4155,7 @@ mod tests {
}
let unfiled: std::collections::HashMap<_, _> =
pane_environment(&std::collections::HashMap::new(), 42, None)
pane_environment(&std::collections::HashMap::new(), false, 42, None)
.into_iter()
.collect();
assert!(
@@ -4160,7 +4178,9 @@ mod tests {
.collect();
let applied: std::collections::HashMap<_, _> =
pane_environment(&configured, 1, None).into_iter().collect();
pane_environment(&configured, false, 1, None)
.into_iter()
.collect();
assert_eq!(
applied.get("TERM_PROGRAM").map(String::as_str),
@@ -4181,6 +4201,37 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn pane_environment_advertises_light_and_dark_backgrounds() {
let empty = std::collections::HashMap::new();
let light: std::collections::HashMap<_, _> = pane_environment(&empty, false, 1, None)
.into_iter()
.collect();
let dark: std::collections::HashMap<_, _> = pane_environment(&empty, true, 1, None)
.into_iter()
.collect();
assert_eq!(light.get("COLORFGBG").map(String::as_str), Some("0;15"));
assert_eq!(dark.get("COLORFGBG").map(String::as_str), Some("15;0"));
}
#[cfg(windows)]
#[test]
fn configured_colorfgbg_wins_case_insensitively() {
let configured = [("ColorFgBg".to_string(), "3;4".to_string())]
.into_iter()
.collect();
let applied = pane_environment(&configured, false, 1, None);
assert!(!applied.iter().any(|(key, _)| key == "COLORFGBG"));
assert!(
applied
.iter()
.any(|(key, value)| key == "ColorFgBg" && value == "3;4")
);
}
#[cfg(windows)]
#[test]
fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() {
@@ -4189,7 +4240,7 @@ mod tests {
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let applied = pane_environment(&configured, 1, None);
let applied = pane_environment(&configured, false, 1, None);
assert!(
!applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"),
+5
View File
@@ -243,6 +243,11 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
refresh_system_appearance(cx);
}
let theme = presets::by_id(cx, &effective_preset_id(cx));
// Cache the mode beside the machine tree: the daemon is a separate process
// and reads it back when it spawns a Windows pane, where ConPTY drops an
// OSC 11 background query before tty7's emulator can answer it. Derived
// state, so it deliberately stays out of the user's `config.json`.
crate::core::machine::note_appearance(theme.dark);
let config = cx.global::<Config>();
let mode = if theme.dark {
ThemeMode::Dark