From 4aedb1faf7091ee8643c6318667bb2e467f1d235 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:11:58 +0800 Subject: [PATCH 1/6] feat(daemon): tell every pane which terminal it is running in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TERM` names terminfo capabilities; it cannot answer "which program is this". The de-facto standard pair that does — `TERM_PROGRAM` and `TERM_PROGRAM_VERSION`, introduced by Apple Terminal and set by iTerm2, WezTerm, Ghostty, VS Code and tmux — went unset, so anything asking was told nothing. Plenty asks. Capability probes (`supports-color`, `supports-hyperlinks`, and the CLI ecosystem built on them) read the program name to decide on truecolor and OSC 8; editors branch on it for terminal-specific workarounds; shell prompts adapt their glyphs to it. Absent, they all fall back to their most conservative behaviour. The `TTY7` marker we do export is no substitute: it exists so globally-installed agent hooks stay silent in other terminals, and nothing third-party looks for it. Both new variables stay overridable through `env` in `config.json`, unlike `TERM` and `COLORTERM`. Those two state what the pane's decoder implements, which isn't the user's to contradict; the program name is an identity, and posing as another terminal is a legitimate way to get a tool that only recognises a fixed list to light up. Building the pane's environment is now one function returning the pairs in application order, so that precedence is testable without a `CommandBuilder` or a real `config.json`. Local panes only. ssh forwards environment variables solely by agreement between client and server (`SendEnv`/`AcceptEnv`, `LANG` and `LC_*` by default), so a native-SSH pane still sees whatever the remote host sets for itself — as is already true of `COLORTERM` and `TTY7`. Closes #212 --- CHANGELOG.md | 20 +++++++ src/daemon/pane.rs | 143 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 145 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..9fd27312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Panes are told which terminal they're running in** — every pane now carries + `TERM_PROGRAM=tty7` and `TERM_PROGRAM_VERSION`, the de-facto standard pair + Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all + set. `TERM` names terminfo capabilities and can't answer "which program is + this", so without the pair, capability probes (`supports-color`, + `supports-hyperlinks`, and the CLI ecosystem built on them), editors applying + terminal-specific workarounds, and shell prompts all fell back to their most + conservative behaviour. tty7's own `TTY7` marker doesn't help them — it exists + so globally-installed agent hooks stay silent in other terminals, and nothing + third-party knows to look for it. Unlike `TERM` and `COLORTERM`, both new + variables can be overridden from `env` in `config.json`: they name an + identity, not a capability, and posing as another terminal is a legitimate way + to get a tool that only recognises a fixed list to light up. Local panes only + — ssh forwards environment variables solely by agreement between client and + server, so a remote host still sees whatever it sets for itself. (#212) + ## [26.7.5] - 2026-07-27 ### Added diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index a7a167a2..3f178736 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -422,29 +422,68 @@ fn system_locale_identifier() -> Option { } } +/// What tty7 answers to in `TERM_PROGRAM`. Terminals name themselves in the +/// form they brand themselves in — `Apple_Terminal`, `iTerm.app`, `WezTerm`, +/// `ghostty`, `vscode` — so ours is the lowercase product name. +const TERM_PROGRAM_NAME: &str = "tty7"; + +/// Env keys that describe our emulator's real capabilities. A user's `env` map +/// must not override these: the answer isn't a preference, it's a fact about +/// what the pane on the other end can decode. +const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; + +/// The environment every pane starts with, in application order — tty7's own +/// advertisements first, then the user's `env` map, which overrides all but +/// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the +/// precedence is testable without a `CommandBuilder` or a real `config.json`. +fn pane_environment( + extra_env: &std::collections::HashMap, +) -> Vec<(String, String)> { + let version = env!("CARGO_PKG_VERSION"); + let mut env = vec![ + // A widely-available terminfo + truecolor. + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + // Mark the session as tty7's, for tooling that adapts to its host + // terminal — most importantly the `tty7 agent-hook` emitter, which + // stays silent without it so globally-installed agent hooks can't leak + // escape sequences into other terminals (see `core::agent_hooks`). + ( + crate::core::agent_hooks::TTY7_ENV_MARKER.to_string(), + version.to_string(), + ), + // The de-facto standard pair for "which terminal is this": Apple + // Terminal introduced it, and iTerm2, WezTerm, Ghostty, VS Code and + // tmux all set it. `TERM` describes terminfo capabilities and can't + // answer this — but capability probes (`supports-color`, + // `supports-hyperlinks` and the JS CLI ecosystem built on them), + // editors applying terminal-specific workarounds, and shell prompts all + // branch on the program name, falling back to their most conservative + // behaviour when it's missing. `TTY7` doesn't help them: it's ours, and + // nothing third-party knows to look for it. + // + // Deliberately overridable below, unlike the capability keys: this + // names an identity, and posing as another terminal is a legitimate way + // to get a tool that only recognises a fixed list to light up. + ("TERM_PROGRAM".to_string(), TERM_PROGRAM_NAME.to_string()), + ("TERM_PROGRAM_VERSION".to_string(), version.to_string()), + ]; + env.extend( + extra_env + .iter() + .filter(|(k, _)| !CAPABILITY_ENV.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())), + ); + env +} + fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option) { if let Some(dir) = initial_cwd { cmd.cwd(dir); } - // Advertise a widely-available terminfo + truecolor. - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - // Mark the session as tty7's, for tooling that adapts to its host terminal - // — most importantly the `tty7 agent-hook` emitter, which stays silent - // without it so globally-installed agent hooks can't leak escape sequences - // into other terminals (see `core::agent_hooks`). - cmd.env( - crate::core::agent_hooks::TTY7_ENV_MARKER, - env!("CARGO_PKG_VERSION"), - ); - - // User-configured environment variables override inherited values (but not - // TERM/COLORTERM above, which reflect our emulator's real capabilities). let extra_env = crate::core::config::extra_env(); - for (k, v) in &extra_env { - if k != "TERM" && k != "COLORTERM" { - cmd.env(k, v); - } + for (k, v) in pane_environment(&extra_env) { + cmd.env(k, v); } // LaunchServices commonly starts a macOS app with no locale variables at @@ -4042,6 +4081,74 @@ mod tests { assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once"); } + /// Every pane is told which terminal it is running in, under the names the + /// rest of the world reads (`TERM_PROGRAM`/`TERM_PROGRAM_VERSION`) as well + /// as our own `TTY7` marker. Nothing third-party looks for the marker, so + /// dropping the standard pair would leave capability probes guessing. + #[test] + fn pane_environment_advertises_the_terminal_under_the_standard_names() { + let env: std::collections::HashMap<_, _> = + pane_environment(&std::collections::HashMap::new()) + .into_iter() + .collect(); + let version = env!("CARGO_PKG_VERSION"); + + assert_eq!(env.get("TERM_PROGRAM").map(String::as_str), Some("tty7")); + assert_eq!( + env.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some(version) + ); + assert_eq!( + env.get(crate::core::agent_hooks::TTY7_ENV_MARKER) + .map(String::as_str), + Some(version) + ); + assert_eq!( + env.get("TERM").map(String::as_str), + Some("xterm-256color"), + "terminfo name is what the pane's decoder actually implements" + ); + } + + /// The user's `env` map may rename the terminal — posing as another program + /// is how you get a tool that only recognises a fixed list to light up — + /// but it may not contradict what our emulator can decode. Later entries + /// win, so the ordering is the precedence. + #[test] + fn pane_environment_lets_configured_env_override_identity_but_not_capability() { + let configured = [ + ("TERM_PROGRAM", "iTerm.app"), + ("TERM_PROGRAM_VERSION", "3.5.0"), + ("TERM", "dumb"), + ("COLORTERM", ""), + ("EDITOR", "hx"), + ] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied: std::collections::HashMap<_, _> = + pane_environment(&configured).into_iter().collect(); + + assert_eq!( + applied.get("TERM_PROGRAM").map(String::as_str), + Some("iTerm.app") + ); + assert_eq!( + applied.get("TERM_PROGRAM_VERSION").map(String::as_str), + Some("3.5.0") + ); + assert_eq!(applied.get("EDITOR").map(String::as_str), Some("hx")); + assert_eq!( + applied.get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert_eq!( + applied.get("COLORTERM").map(String::as_str), + Some("truecolor") + ); + } + /// The macOS UTF-8 fallback applies only when the inherited environment has /// no locale and the user has not taken control through the generic `env` /// map. Key presence is authoritative there, including an empty value. From a7835a0b8c221d8f24aeb872182c4d345ac3969b Mon Sep 17 00:00:00 2001 From: mingrath Date: Mon, 27 Jul 2026 16:42:29 +0700 Subject: [PATCH 2/6] fix(terminal): shape Thai SARA AM with the base it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SARA AM (ำ U+0E33) is `Lo` and width 1, so the grid gives it its own column — but it is not atomic to the shaper. The Thai shaper decomposes it into NIKHAHIT + SARA AA and moves the nikhahit backwards over any above-base marks onto the base consonant. Shaped in a run of its own it has no base to reorder onto, so `น้ำ` came out as `น้` plus a dotted circle, losing the vowel entirely. Absorb a following SARA AM into the preceding cell's cluster, so base, tone mark and SARA AM reach `shape_line` in one string. Lao SARA AM (U+0EB3) takes the same shaper path and is handled with it. That makes `cells == 2` ambiguous, so `Cluster` now records why: a wide base is one glyph spanning two columns and pins at `2 × cell_width`, while an absorbed SARA AM is two base glyphs of one column each and pins like a `Run`. `apply_force_width_to_layout` classifies glyphs by advance rather than by count, so the marks ride their base under either pinning. Two deliberate limits, both pinned by tests: A SARA AM is not a base for another one. Absorbing there would pin the second one's glyphs past the cluster's two-cell clip and swallow it, so `ำำ` stays two `Solo`s and both remain visible. A SARA AM with nothing before it likewise paints alone — a dotted circle is the shaper's honest answer for an orphaned mark, and inventing a base would be worse. An absorbed SARA AM takes its base's style rather than its own, so a colour change mid-syllable (`grep --color` landing between a consonant and its vowel) recolours the vowel. Unlike `Run` and `Wide`, the cluster cannot break on a style change: split off, the vowel renders as a dotted circle. A recoloured vowel beats a broken one. Co-Authored-By: Claude Opus 5 (1M context) --- src/terminal/element.rs | 164 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 149 insertions(+), 15 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 5e18f837..4d8f3352 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -490,18 +490,51 @@ enum RowSeg { /// drawing, accented Latin, …) that may route to a fallback face whose /// advance isn't the cell width. Solo { col: usize }, - /// A base plus the combining marks stacked on it, shaped as one string so - /// the marks reach the shaper. Never batched with neighbours: the marks add + /// A base with everything that has to shape alongside it — the combining + /// marks stacked on it, and a following SARA AM — as one string, so the + /// shaper sees the whole cluster. Never batched with neighbours: marks add /// characters without adding columns, which is exactly the correspondence /// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`]. + /// + /// An absorbed SARA AM takes the base's style rather than its own. Unlike + /// a [`RowSeg::Run`], the cluster can't break on a style change: split off, + /// SARA AM has no base to reorder its nikhahit onto and renders as a dotted + /// circle. A recoloured vowel beats a broken one. Cluster { col: usize, - /// Columns the base occupies — 2 once the grid marked it wide. + /// Columns the whole cluster occupies — 2 for a wide base, or for a + /// narrow base that absorbed a following SARA AM. cells: usize, text: String, + /// Whether `cells == 2` because the *base* is wide, rather than because + /// a spacing character joined it. The two need opposite pinning: a wide + /// base is one glyph across two columns, an absorbed SARA AM is two + /// glyphs of one column each. + wide_base: bool, }, } +/// Append a cell's character followed by any combining marks riding on it. +fn push_cell(text: &mut String, cell: &RenderCell) { + text.push(cell.c); + text.extend(cell.marks.iter().flat_map(|marks| marks.iter())); +} + +/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not +/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA +/// and moves the nikhahit backwards over any above-base marks onto the base +/// consonant. Shaped in a run of its own it has no base to reorder onto, and +/// comes out as a dotted circle. +fn is_sara_am(c: char) -> bool { + matches!(c, '\u{0E33}' | '\u{0EB3}') +} + +/// Does `col` hold a SARA AM that should join the preceding cell's cluster? +fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { + row.get(col) + .filter(|cell| !cell.spacer && is_sara_am(cell.c)) +} + /// Split one grid row into paintable segments. /// /// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the @@ -530,15 +563,26 @@ fn segment_row(row: &[RenderCell]) -> Vec { // Combining marks come first: they can sit on an ASCII base too, and // either way the whole cluster has to reach the shaper in one string. if let Some(marks) = &cell.marks { - let cells = if col + 1 < row.len() && row[col + 1].spacer { - 2 - } else { - 1 - }; + let wide_base = col + 1 < row.len() && row[col + 1].spacer; + let mut cells = if wide_base { 2 } else { 1 }; let mut text = String::with_capacity(1 + marks.len()); - text.push(cell.c); - text.extend(marks.iter()); - segs.push(RowSeg::Cluster { col, cells, text }); + push_cell(&mut text, cell); + // A wide base already owns both columns, so only a narrow one has a + // column spare for SARA AM to join it in. A SARA AM is not itself a + // base to absorb onto — two in a row stay separate. + if !wide_base + && !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + push_cell(&mut text, am); + cells = 2; + } + segs.push(RowSeg::Cluster { + col, + cells, + text, + wide_base, + }); col += cells; continue; } @@ -569,6 +613,23 @@ fn segment_row(row: &[RenderCell]) -> Vec { cells: col - start, text, }); + } else if !is_sara_am(cell.c) + && let Some(am) = sara_am_at(row, col + 1) + { + // An unmarked base still has to shape with its SARA AM. A + // baseless SARA AM is not a base for the next one: absorbing + // there would pin the second one's glyphs outside the cluster's + // clip, so two in a row stay separate and both stay visible. + let mut text = String::with_capacity(2); + push_cell(&mut text, cell); + push_cell(&mut text, am); + segs.push(RowSeg::Cluster { + col, + cells: 2, + text, + wide_base: false, + }); + col += 2; } else { segs.push(RowSeg::Solo { col }); col += 1; @@ -848,11 +909,22 @@ fn paint_glyphs( // Same pinning as the batched runs, just for one base: two // columns get `force_width` so a fallback emoji face can't // drift, one column paints at the origin like `Solo`. - RowSeg::Cluster { col, cells, text } => ( + // Two columns pin per *base glyph*, and which that is depends + // on why the cluster is two cells wide: a wide base is one + // glyph spanning both, an absorbed SARA AM is two glyphs of one + // column each. `force_width` classifies by advance, so the + // marks ride their base under either. One column paints at the + // origin like `Solo`. + RowSeg::Cluster { + col, + cells, + text, + wide_base, + } => ( col, cells, SharedString::from(text), - (cells == 2).then(|| geom.cell_width * 2.), + (cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }), cells == 1, ), }; @@ -2127,6 +2199,16 @@ mod tests { col, cells, text: text.to_string(), + wide_base: false, + } + } + + fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg { + RowSeg::Cluster { + col, + cells, + text: text.to_string(), + wide_base: true, } } @@ -2145,7 +2227,7 @@ mod tests { // spacer too (❤ + U+FE0F). let mut row = wide_cells("\u{2764}"); row[0].marks = Some(Box::from(['\u{FE0F}'])); - assert_eq!(segment_row(&row), [cluster(0, 2, "\u{2764}\u{FE0F}")]); + assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]); // Several marks on one base: an above-base vowel and a tone mark both // sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48). @@ -2157,6 +2239,58 @@ mod tests { ); } + /// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid + /// gives it its own column — but the shaper decomposes it into NIKHAHIT + + /// SARA AA and reorders the nikhahit backwards onto the base consonant. + /// Shaped in its own run it has no base to reorder onto and comes out as a + /// dotted circle, so it has to join the preceding cell's cluster. + #[test] + fn segment_row_absorbs_sara_am_into_its_base() { + // น + ้ (tone) + ำ — the base already carries a mark. + let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')]; + row[0].marks = Some(Box::from(['\u{0E49}'])); + assert_eq!( + segment_row(&row), + [cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")] + ); + + // ก + ำ — an unmarked base still has to shape with it. + let row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); + + // Lao SARA AM (U+0EB3) takes the same shaper path. + let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')]; + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]); + + // A style change does not break the cluster, unlike a `Run` or `Wide` + // batch: split off, the vowel has no base and paints a dotted circle, + // so it takes the base's style instead. + let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; + row[1].fg = gpui::red(); + assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); + } + + /// With nothing to attach to, SARA AM paints alone — a dotted circle is the + /// shaper's honest answer for an orphaned mark, and inventing a base would + /// be worse. + #[test] + fn segment_row_leaves_a_baseless_sara_am_alone() { + let row = vec![cell('\u{0E33}'), cell('a')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]); + + // A blank before it is not a base either. + let row = vec![cell(' '), cell('\u{0E33}')]; + assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]); + + // Nor is another SARA AM: absorbing would pin the second one's glyphs + // past the cluster's two-cell clip and swallow it entirely. + let row = vec![cell('\u{0E33}'), cell('\u{0E33}')]; + assert_eq!( + segment_row(&row), + [RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }] + ); + } + /// A marked cell never joins a batch: marks add characters without adding /// columns, which would desync `force_width`'s glyph-per-column pinning. #[test] @@ -2176,7 +2310,7 @@ mod tests { segment_row(&row), [ wide(0, 2, "你"), - cluster(2, 2, "好\u{FE0F}"), + wide_cluster(2, 2, "好\u{FE0F}"), wide(4, 2, "世"), ] ); From c21508df6fc8f69bf011c767512301c3ea722c37 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 08:04:27 +0800 Subject: [PATCH 3/6] chore(settings): finish off the dim-inactive-panes setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #214 added the switch itself; this is the wiring around it that a new setting in this codebase is expected to carry. - Index the row in `settings_search_entries`, which the settings search box matches against. Without an entry, searching "dim", "fade" or "unfocused" — the words someone actually looks for — finds nothing, and the switch is only reachable by scrolling to it. Pinned in `index_titles_match_rendered_row_labels` so the title cannot drift. - Pin the default and the round trip, as every other `default_true` flag here does (see `confirm_window_close_defaults_on_and_round_trips`): a config written before the switch existed must still dim, and a `false` must survive save/load or the effect comes back next launch. - Hand the flag to `Pane::render` instead of reading the `Config` global from inside it. `pane.rs` had no global state before, deliberately — the leaf type is generic so the tree logic can be tested with plain values. The caller already computes the split test the dimming was gated on, so it can compute this too: one lookup per frame rather than one per leaf, and the tree stays renderable without a Config global. While there, `show_focus` is now named for what it does — nothing drew a focus ring; it only ever gated the fade. - Move the row below "Follow theme". That button clears the opacity and blur overrides only, and a third row directly above it read as something it would also reset. - Changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++++++++++ src/core/config.rs | 19 +++++++++++++++++++ src/ui/app.rs | 11 +++++++---- src/ui/pane.rs | 42 +++++++++++++++++++++--------------------- src/ui/settings.rs | 30 ++++++++++++++++++++---------- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..487e9cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Inactive panes only fade if you want them to** — a split tab dims every pane + but the focused one so the active terminal reads as foreground. That is the + right default, but it is not free: at 55% opacity a dim theme's comment color + or a long-running build's output in the pane you are *watching* rather than + typing into gets harder to read, and some people track panes by cursor alone + and never needed the cue. Settings → Appearance → Transparency now carries a + "Dim inactive panes" switch. On by default, so nothing changes for anyone who + was happy; off renders every pane at full opacity. (#214) + ## [26.7.5] - 2026-07-27 ### Added diff --git a/src/core/config.rs b/src/core/config.rs index 700f3b94..98e53117 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1095,6 +1095,25 @@ mod tests { assert!(!newer.confirm_window_close); } + /// Also opt-*out*: every config written before the switch existed predates + /// the choice, and those users have been looking at dimmed panes all along — + /// defaulting to `false` would silently change how every split tab looks on + /// upgrade. And once someone does turn it off, the `false` has to survive a + /// save/load cycle, or the effect they opted out of returns on next launch. + #[test] + fn dim_inactive_panes_defaults_on_and_round_trips() { + assert!(Config::default().dim_inactive_panes); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.dim_inactive_panes); + + let off: Config = serde_json::from_str(r#"{"dim_inactive_panes": false}"#).unwrap(); + assert!(!off.dim_inactive_panes); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.dim_inactive_panes); + } + #[test] fn theme_follow_system_defaults_and_round_trips() { // Old configs (no follow-system keys) must land on off + the built-in diff --git a/src/ui/app.rs b/src/ui/app.rs index b2181f7d..b73cb4ad 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2506,8 +2506,8 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.check_for_updates = on); } - /// Toggle inactive-pane dimming. Applies on the next render — the pane tree - /// reads the flag from the `Config` global each frame. + /// Toggle inactive-pane dimming. Applies on the next render — `update_config` + /// notifies, and this view's render is what hands the flag to the pane tree. pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -5235,8 +5235,11 @@ impl Render for Tty7App { .child(leaf.clone()) .into_any_element(), None => { - let show_focus = active_tab.pane.leaves().len() > 1; - active_tab.pane.render(show_focus, window, cx) + // Fading the unfocused panes only says anything once the + // tab is actually split, and the user can turn it off. + let dim_inactive = active_tab.pane.leaves().len() > 1 + && cx.global::().dim_inactive_panes; + active_tab.pane.render(dim_inactive, window, cx) } } } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 5f658295..cade6881 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -524,33 +524,33 @@ impl Pane> { self.close_leaf_where(&|v| v.entity_id() == target.entity_id()) } - /// Render the subtree. `show_focus` draws a focus ring on the active leaf - /// (suppressed when the tab has a single pane). - pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement { + /// Render the subtree. `dim_inactive` fades every leaf but the focused one; + /// the caller decides it — it is off for an unsplit tab (nothing to + /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept + /// a parameter rather than a `Config` global read here so the tree stays + /// renderable without one, as the rest of this module is. + pub fn render( + &self, + dim_inactive: bool, + window: &mut Window, + cx: &mut App, + ) -> gpui::AnyElement { match self { Pane::Empty => div().into_any_element(), Pane::Leaf(v) => { - let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx); + let focused = v.read(cx).focus_handle.contains_focused(window, cx); // No full border (it reads as a hard rectangle). div() .size_full() .relative() .overflow_hidden() - // Inactive panes (only when the tab is actually split) fade back - // so the focused terminal reads as foreground without a hard - // border. Element opacity multiplies through the whole subtree - // (terminal glyphs + cell fills), unlike a background-tinted - // scrim which is near-invisible on a light theme (white on - // white). Applied to the container, so a click still lands on - // the terminal and focuses it. `dim_inactive_panes` opts out. - .when( - show_focus - && !focused - && cx - .global::() - .dim_inactive_panes, - |d| d.opacity(0.55), - ) + // Inactive panes fade back so the focused terminal reads as + // foreground without a hard border. Element opacity multiplies + // through the whole subtree (terminal glyphs + cell fills), + // unlike a background-tinted scrim which is near-invisible on a + // light theme (white on white). Applied to the container, so a + // click still lands on the terminal and focuses it. + .when(dim_inactive && !focused, |d| d.opacity(0.55)) .child(v.clone()) .into_any_element() } @@ -681,7 +681,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(a.render(show_focus, window, cx)), + .child(a.render(dim_inactive, window, cx)), ) .child(divider) .child( @@ -691,7 +691,7 @@ impl Pane> { .flex_basis(px(0.)) .min_w_0() .min_h_0() - .child(b.render(show_focus, window, cx)), + .child(b.render(dim_inactive, window, cx)), ) .into_any_element() } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 4eeb63ff..edd32d10 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Blur", keywords: "transparency translucent frosted vibrancy window background", }, + SearchEntry { + section: Appearance, + title: "Dim inactive panes", + keywords: "fade unfocused inactive split pane focus opacity highlight active dimming", + }, SearchEntry { section: Appearance, title: "Font size", @@ -1518,10 +1523,12 @@ impl Tty7App { .into_any_element() } - /// Window section (Appearance): global opacity slider + blur switch that - /// apply to every theme. Both are config *overrides* — until touched they - /// follow the active theme's own `opacity`/`blur`, and "Follow theme" - /// clears them back to that state. + /// Window section (Appearance): the global opacity slider and blur switch + /// that apply to every theme, then the inactive-pane dimming switch. The + /// first two are config *overrides* — until touched they follow the active + /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that + /// state; the dimming switch is a plain flag no theme carries a value for, + /// so it sits below that button and "Follow theme" leaves it alone. fn render_window_section(&self, cx: &mut Context) -> AnyElement { let Some(slider) = self .active_settings() @@ -1578,12 +1585,6 @@ impl Tty7App { blur_switch, cx, )) - .child(self.settings_row( - "Dim inactive panes", - "Fade unfocused panes in a split so the active one stands out.", - dim_switch, - cx, - )) // Only offered while an override is active; otherwise the values // already follow the theme and the button would be a no-op. .when(overridden, |this| { @@ -1598,6 +1599,14 @@ impl Tty7App { ), ) }) + // Below "Follow theme", which resets the two rows above it and not + // this one — a plain setting with no theme value behind it. + .child(self.settings_row( + "Dim inactive panes", + "Fade unfocused panes in a split so the active one stands out.", + dim_switch, + cx, + )) .into_any_element() } @@ -4623,6 +4632,7 @@ mod tests { "Sidebar grouping", "Tab completion", "History search", + "Dim inactive panes", ] { assert!( settings_search_entries().iter().any(|e| e.title == title), From 0be3b6764024f8eaa8d34835be3aa87a95a2b5e1 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 08:42:08 +0800 Subject: [PATCH 4/6] fix(render): stop italic CJK rendering as unrelated CJK on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every character came out as a different character, one for one, consistently — it read as a broken locale or a mangled encoding, and it was neither. Hack, the bundled default, has no CJK, so those cells are shaped through the font-fallback chain. gpui's Windows backend then threw away the face DirectWrite shaped the run with and looked a fresh one up by family, weight and style. That round trip mapped DirectWrite's italic to oblique — the enum is numbered OBLIQUE = 1, ITALIC = 2, and the mapping had them the other way around — so an italic fallback face resolved to a request for an oblique one, and a family with no oblique face (Maple Mono NF CN, first in our Windows chain) came back as its upright face instead. The glyph indices were right; the outlines they indexed belonged to a different face, at a fixed glyph-id skew. Fixed upstream in our gpui fork by registering the face DirectWrite actually chose rather than re-deriving one, which also closes a latent use-after-free in the same cache: it keyed fonts by a raw pointer to a face nothing held a reference to, so a released face could be aliased by any later allocation. Bumps the fork pin; no tty7 code changes. Covered there by two tests in `gpui_windows::direct_write` — one asserting a shaped run's glyphs round-trip through the font id the run reports, one asserting every font-face cache key is owned by the font it maps to. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++++++++++++++++ Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 21 +++++++++++++++------ 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478bb9c1..ea70d003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Italic CJK rendered as unrelated CJK on Windows** — every character came out + as a different character, one for one, consistently, so it read as a broken + locale or a mangled encoding. It was neither. Hack, the bundled default, has no + CJK, so those cells are shaped by the font-fallback chain; gpui's Windows + backend then threw away the face DirectWrite shaped with and looked a fresh one + up by family, weight and style. That round trip mapped DirectWrite's *italic* + to *oblique* — the two are numbered the other way around in the API — and a + family with no oblique face resolved to its upright one. The glyph indices were + right; the outlines they were pointing into belonged to a different face. Fixed + in our gpui fork by rasterizing the face DirectWrite actually chose, which also + closes a latent use-after-free in the same cache: it keyed fonts by a raw + pointer to a face nothing held a reference to. + ## [26.7.5] - 2026-07-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index f63f5adf..605e3ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "gpui_util", "indexmap", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "proc-macro2", "quote", @@ -3096,7 +3096,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "anyhow", @@ -3287,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_unix", @@ -3338,7 +3338,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_macos", @@ -3385,7 +3385,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "console_error_panic_hook", "gpui", @@ -3409,7 +3409,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "schemars", "serde", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "log", @@ -3428,7 +3428,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytemuck", @@ -3481,7 +3481,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "accesskit", "accesskit_windows", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-compression", @@ -3825,7 +3825,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4946,7 +4946,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bindgen", @@ -6044,7 +6044,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "collections", "serde", @@ -7021,7 +7021,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "derive_refineable", ] @@ -7064,7 +7064,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "bytes", @@ -7589,7 +7589,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "async-task", "backtrace", @@ -8396,7 +8396,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "heapless", "log", @@ -9775,7 +9775,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "async-fs", @@ -9814,7 +9814,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "perf", "quote", @@ -11620,7 +11620,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "anyhow", "chrono", @@ -11665,7 +11665,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" dependencies = [ "tracing", "tracing-subscriber", @@ -11676,7 +11676,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02" +source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 9d076654..b1b0a15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -319,12 +319,21 @@ lto = "thin" codegen-units = 1 # ---- gpui fork ------------------------------------------------------------ -# Our `tty7` branch (cut from the pinned upstream rev, one commit on top) carries -# a single patch: `prefers_ime_for_printable_keys` takes the keystroke, so an -# input handler can answer per key instead of per view. tty7 needs it for -# Option-as-Meta — macOS routes ⌥-chords to the IME whenever a CJK input source -# is active, and without the keystroke there is no way to decline just those -# chords (see `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# Our `tty7` branch (cut from the pinned upstream rev, two commits on top) carries: +# +# 1. `prefers_ime_for_printable_keys` takes the keystroke, so an input handler can +# answer per key instead of per view. tty7 needs it for Option-as-Meta — macOS +# routes ⌥-chords to the IME whenever a CJK input source is active, and without +# the keystroke there is no way to decline just those chords (see +# `terminal::input::prefers_ime_for_printable_keys`, issue #177). +# +# 2. gpui's Windows backend rasterizes a font-fallback run with the face +# DirectWrite actually shaped it with, instead of re-deriving one from the +# face's family/weight/style. The round trip mapped DirectWrite's italic to +# oblique, so italic CJK — which every pane reaches through the fallback chain, +# Hack having no CJK — drew a *different* face's outlines at the shaped glyph +# indices. Every character rendered as an unrelated character, one for one, +# which reads as mojibake rather than as a font bug. # # Patching by source rather than editing the `gpui`/`gpui_platform` pins above is # deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and From 4d09df3b714c0895a6e6bf524490d0a15c86aebd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:36:28 +0800 Subject: [PATCH 5/6] chore: drop stale "focus rings" comment the show_focus rename missed The render call site still said "show focus rings only when split" -- the same misdescription the parameter rename in this PR removes: nothing ever drew a ring, the flag only gated the fade, and the fade condition is now spelled out two lines below. Co-Authored-By: Claude Fable 5 --- src/ui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index b73cb4ad..26c1925f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5214,7 +5214,7 @@ impl Render for Tty7App { .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); - // Render the active tab's pane tree; show focus rings only when split. + // Render the active tab's pane tree. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). None => self.render_home(cx).into_any_element(), From 042bb784edfae735ef82df291decf08072ad067a Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 10:40:58 +0800 Subject: [PATCH 6/6] fix(daemon): match capability env keys case-insensitively on Windows Windows environment blocks are case-insensitive: portable-pty's CommandBuilder keeps one slot per lowercased key, so a configured `Term`/`ColorTerm` in `env` would land in the same slot as `TERM`/`COLORTERM` and, coming later, replace it -- sidestepping the rule that user env may rename the terminal but not contradict what the pane's decoder implements. Filter capability keys with the platform's own notion of "the same variable": case-insensitive on Windows, exact elsewhere (where a differently-cased key is a genuinely distinct variable and stays the user's to set). Pinned by a Windows-only test. Co-Authored-By: Claude Fable 5 --- src/daemon/pane.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 3f178736..e4e17711 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -432,6 +432,22 @@ const TERM_PROGRAM_NAME: &str = "tty7"; /// what the pane on the other end can decode. const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; +/// Whether a configured `env` key names one of [`CAPABILITY_ENV`]. Windows +/// environment blocks are case-insensitive — `portable-pty` keeps one slot per +/// lowercased key, so a configured `Term` there would replace `TERM` just as +/// surely as the exact spelling — so the filter must use the platform's own +/// notion of "the same variable". On Unix a differently-cased key is a genuinely +/// distinct variable and stays the user's to set. +fn names_capability_env(key: &str) -> bool { + CAPABILITY_ENV.iter().any(|cap| { + if cfg!(windows) { + key.eq_ignore_ascii_case(cap) + } else { + key == *cap + } + }) +} + /// The environment every pane starts with, in application order — tty7's own /// advertisements first, then the user's `env` map, which overrides all but /// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the @@ -471,7 +487,7 @@ fn pane_environment( env.extend( extra_env .iter() - .filter(|(k, _)| !CAPABILITY_ENV.contains(&k.as_str())) + .filter(|(k, _)| !names_capability_env(k)) .map(|(k, v)| (k.clone(), v.clone())), ); env @@ -4149,6 +4165,39 @@ mod tests { ); } + /// Windows environment blocks are case-insensitive — `portable-pty` keeps + /// one slot per lowercased key — so a configured `Term` would replace + /// `TERM` just as surely as the exact spelling. The capability filter must + /// therefore drop any casing of a capability key, not just the canonical + /// one. (On Unix a differently-cased key is a distinct variable and passes + /// through untouched.) + #[cfg(windows)] + #[test] + fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() { + let configured = [("Term", "dumb"), ("ColorTerm", ""), ("term_program", "x")] + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + let applied = pane_environment(&configured); + + assert!( + !applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"), + "a recased capability key must be filtered out, or it would land \ + in the same case-folded slot and win by coming later" + ); + let get = |key: &str| { + applied + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("TERM"), Some("xterm-256color")); + assert_eq!(get("COLORTERM"), Some("truecolor")); + // Identity keys stay overridable in any casing the user spells. + assert_eq!(get("term_program"), Some("x")); + } + /// The macOS UTF-8 fallback applies only when the inherited environment has /// no locale and the user has not taken control through the generic `env` /// map. Key presence is authoritative there, including an empty value.