From a62efad35c4ebf3b7e2d1bc6ec0a86907336343a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:03:27 +0800 Subject: [PATCH 01/46] fix(files): put the tree on the panel's own left rail Every list in the right panel lays its column out a ROW_INSET short of CONTENT_INSET and has each row pad itself back out, so a row's text lands on the 12px rail and its hover and selection fill bleeds past it to 8. The file tree ran its own pair of numbers instead: a px_1() column and a 6px row inset, which put a depth-0 name at 10 and let the fill reach 4. Two of those disagreements are visible. The tree sits directly under the panel's search field, so the root row's folder glyph and the search magnifier are two adjacent left edges 2px out of line. And a selected row runs nearly edge to edge where the same row under Info or Source Control stops 8px short. Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER --- src/ui/file_tree.rs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 0d379823..f609284a 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -6,12 +6,12 @@ use std::sync::Arc; use crate::core::config::RightPanelTab; use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex}; use crate::terminal::git_data::index_of; -use crate::ui::app::Tty7App; +use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::file_copy; use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub}; use crate::ui::host_registry::HostRegistry; use crate::ui::i18n::{L10nKey, t, t_fmt}; -use crate::ui::right_panel::{ROW_GLYPH, git_badge}; +use crate::ui::right_panel::{ROW_GLYPH, ROW_INSET, git_badge}; use crate::ui::scm::status::{status_color, status_glyph}; use gpui::prelude::*; use gpui::{ @@ -24,6 +24,18 @@ use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; +// The tree is laid out the way every other list in this panel is: the column +// sits a `ROW_INSET` short of `CONTENT_INSET` and each row pads itself back +// out, so a depth-0 name lands on the panel's 12px rail while the row's hover +// and selection fill bleeds past it to 8. Depth is added on top of that inset, +// so `INDENT` is the step between levels and nothing else. +// +// It used to run its own pair of numbers instead — a `px_1()` column and a 6px +// row — which put the tree's names 2px left of the search field directly above +// them and let a selected row's fill reach twice as close to the panel edge as +// an Info or Source Control row's. Two adjacent left edges that disagree by +// 2px is the one misalignment a reader can actually catch, because the search +// glyph sits right there to compare against. const INDENT: f32 = 14.0; const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); @@ -1579,7 +1591,7 @@ impl Tty7App { .min_h_0() .overflow_y_scroll() .track_scroll(&self.right_panel.tree_scroll) - .px_1() + .px(px(CONTENT_INSET - ROW_INSET)) .pb_1() .track_focus(&self.file_tree.focus_handle) .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { @@ -1718,9 +1730,9 @@ impl Tty7App { return vec![ h_flex() // Aligned with the label column of a real row at this - // depth: 6 for the row's own inset, INDENT for the depth, - // then the width of the icon and its gap. - .pl(px(6.0 + row.depth as f32 * INDENT + 20.0)) + // depth: ROW_INSET for the row's own inset, INDENT for the + // depth, then the width of the icon and its gap. + .pl(px(ROW_INSET + row.depth as f32 * INDENT + 20.0)) .py_1() .items_center() .text_xs() @@ -1806,8 +1818,8 @@ impl Tty7App { .id(SharedString::from(format!("tree-{}", path.display()))) .items_center() .gap_1() - .pl(px(6.0 + row.depth as f32 * INDENT)) - .pr_1() + .pl(px(ROW_INSET + row.depth as f32 * INDENT)) + .pr(px(ROW_INSET)) .py_1() .rounded(cx.theme().radius) .cursor_pointer() @@ -1903,8 +1915,8 @@ impl Tty7App { h_flex() .items_center() .gap_1() - .pl(px(6.0 + (row.depth + 1) as f32 * INDENT)) - .pr_1() + .pl(px(ROW_INSET + (row.depth + 1) as f32 * INDENT)) + .pr(px(ROW_INSET)) .py_0p5() .child(Input::new(&input).xsmall()) .into_any_element(), From 168f76d5a749372d725f93df3f07aeac3c499c3d Mon Sep 17 00:00:00 2001 From: ayamir Date: Thu, 10 Sep 2026 16:57:25 +0800 Subject: [PATCH 02/46] fix(update): explain GitHub API rate limits --- src/core/update.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/src/core/update.rs b/src/core/update.rs index 1d4ed849..e1e775d4 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -1766,6 +1766,11 @@ struct GitHubAsset { browser_download_url: String, } +#[derive(serde::Deserialize)] +struct GitHubError { + message: String, +} + /// `nightly.json`, written by the nightly workflow. Only `version` is read /// today; the rest is there so a build can be traced back to its commit /// without cross-referencing the release notes. @@ -1853,7 +1858,30 @@ async fn fetch_json( let mut response = client.send(request).await.context("sending the request")?; if !response.status().is_success() { - anyhow::bail!("GitHub returned HTTP {}", response.status().as_u16()); + let status = response.status().as_u16(); + let rate_remaining = response + .headers() + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let rate_reset = response + .headers() + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let mut body = Vec::new(); + let _ = response + .body_mut() + .take(8 * 1024) + .read_to_end(&mut body) + .await; + anyhow::bail!(github_http_error( + status, + rate_remaining.as_deref(), + rate_reset.as_deref(), + &body, + now_secs(), + )); } let mut body = Vec::new(); @@ -1866,6 +1894,52 @@ async fn fetch_json( serde_json::from_slice(&body).context("parsing JSON") } +fn github_http_error( + status: u16, + rate_remaining: Option<&str>, + rate_reset: Option<&str>, + body: &[u8], + now: u64, +) -> String { + let message = serde_json::from_slice::(body) + .ok() + .map(|error| sanitize_github_message(&error.message)); + let rate_limited = status == 403 + && (rate_remaining == Some("0") + || message.as_deref().is_some_and(|message| { + message.to_ascii_lowercase().contains("rate limit exceeded") + })); + + if rate_limited { + let retry = rate_reset + .and_then(|reset| reset.parse::().ok()) + .filter(|reset| *reset > now) + .map(|reset| { + let minutes = (reset - now).div_ceil(60); + let suffix = if minutes == 1 { "" } else { "s" }; + format!("try again in about {minutes} minute{suffix}") + }) + .unwrap_or_else(|| "try again shortly".to_string()); + return format!("GitHub API rate limit exceeded; {retry} (HTTP {status})"); + } + + match message.filter(|message| !message.is_empty()) { + Some(message) => format!("GitHub returned HTTP {status}: {message}"), + None => format!("GitHub returned HTTP {status}"), + } +} + +fn sanitize_github_message(message: &str) -> String { + let single_line = message.split_whitespace().collect::>().join(" "); + let mut chars = single_line.chars(); + let shortened: String = chars.by_ref().take(200).collect(); + if chars.next().is_some() { + format!("{shortened}…") + } else { + shortened + } +} + /// Returns the release together with the version it advertises, which is not /// always something the release object states outright — see `resolve_version`. async fn fetch_latest_release( @@ -2895,6 +2969,54 @@ fn is_update_available(latest: &str, current: &str) -> bool { mod tests { use super::*; + #[test] + fn github_rate_limit_error_says_when_to_retry() { + let error = github_http_error( + 403, + Some("0"), + Some("4600"), + br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again in about 60 minutes (HTTP 403)" + ); + } + + #[test] + fn expired_github_rate_limit_says_to_retry_shortly() { + let error = github_http_error( + 403, + Some("0"), + Some("999"), + br#"{"message":"API rate limit exceeded."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again shortly (HTTP 403)" + ); + } + + #[test] + fn github_json_error_keeps_a_short_actionable_message() { + let error = github_http_error( + 404, + None, + None, + br#"{"message":"Not Found\nPlease check the repository"}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub returned HTTP 404: Not Found Please check the repository" + ); + } + fn github_asset(name: &str) -> GitHubAsset { GitHubAsset { name: name.to_string(), From ac36d4a4fab6d89d5ffbac968e0ff2ef6188f4ae Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:05:58 +0800 Subject: [PATCH 03/46] feat(ui): say how far away a remote pane's shell is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Session table named the machine a pane's shell was on but never the distance to it. A remote workspace gone slow looked exactly like one that had not, and the only way to tell them apart was to leave tty7 and ping the box by hand — with nothing to say whether tty7's own link was the one that was slow. Time the control link's `Ping` and put the last measurement on a `latency` row, drawn only where there is a network between here and the shell. Every ping that comes back feeds it, the keepalive's included, so a link being kept alive already carries a number before anyone asks for one. Nothing else is timed: every other request does work on the far side, so its round trip measures that work rather than the link, and a `ReadFile` of a large file would read as a network seconds slow. The poll rides the Info panel's existing process-and-port round, and only while the panel is open — that round also runs with the panel shut, watching for ports to forward, and a round trip for a row nobody can see is the far end's time spent on nothing. A link that drops keeps its last measurement rather than blanking: a dropped link is exactly when someone is reading the row to work out why a pane stopped answering. Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt --- crates/tty7-core/src/daemon/control.rs | 39 +++++++++- crates/tty7-core/src/host/mod.rs | 9 +++ crates/tty7-core/src/host/remote.rs | 85 +++++++++++++++++++++ src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/right_panel.rs | 101 ++++++++++++++++++++++++- 8 files changed, 232 insertions(+), 6 deletions(-) diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 72bb557f..d8b01dd4 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -938,6 +938,11 @@ struct ClientInner { blobs: Mutex>>, connected: AtomicBool, last_inbound: Mutex, + /// How long the last `Ping` took to come back, in microseconds; zero until + /// one has. Only `Ping` is timed: every other request does work on the far + /// side, so its round trip measures that work and not the link, and a + /// `ReadFile` of a large file would report the network as seconds slow. + last_rtt_us: AtomicU64, hello: ControlHelloOk, shutdown: Option>, reader_done: Mutex, @@ -1016,6 +1021,7 @@ impl ControlClient { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ok, shutdown, reader_done: Mutex::new(false), @@ -1059,6 +1065,19 @@ impl ControlClient { .unwrap_or_default() } + /// The last measured round trip to the peer, or `None` on a link nothing + /// has pinged yet. + /// + /// Fed by every [`ControlRequest::Ping`] that comes back — the keepalive's + /// as well as any a caller sends itself — so a link that is being kept + /// alive already carries a number without anyone asking for one. + pub fn last_rtt(&self) -> Option { + match self.inner.last_rtt_us.load(Ordering::Relaxed) { + 0 => None, + us => Some(Duration::from_micros(us)), + } + } + pub fn call(&self, req: ControlRequest) -> io::Result { self.call_full(req, &[]).map(|r| r.reply) } @@ -1086,6 +1105,9 @@ impl ControlClient { } let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed); + // Read off before `req` is moved into the message below. + let timed = matches!(req, ControlRequest::Ping); + let sent_at = Instant::now(); log::debug!(target: "tty7::control", "#{req_id} {req:?}"); let (tx, rx) = sync_channel(1); self.inner.pending()?.insert(req_id, tx); @@ -1108,9 +1130,21 @@ impl ControlClient { match rx.recv_timeout(deadline) { Ok(reply) => { let blob = self.inner.take_blob(req_id); - reply + let out = reply .into_result() - .map(|reply| ControlResponse { reply, blob }) + .map(|reply| ControlResponse { reply, blob }); + // Only a ping that actually came back. A timeout leaves the + // last good number in place rather than recording the deadline + // as the link's latency, and a refusal measures the peer's + // opinion of the request rather than the distance to it. + if timed && out.is_ok() { + let us = sent_at.elapsed().as_micros().min(u128::from(u64::MAX)) as u64; + // Zero means "never measured", so a sub-microsecond round + // trip on a loopback link rounds up rather than reading as + // no measurement at all. + self.inner.last_rtt_us.store(us.max(1), Ordering::Relaxed); + } + out } Err(RecvTimeoutError::Timeout) => { self.inner.forget(req_id); @@ -1343,6 +1377,7 @@ mod tests { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ControlHelloOk { control_version: CONTROL_VERSION, protocol_version: 0, diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 6bd0a7de..28291a03 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -173,6 +173,15 @@ pub trait WatchHandle: Send + Sync { pub trait Host: Send + Sync + 'static { fn id(&self) -> HostId; + /// How far away this host is: the round trip to it, measured now. + /// + /// `None` from a host with no link to measure — the local one, whose + /// "peer" is this process's own daemon over a Unix socket — and from a + /// remote one whose link is down or has not answered a ping yet. + fn link_rtt(&self) -> Option { + None + } + fn separator(&self) -> char; fn join(&self, dir: &Path, name: &str) -> PathBuf { diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 7183abe8..e3803b3f 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -257,6 +257,22 @@ impl Host for RemoteHost { /// process trees. A peer that does not announce the feature is not asked: /// it would answer `Err` and the caller cannot tell that apart from a pane /// serving nothing. + fn link_rtt(&self) -> Option { + // A ping of our own rather than whatever the keepalive last left + // behind: that one only fires on an idle link, and a link being polled + // for this is by definition not idle, so its number would age out of + // date exactly while someone is watching it. + if self.client.is_connected() + && let Err(e) = self.client.ping() + { + // The last good measurement below still stands. A link that has + // just gone down reports the distance it had while it was up, + // which is better than a blank until something notices it is gone. + log::debug!("could not ping {:?}: {e}", self.id); + } + self.client.last_rtt() + } + fn pane_procs(&self, pane_id: u64) -> Option { if !self .peer() @@ -1051,6 +1067,75 @@ mod tests { .unwrap() } + #[test] + fn link_rtt_pings_and_reports_what_came_back() { + let (host, seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert!( + host.link_rtt().is_some(), + "a ping that came back is a measurement" + ); + assert_eq!(seen.recv().unwrap(), ControlRequest::Ping); + assert_eq!( + seen.try_recv().ok(), + None, + "one row is worth one round trip and no more" + ); + } + + /// The latency row must mean the distance to the peer, not how long the + /// peer spent on whatever was asked of it. Timing every call would put a + /// `ReadFile` of a large file, or a `Git` that shells out, on that row and + /// read as a network gone seconds slow. + #[test] + fn only_a_ping_is_timed() { + let (host, _seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + ControlRequest::Exists { .. } => Some((ControlReply::Ok(ReplyOk::Bool(true)), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert_eq!( + host.client().last_rtt(), + None, + "a link nothing has pinged has no measurement to report" + ); + assert!(host.exists(Path::new("/etc/hosts"))); + assert_eq!( + host.client().last_rtt(), + None, + "an ordinary call measures the peer's work, so it leaves the link's latency alone" + ); + host.client().ping().unwrap(); + assert!(host.client().last_rtt().is_some()); + } + + /// A link that has just gone down keeps the distance it had while it was + /// up. Blanking the row on the first failed ping would take the number + /// away at exactly the moment someone is looking at it to work out why the + /// pane has stopped responding. + #[test] + fn a_link_that_goes_down_keeps_its_last_measurement() { + let served = std::sync::atomic::AtomicBool::new(true); + let (host, _seen) = host_with_peer('/', move |req| match req { + ControlRequest::Ping => match served.swap(false, Ordering::Relaxed) { + true => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + // Hanging up rather than answering, which is what a peer whose + // machine went away looks like from here. + false => None, + }, + other => panic!("unexpected request {other:?}"), + }); + + let measured = host.link_rtt().expect("the first ping came back"); + assert_eq!(host.link_rtt(), Some(measured)); + assert!(!host.is_connected(), "the second ping took the link down"); + assert_eq!(host.link_rtt(), Some(measured)); + } + #[test] fn shells_come_from_the_peer() { let (host, seen) = host_with_peer('/', |req| match req { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4884935d..d2fd7cf9 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1070,6 +1070,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelPortsRestricted => { "Something here runs as another user, whose ports aren't visible." } + L10nKey::PanelLatency => "latency", L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}", L10nKey::PanelCwd => "cwd", L10nKey::PanelShell => "shell", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index d4e30d71..c4b3810e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1129,6 +1129,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "セッション", L10nKey::PanelProcessesSubtitle => "プロセス", L10nKey::PanelPortsSubtitle => "ポート", + L10nKey::PanelLatency => "遅延", L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。", L10nKey::PanelPortsProbeFailed => { "このペインが何をリッスンしているか確認できませんでした。" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 627f7c2c..90f47ca6 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -788,6 +788,7 @@ l10n_keys! { PanelPortsUnsupported, PanelPortsProbeFailed, PanelPortsRestricted, + PanelLatency, PortAutoForwarded, PanelCwd, PanelShell, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 8b53c7a7..b3d2ddc3 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1020,6 +1020,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "会话", L10nKey::PanelProcessesSubtitle => "进程", L10nKey::PanelPortsSubtitle => "端口", + L10nKey::PanelLatency => "延迟", L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。", L10nKey::PanelPortsProbeFailed => "没能查出这个窗格在监听什么。", L10nKey::PanelPortsRestricted => "这里有以其他用户身份运行的进程,看不到它们的端口。", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 43e21b74..3521a53f 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -167,6 +167,16 @@ pub(crate) struct RightPanelState { /// and "nobody could tell us" are different sentences and the panel has to /// say which one it means. pub(crate) procs_unsupported: bool, + /// The last round trip measured to the machine `procs_pane` lives on. + /// `None` before the first ping comes back. + pub(crate) link_rtt: Option, + /// Which host `link_rtt` was measured against, and — since only a remote + /// pane has one — whether the latency row is drawn at all. Held per host + /// rather than per pane so that moving between two panes of the same + /// machine keeps the number on screen: it belongs to the link the two + /// panes share, and blanking it per pane would empty the row for as long + /// as the next poll takes to cross the network. + pub(crate) link_host: Option, /// How `procs_pane`'s loopback ports can be reached from this machine. /// Read by the Ports list to decide what a click on a port does, and by /// the watch to decide whether it has to keep looking with the panel shut. @@ -251,6 +261,29 @@ enum InfoValue { }, } +/// The table convention for a cell with nothing in it. Needs no translating, +/// and is shorter to read than any of the sentences it stands in for. +const EMPTY: &str = "—"; + +/// A round trip, at the precision the number is worth reading to. +/// +/// Whole milliseconds up to a second: tenths of a millisecond on a link that +/// varies by whole ones is noise dressed as measurement. Past a second the +/// millisecond stops mattering and the second is the unit anyone would say it +/// in. +fn format_rtt(rtt: std::time::Duration) -> String { + let ms = rtt.as_secs_f64() * 1000.; + if ms < 1. { + // Loopback and a peer on the same LAN both land here. Rounding to + // "0 ms" would read as a failed measurement rather than a fast one. + return "<1 ms".to_string(); + } + if ms < 1000. { + return format!("{} ms", ms.round() as u64); + } + format!("{:.1} s", rtt.as_secs_f64()) +} + /// One label/value line of the Session section. struct InfoRow { label: &'static str, @@ -781,6 +814,23 @@ impl Tty7App { if let Some(ssh) = view.ssh_spec() { rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable()); } + // Only where there is a network between here and the shell. On + // a pane of this machine's own the row would be reporting the + // round trip to a Unix socket, which is a number with nothing + // to compare it against. + if self.right_panel.link_host.is_some() { + rows.push(InfoRow::text( + t(L10nKey::PanelLatency), + // A link whose first ping has not come back yet, + // rather than one measured at zero. The dash is the + // table's empty cell, the same one a clean working + // tree gets. + self.right_panel + .link_rtt + .map(format_rtt) + .unwrap_or_else(|| EMPTY.to_string()), + )); + } git = view.git_status(cx); } // Read off the same pane the rows above describe, rather than off @@ -916,7 +966,7 @@ impl Tty7App { this.child( div() .text_color(cx.theme().muted_foreground) - .child("—".to_string()), + .child(EMPTY.to_string()), ) }) .when(added > 0, |this| { @@ -1589,6 +1639,14 @@ impl Tty7App { let Some(pane_id) = pane_id else { return }; self.right_panel.procs_forwards = forwards.clone(); self.right_panel.procs_host = host.clone(); + // Per host, not per pane — see `link_host`. A pane of this machine's + // own has no host at all, which is what clears the section rather than + // leaving the last remote pane's numbers under a local one. + let link_host = host.as_ref().map(|h| h.id()); + if self.right_panel.link_host != link_host { + self.right_panel.link_host = link_host; + self.right_panel.link_rtt = None; + } if self.right_panel.procs_pane != Some(pane_id) { self.right_panel.procs_pane = Some(pane_id); self.right_panel.procs = None; @@ -1620,7 +1678,15 @@ impl Tty7App { ) { cx.spawn(async move |this, cx| { let route = forwards.clone(); - let (procs, managed) = cx + // Only while someone is looking. This poll also runs with the panel + // shut, watching for ports to forward, and a round trip per round + // for a row nobody can see is the far end's time spent on nothing. + let want_link = this + .read_with(cx, |app, _| { + app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info + }) + .unwrap_or(false); + let (procs, managed, link) = cx .background_executor() .spawn(async move { // A remote workspace's pane runs on the peer, so the peer @@ -1637,7 +1703,11 @@ impl Tty7App { None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)), }; let managed = route.map(|r| r.list()).unwrap_or_default(); - (procs, managed) + let link = match (want_link, &host) { + (true, Some(host)) => host.link_rtt(), + _ => None, + }; + (procs, managed, link) }) .await; let keep_polling = this @@ -1655,6 +1725,13 @@ impl Tty7App { if forwards.is_some() { app.loopback_panel.managed = managed; } + // Only when this round actually asked. A round that did not + // leaves the last answer in place, so reopening the panel + // shows the number it was closed on rather than a dash + // until the next poll lands. + if want_link { + app.right_panel.link_rtt = link; + } cx.notify(); let wanted = app.procs_wanted(); if !wanted { @@ -1813,7 +1890,7 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin #[cfg(test)] mod tests { - use super::{InfoRow, InfoValue, forwards_port}; + use super::{InfoRow, InfoValue, format_rtt, forwards_port}; use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind}; fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward { @@ -1919,6 +1996,22 @@ mod tests { ); } + #[test] + fn a_round_trip_is_read_at_the_precision_it_is_worth() { + use std::time::Duration; + // A peer on the same machine or the same LAN. "0 ms" would read as a + // measurement that failed rather than one that was fast. + assert_eq!(format_rtt(Duration::from_micros(120)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_micros(999)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms"); + assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms"); + assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms"); + // Past a second the millisecond has stopped carrying information, and + // the second is the unit anyone would say the number in. + assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s"); + assert_eq!(format_rtt(Duration::from_secs(4)), "4.0 s"); + } + #[test] fn copyable_takes_the_text_the_row_shows_and_nothing_else() { // `copyable()` reads the value it was given; rows built with an From 0ede353724655c372daf0619b9c7c26ce754c98b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:11:20 +0800 Subject: [PATCH 04/46] chore(issues): split the issue form into bug and idea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single form was doing two jobs: three of its five fields carried a "(bugs)" suffix because they made no sense for an idea, which also meant nothing bug-specific could be required without blocking the idea path. Two forms instead. The bug form requires steps to reproduce, the expected behaviour, the version and the platform, and adds a log field rendered as code so pasted escape sequences survive markdown. The idea form asks for the problem before the solution. Both auto-label and both open with a duplicate-search checkbox, so the type dropdown is gone — picking the form is picking the type. Blank issues are off, since they let a reporter walk past every required field. The Discussions contact link is dropped as well: the repo has discussions disabled, so it was a dead link. Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa --- .github/ISSUE_TEMPLATE/bug.yml | 89 +++++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 +- .github/ISSUE_TEMPLATE/idea.yml | 35 ++++++++++++ .github/ISSUE_TEMPLATE/issue.yml | 43 --------------- 4 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/idea.yml delete mode 100644 .github/ISSUE_TEMPLATE/issue.yml diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..9fab6a16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,89 @@ +name: Bug report +description: Something in tty7 doesn't work as expected +labels: ["bug"] +body: + - type: checkboxes + id: checks + attributes: + label: Before you file + options: + - label: I searched the existing issues and this isn't a duplicate. + required: true + - label: I'm on the latest tty7 release, or I've said below why I can't be. + required: true + + - type: textarea + id: summary + attributes: + label: What happened? + description: The behaviour you saw, in a sentence or two. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: > + Numbered steps starting from a freshly opened tty7 window. If a + specific command or escape sequence triggers it, paste the exact one — + "some TUI app" is rarely enough to reproduce a terminal bug. + placeholder: | + 1. Open a new tab + 2. Run `printf '\e[?2004h'` + 3. Type a character — the pane freezes + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect instead? + validations: + required: true + + - type: input + id: version + attributes: + label: tty7 version + description: Run `tty7 --version`, or check the About window. + placeholder: "26.9.2" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + validations: + required: true + + - type: input + id: context + attributes: + label: Shell and TUI app involved + description: > + The shell you were running, and the TUI app plus its version if one is + on screen when it happens. + placeholder: fish 3.7.1, neovim 0.10.2 + + - type: textarea + id: logs + attributes: + label: Log output + description: > + The tail of `~/.config/tty7/tty7.log` + (`%APPDATA%\tty7\tty7.log` on Windows), if it has anything from around + the time it broke. This is rendered as code, so no backticks needed. + render: shell + + - type: textarea + id: extra + attributes: + label: Anything else? + description: Screenshots, a recording, or anything else worth knowing. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bcbeb42f..62e78b3f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Questions & ideas - url: https://github.com/l0ng-ai/tty7/discussions - about: Not sure it's a bug? Want to discuss an idea first? Start a discussion. - name: Security vulnerabilities url: https://github.com/l0ng-ai/tty7/security/advisories/new about: Please report security issues privately, not as public issues. diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml new file mode 100644 index 00000000..8424395b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.yml @@ -0,0 +1,35 @@ +name: Idea +description: Suggest an improvement or a new capability +labels: ["enhancement"] +body: + - type: checkboxes + id: checks + attributes: + label: Before you file + options: + - label: I searched the existing issues and this isn't already proposed. + required: true + + - type: textarea + id: problem + attributes: + label: What's the problem? + description: > + What you were trying to do, and where tty7 got in the way. Leave the + solution for the next box — the problem is the part we can't guess. + validations: + required: true + + - type: textarea + id: behaviour + attributes: + label: How should it behave? + description: The shape you have in mind, if you have one. + + - type: textarea + id: prior_art + attributes: + label: Prior art and workarounds + description: > + How other terminals handle it, and what you're doing today to work + around it. diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml deleted file mode 100644 index d32ab16a..00000000 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Issue -description: Report a bug or suggest an improvement -body: - - type: dropdown - id: kind - attributes: - label: Type - options: - - Bug — something doesn't work as expected - - Idea — suggest an improvement or new capability - validations: - required: true - - type: textarea - id: detail - attributes: - label: What's going on? - description: > - For a bug: what you did, what you saw, and what you expected instead. - For an idea: the problem it solves and how it should behave. - validations: - required: true - - type: input - id: version - attributes: - label: tty7 version (bugs) - placeholder: v0.2.0 - - type: dropdown - id: platform - attributes: - label: Platform (bugs) - options: - - macOS (Apple Silicon) - - macOS (Intel) - - Windows - - Linux - - type: textarea - id: extra - attributes: - label: Anything else? - description: > - Screenshots or recordings, the exact command / escape sequence that - triggers it, the shell you were using, or the TUI app (vim, htop, …) - and its version if one is involved. From ab26166f96adee7f6989ae24b172fa66a575adf8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:19:39 +0800 Subject: [PATCH 05/46] fix(review): keep pane_procs's doc on pane_procs, round rtt before the unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new `link_rtt` landed between `pane_procs`'s doc comment and `pane_procs` itself, so the comment about walking pane process trees documented the latency probe instead. `format_rtt` also compared the unrounded milliseconds against 1000, so a 999.6 ms round trip printed as "1000 ms" — a millisecond reading past the range the millisecond branch exists to cover. Round first, then pick the unit. Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER --- crates/tty7-core/src/host/remote.rs | 8 ++++---- src/ui/right_panel.rs | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index e3803b3f..c90896f4 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -253,10 +253,6 @@ impl Host for RemoteHost { }) } - /// The peer owns these panes' PTYs, so it is the one that can walk their - /// process trees. A peer that does not announce the feature is not asked: - /// it would answer `Err` and the caller cannot tell that apart from a pane - /// serving nothing. fn link_rtt(&self) -> Option { // A ping of our own rather than whatever the keepalive last left // behind: that one only fires on an idle link, and a link being polled @@ -273,6 +269,10 @@ impl Host for RemoteHost { self.client.last_rtt() } + /// The peer owns these panes' PTYs, so it is the one that can walk their + /// process trees. A peer that does not announce the feature is not asked: + /// it would answer `Err` and the caller cannot tell that apart from a pane + /// serving nothing. fn pane_procs(&self, pane_id: u64) -> Option { if !self .peer() diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 3521a53f..364621f4 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -278,8 +278,11 @@ fn format_rtt(rtt: std::time::Duration) -> String { // "0 ms" would read as a failed measurement rather than a fast one. return "<1 ms".to_string(); } - if ms < 1000. { - return format!("{} ms", ms.round() as u64); + // Rounded before the comparison, so 999.6 ms is not shown as "1000 ms" — + // a millisecond reading that has run past the unit's own range. + let rounded = ms.round() as u64; + if rounded < 1000 { + return format!("{rounded} ms"); } format!("{:.1} s", rtt.as_secs_f64()) } @@ -2006,6 +2009,9 @@ mod tests { assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms"); assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms"); assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms"); + // Rounding up out of the millisecond's own range hands the number to + // the unit above rather than printing a four-digit millisecond. + assert_eq!(format_rtt(Duration::from_micros(999_600)), "1.0 s"); // Past a second the millisecond has stopped carrying information, and // the second is the unit anyone would say the number in. assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s"); From 159f00f4c13a8de18ab4b0278edaf6d32a30bc92 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:20:48 +0800 Subject: [PATCH 06/46] fix(update): read a 429 as a rate limit too GitHub's REST API answers a spent quota with 403 or 429 depending on the endpoint and the era, and both carry the same x-ratelimit headers. Only the 403 spelling reached the retry advice, so a 429 told the reader the quota was gone without saying when it comes back. Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER --- src/core/update.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/update.rs b/src/core/update.rs index e1e775d4..31a865d1 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -1904,7 +1904,9 @@ fn github_http_error( let message = serde_json::from_slice::(body) .ok() .map(|error| sanitize_github_message(&error.message)); - let rate_limited = status == 403 + // 403 is what the REST API has always answered a spent quota with; 429 is + // what it increasingly answers instead, and both carry the same headers. + let rate_limited = matches!(status, 403 | 429) && (rate_remaining == Some("0") || message.as_deref().is_some_and(|message| { message.to_ascii_lowercase().contains("rate limit exceeded") @@ -2985,6 +2987,25 @@ mod tests { ); } + /// GitHub answers a spent quota with 403 or 429 depending on the endpoint + /// and the era. Only the 403 spelling used to reach the retry advice, so a + /// 429 told the reader the quota was gone without saying when it returns. + #[test] + fn a_429_is_a_rate_limit_too() { + let error = github_http_error( + 429, + Some("0"), + Some("1600"), + br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again in about 10 minutes (HTTP 429)" + ); + } + #[test] fn expired_github_rate_limit_says_to_retry_shortly() { let error = github_http_error( From d1f224f6345fcb1e97314c0e766f66ffdef4da0b Mon Sep 17 00:00:00 2001 From: hhdebb Date: Thu, 10 Sep 2026 18:29:00 +0800 Subject: [PATCH 07/46] fix(window): hide the title bar in fullscreen where it is the app's to draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows and Linux a fullscreen window has no caption. The platform asks what is under the pointer through `WM_NCHITTEST`, gpui answers from the window control hitboxes the frame registered, and fullscreen clears `WS_CAPTION` — there is nothing left to answer with. Measured on a fullscreen tty7: `GetWindowLong` reports `WS_CAPTION` clear, and every point along the top of the window comes back `HTCLIENT`, where the same window a moment earlier answered `HTCAPTION`, `HTMINBUTTON` and `HTCLOSE`. The bar was drawn anyway. `WindowControls` renders minimize, maximize and close whenever the target is not macOS, without asking whether the window is fullscreen, so all three sat there taking hover styling — gpui's own dispatch reaches them fine — and doing nothing at all when clicked. Dragging the bar did nothing either. So on those two the bar goes. It is the app's own chrome there: a caption to move the window by and the controls at its end, none of which a fullscreen window has. Drawing chrome that cannot work is worse than drawing none. Not on macOS, and the reason is not that the bug is milder there — it is that the premise does not hold at all. `WindowControls` draws none of the three on macOS; the ones that go dead elsewhere are the system's traffic lights, and the system hides them itself. What that bar does in fullscreen is hold the band the system reserves: the traffic lights land on it when the menu bar is revealed, and so does the translucent strip drawn under the menu bar. Take the bar away and that strip lands on the terminal and covers its first row instead — measured, and the difference is exactly `TITLE_BAR_HEIGHT`. Fullscreen belongs to the system on macOS, and the bar is part of how the system dresses the window rather than something broken. Nothing on the bar becomes unreachable where it goes. Its controls are actions first, dispatched from the window's root rather than from the bar, and each has a chord or a seat in the palette, which has one; `what_the_title_bar_offers_is_reachable_without_it` is that in a test. Worth noting for anyone reading it: `ToggleTabSidebar` ships with no chord, so in fullscreen the palette is how it is reached. Entering says how to leave, because entering is the instant the bar disappears — so only where it does, and only through the action: a window that starts fullscreen because the setting says so is not a surprise anybody needs explaining, and `startup_mode` is untouched by the toggle either way. The chord comes from the keymap rather than from a string, so it reads `F11` or whatever it was rebound to. The notice carries an id, which is what keeps a held-down `F11` to one notice rather than a column of identical ones: pushing under an id already on screen replaces that one. Leaving through the action takes it back as well. Leaving some other way lets it time out instead — a second or two of a stale notice, which is not worth a per-frame watch on a state that lies: `toggle_fullscreen` is spawned onto the executor on every backend, so `is_fullscreen` still reports the old value when the action returns, and a render-time test for "not fullscreen now" can take the notice back before it has been seen. Verified on Windows 11 26200, and on macOS 26.5.2 by a second pair of hands: the macOS half of this is the reason the change is not applied there. Linux is reasoned about rather than measured — it draws its own chrome the way Windows does, and the same `WM_NCHITTEST`-shaped question is answered through gpui's window control hitboxes. --- src/ui/app.rs | 89 ++++++++++++++++++++++++++++++++++++++++------ src/ui/i18n/en.rs | 4 +++ src/ui/i18n/ja.rs | 2 ++ src/ui/i18n/mod.rs | 2 ++ src/ui/i18n/zh.rs | 2 ++ src/ui/keymap.rs | 49 +++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 8409a7c5..2a2c7a57 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1166,6 +1166,22 @@ fn clear_window_override_values(config: &mut Config, backdrop_is_local: bool) { } } +/// The id the fullscreen hint is pushed under, so that entering again replaces +/// it and leaving takes it away. +struct FullscreenHint; + +/// Whether fullscreen takes the title bar away on this platform. +/// +/// Not on macOS, where fullscreen belongs to the system rather than to the app. +/// The traffic lights live on that bar, and revealing the menu bar draws a +/// translucent strip over the same band, which the bar absorbs; without it the +/// strip lands on the terminal instead and covers its first row. There is also +/// nothing there to fix: `WindowControls` draws no minimize, maximize or close +/// on macOS — the three that are dead in fullscreen elsewhere are the system's +/// there, and it hides them itself. So the bar is not broken chrome on macOS, +/// it is part of how the system dresses a fullscreen window. +const FULLSCREEN_TAKES_THE_TITLE_BAR: bool = !cfg!(target_os = "macos"); + impl Tty7App { pub fn for_workspace( id: Option, @@ -3526,6 +3542,46 @@ impl Tty7App { remember_leaf_in(&mut self.tabs, leaf); } + /// Toggle fullscreen, and say how to leave it on the way in. + /// + /// Only on the way in, and only from the action: entering is an instant in + /// which the title bar disappears, and a window that starts fullscreen + /// because the setting says so is not a surprise anybody needs explaining. + /// The chord comes from the keymap rather than from a string, because it is + /// `F11` on Windows and Linux, `Cmd+Enter` on macOS, and either of them may + /// have been rebound. + /// + /// The hint carries an id of its own, which is what keeps a held-down + /// `F11` to one notice rather than a column of identical ones: pushing + /// under an id already on screen replaces that one. Leaving through the + /// action takes it back too, so a quick in-and-out does not leave the way + /// out on screen after it has been taken. Leaving some other way — a + /// window manager with a chord of its own — just lets it time out, which + /// is a second or two of a stale notice and not worth watching every + /// frame for. + fn toggle_fullscreen(&self, window: &mut Window, cx: &mut App) { + let entering = !window.is_fullscreen(); + window.toggle_fullscreen(); + window.remove_notification::(cx); + // Nothing disappeared where the bar stays, so there is nothing to + // explain. + if !entering || !FULLSCREEN_TAKES_THE_TITLE_BAR { + return; + } + let hint = match crate::ui::home::key_hint("ToggleFullscreen", cx) { + Some(chord) => t_fmt(L10nKey::AppFullscreenEntered, &[("key", &chord)]), + // Rebound to nothing at all: still worth saying the bar is gone, + // just without naming a key that would not work. + None => t(L10nKey::AppFullscreenEnteredNoKey).to_string(), + }; + window.push_notification( + gpui_component::notification::Notification::new() + .id::() + .message(hint), + cx, + ); + } + fn focus_leaf(&self, leaf: &PaneSlot, window: &mut Window, cx: &mut App) { let handle = leaf.focus_handle(cx); window.focus(&handle, cx); @@ -5358,7 +5414,7 @@ impl Tty7App { NextTab => self.cycle_tab(true, window, cx), PrevTab => self.cycle_tab(false, window, cx), ToggleMaximizePane => self.toggle_maximize(window, cx), - ToggleFullscreen => window.toggle_fullscreen(), + ToggleFullscreen => self.toggle_fullscreen(window, cx), ToggleTabSidebar => self.toggle_tab_sidebar(cx), ToggleLeftPanel => self.toggle_left_panel(cx), ToggleRightPanel => self.toggle_right_panel(cx), @@ -7609,11 +7665,22 @@ impl Render for Tty7App { } }; - let title_bar = TitleBar::new() - .h(px(TITLE_BAR_HEIGHT)) - .bg(cx.theme().transparent) - .border_color(cx.theme().transparent) - .child(strip); + // No title bar in fullscreen. The bar is window chrome — a caption to + // drag the window by and the three controls at its end — and a + // fullscreen window has none of that to offer: it has no caption for + // the platform to hit-test, so the buttons draw, light up under the + // pointer and do nothing at all when clicked. Drawing chrome that + // cannot work is worse than drawing none, and taking it away is also + // what the mode is for. + let fullscreen = window.is_fullscreen(); + let bar_is_gone = fullscreen && FULLSCREEN_TAKES_THE_TITLE_BAR; + let title_bar = (!bar_is_gone).then(|| { + TitleBar::new() + .h(px(TITLE_BAR_HEIGHT)) + .bg(cx.theme().transparent) + .border_color(cx.theme().transparent) + .child(strip) + }); let body_area = div() .flex_1() .relative() @@ -7717,9 +7784,9 @@ impl Render for Tty7App { let panel_below_title_bar = (right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos"); let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { - (None, Some(title_bar)) + (None, title_bar) } else { - (Some(title_bar), None) + (title_bar, None) }; let (column_overlays, hoisted_overlays) = if panel_below_title_bar { (Vec::new(), overlays) @@ -8012,9 +8079,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &ToggleMaximizePane, window, cx| { this.toggle_maximize(window, cx) })) - .on_action( - cx.listener(|_, _: &ToggleFullscreen, window, _cx| window.toggle_fullscreen()), - ) + .on_action(cx.listener(|this, _: &ToggleFullscreen, window, cx| { + this.toggle_fullscreen(window, cx) + })) .on_action(cx.listener(|this, _: &ToggleTabSidebar, _window, cx| { this.toggle_tab_sidebar(cx) })) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4884935d..516967b2 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1577,6 +1577,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppReopenTabFailed => "Could not reopen the tab: no terminal started", L10nKey::AppOpenTerminalFailed => "Could not open a terminal: {error}", L10nKey::AppTabsNotRestored => "{count} tabs from last time could not be reopened", + L10nKey::AppFullscreenEntered => "Fullscreen — press {key} to leave", + L10nKey::AppFullscreenEnteredNoKey => { + "Fullscreen — the title bar is hidden until you leave" + } L10nKey::LaunchWorkspacesLeftRunning => { "Only this window was restored — {count} workspaces are still running in the background. Reopen them from the sidebar." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index d4e30d71..c9e4c8a2 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1638,6 +1638,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "タブを開き直せませんでした: ターミナルが起動しませんでした", L10nKey::AppOpenTerminalFailed => "ターミナルを開けませんでした: {error}", L10nKey::AppTabsNotRestored => "前回のタブ {count} 個を開き直せませんでした", + L10nKey::AppFullscreenEntered => "全画面表示 — 解除するには {key}", + L10nKey::AppFullscreenEnteredNoKey => "全画面表示 — 解除するまでタイトルバーは非表示です", L10nKey::LaunchWorkspacesLeftRunning => { "このウィンドウだけを復元しました — あと {count} 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 627f7c2c..a4a585af 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1276,6 +1276,8 @@ l10n_keys! { AppReopenTabFailed, AppOpenTerminalFailed, AppTabsNotRestored, + AppFullscreenEntered, + AppFullscreenEnteredNoKey, LaunchWorkspacesLeftRunning, AppSshConnectionFailed, AppSshReconnectFailed, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 8b53c7a7..77b0b9d2 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1493,6 +1493,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "无法重新打开标签页:没有启动终端", L10nKey::AppOpenTerminalFailed => "无法打开终端:{error}", L10nKey::AppTabsNotRestored => "上次的 {count} 个标签页没能重新打开", + L10nKey::AppFullscreenEntered => "已进入全屏 —— 按 {key} 退出", + L10nKey::AppFullscreenEnteredNoKey => "已进入全屏 —— 标题栏在退出前会一直隐藏", L10nKey::LaunchWorkspacesLeftRunning => { "只恢复了这个窗口——还有 {count} 个工作区在后台运行,可从侧边栏重新打开。" } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6fb2d808..3acdbb31 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1339,6 +1339,55 @@ mod tests { use super::*; use gpui::Action as _; + /// Everything the title bar offers a button for stays reachable from the + /// keyboard, because on Windows and Linux the title bar is not drawn in + /// fullscreen at all — it is window chrome there, and a fullscreen window + /// has no chrome for the platform to hit-test, so its buttons would light + /// up under the pointer and do nothing when clicked. (On macOS the bar + /// stays: fullscreen is the system's there, and the bar is where it puts + /// the traffic lights.) + /// + /// Reachable means either a chord of its own or a seat in the palette, + /// which has one; both are hands-free, and the palette is how the sidebar + /// toggle is reached, since it ships without a chord. What this pins is + /// that a control on that bar is never mouse-only — if one ever is, + /// hiding the bar would take a feature away with it, and this is where + /// that should be noticed. + #[test] + fn what_the_title_bar_offers_is_reachable_without_it() { + let defaults = default_bindings(); + let chord = |action: &str| { + defaults + .iter() + .any(|(name, keystroke)| *name == action && !keystroke.is_empty()) + }; + // The palette is the fallback, so it is the one that must not be. + assert!( + chord("TogglePalette"), + "the fallback needs a chord of its own" + ); + for action in [ + "NewTab", + "ToggleTabSidebar", + "OpenSettings", + "ToggleFullscreen", + "ToggleSwitcher", + ] { + assert!( + chord(action) || palette_lists(action), + "{action} would be mouse-only once the bar is hidden" + ); + } + } + + /// Whether the palette lists `action` under a name somebody wrote, which is + /// what having a real seat there means: `action_entry` answers for every + /// action, falling back to a name split on capitals, and a fallback name is + /// not evidence that anyone meant the action to be found. + fn palette_lists(action: &str) -> bool { + authored_entry(action).is_some() + } + /// The actions a keymap built from `action_bindings` dispatches for `keys` /// typed in `context`, in precedence order — the same lookup gpui performs /// on a real keypress. From 56238bf3bb31375c6127c10aff5528006593d2ef Mon Sep 17 00:00:00 2001 From: hhdebb Date: Thu, 10 Sep 2026 21:32:43 +0800 Subject: [PATCH 08/46] fix(tabs): take the agent's status mark off the title it writes (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents animate in the terminal title while they work, and they do not agree on an alphabet: Claude Code cycles the quadrant circles and rests on an asterisk, others step through the braille frames, some write nothing at all. Rendered as they arrive, a column of tabs carries a mark in front of some rows and not others, in three vocabularies — while the row already says what the agent is doing, in one, with its status dot. So the mark comes off, for everyone, with no setting. A switch would not settle this: nobody opens settings to decide how a spinner is drawn, and a default-off toggle buys two render paths to maintain forever in order to answer a question that has one right answer per person and no way for the app to know which. **A known alphabet, not a shape.** The obvious rule — a leading character that is non-ASCII and above some code point, followed by a space — matches by shape, and `🔥 build`, or `📁 ~/repo` written by somebody's shell integration, fits it exactly and quietly loses its first character with no way to ask for it back and no clue as to what took it. Matching marks we have actually seen costs the same and cannot do that: the braille block, the four quadrant circles, and Claude Code's resting asterisk. When an agent invents a mark that is not on the list, the failure is today's behaviour — the mark stays — which is the safe direction to fail in, and adding it is a line in the table. Two things the rule insists on, both to keep it from reaching past what it is for. A mark only counts with whitespace behind it, so `✳fixing` is a word that starts with a character rather than a mark in front of one. And a title that is *only* a mark keeps it: taking it would leave an empty string, and an empty title is not a tab called nothing, it is a tab that falls back to its number — less than the mark was saying. It happens in `TabView::label`, which is where a title becomes a label, so the strip, the sidebar, the switcher and the rename box's prefill all agree without being told separately — and, because `label` reaches a given name before it reaches the title, a tab somebody deliberately called `✳ release` keeps what they called it. That ordering is the only thing standing between a user's name and a rename behind their back, so there is a test on it rather than a comment. Three existing tests carried `✳` in their fixtures and now expect it gone. The one in `switcher.rs` was asserting that a tab in another window is named the way a local one would be, which is still exactly what it asserts; the one in `tty7-cli` is the table getting this for free, since `tab_label` reads `label` and so `tty7 ls` says what the tab strip says without either being told about the other. The daemon's fixtures keep their marks on purpose: a title is stored as the terminal wrote it, and only what turns one into a label takes anything off. This leaves the row with nothing moving in it, which is a real loss and is answered separately: `AgentStatus::dot_rgb` returns three flat colours, and a `Working` dot that breathes says the same thing in the vocabulary the row already speaks. --- crates/tty7-cli/src/output.rs | 5 +- crates/tty7-core/src/core/tab_view.rs | 136 +++++++++++++++++++++++++- src/ui/switcher.rs | 4 +- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index c44e3219..d92ab7ef 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -526,12 +526,15 @@ mod tests { ); // What the pane's own terminal says it is doing beats naming the agent // running it — every tab of a workspace would otherwise read alike. + // The mark the agent writes in front of that title comes off here too: + // `tab_label` reads `TabView::label`, so the table says what the tab + // strip says without either being told about the other. assert_eq!( tab_label(&view(&|v| { v.osc_title = Some("✳ fixing the switcher".into()); v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude); })), - "✳ fixing the switcher" + "fixing the switcher" ); assert_eq!( tab_label(&view( diff --git a/crates/tty7-core/src/core/tab_view.rs b/crates/tty7-core/src/core/tab_view.rs index 149c585f..bbed34aa 100644 --- a/crates/tty7-core/src/core/tab_view.rs +++ b/crates/tty7-core/src/core/tab_view.rs @@ -91,6 +91,73 @@ pub fn strip_host_prefix(raw: &str) -> &str { } } +/// The marks a coding agent writes in front of the title it sets while it +/// works, and which of them to take back off. +/// +/// Agents animate in the terminal title and do not agree on an alphabet: +/// Claude Code cycles the quadrant circles and rests on an asterisk, others +/// step through the braille frames, some write nothing. Rendered as they +/// arrive, a column of tabs carries a mark in front of some rows and not +/// others, in three vocabularies, while the row already says what the agent is +/// doing — in one, with its status dot. +/// +/// **A known alphabet, not a shape.** The obvious rule — a leading character +/// that is non-ASCII and above some code point, followed by a space — matches +/// by shape, and a tab called `🔥 build`, or `📁 ~/repo` from somebody's shell +/// integration, fits it exactly and loses its first character with no way to +/// ask for it back and no clue as to what took it. Matching a list of marks we +/// have actually seen costs the same and cannot do that. When an agent invents +/// a mark that is not here yet the failure is today's behaviour — the mark +/// stays — which is the safe direction to fail in, and adding it is a line in +/// the table below. +const STATUS_MARKS: &[char] = &[ + // Claude Code: the quadrant circles while it works, the asterisk at rest. + '\u{25D0}', '\u{25D1}', '\u{25D2}', '\u{25D3}', '\u{2733}', +]; + +/// Whether `c` is one of the braille cells the common spinners are built from. +/// The whole block, because the frame sets differ between agents and every +/// cell in it is a spinner frame somewhere — none is a character a human puts +/// at the front of a tab's name. +fn is_braille_frame(c: char) -> bool { + ('\u{2800}'..='\u{28FF}').contains(&c) +} + +/// `title` with any leading status marks taken off. +/// +/// A mark only counts with whitespace behind it, which is how every agent +/// writes one and is one more thing a title would have to do by accident. +/// Variation selectors and zero-width joiners ride along with the mark. +pub fn strip_status_mark(title: &str) -> &str { + let mut rest = title; + loop { + let mut chars = rest.chars(); + let Some(first) = chars.next() else { + return rest; + }; + if !STATUS_MARKS.contains(&first) && !is_braille_frame(first) { + return rest; + } + let after = chars + .as_str() + .trim_start_matches(|c: char| matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{200D}')); + let trimmed = after.trim_start(); + // Nothing between the mark and the rest of the title: a title that + // happens to start with the character, not a mark in front of one. + if trimmed.len() == after.len() { + return rest; + } + // A mark with nothing behind it is the whole title. Taking it would + // leave an empty string, and an empty title is not a tab called + // nothing — it is a tab that falls back to its number, which is less + // than the mark was saying. + if trimmed.is_empty() { + return rest; + } + rest = trimmed; + } +} + impl TabView { pub fn label(&self) -> TabLabel<'_> { if let Some(name) = self @@ -105,6 +172,7 @@ impl TabView { .osc_title .as_deref() .map(str::trim) + .map(strip_status_mark) .filter(|t| !t.is_empty()) { return TabLabel::Osc(title); @@ -159,6 +227,72 @@ pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec { #[cfg(test)] mod tests { + + /// The marks come off, whichever alphabet the agent picked. + #[test] + fn a_status_mark_comes_off_the_front_of_a_title() { + for raw in [ + "\u{2733} fixing the switcher", // Claude Code at rest + "\u{25D0} fixing the switcher", // and while it works + "\u{25D3} fixing the switcher", + "\u{280B} fixing the switcher", // a braille frame + "\u{28FF} fixing the switcher", // the far end of the block + "\u{2733}\u{FE0F} fixing the switcher", // with an emoji selector + "\u{2733} \u{280B} fixing the switcher", // two of them, both go + ] { + assert_eq!(strip_status_mark(raw), "fixing the switcher", "on {raw:?}"); + } + } + + /// The reason this matches an alphabet rather than a shape. Every one of + /// these fits "leading non-ASCII character above U+2000, then a space", + /// and every one of them is somebody's title rather than an agent's mark — + /// a shape rule eats the first character of each, silently. + #[test] + fn a_title_that_merely_looks_like_one_is_left_alone() { + for raw in [ + "\u{1F525} build", // fire, a name somebody chose + "\u{1F4C1} ~/repo", // folder, from a shell integration + "\u{2192} deploy", // an arrow + "\u{2714} done", // a tick + "\u{2022} notes", // a bullet + "\u{4E2D}\u{6587} title", // a title in a script with no case + "\u{2733}fixing", // no space: part of the word + "fixing the switcher", // nothing to take + "", + ] { + assert_eq!(strip_status_mark(raw), raw, "on {raw:?}"); + } + } + + /// A name the user typed is theirs, mark or no mark. The strip is for the + /// title an agent writes, and `label` reaches the name first — but that + /// ordering is the only thing keeping a tab someone deliberately called + /// `\u{2733} release` from being renamed behind their back, so it is worth + /// saying out loud. + #[test] + fn a_name_the_user_gave_is_never_stripped() { + let view = TabView { + id: TabId::new(), + name: Some("\u{2733} release".to_string()), + title: "zsh".to_string(), + osc_title: Some("\u{2733} fixing the switcher".to_string()), + cwd: None, + agent: None, + status: None, + live: true, + panes: 1, + }; + assert_eq!(view.label(), TabLabel::Named("\u{2733} release")); + } + + /// A title that is only a mark keeps it, rather than becoming empty and + /// falling through to the tab's number. + #[test] + fn a_mark_on_its_own_is_still_a_title() { + assert_eq!(strip_status_mark("\u{2733}"), "\u{2733}"); + assert_eq!(strip_status_mark("\u{2733} "), "\u{2733} "); + } use super::*; use crate::core::machine::{AgentFacts, Tab}; @@ -196,7 +330,7 @@ mod tests { cwd: Some("/work".into()), ..view() }; - assert_eq!(titled.label(), TabLabel::Osc("✳ fixing the switcher")); + assert_eq!(titled.label(), TabLabel::Osc("fixing the switcher")); let blank_title = TabView { osc_title: Some(" ".into()), diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index a00b3943..1891a9df 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -3725,8 +3725,8 @@ mod tests { view.name = None; assert_eq!( tab_view_label(&view, 0, None), - "✳ 修复 workspace switcher", - "then the title the local strip would be showing, verbatim" + "修复 workspace switcher", + "then the title the local strip would be showing — mark and all, which is to say without the mark" ); view.osc_title = Some("user@host:~/repo/025/tty7".to_string()); From 9e338b1d60feae28c97154d3f6e37c0fa87a96c4 Mon Sep 17 00:00:00 2001 From: White Date: Fri, 11 Sep 2026 10:30:19 +0800 Subject: [PATCH 09/46] feat(agents): add Qoder CLI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook events map Qoder's lifecycle to tty7's state machine: session start, prompt submit, permission requests, MCP tool elicitation (an authorized MCP tool can still pause for user input mid-call), tool completion, stop, and session end. Compaction events are filtered out—Qoder emits a session-start after compacting the active turn, which would reset the status line to Idle without this filter, even though the turn is still running. Settings path resolution respects QODER_CONFIG_DIR for local installs, falling back to ~/.qoder/settings.json. Remote targets ignore the override (a local env var must not redirect remote hooks). Session commands support --resume and --fork-session. The resume command strips conflicting flags (--resume, -r, --continue, -c, --session-id, --worktree, --fork-session) from the original launch argv before appending the new session id. The -w/--cwd flags survive (Qoder's -w means --cwd, not --worktree). Both commands require session persistence: when --no-session-persistence is present, there is no saved conversation to reopen, so the commands return None. Tests cover compaction preservation, MCP elicitation state transitions, QODER_CONFIG_DIR's effect on the hook lifecycle (multi-case isolation), resume/fork command generation, worktree flag handling, and persistence requirements. Localization complete for en/ja/zh. Icon embedded, search keywords wired. --- assets/icons/agents/qodercli.svg | 4 + crates/tty7-core/src/core/agent_hooks.rs | 241 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 118 ++++++++++- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 2 + src/ui/settings.rs | 5 + 9 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 assets/icons/agents/qodercli.svg diff --git a/assets/icons/agents/qodercli.svg b/assets/icons/agents/qodercli.svg new file mode 100644 index 00000000..44eb1276 --- /dev/null +++ b/assets/icons/agents/qodercli.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 63298afb..6d60c386 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,15 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // Qoder also emits SessionStart after compacting the active turn. + // Preserve its status until a real turn or session boundary arrives. + if agent == "qodercli" + && event == "session-start" + && let Ok(payload) = serde_json::from_str::(stdin_json) + && payload.get("source").and_then(|value| value.as_str()) == Some("compact") + { + return None; + } if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt")) @@ -257,10 +266,11 @@ pub enum HookAgent { Qwen, Goose, Kimi, + QoderCLI, } impl HookAgent { - pub const ALL: [HookAgent; 13] = [ + pub const ALL: [HookAgent; 14] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -274,6 +284,7 @@ impl HookAgent { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, ]; /// The hooks behind a detected agent process, if it has any. @@ -296,6 +307,7 @@ impl HookAgent { CLIAgent::Qwen => Some(HookAgent::Qwen), CLIAgent::Goose => Some(HookAgent::Goose), CLIAgent::Kimi => Some(HookAgent::Kimi), + CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -317,6 +329,7 @@ impl HookAgent { HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), + HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi @@ -352,6 +365,7 @@ impl HookAgent { HookAgent::Qwen => "qwen", HookAgent::Goose => "goose", HookAgent::Kimi => "kimi", + HookAgent::QoderCLI => "qodercli", } } @@ -370,6 +384,7 @@ impl HookAgent { HookAgent::Qwen => "Qwen Code", HookAgent::Goose => "Goose", HookAgent::Kimi => "Kimi Code", + HookAgent::QoderCLI => "Qoder CLI", } } @@ -402,6 +417,7 @@ impl HookAgent { target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) } HookAgent::Kimi => target.kimi_config_path(), + HookAgent::QoderCLI => target.qoder_settings_path(), } } @@ -494,6 +510,15 @@ impl<'a> HookTarget<'a> { self.under_home(&[".kimi-code", "config.toml"]) } + fn qoder_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("QODER_CONFIG_DIR").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("settings.json"); + } + self.under_home(&[".qoder", "settings.json"]) + } + fn traecli_hooks_path(&self) -> PathBuf { if self.is_local() { if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) { @@ -794,6 +819,18 @@ const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ ("SessionEnd", "session-end", None), ]; +const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + // An authorized MCP tool can still pause for user input mid-call. + ("Elicitation", "question-asked"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + fn hook_map_state( target: &HookTarget, path: &Path, @@ -1138,6 +1175,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Gemini | HookAgent::Droid | HookAgent::Qwen + | HookAgent::QoderCLI | HookAgent::Kimi => None, } } @@ -1570,6 +1608,7 @@ mod tests { .chain(TRAE_CODE_HOOK_EVENTS) .chain(GEMINI_HOOK_EVENTS) .chain(DROID_HOOK_EVENTS) + .chain(QODER_HOOK_EVENTS) .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) .chain(KIMI_HOOK_EVENTS) @@ -1607,6 +1646,7 @@ mod tests { "/home/me/.agents/plugins/tty7/hooks/hooks.json", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), ] { assert_eq!( agent.target_path(&t), @@ -1627,6 +1667,7 @@ mod tests { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1649,6 +1690,105 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_compaction_preserves_the_active_turn() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let mut state = AgentSessionState::default(); + state.apply_event(&round_trip( + "qodercli", + "prompt-submit", + r#"{"session_id":"q-1","cwd":"/repo","prompt":"Continue the task"}"#, + )); + let before = state.clone(); + let compact = r#"{"session_id":"q-1","cwd":"/repo","source":"compact"}"#; + if let Some(event) = effective_event("qodercli", "session-start", compact) { + state.apply_event(&round_trip("qodercli", event, compact)); + } + assert_eq!(state, before, "compaction must preserve the active turn"); + + state.apply_event(&round_trip("qodercli", "tool-complete", "{}")); + assert_eq!(state.status, AgentStatus::Working); + state.apply_event(&round_trip("qodercli", "stop", "{}")); + assert_eq!(state.status, AgentStatus::Done); + + for input in [ + r#"{"source":"startup","message":"compact"}"#, + r#"{"source":"resume"}"#, + r#"{"source":"clear"}"#, + "{}", + "not JSON", + ] { + let event = effective_event("qodercli", "session-start", input) + .expect("ordinary session starts still reach the state machine"); + let mut session = before.clone(); + session.apply_event(&round_trip("qodercli", event, input)); + assert_eq!(session.status, AgentStatus::Idle, "{input}"); + } + assert_eq!( + effective_event("claude", "session-start", compact), + Some("session-start"), + "the filter is specific to Qoder" + ); + assert_eq!( + effective_event("qodercli", "prompt-submit", compact), + Some("prompt-submit") + ); + } + + #[test] + fn qoder_mcp_elicitation_waits_for_user_input() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let apply_hook = |state: &mut AgentSessionState, hook: &str, input: &str| { + let event = HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .find_map(|(name, event)| (*name == hook).then_some(*event)) + .and_then(|event| effective_event("qodercli", event, input)); + if let Some(event) = event { + state.apply_event(&round_trip("qodercli", event, input)); + } + }; + let mut state = AgentSessionState::default(); + apply_hook( + &mut state, + "UserPromptSubmit", + r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + apply_hook( + &mut state, + "Notification", + r#"{"notification_type":"auth_success","message":"Signed in"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + + // The MCP tool is already authorized, so no PermissionRequest precedes + // its request for more information from the user. + apply_hook( + &mut state, + "Elicitation", + r#"{ + "session_id":"q-1", + "hook_event_name":"Elicitation", + "mcp_server_name":"tickets", + "message":"Choose a project", + "mode":"form" + }"#, + ); + assert_eq!(state.status, AgentStatus::Waiting); + assert_eq!(state.message.as_deref(), Some("Choose a project")); + assert_eq!(state.session_id.as_deref(), Some("q-1")); + + apply_hook(&mut state, "PostToolUse", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Working); + assert_eq!(state.message, None); + apply_hook(&mut state, "Stop", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Done); + } + /// Qwen is the one agent that reports a blocked turn outright, so it must /// not also carry the `Notification` hook the others need — that event fires /// for non-blocking alerts too and would strand the pane on "waiting". @@ -1906,6 +2046,7 @@ mod tests { "/home/me/.omp/agent/extensions/tty7/index.ts", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), ] { assert_eq!( agent.target_path(&target), @@ -2131,6 +2272,104 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_config_dir_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_QODER_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_QODER_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + // Each case gets its own environment, without changing the one + // shared by the other tests or touching the user's settings. + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::qoder_config_dir_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("QODER_CONFIG_DIR", sandbox.path().join("custom config")) + } + "empty" => child.env("QODER_CONFIG_DIR", ""), + _ => child.env_remove("QODER_CONFIG_DIR"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Qoder hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_settings = target.home.join(".qoder").join("settings.json"); + let custom_settings = root.join("custom config").join("settings.json"); + let (settings, untouched) = if case == "override" { + (&custom_settings, &default_settings) + } else { + (&default_settings, &custom_settings) + }; + let user_config = serde_json::json!({ + "model": "qoder-test", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "echo user-hook" }] }] + } + }); + for path in [settings, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, user_config.to_string()).unwrap(); + } + + let agent = HookAgent::QoderCLI; + assert_eq!(agent.target_path(&target), *settings); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.qoder/settings.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(settings) + .unwrap() + .contains("agent-hook qodercli") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [settings, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + #[test] fn install_is_idempotent_and_preserves_user_hooks() { let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index f504631f..b12431eb 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -26,10 +26,11 @@ pub enum CLIAgent { // Keep new variants at the end: daemon messages serialize this enum and // moving an existing discriminant would break mixed-version clients. TraeCode, + QoderCLI, } impl CLIAgent { - pub const ALL: [CLIAgent; 20] = [ + pub const ALL: [CLIAgent; 21] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -50,6 +51,7 @@ impl CLIAgent { CLIAgent::Qwen, CLIAgent::OhMyPi, CLIAgent::Kimi, + CLIAgent::QoderCLI, ]; fn aliases(self) -> &'static [&'static str] { @@ -83,6 +85,8 @@ impl CLIAgent { // kimi-cli install a `kimi` — same vendor, same brand, so one // detection covers them. Only the standalone one has hooks. CLIAgent::Kimi => &["kimi", "kimi-code"], + // `qoder` launches the IDE; CLI wrappers can use a custom rule. + CLIAgent::QoderCLI => &["qodercli"], } } @@ -108,6 +112,7 @@ impl CLIAgent { CLIAgent::Qwen => "qwen", CLIAgent::OhMyPi => "omp", CLIAgent::Kimi => "kimi", + CLIAgent::QoderCLI => "qodercli", } } @@ -138,6 +143,7 @@ impl CLIAgent { CLIAgent::Qwen => "Qwen Code", CLIAgent::OhMyPi => "Oh My Pi", CLIAgent::Kimi => "Kimi Code", + CLIAgent::QoderCLI => "Qoder CLI", } } @@ -169,6 +175,7 @@ impl CLIAgent { CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")), CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), + CLIAgent::QoderCLI => Some(format!("qodercli{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), @@ -185,6 +192,9 @@ impl CLIAgent { // "If false, chat history is not saved and --continue/--resume // will not work" — the yargs negation of `--chat-recording`. CLIAgent::Qwen => &["--no-chat-recording"], + // Print mode still emits a session id in hooks when persistence + // is disabled, but there is no saved conversation to reopen. + CLIAgent::QoderCLI => &["--no-session-persistence"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -202,6 +212,9 @@ impl CLIAgent { "claude{flags} --resume {session_id} --fork-session" )), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), + CLIAgent::QoderCLI => Some(format!( + "qodercli{flags} --resume {session_id} --fork-session" + )), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), // Droid forks with a standalone flag rather than resume-plus-a-switch. @@ -229,7 +242,8 @@ impl CLIAgent { | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen - | CLIAgent::Goose => Some("Fork Session"), + | CLIAgent::Goose + | CLIAgent::QoderCLI => Some("Fork Session"), _ => None, } } @@ -393,6 +407,21 @@ impl CLIAgent { "--worktree-ref", "--ref", ], + // `--resume`/`-r` resumes a past session and `--continue`/`-c` the + // most recent one, both of which clash with the `--resume {id}` + // this command appends; `--session-id` names a *new* session and is + // rejected next to `--resume`, and `--fork-session` is the flag the + // fork variant appends itself. `--worktree` would create or switch + // trees again; Qoder's `-w` means `--cwd` and must survive. + CLIAgent::QoderCLI => &[ + "--resume", + "-r", + "--continue", + "-c", + "--session-id", + "--fork-session", + "--worktree", + ], _ => &[], }; let mut i = 0; @@ -457,6 +486,7 @@ impl CLIAgent { // The blue of the flame in Kimi's brand mark; the glyph itself is // black, which Codex and Grok already have covered. CLIAgent::Kimi => 0x027AFF, + CLIAgent::QoderCLI => 0xFFFFFF, } } @@ -475,6 +505,7 @@ impl CLIAgent { pub fn icon_rgb(self) -> u32 { match self { CLIAgent::TraeCode => 0x32F08C, + CLIAgent::QoderCLI => 0x000000, _ => 0xFFFFFF, } } @@ -496,6 +527,7 @@ impl CLIAgent { CLIAgent::OhMyPi => "icons/agents/omp.svg", CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Kimi => "icons/agents/kimi.svg", + CLIAgent::QoderCLI => "icons/agents/qodercli.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -1508,6 +1540,88 @@ mod tests { .as_deref(), Some("grok --yolo --resume g-3") ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&argv(&["qodercli", "--model", "qoder-1"]))) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-2", + Some(&argv(&["qodercli", "--resume", "q-1", "--fork-session"])) + ) + .as_deref(), + Some("qodercli --resume q-2"), + "a stale --resume id and --fork-session come off before the new one goes on" + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-3", + Some(&argv(&["qodercli", "--session-id", "old", "--yolo"])) + ) + .as_deref(), + Some("qodercli --yolo --resume q-3"), + "`--session-id` names a new session and is rejected next to `--resume`" + ); + } + + #[test] + fn qoder_resume_and_fork_do_not_recreate_worktrees() { + for worktree in [ + vec!["--worktree"], + vec!["--worktree", "old-tree"], + vec!["--worktree=old-tree"], + ] { + for cwd_flag in ["-w", "--cwd"] { + let mut launch = argv(&["qodercli", "--model", "qoder-1"]); + launch.extend(argv(&worktree)); + launch.extend(argv(&[cwd_flag, "/repo/current-tree"])); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1" + )), + "launch argv: {launch:?}" + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1 --fork-session" + )), + "launch argv: {launch:?}" + ); + } + } + } + + #[test] + fn qoder_session_commands_require_persistence() { + let ephemeral = argv(&["qodercli", "--print", "--no-session-persistence"]); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&ephemeral)), + None + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&ephemeral)), + None + ); + + let persistent = argv(&["qodercli", "--model", "qoder-1"]); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .fork_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1 --fork-session") + ); } #[test] diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 801694f7..7c449076 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -62,6 +62,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), + "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index d2fd7cf9..15768a5e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -760,6 +760,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -855,6 +856,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchKimiCodeKeywords => { "agent integration hooks install kimi code kimi-code moonshot" } + L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli", L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c4b3810e..a45e7b07 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -769,6 +769,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -906,6 +907,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "エージェント 統合 フック インストール kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => { + "エージェント 統合 フック インストール qoder qodercli agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 90f47ca6..4f512c23 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -586,6 +586,7 @@ l10n_keys! { SettingsAgentQwenCode, SettingsAgentGoose, SettingsAgentKimiCode, + SettingsAgentQoderCLI, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -640,6 +641,7 @@ l10n_keys! { SettingsSearchPortForwardingKeywords, SettingsSearchProgramKeywords, SettingsSearchQwenCodeKeywords, + SettingsSearchQoderCLIKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1565,6 +1567,7 @@ mod tests { L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, L10nKey::SettingsAgentQwenCode, + L10nKey::SettingsAgentQoderCLI, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index b3d2ddc3..c570bf0a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -676,6 +676,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -811,6 +812,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli", L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 45fdfe3b..5c2cb38c 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -656,6 +656,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentKimiCode, keywords: SettingsSearchKimiCodeKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentQoderCLI, + keywords: SettingsSearchQoderCLIKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow, From 2f63a01f7b12ff29406d202b544540ea1d1bf7c4 Mon Sep 17 00:00:00 2001 From: hhdebb Date: Fri, 11 Sep 2026 11:09:36 +0800 Subject: [PATCH 10/46] fix(terminal): report the terminal as the focused element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal draws its own glyphs, so nothing outside the window can read what is on screen. That much is a terminal being a terminal. What is not is that the window reports no focused element at all. gpui sets accessibility focus in exactly one place: a `div` that tracks a focus handle and has an a11y node of its own. The terminal surface tracks the focus handle and never asks for a role, so it has no node, so `set_focus` is never reached — and a client asking the window what has focus is handed the window. Measured on Windows 11 26200 with a UI Automation probe: the window answers with `WindowPattern` and nothing else, publishes zero descendants, and `FocusedElement` is the top-level window, supporting neither `ValuePattern` nor `TextPattern`. A screen reader has nothing to say about a tty7 window for the same reason. It reaches past screen readers. A dictation tool pastes its transcript and then asks the focused element what it now says, to check the text arrived. Against tty7 it gets no element to ask, concludes the paste failed, and hands the transcript back for the user to paste by hand — while the bytes it sent are already in the pty and the text is on screen. That is what led here. The fix is the surface asking for a role: it gets a node, and focus lands on it. `MultilineTextInput` rather than `Terminal` because `Terminal` maps to a document that reports itself as not editable, and "is this something text can be put into" is the question these clients are actually asking. The node carries no text of its own yet — reading the grid out is a separate change with a cost per frame, and this one has none: gpui builds the a11y tree only once something attaches to it, so a window nobody is inspecting still builds nothing. The path this fixes is platform-independent; it was verified on Windows, where the dictation tool that surfaced it runs. --- src/terminal/view.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e5c2f206..56555d5d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6820,6 +6820,11 @@ impl Render for TerminalView { div() .id("terminal-surface") + // The surface, not the grid inside it, is what carries the role: + // a11y focus is only ever reported for a `div` that tracks a focus + // handle *and* has a node of its own, so a terminal with no role + // here is a window whose focused element is the window. + .role(gpui::Role::MultilineTextInput) .track_focus(&self.focus_handle) .key_context(self.key_context()) .size_full() From 63b3951ef3ef848d1d4298b18364f2b7ef3f8031 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:38:40 +0800 Subject: [PATCH 11/46] fix(agents): detect Qoder through the binary its docs tell you to run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm package installs two binaries. `qoder` is a dispatcher that routes to the CLI for a bare invocation, a flag, or a prompt, and only hands off to the IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists — and it is the one the documentation tells people to run. Both are `#!/usr/bin/env node` scripts, so what the pty carries is node plus the path to the shim; the dispatcher's child, where `qodercli` appears on the path, is not the process group leader and is never read. Detecting `qodercli` alone missed every session started the documented way. An IDE launch now wears the CLI's avatar for as long as the launcher takes to exit, which is the cost of covering the common case. Also: `--session-id` restores a session rather than naming a new one, so say that where the flag is stripped, and assert Qoder has no `Notification` seat instead of putting a payload through a hook map that has none. Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh --- crates/tty7-core/src/core/agent_hooks.rs | 18 ++++++--- crates/tty7-core/src/core/cli_agent.rs | 49 ++++++++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 6d60c386..e8865107 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1758,12 +1758,20 @@ mod tests { r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#, ); assert_eq!(state.status, AgentStatus::Working); - apply_hook( - &mut state, - "Notification", - r#"{"notification_type":"auth_success","message":"Signed in"}"#, + // Qoder says outright when it is blocked — `PermissionRequest` and + // `Elicitation` — so it must not also carry `Notification`, which + // fires for non-blocking alerts and would strand the pane on + // "waiting". Asserting the map has no seat for it is the check; a + // `Notification` payload put through `apply_hook` would be dropped + // for want of one and prove nothing. + assert!( + !HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .any(|(hook, _)| *hook == "Notification"), + "an unblocking alert must not read as a question" ); - assert_eq!(state.status, AgentStatus::Working); // The MCP tool is already authorized, so no PermissionRequest precedes // its request for more information from the user. diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index b12431eb..1af51403 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -85,8 +85,15 @@ impl CLIAgent { // kimi-cli install a `kimi` — same vendor, same brand, so one // detection covers them. Only the standalone one has hooks. CLIAgent::Kimi => &["kimi", "kimi-code"], - // `qoder` launches the IDE; CLI wrappers can use a custom rule. - CLIAgent::QoderCLI => &["qodercli"], + // The npm package installs two binaries and `qoder` is the one the + // documentation tells people to run: it dispatches to the CLI for a + // bare invocation, a flag, or a prompt, and only hands off to the + // IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists. + // Detecting only `qodercli` would miss every session started the + // documented way, since the dispatcher is what the pty sees. An IDE + // launch is the cost: it wears the CLI's avatar for as long as the + // launcher takes to exit. + CLIAgent::QoderCLI => &["qoder", "qodercli"], } } @@ -407,12 +414,12 @@ impl CLIAgent { "--worktree-ref", "--ref", ], - // `--resume`/`-r` resumes a past session and `--continue`/`-c` the - // most recent one, both of which clash with the `--resume {id}` - // this command appends; `--session-id` names a *new* session and is - // rejected next to `--resume`, and `--fork-session` is the flag the - // fork variant appends itself. `--worktree` would create or switch - // trees again; Qoder's `-w` means `--cwd` and must survive. + // `--resume`/`-r` restores a past session and `--continue`/`-c` the + // most recent one; `--session-id` is a third spelling of the same + // thing. All three clash with the `--resume {id}` this command + // appends, and `--fork-session` is the flag the fork variant + // appends itself. `--worktree` would create or switch trees again; + // Qoder's `-w` means `--cwd` and must survive. CLIAgent::QoderCLI => &[ "--resume", "-r", @@ -880,6 +887,32 @@ mod tests { ); } + /// The npm package installs `qoder` and `qodercli`, and the documentation + /// tells people to run the first one. Both are `#!/usr/bin/env node` + /// scripts, so what the pty carries is node plus the path to the shim — + /// the dispatcher's own child, which is where the name `qodercli` appears + /// on that path, is not the process group leader and is never read. + #[test] + fn qoder_is_detected_through_either_of_its_binaries() { + for launcher in [ + "qoder", + "qodercli", + "/opt/homebrew/bin/qoder", + "/opt/homebrew/bin/qodercli", + ] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["node", launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + } + } + #[test] fn detects_npx_package_form() { assert_eq!( From c2d2db2cfc675a1eb50afbf1b976009ece20c0aa Mon Sep 17 00:00:00 2001 From: Fabrice Aneche Date: Fri, 11 Sep 2026 21:55:49 -0400 Subject: [PATCH 12/46] added support for Crush agent --- README.md | 6 +- assets/icons/agents/crush.svg | 31 +++ crates/tty7-core/src/core/agent_hooks.rs | 271 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 56 ++++- docs/agents/overview.mdx | 8 +- docs/agents/status.mdx | 8 +- docs/getting-started/first-launch.mdx | 2 +- docs/index.mdx | 2 +- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 2 + src/ui/settings.rs | 5 + 14 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 assets/icons/agents/crush.svg diff --git a/README.md b/README.md index fa649748..8aad1808 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (20 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **Agent-aware** | per-pane detection (22 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | @@ -69,7 +69,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty +The full support matrix, all twenty-two | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -82,10 +82,12 @@ that tells tty7 which session to fork. | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | +| **Qoder CLI** | ✓ | ✓ | ✓ | | **Gemini** | ✓ | ✓ | | | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | +| **Crush** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Cursor | ✓ | | | diff --git a/assets/icons/agents/crush.svg b/assets/icons/agents/crush.svg new file mode 100644 index 00000000..9c900100 --- /dev/null +++ b/assets/icons/agents/crush.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index e8865107..e2a05d1c 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -267,10 +267,11 @@ pub enum HookAgent { Goose, Kimi, QoderCLI, + Crush, } impl HookAgent { - pub const ALL: [HookAgent; 14] = [ + pub const ALL: [HookAgent; 15] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -285,6 +286,7 @@ impl HookAgent { HookAgent::Goose, HookAgent::Kimi, HookAgent::QoderCLI, + HookAgent::Crush, ]; /// The hooks behind a detected agent process, if it has any. @@ -308,6 +310,7 @@ impl HookAgent { CLIAgent::Goose => Some(HookAgent::Goose), CLIAgent::Kimi => Some(HookAgent::Kimi), CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), + CLIAgent::Crush => Some(HookAgent::Crush), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -330,6 +333,7 @@ impl HookAgent { HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), + HookAgent::Crush => Some(CRUSH_HOOK_EVENTS), HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi @@ -350,6 +354,14 @@ impl HookAgent { } } + /// Whether this agent's hook-map entries carry `command` and `matcher` at + /// the top level rather than nesting a `hooks` array of `{type, command}` + /// objects inside the matcher. Claude's shape is the latter; Crush flattened + /// it, and still calls itself Claude-Code-compatible on the wire. + fn flat_hook_map(self) -> bool { + matches!(self, HookAgent::Crush) + } + pub fn slug(self) -> &'static str { match self { HookAgent::Claude => "claude", @@ -366,6 +378,7 @@ impl HookAgent { HookAgent::Goose => "goose", HookAgent::Kimi => "kimi", HookAgent::QoderCLI => "qodercli", + HookAgent::Crush => "crush", } } @@ -385,6 +398,7 @@ impl HookAgent { HookAgent::Goose => "Goose", HookAgent::Kimi => "Kimi Code", HookAgent::QoderCLI => "Qoder CLI", + HookAgent::Crush => "Crush", } } @@ -418,6 +432,7 @@ impl HookAgent { } HookAgent::Kimi => target.kimi_config_path(), HookAgent::QoderCLI => target.qoder_settings_path(), + HookAgent::Crush => target.crush_settings_path(), } } @@ -519,6 +534,20 @@ impl<'a> HookTarget<'a> { self.under_home(&[".qoder", "settings.json"]) } + /// The global `crush.json`. Crush resolves it through `CRUSH_GLOBAL_CONFIG` + /// when set, otherwise `$XDG_CONFIG_HOME/crush/crush.json` (and `~/.config` + /// when that is unset) — which is exactly what [`Self::xdg_config_dir`] + /// answers. The override is local-only: a local env var must not redirect a + /// remote machine's hooks. + fn crush_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("CRUSH_GLOBAL_CONFIG").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("crush.json"); + } + self.under(&self.xdg_config_dir(), &["crush", "crush.json"]) + } + fn traecli_hooks_path(&self) -> PathBuf { if self.is_local() { if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) { @@ -831,6 +860,17 @@ const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// Crush currently fires exactly one hook, `PreToolUse`, before every +/// top-level tool call and before its permission check. There is no turn +/// boundary to report, so a tool call is the only evidence that Crush is +/// working at all: it maps to `prompt-submit`, and the pane is cleared when +/// Crush exits and the foreground process goes back to the shell. +/// +/// The cost is that a turn cannot report done: `tty7 wait` will time out +/// rather than return. That is Crush's limitation, not tty7's; when it ships +/// `UserPromptSubmit`/`Stop` and friends, they slot in here. +const CRUSH_HOOK_EVENTS: &[(&str, &str)] = &[("PreToolUse", "prompt-submit")]; + fn hook_map_state( target: &HookTarget, path: &Path, @@ -915,9 +955,13 @@ fn hook_map_install( continue; }; list.retain(|matcher| marker_command(matcher, &marker).is_none()); - list.push(serde_json::json!({ - "hooks": [{ "type": "command", "command": command }] - })); + if agent.flat_hook_map() { + list.push(serde_json::json!({ "command": command })); + } else { + list.push(serde_json::json!({ + "hooks": [{ "type": "command", "command": command }] + })); + } } target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) @@ -961,8 +1005,21 @@ fn hook_map_uninstall( Ok(HookOutcome::Removed) } -fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'a str> { - matcher +/// The tty7 command an entry in a hook map carries, if it is one of ours. +/// +/// Two shapes reach here. Claude and its imitators nest it at +/// `entry.hooks[].command`; Crush lifts `command` to the entry itself, the +/// same level as its `matcher`. Both are one list under `hooks.`, so +/// the state reader, the installer and the uninstaller all stay shared. +fn marker_command<'a>(entry: &'a serde_json::Value, marker: &str) -> Option<&'a str> { + if let Some(command) = entry + .get("command") + .and_then(|c| c.as_str()) + .filter(|c| c.contains(marker)) + { + return Some(command); + } + entry .get("hooks") .and_then(|h| h.as_array())? .iter() @@ -1176,6 +1233,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Droid | HookAgent::Qwen | HookAgent::QoderCLI + | HookAgent::Crush | HookAgent::Kimi => None, } } @@ -1612,6 +1670,7 @@ mod tests { .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) .chain(KIMI_HOOK_EVENTS) + .chain(CRUSH_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1647,6 +1706,7 @@ mod tests { ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::Crush, "/home/me/.config/crush/crush.json"), ] { assert_eq!( agent.target_path(&t), @@ -1668,6 +1728,7 @@ mod tests { HookAgent::Goose, HookAgent::Kimi, HookAgent::QoderCLI, + HookAgent::Crush, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1945,6 +2006,18 @@ mod tests { }); assert!(marker_command(&theirs, "agent-hook claude").is_none()); assert!(marker_command(&serde_json::json!({}), "agent-hook claude").is_none()); + + // Crush flattens the same entry: `command` sits beside `matcher` rather + // than inside a nested `hooks` array, and the reader has to see it. + let flat = serde_json::json!({ + "matcher": "^bash$", + "command": "\"/x/tty7\" agent-hook crush prompt-submit" + }); + assert_eq!( + marker_command(&flat, "agent-hook crush"), + Some("\"/x/tty7\" agent-hook crush prompt-submit") + ); + assert!(marker_command(&flat, "agent-hook claude").is_none()); } fn local_host() -> crate::host::SharedHost { @@ -2055,6 +2128,7 @@ mod tests { ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::Crush, "/home/me/.config/crush/crush.json"), ] { assert_eq!( agent.target_path(&target), @@ -2378,6 +2452,191 @@ mod tests { } } + /// Crush's hook map is one list under `hooks.PreToolUse` like Claude's, but + /// each entry carries `command` and `matcher` directly instead of wrapping + /// them in a nested `hooks` array. The merge has to write the flat shape and + /// still leave a user's own hooks and the rest of `crush.json` alone. + #[test] + fn crush_installs_a_flat_hook_and_preserves_user_entries() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-crush-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let target = HookTarget::remote(&*host, base.clone()); + let config = HookAgent::Crush.target_path(&target); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let user_config = serde_json::json!({ + "model": "crush-test", + "hooks": { + "PreToolUse": [ + { "matcher": "^bash$", "command": "echo user-hook", "timeout": 5 } + ] + } + }); + std::fs::write(&config, serde_json::to_string_pretty(&user_config).unwrap()).unwrap(); + + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Crush).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::Installed + ); + install_hooks(&target, HookAgent::Crush).expect("re-install succeeds"); + + let merged: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + assert_eq!(merged["model"], "crush-test"); + let entries = merged["hooks"]["PreToolUse"].as_array().unwrap(); + let ours: Vec<&serde_json::Value> = entries + .iter() + .filter(|e| { + e.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("agent-hook crush")) + }) + .collect(); + assert_eq!(ours.len(), 1, "exactly one tty7 entry after two installs"); + assert_eq!( + ours[0]["command"].as_str(), + Some( + target + .hook_command(HookAgent::Crush, "prompt-submit") + .as_str() + ), + "the entry is flat and names the prompt-submit emitter" + ); + assert!( + ours[0].get("hooks").is_none(), + "Crush does not nest a hooks array inside the entry" + ); + assert!( + entries.iter().any(|e| e + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("user-hook"))), + "the user's own PreToolUse hook survives" + ); + + assert_eq!( + uninstall_hooks(&target, HookAgent::Crush).unwrap(), + HookOutcome::Removed + ); + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::NotInstalled + ); + let after: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + assert_eq!( + after, user_config, + "uninstall restores the user's file exactly" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn crush_global_config_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_CRUSH_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_CRUSH_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + // Each case gets its own environment, without changing the one + // shared by the other tests or touching the user's settings. + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::crush_global_config_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("CRUSH_GLOBAL_CONFIG", sandbox.path().join("custom config")) + } + "empty" => child.env("CRUSH_GLOBAL_CONFIG", ""), + _ => child.env_remove("CRUSH_GLOBAL_CONFIG"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Crush hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_config = target.home.join(".config").join("crush").join("crush.json"); + let custom_config = root.join("custom config").join("crush.json"); + let (config, untouched) = if case == "override" { + (&custom_config, &default_config) + } else { + (&default_config, &custom_config) + }; + let user_config = serde_json::json!({ + "model": "crush-test", + "hooks": { + "PreToolUse": [ + { "command": "echo user-hook" } + ] + } + }); + for path in [config, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string(&user_config).unwrap()).unwrap(); + } + + let agent = HookAgent::Crush; + assert_eq!(agent.target_path(&target), *config); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.config/crush/crush.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(config) + .unwrap() + .contains("agent-hook crush") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [config, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + #[test] fn install_is_idempotent_and_preserves_user_hooks() { let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 1af51403..952831e8 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -27,10 +27,11 @@ pub enum CLIAgent { // moving an existing discriminant would break mixed-version clients. TraeCode, QoderCLI, + Crush, } impl CLIAgent { - pub const ALL: [CLIAgent; 21] = [ + pub const ALL: [CLIAgent; 22] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -52,6 +53,7 @@ impl CLIAgent { CLIAgent::OhMyPi, CLIAgent::Kimi, CLIAgent::QoderCLI, + CLIAgent::Crush, ]; fn aliases(self) -> &'static [&'static str] { @@ -94,6 +96,9 @@ impl CLIAgent { // launch is the cost: it wears the CLI's avatar for as long as the // launcher takes to exit. CLIAgent::QoderCLI => &["qoder", "qodercli"], + // Charm's terminal agent. One binary, and the name on `PATH` is + // the one it starts as. + CLIAgent::Crush => &["crush"], } } @@ -120,6 +125,7 @@ impl CLIAgent { CLIAgent::OhMyPi => "omp", CLIAgent::Kimi => "kimi", CLIAgent::QoderCLI => "qodercli", + CLIAgent::Crush => "crush", } } @@ -151,6 +157,7 @@ impl CLIAgent { CLIAgent::OhMyPi => "Oh My Pi", CLIAgent::Kimi => "Kimi Code", CLIAgent::QoderCLI => "Qoder CLI", + CLIAgent::Crush => "Crush", } } @@ -186,6 +193,7 @@ impl CLIAgent { CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), + CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), _ => None, } } @@ -429,6 +437,11 @@ impl CLIAgent { "--fork-session", "--worktree", ], + // `--session`/`-s` names the conversation to restore and + // `--continue`/`-C` the most recent one; both clash with the + // `--session {id}` this command appends. `-c` is *not* in that + // group here — Crush spells `--cwd` with it, and it must survive. + CLIAgent::Crush => &["--session", "-s", "--continue", "-C"], _ => &[], }; let mut i = 0; @@ -494,6 +507,8 @@ impl CLIAgent { // black, which Codex and Grok already have covered. CLIAgent::Kimi => 0x027AFF, CLIAgent::QoderCLI => 0xFFFFFF, + // The blue-violet field Charm ships the Crush heart on. + CLIAgent::Crush => 0x6B50FF, } } @@ -535,6 +550,7 @@ impl CLIAgent { CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Kimi => "icons/agents/kimi.svg", CLIAgent::QoderCLI => "icons/agents/qodercli.svg", + CLIAgent::Crush => "icons/agents/crush.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -1014,6 +1030,8 @@ mod tests { ("/opt/homebrew/bin/omp", CLIAgent::OhMyPi), ("kimi", CLIAgent::Kimi), ("/usr/local/bin/kimi", CLIAgent::Kimi), + ("crush", CLIAgent::Crush), + ("/opt/homebrew/bin/crush", CLIAgent::Crush), ] { assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent)); } @@ -1657,6 +1675,42 @@ mod tests { ); } + #[test] + fn crush_resumes_by_session_and_keeps_its_cwd_flag() { + let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); + + assert_eq!( + CLIAgent::Crush.resume_command("c-1", None).as_deref(), + Some("crush --session c-1") + ); + assert_eq!( + CLIAgent::Crush + .resume_command("c-2", Some(&argv(&["crush", "--session", "c-1", "--yolo"]))) + .as_deref(), + Some("crush --yolo --session c-2"), + "a stale --session and its id come off before the new one goes on" + ); + assert_eq!( + CLIAgent::Crush + .resume_command("c-3", Some(&argv(&["crush", "--continue", "-C", "--yolo"]))) + .as_deref(), + Some("crush --yolo --session c-3"), + "both spellings of continue are stale with it" + ); + // `-c` is `--cwd` in Crush, not `--continue`, and must survive. + assert_eq!( + CLIAgent::Crush + .resume_command("c-4", Some(&argv(&["crush", "-c", "/repo/tree", "--yolo"]))) + .as_deref(), + Some("crush -c /repo/tree --yolo --session c-4") + ); + assert_eq!( + CLIAgent::Crush.fork_command("c-1", None), + None, + "Crush has no fork command" + ); + } + #[test] fn oh_my_pi_resume_and_fork_use_its_own_flags() { let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index e0e21f5b..62f9a602 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, TraeCode, and 17 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, TraeCode, and 19 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty CLIs are recognised on sight, by the command running in the pane: +Twenty-two CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -33,6 +33,8 @@ Twenty CLIs are recognised on sight, by the command running in the pane: | Grok | `grok` | | Qwen Code | `qwen`, `qwen-code` | | Kimi Code | `kimi`, `kimi-code` | +| Qoder CLI | `qoder`, `qodercli` | +| Crush | `crush` | | Auggie | `auggie` | | Hermes | `hermes` | | Vibe | `vibe`, `vibe-acp` | @@ -61,7 +63,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`). +`omp`, `kimi`, `qodercli`, `crush`). ## What you get for free diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 256e7a87..d6a40293 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,7 +14,7 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush | Hooks available | | Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed @@ -22,6 +22,12 @@ the row grows a second **Uninstall** button beside the first, which itself becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7 ships a newer hook. + + Crush ships only a `PreToolUse` hook today, so it reports **working** — a + tool call is the only turn signal there is — but never **done**. On a Crush + pane, `tty7 wait` times out rather than returning. + + The hooks only do anything inside tty7. Running the same agent in another terminal is unaffected. diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 80eb645a..314693c0 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Agents.** tty7 detects 20 coding CLIs by process name on its own — +**Settings → Agents.** tty7 detects 22 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/index.mdx b/docs/index.mdx index a298a154..07e72400 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 20 coding CLIs are recognised on sight. Per-pane status dots, notifications + 22 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 7c449076..f8243eb0 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -63,6 +63,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), + "icons/agents/crush.svg" => include_bytes!("../../assets/icons/agents/crush.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 15768a5e..9bd68e69 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -761,6 +761,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -857,6 +858,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "agent integration hooks install kimi code kimi-code moonshot" } L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli", + L10nKey::SettingsSearchCrushKeywords => "agent integration hooks install crush", L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a45e7b07..b1c55184 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -770,6 +770,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -910,6 +911,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchQoderCLIKeywords => { "エージェント 統合 フック インストール qoder qodercli agent integration hooks install" } + L10nKey::SettingsSearchCrushKeywords => { + "エージェント 統合 フック インストール crush agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4f512c23..33a35755 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -587,6 +587,7 @@ l10n_keys! { SettingsAgentGoose, SettingsAgentKimiCode, SettingsAgentQoderCLI, + SettingsAgentCrush, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -642,6 +643,7 @@ l10n_keys! { SettingsSearchProgramKeywords, SettingsSearchQwenCodeKeywords, SettingsSearchQoderCLIKeywords, + SettingsSearchCrushKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1568,6 +1570,7 @@ mod tests { L10nKey::SettingsAgentPi, L10nKey::SettingsAgentQwenCode, L10nKey::SettingsAgentQoderCLI, + L10nKey::SettingsAgentCrush, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index c570bf0a..cc33a21f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -677,6 +677,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -813,6 +814,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" } L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli", + L10nKey::SettingsSearchCrushKeywords => "Crush agent 集成 钩子 安装 crush", L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5c2cb38c..3e26c714 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -661,6 +661,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentQoderCLI, keywords: SettingsSearchQoderCLIKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentCrush, + keywords: SettingsSearchCrushKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow, From 52b27429773b8204897e5c3154cb9d278b0b022a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:06:36 +0800 Subject: [PATCH 13/46] Give the SSH host editor the credentials it was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host could be described in full in Settings and still not be connectable from there: there was no password box anywhere on the form. The only way to store a password was to connect, wait to be asked, and tick "remember" — and the only way to correct a wrong one was to connect again and fail first. The key file lived two disclosure triangles deep under Advanced, as a textarea of paths with nothing to pick one. The form now carries the credential half of a connection, in an Authentication block between the address and the collapsed sections: - A password box, masked with a reveal toggle, seeded from the system keychain so a stored password can be read back, corrected or cleared without dialling anything. Clearing it and saving is how a saved password is let go of. - Identity files, moved up out of Advanced, with a Browse button that opens the system picker and writes the path back as `~/.ssh/...` rather than the absolute path the dialog hands over. - A key passphrase box beside it, stored against the contents of the key it unlocks — the same account the connect-time prompt uses. It follows whichever key the field names, and says so when there is no readable key to store one against. Which boxes appear follows the method, the way every other SSH client does it. The split is `build_spec_inner`'s: a password for Auto and Password, key passphrases for Auto and Key, and nothing for Agent, GSSAPI or 2FA — a box outside that would collect a secret, store it in the keychain, and never offer it to anybody. Nothing secret reaches the config file. That is also why Save could not see a typed password: the dirty check compares profiles, and no profile holds one. It now folds the two secrets in, so Save lights up for a password the way it does for a port. Saving moves a password with the address it is filed under — the keychain accounts by endpoint, not by profile — and leaves nothing behind under the old one, unless another host still dials it. A passphrase belongs to its key rather than to this profile, so pointing a host at a different key never touches the first key's entry. Test dials with what is on screen rather than only with what is stored, so it stops reporting a failure the form could not explain. The layout is the other half of the report. These rows were built out of the settings rows the rest of the page uses, which push their control to the far right edge: right for a list of independent switches, wrong for a form, and it left a hand's width of nothing between the word "Host" and the box a hostname goes in. Labels now sit right-aligned against their fields, descriptions and errors moved under the field they are about, and the three that only restated their label became hints inside the box. Two bugs the new shape turned up: a percentage-width control inside a flex-grown wrapper has no definite parent to resolve against, so the host and key fields collapsed to one character and the method dropdown clipped its own menu to "GSSAP"; and "Needs a host" appeared in red on a form nobody had typed in, because the untouched check counted a port field that opens on 22 and is never empty. Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh --- src/ui/i18n/en.rs | 22 +- src/ui/i18n/ja.rs | 22 +- src/ui/i18n/mod.rs | 16 +- src/ui/i18n/zh.rs | 16 +- src/ui/settings.rs | 944 ++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 920 insertions(+), 100 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 15768a5e..a6673ed2 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -261,13 +261,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsKeepEditing => "Keep Editing", L10nKey::SettingsName => "Name", - L10nKey::SettingsNameDesc => "A label for this connection.", L10nKey::SettingsHost => "Host", - L10nKey::SettingsHostDesc => "Hostname or IP address.", L10nKey::SettingsHostRequired => "Needs a host — won't be saved.", L10nKey::SettingsPortInvalid => "Port must be 1-65535 — blank means 22.", L10nKey::SettingsUser => "User", - L10nKey::SettingsUserDesc => "Login user (blank = resolve at connect).", L10nKey::SettingsAuth => "Auth", L10nKey::SettingsAuthDesc => "Authentication method. Auto tries every applicable method.", L10nKey::SettingsAuthModeAuto => "Auto", @@ -275,6 +272,25 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAuthModeKey => "Key", L10nKey::SettingsAuthModeAgent => "Agent", L10nKey::SettingsAuthMode2Fa => "2FA", + L10nKey::SettingsPassword => "Password", + L10nKey::SettingsNameHint => "Optional label", + L10nKey::SettingsHostHint => "hostname or IP", + L10nKey::SettingsUserHint => "resolved at connect", + L10nKey::SettingsPasswordDesc => "Kept in the system keychain, never in the config file.", + L10nKey::SettingsPasswordHint => "Ask when connecting", + L10nKey::SettingsForget => "Forget", + L10nKey::SettingsKeyPassphrase => "Key passphrase", + L10nKey::SettingsKeyPassphraseDesc => "Unlocks the key above. Kept in the system keychain.", + L10nKey::SettingsPassphraseNeedsKey => { + "Name a key file first — a passphrase is stored against the key it unlocks." + } + L10nKey::SettingsBrowseKey => "Browse…", + L10nKey::SettingsCouldntSavePassword => { + "Could not save the password for {endpoint}: {error}" + } + L10nKey::SettingsCouldntSavePassphrase => { + "Could not save the passphrase for {key}: {error}" + } L10nKey::SettingsJumpHost => "Jump host", L10nKey::SettingsJumpHostDesc => { "Name of another profile to tunnel through (blank = direct)." diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a45e7b07..544f18ad 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -262,13 +262,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiscardChangesBody => "編集中の接続に、まだ保存していない変更があります。", L10nKey::SettingsKeepEditing => "編集を続ける", L10nKey::SettingsName => "名前", - L10nKey::SettingsNameDesc => "この接続の表示名", L10nKey::SettingsHost => "ホスト名", - L10nKey::SettingsHostDesc => "ホスト名または IP アドレス", L10nKey::SettingsHostRequired => "ホスト名が必要です — 保存されません", L10nKey::SettingsPortInvalid => "ポートは 1-65535 の範囲です — 空欄なら 22 です", L10nKey::SettingsUser => "ユーザー名", - L10nKey::SettingsUserDesc => "ログインユーザー (空欄 = 接続時に解決)", L10nKey::SettingsAuth => "認証方式", L10nKey::SettingsAuthDesc => "認証方式。自動の場合は適用可能なすべての方式を試します", L10nKey::SettingsAuthModeAuto => "自動", @@ -276,6 +273,25 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAuthModeKey => "公開鍵", L10nKey::SettingsAuthModeAgent => "SSH エージェント", L10nKey::SettingsAuthMode2Fa => "二要素認証 (2FA)", + L10nKey::SettingsPassword => "パスワード", + L10nKey::SettingsNameHint => "任意のラベル", + L10nKey::SettingsHostHint => "ホスト名または IP", + L10nKey::SettingsUserHint => "接続時に解決", + L10nKey::SettingsPasswordDesc => { + "システムのキーチェーンに保存され、設定ファイルには書き込まれません。" + } + L10nKey::SettingsPasswordHint => "接続時に入力する", + L10nKey::SettingsForget => "削除", + L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ", + L10nKey::SettingsKeyPassphraseDesc => { + "上の鍵を解錠します。システムのキーチェーンに保存されます。" + } + L10nKey::SettingsPassphraseNeedsKey => { + "先に鍵ファイルを指定してください。パスフレーズは解錠する鍵ごとに保存されます。" + } + L10nKey::SettingsBrowseKey => "参照…", + L10nKey::SettingsCouldntSavePassword => "{endpoint} のパスワードを保存できません: {error}", + L10nKey::SettingsCouldntSavePassphrase => "{key} のパスフレーズを保存できません: {error}", L10nKey::SettingsJumpHost => "ジャンプホスト", L10nKey::SettingsJumpHostDesc => { "トンネリングに使用する別のプロファイル名 (空欄 = 直接接続)" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4f512c23..6072b77c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -256,13 +256,10 @@ l10n_keys! { SettingsDiscardChangesBody, SettingsKeepEditing, SettingsName, - SettingsNameDesc, SettingsHost, - SettingsHostDesc, SettingsHostRequired, SettingsPortInvalid, SettingsUser, - SettingsUserDesc, SettingsAuth, SettingsAuthDesc, SettingsAuthModeAuto, @@ -270,6 +267,19 @@ l10n_keys! { SettingsAuthModeKey, SettingsAuthModeAgent, SettingsAuthMode2Fa, + SettingsNameHint, + SettingsHostHint, + SettingsUserHint, + SettingsPassword, + SettingsPasswordDesc, + SettingsPasswordHint, + SettingsForget, + SettingsKeyPassphrase, + SettingsKeyPassphraseDesc, + SettingsPassphraseNeedsKey, + SettingsBrowseKey, + SettingsCouldntSavePassword, + SettingsCouldntSavePassphrase, SettingsJumpHost, SettingsJumpHostDesc, SettingsJumpHostUnknown, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index c570bf0a..6724f8ef 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -234,13 +234,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiscardChangesBody => "你正在编辑的连接有还没保存的改动。", L10nKey::SettingsKeepEditing => "继续编辑", L10nKey::SettingsName => "名称", - L10nKey::SettingsNameDesc => "此连接的标签。", L10nKey::SettingsHost => "主机", - L10nKey::SettingsHostDesc => "主机名或 IP 地址。", L10nKey::SettingsHostRequired => "需要填写主机——不会被保存。", L10nKey::SettingsPortInvalid => "端口必须在 1-65535 之间——留空表示 22。", L10nKey::SettingsUser => "用户", - L10nKey::SettingsUserDesc => "登录用户(留空表示连接时解析)。", L10nKey::SettingsAuth => "认证", L10nKey::SettingsAuthDesc => "认证方式。自动会依次尝试所有适用的方式。", L10nKey::SettingsAuthModeAuto => "自动", @@ -248,6 +245,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAuthModeKey => "密钥", L10nKey::SettingsAuthModeAgent => "ssh-agent", L10nKey::SettingsAuthMode2Fa => "2FA", + L10nKey::SettingsPassword => "密码", + L10nKey::SettingsNameHint => "可不填", + L10nKey::SettingsHostHint => "主机名或 IP", + L10nKey::SettingsUserHint => "连接时再定", + L10nKey::SettingsPasswordDesc => "存在系统钥匙串里,不会写进配置文件。", + L10nKey::SettingsPasswordHint => "连接时再问", + L10nKey::SettingsForget => "清除", + L10nKey::SettingsKeyPassphrase => "密钥口令", + L10nKey::SettingsKeyPassphraseDesc => "用来解锁上面那个密钥,存在系统钥匙串里。", + L10nKey::SettingsPassphraseNeedsKey => "先填一个密钥文件——口令是跟着它解锁的那个密钥存的。", + L10nKey::SettingsBrowseKey => "浏览…", + L10nKey::SettingsCouldntSavePassword => "无法保存 {endpoint} 的密码:{error}", + L10nKey::SettingsCouldntSavePassphrase => "无法保存 {key} 的口令:{error}", L10nKey::SettingsJumpHost => "跳板主机", L10nKey::SettingsJumpHostDesc => "用于中转的另一个主机配置的名称(留空 = 直连)。", L10nKey::SettingsJumpHostUnknown => "没有名为 {jump_name} 的主机配置——不会被保存。", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5c2cb38c..b821685f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -26,7 +26,9 @@ use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; -use crate::core::keychain::CredentialRef; +use crate::core::keychain::{ + CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, +}; use crate::core::ssh_profile::{ Algorithms, AuthMode, ForwardKind, ForwardRule, HostPort, SshProfile, to_connect_string, }; @@ -228,6 +230,19 @@ fn ui_scale(cx: &App) -> f32 { /// without being truncated. const FIELD_W: f32 = 260.; +/// The host editor's own two numbers: the column its labels stand in, and how +/// wide a field beside one grows to. The label column fits the longest field +/// name in any locale at the default interface font; the field is wider than +/// [`FIELD_W`] because the form is what a path, a key file and a hostname are +/// actually typed into. +const SSH_LABEL_W: f32 = 104.; +const FORM_FIELD_W: f32 = 320.; + +/// Width a host-editor row needs before its label column and its field fit +/// side by side. Under it the label goes above the field instead, which is +/// what the narrowest window leaves the SSH page room for. +const STACK_FIELD_ROW_BELOW: f32 = 420.; + /// Width a settings row needs before its label and its control fit side by /// side: a [`FIELD_W`] control, the `gap_8` between them, and enough left for a /// description to read as prose rather than as a column of words. @@ -981,6 +996,21 @@ pub(crate) struct SshProfileForm { auth: AuthMode, auth_select: Entity>>, + /// The secret half of a connection. Neither of these is part of the + /// profile — they live in the system keychain, and the config file holds + /// no copy — so the form carries what the keychain had when it opened and + /// compares against it on the way out. A form that was only read writes + /// nothing back, and one that cleared a field says so. + password: Entity, + passphrase: Entity, + loaded_password: String, + loaded_passphrase: String, + /// The endpoint the password above was read for, and the key file the + /// passphrase belongs to. An edit to the address moves the entry, and + /// without these there is nothing left pointing at the one to remove. + loaded_endpoint: (String, String, u16), + loaded_key: Option, + jump: Entity, forwards: Vec, @@ -1026,10 +1056,72 @@ impl SshProfileForm { /// needs a host before anyone had the chance to type one. Same deal the /// forward rows strike with `ForwardRuleForm::is_blank`. fn core_is_blank(&self, cx: &App) -> bool { - [&self.name, &self.host, &self.port, &self.user] + // The port is not in the list: it opens on 22 and is never empty, so + // counting it meant a brand-new host was never "untouched" and the + // form opened with "Needs a host" already in red under an empty box + // nobody had reached yet. + [&self.name, &self.host, &self.user] .iter() .all(|e| e.read(cx).value().trim().is_empty()) } + + /// Whether either secret differs from what the keychain handed over. + /// + /// Nothing about a password reaches the profile, so the dirty check that + /// compares profiles cannot see one being typed — without this, Save stays + /// greyed out over a password the user just entered. + /// + /// Untrimmed on purpose: a trailing space is a character of the secret, + /// and the server is the one that decides whether it belongs. + fn secrets_changed(&self, cx: &App) -> bool { + self.password.read(cx).value().as_ref() != self.loaded_password + || self.passphrase.read(cx).value().as_ref() != self.loaded_passphrase + } + + fn wants_password(&self) -> bool { + auth_uses_password(self.auth) + } + + fn wants_key(&self) -> bool { + auth_uses_key(self.auth) + } +} + +/// Which credential fields a method actually uses — the same split +/// `ssh_connect::build_spec_inner` makes when it decides what to hand the +/// daemon. A password box under "Agent" would be a secret that is stored and +/// then never offered, and a form holding one quietly is worse than a form +/// that has none. +fn auth_uses_password(mode: AuthMode) -> bool { + matches!(mode, AuthMode::Auto | AuthMode::Password) +} + +fn auth_uses_key(mode: AuthMode) -> bool { + matches!(mode, AuthMode::Auto | AuthMode::PublicKey) +} + +/// What saving does to the keychain for the password field: which entry to +/// drop, and whether to write one. +/// +/// A plain function because these are the cases a keychain makes expensive to +/// reach by hand — an address edited out from under a saved password, a field +/// cleared to mean "stop remembering this", a form opened and closed without a +/// keystroke. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct PasswordPlan { + drop_old: bool, + store: bool, +} + +fn password_plan(was: &str, typed: &str, moved: bool) -> PasswordPlan { + PasswordPlan { + // Only what this form read is ours to drop, and only once it is no + // longer the entry this form would write to. + drop_old: !was.is_empty() && (moved || typed.is_empty()), + // A move rewrites even an unchanged secret: the account it is filed + // under is the address, and the address is what changed. + store: !typed.is_empty() && (typed != was || moved), + } } pub(crate) struct ForwardRuleForm { @@ -1535,6 +1627,102 @@ fn seed_input( }) } +fn seed_hinted_multi( + window: &mut Window, + cx: &mut Context, + value: &str, + placeholder: &'static str, +) -> Entity { + let value = value.to_string(); + cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .placeholder(placeholder) + .default_value(value) + }) +} + +/// A picked path written the way a config file spells it. `~/.ssh/id_ed25519` +/// keeps meaning the right file on another machine, or after the account is +/// renamed; the absolute path the system picker hands back does not. +fn tildify(path: &str) -> String { + #[cfg(windows)] + let home = std::env::var("USERPROFILE").ok(); + #[cfg(not(windows))] + let home = std::env::var("HOME").ok(); + tildify_with(path, home.as_deref().filter(|h| !h.is_empty())) +} + +fn tildify_with(path: &str, home: Option<&str>) -> String { + let Some(home) = home else { + return path.to_string(); + }; + let home = home.trim_end_matches(['/', '\\']); + match path.strip_prefix(home) { + // A separator has to follow, or `/Users/adalovelace` would come back + // as a file inside `/Users/ada`. + Some(rest) if rest.starts_with('/') || rest.starts_with('\\') => format!( + "~/{}", + rest.trim_start_matches(['/', '\\']).replace('\\', "/") + ), + _ => path.to_string(), + } +} + +/// What the key field shows while it is empty: the file ssh would reach for on +/// its own. A hint, not a value — an empty field still means "try the usual +/// `~/.ssh` keys", which is exactly what `default_identity_candidates` does. +const DEFAULT_KEY_HINT: &str = "~/.ssh/id_ed25519"; + +/// The password the keychain holds for this profile's endpoint, or nothing. +/// +/// Read once, when a host is opened for editing — not per render, and not per +/// keystroke. A profile with no host yet has no endpoint to ask about: the +/// account would come out as `@:22`, which belongs to no server. +fn stored_password(profile: &SshProfile) -> String { + if profile.host.trim().is_empty() { + return String::new(); + } + OsCredentialStore + .password_for(&profile.user, &profile.host, profile.port) + .ok() + .flatten() + .unwrap_or_default() +} + +/// The first key file a profile names that is actually there. +/// +/// A passphrase is accounted by the key's *contents*, not by its path, so a +/// file that cannot be read is a key nothing can be stored against. +fn first_readable_key(profile: &SshProfile) -> Option { + profile + .expanded_identity_files() + .into_iter() + .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) +} + +/// The same answer for a form that has not been collected into a profile yet: +/// the key field as typed, with the host and user beside it filling in `%h` +/// and `%r`. +fn first_readable_key_in(files: &[String], host: &str, user: &str) -> Option { + files + .iter() + .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) + .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) +} + +fn stored_passphrase(key_path: &str) -> String { + let path = crate::core::ssh_profile::expand_tilde(key_path); + let Ok(bytes) = std::fs::read(&path) else { + return String::new(); + }; + OsCredentialStore + .passphrase_for_key(&key_account_from_contents(&bytes)) + .ok() + .flatten() + .unwrap_or_default() +} + impl Tty7App { pub(crate) fn render_settings( &self, @@ -3551,17 +3739,45 @@ impl Tty7App { }) .unwrap_or_default(); - let name = seed_input(window, cx, &profile.name, false); - let host = seed_input(window, cx, &profile.host, false); + let name = seed_hinted(window, cx, &profile.name, t(L10nKey::SettingsNameHint)); + let host = seed_hinted(window, cx, &profile.host, t(L10nKey::SettingsHostHint)); let port = seed_input(window, cx, &profile.port.to_string(), false); - let user = seed_input(window, cx, &profile.user, false); + let user = seed_hinted(window, cx, &profile.user, t(L10nKey::SettingsUserHint)); let jump = seed_input(window, cx, &jump_name, false); let forwards: Vec = profile .forwards .iter() .map(|r| seed_forward_row(window, cx, r)) .collect(); - let identity_files = seed_input(window, cx, &profile.identity_files.join("\n"), true); + let identity_files = seed_hinted_multi( + window, + cx, + &profile.identity_files.join("\n"), + DEFAULT_KEY_HINT, + ); + + // One keychain read per host opened, not one per keystroke: the + // password box shows what is actually stored, the way every other SSH + // client shows it, so it can be read back, corrected or cleared + // without connecting first. + let loaded_password = stored_password(profile); + let loaded_key = first_readable_key(profile); + let loaded_passphrase = loaded_key + .as_deref() + .map(stored_passphrase) + .unwrap_or_default(); + let password = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(t(L10nKey::SettingsPasswordHint)) + .default_value(loaded_password.clone()) + }); + let passphrase = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(t(L10nKey::SettingsPasswordHint)) + .default_value(loaded_passphrase.clone()) + }); let proxy_command = seed_input( window, cx, @@ -3638,12 +3854,27 @@ impl Tty7App { } }, )); + // The passphrase belongs to whichever key the field above names, so + // when that answer changes the box has to change with it. Without this + // a form opened on one key and pointed at another would carry the + // first key's passphrase across and save it over the second's. + subs.push(cx.subscribe_in( + &identity_files, + window, + |this, _i, ev: &InputEvent, window, cx| { + if matches!(ev, InputEvent::Change) { + this.resync_key_passphrase(window, cx); + } + }, + )); let mut watch = vec![ &name, &host, &port, &user, &jump, + &password, + &passphrase, &identity_files, &proxy_command, &socks, @@ -3691,6 +3922,12 @@ impl Tty7App { user, auth: profile.auth, auth_select, + password, + passphrase, + loaded_password, + loaded_passphrase, + loaded_endpoint: (profile.user.clone(), profile.host.clone(), profile.port), + loaded_key, jump, forwards, identity_files, @@ -3772,7 +4009,76 @@ impl Tty7App { )) } - pub(crate) fn save_editing_profile(&mut self, cx: &mut Context) -> Option { + /// Point the passphrase box at the key the form now names. + /// + /// A passphrase is stored against the contents of the key it unlocks, so + /// the box is only ever right about one key at a time. A box the user has + /// started typing in is left alone — it is the one place where what is on + /// screen outranks what the keychain holds. + fn resync_key_passphrase(&mut self, window: &mut Window, cx: &mut Context) { + let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let files = split_lines(&form.identity_files.read(cx).value()); + let host = form.host.read(cx).value().trim().to_string(); + let user = form.user.read(cx).value().trim().to_string(); + let key = first_readable_key_in(&files, &host, &user); + if key == form.loaded_key { + return; + } + let stored = key.as_deref().map(stored_passphrase).unwrap_or_default(); + // A box the user has already typed in keeps what they typed — only + // the key it will be saved against moves under it. + let untouched = form.passphrase.read(cx).value().as_ref() == form.loaded_passphrase; + let input = form.passphrase.clone(); + if let Some(form) = self.ssh_form_mut() { + form.loaded_key = key; + form.loaded_passphrase = stored.clone(); + } + if untouched { + input.update(cx, |i, cx| i.set_value(stored, window, cx)); + } + cx.notify(); + } + + /// Add key files to the profile from the system file picker, one per line + /// alongside whatever is already named. Typing the path still works — this + /// is for the far more common case of knowing the key by sight and not by + /// path. + pub(crate) fn pick_ssh_identity_file(&mut self, window: &mut Window, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: None, + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = rx.await else { + return; + }; + let _ = this.update_in(cx, |this, window, cx| { + let Some(form) = this.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let input = form.identity_files.clone(); + let mut lines = split_lines(&input.read(cx).value()); + for path in paths { + let path = tildify(&path.to_string_lossy()); + if !lines.contains(&path) { + lines.push(path); + } + } + input.update(cx, |i, cx| i.set_value(lines.join("\n"), window, cx)); + }); + }) + .detach(); + } + + pub(crate) fn save_editing_profile( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let (profile, errors) = self.ssh_form_collect(cx)?; // Save and Connect are both disabled while anything is wrong, but this // is the door all of them go through, and what gets past it lands in @@ -3784,16 +4090,156 @@ impl Tty7App { let id = profile.id; self.update_config(cx, |cfg| { if let Some(slot) = cfg.ssh_profiles.iter_mut().find(|p| p.id == id) { - *slot = profile; + *slot = profile.clone(); } else { - cfg.ssh_profiles.push(profile); + cfg.ssh_profiles.push(profile.clone()); } }); + self.save_ssh_form_secrets(&profile, window, cx); Some(id) } - pub(crate) fn save_ssh_form(&mut self, cx: &mut Context) { - self.save_editing_profile(cx); + /// Move the two secrets in the form into the keychain — or out of it. + /// + /// The config file never holds either of them, so this is the whole of + /// what saving means for a password: an entry keyed by the endpoint the + /// profile now names. Editing the address moves the entry rather than + /// leaving the old one behind to be offered to a host that no longer + /// exists, and clearing the field removes it. + fn save_ssh_form_secrets( + &mut self, + profile: &SshProfile, + window: &mut Window, + cx: &mut Context, + ) { + let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let typed = form.password.read(cx).value().to_string(); + let typed_passphrase = form.passphrase.read(cx).value().to_string(); + let (old_user, old_host, old_port) = form.loaded_endpoint.clone(); + let was = form.loaded_password.clone(); + let was_passphrase = form.loaded_passphrase.clone(); + let moved = (old_user.as_str(), old_host.as_str(), old_port) + != (profile.user.as_str(), profile.host.as_str(), profile.port); + + let mut failures: Vec = Vec::new(); + let endpoint = |u: &str, h: &str, p: u16| format!("{u}@{h}:{p}"); + let plan = password_plan(&was, &typed, moved); + + // The entry the form read from, once it is no longer the entry the + // form would write to. Left alone when another profile still dials the + // same address — the keychain accounts by endpoint, not by profile. + let stranded = plan.drop_old && !old_host.trim().is_empty(); + if stranded + && !self.endpoint_still_in_use(&old_user, &old_host, old_port, profile.id, cx) + && let Err(e) = OsCredentialStore.delete_password(&old_user, &old_host, old_port) + { + failures.push(t_fmt( + L10nKey::SettingsCouldntForgetPassword, + &[ + ("endpoint", &endpoint(&old_user, &old_host, old_port)), + ("error", &e.to_string()), + ], + )); + } + if plan.store + && !profile.host.trim().is_empty() + && let Err(e) = + OsCredentialStore.set_password(&profile.user, &profile.host, profile.port, &typed) + { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassword, + &[ + ( + "endpoint", + &endpoint(&profile.user, &profile.host, profile.port), + ), + ("error", &e.to_string()), + ], + )); + } + + // A passphrase belongs to the key it unlocks, not to this profile, so + // a save only ever touches the entry for the key named here. Pointing + // the profile at a different key leaves the first key's passphrase + // alone — other hosts use that key too. + let key = first_readable_key(profile); + if typed_passphrase != was_passphrase { + match key.as_deref() { + Some(path) => self.write_key_passphrase(path, &typed_passphrase, &mut failures), + // Nowhere to put it: the field names no key, or names one that + // is not on this machine. Storing nothing quietly would lose a + // passphrase the user watched themselves type. + None if !typed_passphrase.is_empty() => { + failures.push(t(L10nKey::SettingsPassphraseNeedsKey).to_string()) + } + None => {} + } + } + + for line in failures { + window.push_notification(line, cx); + } + + // What the form would now read back, so a save leaves it clean. + if let Some(form) = self.ssh_form_mut() { + form.loaded_password = typed; + form.loaded_passphrase = typed_passphrase; + form.loaded_endpoint = (profile.user.clone(), profile.host.clone(), profile.port); + form.loaded_key = key; + } + } + + /// A blank secret deletes rather than stores: an empty string is not a + /// passphrase, and leaving one behind would keep offering it. + fn write_key_passphrase(&self, key_path: &str, secret: &str, failures: &mut Vec) { + let path = crate::core::ssh_profile::expand_tilde(key_path); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassphrase, + &[("key", key_path), ("error", &e.to_string())], + )); + return; + } + }; + let account = key_account_from_contents(&bytes); + let result = if secret.is_empty() { + OsCredentialStore.delete_key_passphrase(&account) + } else { + OsCredentialStore + .set_key_passphrase(&account, secret) + .map(|_| ()) + }; + if let Err(e) = result { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassphrase, + &[("key", key_path), ("error", &e.to_string())], + )); + } + } + + /// Whether some other saved host still dials this endpoint. The keychain + /// entry is the address's, not the profile's — the same reason "Forget + /// password" counts the hosts it would sign out. + fn endpoint_still_in_use( + &self, + user: &str, + host: &str, + port: u16, + except: Uuid, + cx: &App, + ) -> bool { + cx.global::() + .ssh_profiles + .iter() + .any(|p| p.id != except && p.user == user && p.host == host && p.port == port) + } + + pub(crate) fn save_ssh_form(&mut self, window: &mut Window, cx: &mut Context) { + self.save_editing_profile(window, cx); cx.notify(); } @@ -3812,7 +4258,7 @@ impl Tty7App { .iter() .find(|p| p.id == form.editing) .cloned(); - self.ssh_form_collect(cx).map(|(profile, _)| profile) != saved + self.ssh_form_collect(cx).map(|(profile, _)| profile) != saved || form.secrets_changed(cx) } /// Closing from Escape or the X is the user leaving; every other caller @@ -3847,7 +4293,26 @@ impl Tty7App { if !errors.is_empty() { return; } - let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + let mut spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + // The spec is built from the keychain, so without this Test would dial + // with the *saved* password while a new one sits typed on screen — + // and report a failure the form could not explain. + if let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) { + if form.wants_password() { + let typed = form.password.read(cx).value().to_string(); + if !typed.is_empty() { + spec.password = Some(typed); + } + } + if form.wants_key() { + let typed = form.passphrase.read(cx).value().to_string(); + if let (false, Some(key)) = (typed.is_empty(), first_readable_key(&profile)) { + spec.key_passphrases + .get_or_insert_with(Default::default) + .insert(key, typed); + } + } + } let editing = profile.id; if let Some(form) = self.ssh_form_mut() { form.test = Some(SshTestState::Running); @@ -3873,7 +4338,7 @@ impl Tty7App { } pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(id) = self.save_editing_profile(cx) { + if let Some(id) = self.save_editing_profile(window, cx) { self.close_settings(window, cx); self.connect_ssh_profile(id, window, cx); } @@ -4226,7 +4691,10 @@ impl Tty7App { .cloned(); let (collected, errors) = self.ssh_form_collect(cx).unzip(); let errors = errors.unwrap_or_default(); - let dirty = collected != saved; + // A password is not part of the profile, so a change to one is + // invisible to the comparison above — and Save would sit greyed out + // over a secret the user just typed. + let dirty = collected != saved || form.secrets_changed(cx); let address = collected .as_ref() .map(to_connect_string) @@ -4319,7 +4787,9 @@ impl Tty7App { .label(t(L10nKey::Save)) .small() .disabled(!dirty || !errors.is_empty()) - .on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))), + .on_click( + cx.listener(|this, _, window, cx| this.save_ssh_form(window, cx)), + ), ) .child( // Connect saves first, so it answers to the same @@ -4350,78 +4820,40 @@ impl Tty7App { .map(|e| field_error(e.message(), cx)); let port_error = errors.port.as_ref().map(|e| field_error(e.message(), cx)); + // Three fields whose labels say everything a sentence under them + // would: what goes in them is shown in the box itself, as a hint that + // gets out of the way the moment anything is typed. let core = v_flex() - .gap_3() + .gap_1() + .child(self.ssh_field_row( + t(L10nKey::SettingsName), + Input::new(&form.name).small().w_full().into_any_element(), + vec![], + cx, + )) .child( - self.settings_row( - t(L10nKey::SettingsName), - t(L10nKey::SettingsNameDesc), - div() - .w(px(FIELD_W)) - .max_w_full() - .child(Input::new(&form.name).small()) - .into_any_element(), - cx, - ), - ) - .child( - self.settings_row( + self.ssh_field_row( t(L10nKey::SettingsHost), - t(L10nKey::SettingsHostDesc), - v_flex() - .gap_1() - .max_w_full() - .child( - h_flex() - .gap_2() - .max_w_full() - .child( - div() - .w(px(172.)) - .min_w_0() - .child(Input::new(&form.host).small()), - ) - .child( - div() - .w(px(80.)) - .flex_shrink_0() - .child(Input::new(&form.port).small()), - ), - ) - .when_some(host_error, |col, line| col.child(line)) - .when_some(port_error, |col, line| col.child(line)) + h_flex() + .w_full() + .gap_2() + .child(Input::new(&form.host).small().flex_1().min_w_0()) + .child(Input::new(&form.port).small().w(px(64. * ui_scale(cx)))) .into_any_element(), + host_error + .into_iter() + .chain(port_error) + .map(IntoElement::into_any_element) + .collect(), cx, ), ) - .child( - self.settings_row( - t(L10nKey::SettingsUser), - t(L10nKey::SettingsUserDesc), - div() - .w(px(FIELD_W)) - .max_w_full() - .child(Input::new(&form.user).small()) - .into_any_element(), - cx, - ), - ) - .child( - self.settings_row( - t(L10nKey::SettingsAuth), - t(L10nKey::SettingsAuthDesc), - // Six methods is more than a segmented control can label without - // squeezing, and this row is the one that stacks first on a - // narrow page. A dropdown carries the same choice at a fixed - // width, the way the other long-form pickers on this page do. - Select::new(&form.auth_select) - .small() - .w(px(FIELD_W)) - .max_w_full() - .into_any_element(), - cx, - ), - ); + .child(self.ssh_field_row( + t(L10nKey::SettingsUser), + Input::new(&form.user).small().w_full().into_any_element(), + vec![], + cx, + )); v_flex() .gap_4() @@ -4432,12 +4864,169 @@ impl Tty7App { col.child(h_flex().w_full().justify_end().child(line)) }) .child(core) + .child(self.render_ssh_profile_auth_section(form, cx)) .child(self.render_ssh_profile_jump_section(form, &errors, cx)) .child(self.render_ssh_profile_forwards_section(form, cx)) .child(self.render_ssh_profile_advanced_section(form, &errors, cx)) .into_any_element() } + /// One field of the host editor: its label in a narrow column on the left, + /// the field immediately beside it, and whatever the field has to say — + /// its description, or a complaint about what is in it — underneath the + /// field rather than underneath the label. + /// + /// The settings rows on the rest of this page push their control to the + /// far right edge of the page, which is right for a list of independent + /// switches and wrong for a form: it left a hand's width of nothing + /// between the word "Host" and the box a hostname goes in, and the eye had + /// to cross it once per field. Every SSH client worth borrowing from keeps + /// the two together. + fn ssh_field_row( + &self, + label: &str, + control: AnyElement, + under: Vec, + cx: &Context, + ) -> AnyElement { + let stacked = self.settings_row_under(STACK_FIELD_ROW_BELOW, cx); + let scale = ui_scale(cx); + div() + .flex() + .w_full() + .py_1p5() + .when(stacked, |row| row.flex_col().items_start().gap_1()) + .when(!stacked, |row| row.flex_row().items_start().gap_3()) + .child( + div() + .when(!stacked, |l| { + // Right up against the field, and level with the text + // inside it rather than with the top of its border — + // a left-aligned column of short words would leave a + // different-sized hole after every label. + l.w(px(SSH_LABEL_W * scale)) + .flex_shrink_0() + .pt(px(6.)) + .text_right() + }) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(label.to_string()), + ) + .child( + // A definite width, not `flex_1`: a percentage inside a + // flex-grown box has no definite parent to resolve against, + // and every `w_full` control in here came out at its intrinsic + // size — a hostname field one character wide. + v_flex() + .w(px(FORM_FIELD_W * scale)) + .max_w_full() + .gap_1() + .child(control) + .children(under), + ) + .into_any_element() + } + + /// The credential half of the form, and the only part of it that is not + /// stored in the config file. + /// + /// Which boxes appear follows the method, the way every SSH client does + /// it: a password box under a key-only method would be a secret that is + /// stored and never offered. The split is the one `build_spec_inner` + /// makes when it decides what to hand the daemon. + fn render_ssh_profile_auth_section( + &self, + form: &SshProfileForm, + cx: &mut Context, + ) -> AnyElement { + // Whether there is a key to *store a passphrase against* — which is a + // readable file, not merely a path someone typed. `loaded_key` is that + // answer, kept current by `resync_key_passphrase`. + let has_key = form.loaded_key.is_some(); + v_flex() + .gap_1() + .child(self.subgroup_header(L10nKey::SettingsGroupAuthentication, cx)) + .child( + self.ssh_field_row( + t(L10nKey::SettingsAuth), + // Six methods is more than a segmented control can label + // without squeezing, so a dropdown carries the choice — the + // way the other long-form pickers on this page do. + Select::new(&form.auth_select) + .small() + .w_full() + .into_any_element(), + vec![field_note(t(L10nKey::SettingsAuthDesc), cx).into_any_element()], + cx, + ), + ) + .when(form.wants_password(), |col| { + col.child( + self.ssh_field_row( + t(L10nKey::SettingsPassword), + Input::new(&form.password) + .small() + .mask_toggle() + .w_full() + .into_any_element(), + vec![field_note(t(L10nKey::SettingsPasswordDesc), cx).into_any_element()], + cx, + ), + ) + }) + .when(form.wants_key(), |col| { + col.child( + self.ssh_field_row( + t(L10nKey::SettingsIdentityFiles), + h_flex() + .w_full() + .items_start() + .gap_2() + .child(Input::new(&form.identity_files).small().flex_1().min_w_0()) + .child( + Button::new("ssh-form-browse-key") + .label(t(L10nKey::SettingsBrowseKey)) + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.pick_ssh_identity_file(window, cx) + })), + ) + .into_any_element(), + vec![ + field_note(t(L10nKey::SettingsIdentityFilesDesc), cx) + .into_any_element(), + ], + cx, + ), + ) + .child( + self.ssh_field_row( + t(L10nKey::SettingsKeyPassphrase), + Input::new(&form.passphrase) + .small() + .mask_toggle() + .disabled(!has_key) + .w_full() + .into_any_element(), + vec![ + field_note( + match has_key { + true => t(L10nKey::SettingsKeyPassphraseDesc), + false => t(L10nKey::SettingsPassphraseNeedsKey), + }, + cx, + ) + .into_any_element(), + ], + cx, + ), + ) + }) + .into_any_element() + } + fn disclosure_header( &self, id: &'static str, @@ -4851,14 +5440,12 @@ impl Tty7App { }; section = section + // The key file and the two secrets moved up to the Authentication + // block on the form itself — they are what a connection is made + // of, not a corner of it. What is left here is the option that + // hands this host the local agent, which is a decision about + // trust rather than about how to log in. .child(self.subgroup_header(L10nKey::SettingsGroupAuthentication, cx)) - .child(text_row( - self, - t(L10nKey::SettingsIdentityFiles), - t(L10nKey::SettingsIdentityFilesDesc), - &form.identity_files, - cx, - )) .child( self.settings_row( t(L10nKey::SettingsAgentForwarding), @@ -7411,6 +7998,144 @@ mod tests { ); } + /// The credential boxes the form shows have to be the ones the connection + /// will actually offer. `build_spec_inner` sends a password for Auto and + /// Password and key passphrases for Auto and Key, and nothing for the + /// rest — a box outside that split collects a secret, stores it in the + /// keychain, and never hands it to anybody. + #[test] + fn a_credential_box_only_appears_where_the_handshake_would_use_it() { + for mode in AUTH_MODES { + assert_eq!( + auth_uses_password(mode), + matches!(mode, AuthMode::Auto | AuthMode::Password), + "{mode:?} password box" + ); + assert_eq!( + auth_uses_key(mode), + matches!(mode, AuthMode::Auto | AuthMode::PublicKey), + "{mode:?} key boxes" + ); + } + assert!(!auth_uses_password(AuthMode::Agent)); + assert!(!auth_uses_key(AuthMode::Gssapi)); + } + + /// The password lives in the keychain under the address, not in the + /// profile — so saving has to decide two things the config file cannot + /// record: whether the entry the form read is now stranded, and whether + /// there is anything new to write. + #[test] + fn saving_moves_a_password_with_the_address_it_belongs_to() { + let plan = |was, typed, moved| password_plan(was, typed, moved); + + // A form nobody typed in writes nothing at all. + assert_eq!( + plan("hunter2", "hunter2", false), + PasswordPlan { + drop_old: false, + store: false + } + ); + // A new secret replaces the old one in place. + assert_eq!( + plan("hunter2", "correct horse", false), + PasswordPlan { + drop_old: false, + store: true + } + ); + // Clearing the box is how a saved password is let go of. + assert_eq!( + plan("hunter2", "", false), + PasswordPlan { + drop_old: true, + store: false + } + ); + // Retargeting the host carries the secret across and leaves nothing + // behind under the old address — even when the secret itself is + // untouched, because the account it is filed under is the address. + assert_eq!( + plan("hunter2", "hunter2", true), + PasswordPlan { + drop_old: true, + store: true + } + ); + // A host that never had one, and still does not. + assert_eq!( + plan("", "", true), + PasswordPlan { + drop_old: false, + store: false + } + ); + // The first password a host is given. + assert_eq!( + plan("", "hunter2", false), + PasswordPlan { + drop_old: false, + store: true + } + ); + } + + /// A key picked from the system dialog arrives as an absolute path under + /// the home directory. Written back that way it names the right file on + /// this machine and the wrong one everywhere else — and `~` is how the + /// rest of the field, and `~/.ssh/config` itself, spells it. + #[test] + fn a_picked_key_is_written_the_way_the_config_spells_it() { + let home = Some("/Users/ada"); + assert_eq!( + tildify_with("/Users/ada/.ssh/id_ed25519", home), + "~/.ssh/id_ed25519" + ); + // Outside the home directory there is nothing to shorten. + assert_eq!(tildify_with("/etc/ssh/key", home), "/etc/ssh/key"); + // And a sibling that merely starts with the same letters is not + // inside it. + assert_eq!( + tildify_with("/Users/adalovelace/key", home), + "/Users/adalovelace/key" + ); + // A trailing separator on the home directory changes nothing. + assert_eq!( + tildify_with("/Users/ada/.ssh/id_rsa", Some("/Users/ada/")), + "~/.ssh/id_rsa" + ); + // Nowhere to anchor against leaves the path as it came. + assert_eq!( + tildify_with("/Users/ada/.ssh/id_rsa", None), + "/Users/ada/.ssh/id_rsa" + ); + } + + /// The passphrase box is about one key at a time: the first one named that + /// is actually on disk, with `%h` and `%r` filled in the way the daemon + /// will fill them. A path that is not there can hold no passphrase. + #[test] + fn the_passphrase_follows_the_first_key_that_is_really_there() { + let dir = std::env::temp_dir().join(format!("tty7-keyform-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let real = dir.join("id_example.com"); + std::fs::write(&real, b"key").unwrap(); + let missing = dir.join("absent").to_string_lossy().to_string(); + let pattern = dir.join("id_%h").to_string_lossy().to_string(); + + assert_eq!( + first_readable_key_in(&[missing.clone(), pattern], "example.com", "ada"), + Some(real.to_string_lossy().to_string()) + ); + assert_eq!( + first_readable_key_in(&[missing], "example.com", "ada"), + None + ); + assert_eq!(first_readable_key_in(&[], "example.com", "ada"), None); + std::fs::remove_dir_all(&dir).ok(); + } + /// The dropdown resolves a pick by its row index, so the row a mode opens /// on and the mode that row saves have to be the same one. A list that /// drifted out of step would quietly save the wrong method. @@ -8219,6 +8944,49 @@ mod gpui_tests { (app, vcx) } + /// The password is the one field on the host editor that never reaches the + /// profile — it goes to the system keychain — so the dirty check that + /// compares profiles is blind to it. Without the secret folded in, Save + /// stays greyed out over a password the user has just typed, and the only + /// way to store one is to connect and wait to be asked. + #[gpui::test] + fn a_typed_password_is_something_the_form_has_to_save(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + app.update_in(&mut vcx, |app, window, cx| { + app.open_settings_section(SettingsSection::Ssh, window, cx); + // A saved host that names no address, so opening its editor asks + // the keychain nothing and starts out with nothing to save. + let profile = crate::core::ssh_profile::SshProfile::new("blank"); + app.update_config(cx, |cfg| cfg.ssh_profiles.push(profile.clone())); + app.ssh_form_load(&profile, window, cx); + }); + vcx.simulate_resize(size(px(1100.), px(800.))); + vcx.run_until_parked(); + + assert!( + !vcx.update(|_, cx| app.read(cx).ssh_form_dirty(cx)), + "a form nobody has typed in has nothing to save" + ); + + let password = vcx.update(|_, cx| { + app.read(cx) + .active_settings() + .and_then(|s| s.ssh_form.as_ref()) + .map(|f| f.password.clone()) + .expect("the host editor is open") + }); + app.update_in(&mut vcx, |_app, window, cx| { + password.update(cx, |input, cx| input.set_value("hunter2", window, cx)); + }); + vcx.run_until_parked(); + + assert!( + vcx.update(|_, cx| app.read(cx).ssh_form_dirty(cx)), + "a password typed into the form is an unsaved change" + ); + } + #[gpui::test] fn appearance_section_lays_out_with_its_rounded_controls(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); From 1dd0a0a083d584a5dbcd5805f09690f0bfb81775 Mon Sep 17 00:00:00 2001 From: Austin Spraggins Date: Sat, 12 Sep 2026 14:04:00 -0700 Subject: [PATCH 14/46] fix(terminal): stop injecting Ctrl-U after session exit --- src/terminal/typeahead.rs | 81 +++++++++++++++++++++++++++++++------- src/terminal/view.rs | 82 ++++++++++++++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 21f097c2..52b9887f 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -90,17 +90,15 @@ impl Typeahead { } fn record_enter(&mut self) { - if self.text.len() + 1 > RECORD_CAP { - self.tainted = true; - return; - } - self.text.push('\r'); + // Enter has already gone to the foreground reader. That line is no + // longer pending shell input: keeping even an empty seed would send + // Ctrl-U into the next prompt after `exit` returns from SSH or a TUI. + // Only text typed after this boundary can belong to the next prompt. + self.discard(); } fn record_backspace(&mut self) { - if !self.text.ends_with('\r') { - self.text.pop(); - } + self.text.pop(); } fn taint(&mut self) { @@ -117,8 +115,7 @@ impl Typeahead { if self.tainted { return Some(String::new()); } - let seed = self.text.rsplit('\r').next().unwrap_or(""); - Some(seed.to_string()) + Some(self.text) } } @@ -322,11 +319,11 @@ mod tests { } #[test] - fn fully_submitted_input_wipes_but_seeds_nothing() { + fn fully_submitted_input_owes_no_wipe() { let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -335,7 +332,7 @@ mod tests { p.record_text("ls"); p.record_enter(); p.record_backspace(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -381,7 +378,63 @@ mod tests { let mut p = Typeahead::new(); p.taint(); p.record_text("ls"); - p.record_enter(); assert_eq!(p.drain(), Some(String::new())); } + + #[test] + fn submitting_a_recalled_exit_discards_taint_and_paste_provenance() { + let mut t = Typeahead::new(); + t.observe(RawInput::Pasted("old command"), false); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + assert!(!t.pasted(), "the submitted line's paste mark is spent"); + assert_eq!( + t.adopt(), + None, + "returning to the prompt must not owe Ctrl-U" + ); + assert_eq!(t.drain(), None); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.drain(), Some("git status".to_string())); + } + + #[test] + fn a_submitted_exit_does_not_taint_the_next_prompts_typeahead() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("x".repeat(RECORD_CAP).as_str()), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.adopt(), Some("git status".to_string())); + assert_eq!( + t.drain(), + Some(String::new()), + "only the unsubmitted text needs a wipe" + ); + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 56555d5d..753c1d81 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2291,9 +2291,9 @@ impl TerminalView { if !held { self.release_hold(); if !shell_owns_prompt && interrupt { - // Ctrl-C cancels the foreground input transaction. Clear - // the gap before delivering it so a prompt transition - // cannot flush this interrupt as a later Ctrl-U. + // Ctrl-C interrupts and Ctrl-D can close the foreground reader. + // Discard the gap before sending either so a prompt transition + // cannot turn the pending record into a later Ctrl-U. self.observe_typeahead(RawInput::Interrupt); } self.terminal.write(bytes); @@ -6729,7 +6729,7 @@ impl TerminalView { } fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { - modifiers.control && !modifiers.alt && !modifiers.platform && key == "c" + modifiers.control && !modifiers.alt && !modifiers.platform && matches!(key, "c" | "d") } fn sync_typeahead_owner_state( @@ -8045,13 +8045,14 @@ mod tests { } #[test] - fn only_plain_ctrl_c_is_a_typeahead_interrupt() { + fn ctrl_c_and_ctrl_d_discard_foreground_typeahead() { let ctrl = Modifiers { control: true, ..Default::default() }; assert!(is_typeahead_interrupt("c", &ctrl)); - assert!(!is_typeahead_interrupt("d", &ctrl)); + assert!(is_typeahead_interrupt("d", &ctrl)); + assert!(!is_typeahead_interrupt("u", &ctrl)); let ctrl_alt = Modifiers { control: true, @@ -8059,6 +8060,7 @@ mod tests { ..Default::default() }; assert!(!is_typeahead_interrupt("c", &ctrl_alt)); + assert!(!is_typeahead_interrupt("d", &ctrl_alt)); } fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { @@ -11012,6 +11014,19 @@ mod gpui_tests { #[gpui::test] fn passthrough_ctrl_c_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-c", 0x03); + } + + #[gpui::test] + fn passthrough_ctrl_d_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-d", 0x04); + } + + fn assert_foreground_interrupt_does_not_wipe_prompt( + cx: &mut TestAppContext, + chord: &str, + byte: u8, + ) { let (window, mut daemon) = harness(cx); window .update(cx, |view, window, cx| { @@ -11027,23 +11042,70 @@ mod gpui_tests { view.on_key_down( &KeyDownEvent { - keystroke: key("ctrl-c"), + keystroke: key(chord), is_held: false, prefer_character_input: false, }, window, cx, ); - assert_eq!(view.typeahead.drain(), None); + // Exercise the consumers without draining their input first. + view.adopt_typeahead(); view.flush_typeahead(); + assert!(view.cmd.text().is_empty()); }) .unwrap(); - assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x03])); + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![byte])); assert_eq!( next_input_until_timeout(&mut daemon), None, - "resuming the shell must not synthesize Ctrl-U after Ctrl-C" + "resuming the shell must not synthesize Ctrl-U after {chord}" + ); + } + + #[gpui::test] + fn submitted_exit_typeahead_does_not_wipe_the_returned_prompt(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, window, cx| { + // Ordinary SSH need not take the alternate screen or identify + // as an agent. Its input reaches the passthrough recorder. + assert!(!view.input_active()); + for ch in ["e", "x", "i", "t"] { + type_char(view, ch, window, cx); + } + view.on_key_down( + &KeyDownEvent { + keystroke: key("enter"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + for bytes in [b"e", b"x", b"i", b"t", b"\r"] { + assert_eq!(next_input_until_timeout(&mut daemon), Some(bytes.to_vec())); + } + + prompt_ready(&window, cx, &mut daemon); + window + .update(cx, |view, _, _| { + assert!(view.input_active()); + view.adopt_typeahead(); + view.flush_typeahead(); + assert!( + view.cmd.text().is_empty(), + "exit belongs to the finished session" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "returning from exit must not inject Ctrl-U into the local prompt" ); } From a8da26508757bc710dc083a6a216b6bd47f29d7d Mon Sep 17 00:00:00 2001 From: netcatty Date: Sun, 13 Sep 2026 15:47:07 +0800 Subject: [PATCH 15/46] Add Pi UI-prompt events so the pane reports waiting-for-user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Pi / Oh My Pi bridge only subscribes to four lifecycle events, so a pane running Pi never shows "waiting for you" while a dialog is open — `ask_user_question`, permission gates and any other extension prompt all run under the hood with the status dot still reading "working". tty7 already has the vocabulary for this (`question-asked`, `permission-request`), and `AgentEventKind::QuestionAsked` / `PermissionRequest` already map to `AgentStatus::Waiting` in `cli_agent.rs`. The Pi extension seam to feed them is `pi.on("ui_prompt_start")`, which fires around every blocking user-facing prompt with `event.kind` telling select / confirm / input / editor / custom apart. Two handlers, each guarded on its own so an Oh My Pi fork that does not expose the hook loses only that event rather than the whole bridge: - `ui_prompt_start` → `permission-request` for `kind === "confirm"` (a permission or destructive-action gate), `question-asked` otherwise. - `ui_prompt_end` → `prompt-submit` so the status returns to working once the dialog closes. Without it the pane would stay on "waiting" until the next stop, which is wrong for the model's continued work after an answer. Deliberately not included: `tool-complete`. The Pi bridge emits with `spawnSync`, so one event per tool call would block the extension host for the duration of a process spawn on every read/grep/edit. The test that asserts the bridge's subscriptions now covers both new event names. Verified: the `format!` template still compiles and renders both new handlers, and the bridge contains every string the test asserts. --- crates/tty7-core/src/core/agent_hooks.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index e8865107..97cac9a6 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1449,6 +1449,21 @@ export default function (pi: ExtensionAPI) {{ try {{ pi.on("session_start", (_event, ctx) => emit("session-start", ctx)); }} catch {{}} + // Pi-only: Oh My Pi may not expose the UI-prompt hooks, so each one is + // guarded on its own — a fork that rejects the event name must not take the + // rest of the bridge down with it. Without these the pane never reports + // "waiting for you" while a dialog is open, and the event vocabulary already + // carries question-asked / permission-request for exactly that. + try {{ + pi.on("ui_prompt_start", (event, ctx) => {{ + emit(event.kind === "confirm" ? "permission-request" : "question-asked", ctx); + }}); + }} catch {{}} + // The dialog closed, so the agent is working again. Without this the pane + // would stay on "waiting" until the next stop. + try {{ + pi.on("ui_prompt_end", (_event, ctx) => emit("prompt-submit", ctx)); + }} catch {{}} }} "# )) @@ -2205,6 +2220,8 @@ mod tests { "agent_start", "agent_end", "session_shutdown", + "ui_prompt_start", + "ui_prompt_end", ] { assert!( bridge.contains(&format!(r#"pi.on("{event}""#)), From fdda34ab1c87f7259c888cea7cef1405a743fd8c Mon Sep 17 00:00:00 2001 From: hhdebb Date: Sun, 13 Sep 2026 17:21:39 +0800 Subject: [PATCH 16/46] fix(terminal): reread the font fallback chain instead of cloning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_terminal` builds the chain once, out of `font_family` and `font_fallbacks`, and from then on it is only ever copied: `set_font_family` took it off the font it was replacing, and `alt_font` takes it off the regular face when it builds bold and italic. Nothing reread it. So a `font_fallbacks` edit had no live path at all — only panes opened afterwards saw it. Changing `font_family` and changing it back did not help either, because that path cloned the chain too. Carrying the chain across a family change is also wrong on its own terms. `fallback_chain` decides the pins from the family it is handed: it skips pinning a last-resort face that the family already is, and pins the bundled Hack otherwise. The chain built for `Hack` therefore has no Hack in it, and reusing it after a switch away from Hack leaves the anchor missing. `set_font_family` now rebuilds from the config, `reload_from_config` watches `font_fallbacks` and pushes a rebuild into every open pane, and the rebuild writes all three faces rather than the regular one alone — bold and italic carry no chain of their own, so skipping them would strand two thirds of the text on the old one. --- src/terminal/view.rs | 68 ++++++++++++++++++++++++++++++++++++++++++-- src/ui/app.rs | 22 +++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 56555d5d..1cf69399 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1208,6 +1208,27 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec { chain } +/// Put `chain` on the regular face and on the bold and italic ones. +/// +/// Bold and italic never carry a chain of their own — `alt_font` copies theirs +/// off the regular face when they are built — so a rebuild that skipped them +/// would leave two of the three faces resolving against the old chain. +fn apply_fallback_chain( + chain: Vec, + font: &mut Font, + bold: &mut Option, + italic: &mut Option, +) { + let fallbacks = Some(gpui::FontFallbacks::from_fonts(chain)); + font.fallbacks = fallbacks.clone(); + if let Some(font) = bold { + font.fallbacks = fallbacks.clone(); + } + if let Some(font) = italic { + font.fallbacks = fallbacks; + } +} + impl TerminalView { pub fn spawn_shell_terminal_in( workspace: Option, @@ -3460,16 +3481,32 @@ impl TerminalView { } pub fn set_font_family(&mut self, family: String, cx: &mut Context) { - let fallbacks = self.font.fallbacks.clone(); let mut font = gpui::font(family); - font.fallbacks = fallbacks; if let Some(features) = &self.font_features { font.features = features.clone(); } self.font = font; + // Rebuild rather than carry the chain over: `fallback_chain` skips + // pinning a last-resort face that the family already is, so the chain + // that went with the old family can be missing a pin the new one needs. + self.reread_fallback_chain(cx); cx.notify(); } + /// Rebuild the fallback chain from the config and put it on all three faces. + /// + /// The chain is built once in `with_terminal` and then only ever cloned + /// around, so a `font_fallbacks` edit reaches new panes and no one else. + pub fn reread_fallback_chain(&mut self, cx: &mut Context) { + let chain = fallback_chain(&self.font.family, &cx.global::().font_fallbacks); + apply_fallback_chain( + chain, + &mut self.font, + &mut self.font_bold, + &mut self.font_italic, + ); + } + pub fn set_font_family_bold(&mut self, family: Option, cx: &mut Context) { self.font_bold = self.alt_font(family); cx.notify(); @@ -8579,6 +8616,33 @@ mod tests { ); } + #[test] + fn apply_fallback_chain_reaches_every_face_a_view_has() { + // Bold and italic are the ones at risk: they hold a copy taken off the + // regular face when `alt_font` built them, so a rebuild that wrote only + // the regular face would strand them on the chain it replaced. + let mut font = gpui::font("Hack"); + let mut bold = Some(gpui::font("Hack Bold")); + let mut italic = Some(gpui::font("Hack Italic")); + + super::apply_fallback_chain(vec!["Menlo".to_string()], &mut font, &mut bold, &mut italic); + + for face in [&font, bold.as_ref().unwrap(), italic.as_ref().unwrap()] { + assert_eq!(face.fallbacks.as_ref().unwrap().fallback_list(), ["Menlo"]); + } + + // Neither is configured by default, and a view carries `None` for one + // it was never given. + let (mut none_bold, mut none_italic) = (None, None); + super::apply_fallback_chain( + vec!["Menlo".to_string()], + &mut font, + &mut none_bold, + &mut none_italic, + ); + assert_eq!(font.fallbacks.unwrap().fallback_list(), ["Menlo"]); + } + #[test] fn fallback_chain_appends_platform_stock_faces() { let stock = crate::core::config::platform_last_resort_fallbacks(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 8409a7c5..96f97ead 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -852,6 +852,7 @@ pub struct Tty7App { pub(crate) font_family: String, pub(crate) font_family_bold: Option, pub(crate) font_family_italic: Option, + pub(crate) font_fallbacks: Vec, pub(crate) font_features: Option, terminal_cursor_style: ConfigCursorStyle, terminal_scrollback_limit: usize, @@ -1323,6 +1324,7 @@ impl Tty7App { font_family, font_family_bold, font_family_italic, + font_fallbacks, font_features, terminal_cursor_style, terminal_scrollback_limit, @@ -1334,6 +1336,7 @@ impl Tty7App { cfg.font_family.clone(), cfg.font_family_bold.clone(), cfg.font_family_italic.clone(), + cfg.font_fallbacks.clone(), cfg.font_features .as_ref() .map(crate::core::config::gpui_font_features), @@ -1469,6 +1472,7 @@ impl Tty7App { font_family, font_family_bold, font_family_italic, + font_fallbacks, font_features, terminal_cursor_style, terminal_scrollback_limit, @@ -6336,12 +6340,13 @@ impl Tty7App { self.terminal_scrollback_limit = config.scrollback_limit; self.apply_terminal_config_to_panes(&config, cx); } - let (font_size, line_height, font_family, font_features) = { + let (font_size, line_height, font_family, font_fallbacks, font_features) = { let cfg = cx.global::(); ( cfg.font_size, cfg.line_height, cfg.font_family.clone(), + cfg.font_fallbacks.clone(), cfg.font_features .as_ref() .map(crate::core::config::gpui_font_features), @@ -6384,6 +6389,21 @@ impl Tty7App { } } } + // A `font_fallbacks` edit on its own reached no live pane at all: the + // chain is built once per view and from then on only cloned around. + // `set_font_family` rereads it too, so an edit that moves both ends up + // building the same chain twice rather than disagreeing about it. + if font_fallbacks != self.font_fallbacks { + self.font_fallbacks = font_fallbacks; + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + leaf.update(cx, |v, cx| { + v.reread_fallback_chain(cx); + cx.notify(); + }); + } + } + } if font_features != self.font_features { self.font_features = font_features.clone(); for tab in &self.tabs { From 0499890f76bb994871a9ce442e3d08ff19a37efc Mon Sep 17 00:00:00 2001 From: Silas Su Date: Mon, 14 Sep 2026 00:18:07 +0800 Subject: [PATCH 17/46] fix(macos): answer the Dock's reopen so a retired tty7 can come back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the last window with the tray icon on retires tty7 to the tray: process alive, Dock icon up, nothing on screen. That state had no way back through the icon. macOS relaunching an already-running app arrives as `applicationShouldHandleReopen:hasVisibleWindows:`, gpui's delegate forwards it to a callback registered with `Application::on_reopen`, and tty7 registered none — so the click was a no-op, and the only ways back in were `⌘N`, the tray's "Show tty7", or quitting and relaunching. `windows::reopen` takes that callback, in the two shapes the state has: a window still registered is activated rather than doubled, and no window at all goes through the pathless-launch restore (`restore_target` + `open_at` + `announce_detached_at_launch`) — the same path the tray's windowless branch takes, so the workspace that retired is the one that returns and not a blank one beside it. `reopen_with` is the seam the tests drive, so a reopen that opens a second window beside the one on screen cannot pass. `Application::on_reopen` is registered beside `on_open_urls` in `main`, because it has to exist before `run` — `keymap::init` runs inside the loop — and the callback defers to the loop with `cx.spawn` rather than opening windows on AppKit's delegate stack, the shape `on_open_urls` already uses. `activate_window` is `makeKeyAndOrderFront:` on macOS. Reported from a macOS machine where a lid close and wake left the process frontmost with no window: `launchservicesd SETFRONT` at 23:51:40 with the process still reported `running-active-NotVisible`, and the layout only back after a quit and relaunch. The window itself being lost across display sleep → wake is not explained by this change and carries no guess-fix here: nothing in tty7 or in the pinned gpui hangs off display sleep or wake. cargo fmt --check; cargo check --locked -p tty7 --tests; cargo test --locked -p tty7 --bin tty7-app -- 1888 passed, 0 failed. Co-authored-by: CommandCodeBot --- src/main.rs | 18 ++++++++++ src/ui/windows.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/main.rs b/src/main.rs index 67d36b02..a0412c52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,6 +638,24 @@ fn main() { application.on_open_urls(move |urls| { let _ = external_open_tx.try_send(urls); }); + // macOS relaunching an app that is already running — the Dock icon, a + // double-click on the bundle, `open -a tty7` — only reaches this process + // as `applicationShouldHandleReopen:`, and gpui's delegate does nothing + // with it unless a handler is registered. That is the one entrance a + // tray-resident tty7 has: with `show_tray_icon` on, closing the last + // window keeps the process and its Dock icon alive with nothing on + // screen, and without this the icon's click was a no-op (the only ways + // back in were ⌘N, the tray's "Show tty7", or quitting and relaunching). + // Like `on_open_urls`, the hook lives on `Application`, so it cannot go + // beside the other app-level handlers in `keymap::init`; and like that + // path it defers to the loop rather than opening windows on AppKit's + // delegate stack, which is also how Zed's own reopen handler runs. + application.on_reopen(|cx| { + cx.spawn(async move |cx| { + let _ = cx.update(crate::ui::windows::reopen); + }) + .detach(); + }); application.run(move |cx| { // gpui invokes this callback without an `App` context. Bridge it // back onto the application loop instead of touching UI state on diff --git a/src/ui/windows.rs b/src/ui/windows.rs index e249ba3b..a08bc947 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -480,6 +480,30 @@ fn open_missing_cli_window_with( announce_detached_at_launch(cx, restore); } +/// Brings the UI back when macOS relaunches a tty7 that is already running. +/// +/// AppKit only asks when it found no visible window, and that is a state +/// tty7 lives in on purpose: closing the last window with the tray icon on +/// retires to the tray, process alive and Dock icon up. A window that is +/// still there is activated rather than doubled — `activate_window` is +/// `makeKeyAndOrderFront:`, which is what orders a window AppKit left behind +/// back to the front. None at all gets the last layout back the way a +/// pathless launch and the tray's windowless path do, so the workspace that +/// retired is the one that returns and not a blank one beside it. +pub fn reopen(cx: &mut App) { + reopen_with(cx, open_at); +} + +fn reopen_with( + cx: &mut App, + open: impl FnOnce(&mut App, Option, Option), +) { + match WindowRegistry::most_recent(cx) { + Some(workspace) => activate(cx, workspace), + None => open_missing_cli_window_with(cx, None, open), + } +} + pub fn refresh_menu(cx: &mut App) { crate::ui::theme::set_menus(cx); } @@ -988,6 +1012,65 @@ mod tests { assert_eq!(opened, Some((None, None))); } + #[gpui::test] + fn a_reopen_with_no_window_up_restores_the_workspace_that_retired( + cx: &mut gpui::TestAppContext, + ) { + // The Dock icon after the last window retired to the tray: the + // process is alive with nothing on screen, and the click has to bring + // back the layout that was there, not mint a blank workspace beside it. + let view = WindowView::default(); + let restored = view.id; + let mut opened = None; + + cx.update(|cx| { + WindowRegistry::init(cx); + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(restored), + }, + ); + reopen_with(cx, |_, workspace, path| { + opened = Some((workspace, path)); + }); + }); + + assert_eq!(opened, Some((Some(restored), None))); + } + + #[gpui::test] + fn a_reopen_with_a_window_up_activates_it_instead_of_opening_another( + cx: &mut gpui::TestAppContext, + ) { + // AppKit also asks while a window is still there but off screen. That + // window is the thing to activate; a second one would take the + // restore away from it. + use gpui::VisualContext as _; + + let (app, mut vcx) = crate::ui::app::test_window::harness(cx); + let handle = vcx.window_handle(); + app.update_in(&mut vcx, |_, _, cx| { + WindowRegistry::init(cx); + let view = WindowView::default(); + let open = view.id; + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(open), + }, + ); + WindowRegistry::register(cx, open, handle, app.downgrade()); + + reopen_with(cx, |_, workspace, _| { + panic!("a reopen with a window up opened another for {workspace:?}"); + }); + assert_eq!(WindowRegistry::count(cx), 1); + }); + } + #[gpui::test] fn a_request_carrying_a_path_restores_the_layout_and_brings_the_path_along( cx: &mut gpui::TestAppContext, From 3fefb647e1b3c4732bb8e17ecc9db2068e4e6e3c Mon Sep 17 00:00:00 2001 From: Fabrice Aneche Date: Sun, 13 Sep 2026 20:59:42 -0400 Subject: [PATCH 18/46] test(agents): isolate Crush hook test from XDG_CONFIG_HOME --- crates/tty7-core/src/core/agent_hooks.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index e2a05d1c..b47d346e 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -2554,7 +2554,11 @@ mod tests { "--nocapture", ]) .env(CASE_ENV, case) - .env(ROOT_ENV, sandbox.path()); + .env(ROOT_ENV, sandbox.path()) + // `crush_settings_path` falls back to `$XDG_CONFIG_HOME` + // before the sandbox home, and CI exports it. Neutralise it + // so the default lands under the home this test owns. + .env_remove("XDG_CONFIG_HOME"); match case { "override" => { child.env("CRUSH_GLOBAL_CONFIG", sandbox.path().join("custom config")) From a433ef0a31d12552b525b3c6930cf65461040151 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:31:02 +0800 Subject: [PATCH 19/46] test(windows): pin the config dir before the reopen restore saves views.json Run on its own, the no-window reopen test reached WindowViews::save with no config-dir override and could overwrite the developer's real views.json. --- src/ui/windows.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/windows.rs b/src/ui/windows.rs index a08bc947..3b1c8aaf 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -1019,6 +1019,9 @@ mod tests { // The Dock icon after the last window retired to the tray: the // process is alive with nothing on screen, and the click has to bring // back the layout that was there, not mint a blank workspace beside it. + // The restore saves `views.json`; run alone, this test would otherwise + // write it into the real config dir. + crate::core::config::pin_test_config_dir(); let view = WindowView::default(); let restored = view.id; let mut opened = None; From 2de26bbf35e6d5c053a639bd099c7c224ea5265e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:31:45 +0800 Subject: [PATCH 20/46] fix(pi): restore the pre-prompt status when a UI prompt closes ui_prompt_end sent prompt-submit unconditionally, but Pi also opens prompts while idle (/model, a command's select). Closing one left a finished or fresh pane reading "working" with no agent_end to clear it. Remember the last turn event and re-emit that instead. --- crates/tty7-core/src/core/agent_hooks.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 97cac9a6..7d1aee08 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1440,8 +1440,11 @@ export default function (pi: ExtensionAPI) {{ // Extension load = the agent is running in this pane. No context here yet, // so the id rides on session_start instead. emit("session-start"); - pi.on("agent_start", (_event, ctx) => emit("prompt-submit", ctx)); - pi.on("agent_end", (_event, ctx) => emit("stop", ctx)); + // What the pane showed before a UI prompt put it on "waiting", so closing + // the prompt can put it back. + let turn = "session-start"; + pi.on("agent_start", (_event, ctx) => emit((turn = "prompt-submit"), ctx)); + pi.on("agent_end", (_event, ctx) => emit((turn = "stop"), ctx)); pi.on("session_shutdown", (_event, ctx) => emit("session-end", ctx)); // Last, and guarded: the three above already worked, so a Pi build that // rejects this event name must not take them — or the whole extension — @@ -1459,10 +1462,12 @@ export default function (pi: ExtensionAPI) {{ emit(event.kind === "confirm" ? "permission-request" : "question-asked", ctx); }}); }} catch {{}} - // The dialog closed, so the agent is working again. Without this the pane - // would stay on "waiting" until the next stop. + // The dialog closed: restore what it interrupted. Not a blanket + // prompt-submit — Pi also prompts while idle (/model, a command's select), + // and that would leave a finished pane reading "working" with no turn to + // ever end it. try {{ - pi.on("ui_prompt_end", (_event, ctx) => emit("prompt-submit", ctx)); + pi.on("ui_prompt_end", (_event, ctx) => emit(turn, ctx)); }} catch {{}} }} "# @@ -2228,6 +2233,10 @@ mod tests { "{slug} bridge subscribes to {event}" ); } + assert!( + bridge.contains(r#"pi.on("ui_prompt_end", (_event, ctx) => emit(turn, ctx))"#), + "{slug} restores the pre-prompt status rather than forcing working" + ); } assert!( pi_extension_ts(&target, HookAgent::Claude).is_none(), From 85b4450756b99e28deed20a278635e8c4b87b4b8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:31:59 +0800 Subject: [PATCH 21/46] fix(agents): keep Crush's PreToolUse from opening a turn it can never close Crush fires only PreToolUse, and it mapped to prompt-submit. With no Stop behind it the pane stayed on Working until Crush exited: every close asked whether to cut Crush's work short, the tray and dot stayed on working, and fork warned mid-turn. Map it to tool-complete, which still records session_id/cwd for resume and bumps activity without moving the status. --- crates/tty7-core/src/core/agent_hooks.rs | 41 ++++++++++++++++++------ docs/agents/status.mdx | 6 ++-- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index b47d346e..048fe5b9 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -861,15 +861,16 @@ const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ ]; /// Crush currently fires exactly one hook, `PreToolUse`, before every -/// top-level tool call and before its permission check. There is no turn -/// boundary to report, so a tool call is the only evidence that Crush is -/// working at all: it maps to `prompt-submit`, and the pane is cleared when -/// Crush exits and the foreground process goes back to the shell. +/// top-level tool call and before its permission check. Its payload still +/// carries `session_id` and `cwd`, which is what resume needs. /// -/// The cost is that a turn cannot report done: `tty7 wait` will time out -/// rather than return. That is Crush's limitation, not tty7's; when it ships -/// `UserPromptSubmit`/`Stop` and friends, they slot in here. -const CRUSH_HOOK_EVENTS: &[(&str, &str)] = &[("PreToolUse", "prompt-submit")]; +/// It maps to `tool-complete`, not `prompt-submit`: with no `Stop` to follow, +/// a turn started here would never end, and a pane stuck on working asks +/// before every close and holds the tray and `tty7 wait` on a turn long over. +/// `tool-complete` records the session and bumps the activity counter while +/// leaving the status alone. When Crush ships `UserPromptSubmit`/`Stop` and +/// friends, they slot in here. +const CRUSH_HOOK_EVENTS: &[(&str, &str)] = &[("PreToolUse", "tool-complete")]; fn hook_map_state( target: &HookTarget, @@ -1858,6 +1859,26 @@ mod tests { assert_eq!(state.status, AgentStatus::Done); } + /// Crush's lone `PreToolUse` has no `Stop` to close a turn behind it, so it + /// must not open one: the pane would read as busy until Crush exits. + #[test] + fn crush_tool_calls_record_the_session_without_starting_a_turn() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let mut state = AgentSessionState::default(); + for (hook, event) in HookAgent::Crush.hook_map_events().unwrap() { + assert_eq!(*hook, "PreToolUse"); + let input = + r#"{"event":"PreToolUse","session_id":"c-1","cwd":"/repo","tool_name":"bash"}"#; + let event = effective_event("crush", event, input).unwrap(); + state.apply_event(&round_trip("crush", event, input)); + } + assert_eq!(state.status, AgentStatus::Idle); + assert_eq!(state.session_id.as_deref(), Some("c-1")); + assert_eq!(state.cwd.as_deref(), Some(Path::new("/repo"))); + assert_eq!(state.activity, 1); + } + /// Qwen is the one agent that reports a blocked turn outright, so it must /// not also carry the `Notification` hook the others need — that event fires /// for non-blocking alerts too and would strand the pane on "waiting". @@ -2502,10 +2523,10 @@ mod tests { ours[0]["command"].as_str(), Some( target - .hook_command(HookAgent::Crush, "prompt-submit") + .hook_command(HookAgent::Crush, "tool-complete") .as_str() ), - "the entry is flat and names the prompt-submit emitter" + "the entry is flat and names the tool-complete emitter" ); assert!( ours[0].get("hooks").is_none(), diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index d6a40293..3fe600e9 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -23,9 +23,9 @@ becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty ships a newer hook. - Crush ships only a `PreToolUse` hook today, so it reports **working** — a - tool call is the only turn signal there is — but never **done**. On a Crush - pane, `tty7 wait` times out rather than returning. + Crush ships only a `PreToolUse` hook today. It is enough to remember the + session for resume, but there is no turn boundary behind it, so a Crush pane + carries no status dot and `tty7 wait` times out rather than returning. From 2e8a43a35e6ea5d166dde60e9d9c5e4eadb4fc79 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:32:55 +0800 Subject: [PATCH 22/46] fix(settings): pass the host-boundary guard and follow the default keys The passphrase box checks which key file is on this machine with std::fs::metadata, which the host-boundary guard rejected; allowlist it beside the existing std::fs::read entry for the same client-side key. An empty key field now resolves to the ~/.ssh defaults build_spec_inner offers, so a default encrypted key can be given a passphrase from the form. The key is also re-resolved when host or user change, since they fill %h/%r in the path. Drop the unused SettingsForget string. --- .github/scripts/check-host-boundary.sh | 3 + src/ui/i18n/en.rs | 1 - src/ui/i18n/ja.rs | 1 - src/ui/i18n/mod.rs | 1 - src/ui/i18n/zh.rs | 1 - src/ui/settings.rs | 79 +++++++++++++++++++------- 6 files changed, 61 insertions(+), 25 deletions(-) diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh index bc102de8..e2cf94cf 100755 --- a/.github/scripts/check-host-boundary.sh +++ b/.github/scripts/check-host-boundary.sh @@ -59,6 +59,9 @@ src/ui/app.rs|std::fs::create_dir_all src/ui/ssh_prompt.rs|std::fs::read src/ui/ssh_connect.rs|std::fs::read src/ui/settings.rs|std::fs::read +# The host editor asking which of those keys is on this machine before it offers +# a passphrase box for one — the same client-side key, never a workspace path. +src/ui/settings.rs|std::fs::metadata # Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs # this app's own history search. A remote pane's history is the remote shell's diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a6673ed2..e1621872 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -278,7 +278,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsUserHint => "resolved at connect", L10nKey::SettingsPasswordDesc => "Kept in the system keychain, never in the config file.", L10nKey::SettingsPasswordHint => "Ask when connecting", - L10nKey::SettingsForget => "Forget", L10nKey::SettingsKeyPassphrase => "Key passphrase", L10nKey::SettingsKeyPassphraseDesc => "Unlocks the key above. Kept in the system keychain.", L10nKey::SettingsPassphraseNeedsKey => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 544f18ad..1584488b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -281,7 +281,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "システムのキーチェーンに保存され、設定ファイルには書き込まれません。" } L10nKey::SettingsPasswordHint => "接続時に入力する", - L10nKey::SettingsForget => "削除", L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ", L10nKey::SettingsKeyPassphraseDesc => { "上の鍵を解錠します。システムのキーチェーンに保存されます。" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 6072b77c..0e0dc7de 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -273,7 +273,6 @@ l10n_keys! { SettingsPassword, SettingsPasswordDesc, SettingsPasswordHint, - SettingsForget, SettingsKeyPassphrase, SettingsKeyPassphraseDesc, SettingsPassphraseNeedsKey, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6724f8ef..997e0eee 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -251,7 +251,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsUserHint => "连接时再定", L10nKey::SettingsPasswordDesc => "存在系统钥匙串里,不会写进配置文件。", L10nKey::SettingsPasswordHint => "连接时再问", - L10nKey::SettingsForget => "清除", L10nKey::SettingsKeyPassphrase => "密钥口令", L10nKey::SettingsKeyPassphraseDesc => "用来解锁上面那个密钥,存在系统钥匙串里。", L10nKey::SettingsPassphraseNeedsKey => "先填一个密钥文件——口令是跟着它解锁的那个密钥存的。", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index b821685f..2de588b2 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1690,25 +1690,47 @@ fn stored_password(profile: &SshProfile) -> String { .unwrap_or_default() } -/// The first key file a profile names that is actually there. +/// The first key file a profile would offer that is actually there. /// /// A passphrase is accounted by the key's *contents*, not by its path, so a /// file that cannot be read is a key nothing can be stored against. fn first_readable_key(profile: &SshProfile) -> Option { - profile - .expanded_identity_files() - .into_iter() - .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) + first_readable_key_in(&profile.identity_files, &profile.host, &profile.user) } /// The same answer for a form that has not been collected into a profile yet: /// the key field as typed, with the host and user beside it filling in `%h` /// and `%r`. fn first_readable_key_in(files: &[String], host: &str, user: &str) -> Option { - files - .iter() - .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) - .find(|p| std::fs::metadata(crate::core::ssh_profile::expand_tilde(p)).is_ok()) + first_readable_key_or( + files, + host, + user, + crate::core::ssh_profile::default_identity_candidates, + ) +} + +/// An empty key field is not "no key": `build_spec_inner` offers the `~/.ssh` +/// defaults then, and looks their passphrases up by those exact strings. The +/// box has to follow the same list, or the most common setup — no key named, +/// an encrypted `id_ed25519` — could never be given a passphrase here. +fn first_readable_key_or( + files: &[String], + host: &str, + user: &str, + defaults: impl FnOnce() -> Vec, +) -> Option { + let candidates = if files.is_empty() { + defaults() + } else { + files + .iter() + .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) + .collect() + }; + candidates + .into_iter() + .find(|p| std::fs::metadata(p).is_ok()) } fn stored_passphrase(key_path: &str) -> String { @@ -3857,16 +3879,17 @@ impl Tty7App { // The passphrase belongs to whichever key the field above names, so // when that answer changes the box has to change with it. Without this // a form opened on one key and pointed at another would carry the - // first key's passphrase across and save it over the second's. - subs.push(cx.subscribe_in( - &identity_files, - window, - |this, _i, ev: &InputEvent, window, cx| { - if matches!(ev, InputEvent::Change) { - this.resync_key_passphrase(window, cx); - } - }, - )); + // first key's passphrase across and save it over the second's. Host and + // user count too: they fill in a `%h` / `%r` in the key's path. + for input in [&identity_files, &host, &user] { + subs.push( + cx.subscribe_in(input, window, |this, _i, ev: &InputEvent, window, cx| { + if matches!(ev, InputEvent::Change) { + this.resync_key_passphrase(window, cx); + } + }), + ); + } let mut watch = vec![ &name, &host, @@ -8129,10 +8152,24 @@ mod tests { Some(real.to_string_lossy().to_string()) ); assert_eq!( - first_readable_key_in(&[missing], "example.com", "ada"), + first_readable_key_in(&[missing.clone()], "example.com", "ada"), + None + ); + // An empty field falls back to the defaults the handshake offers — + // and a named key, even a missing one, replaces them entirely. + let defaults = || vec![missing.clone(), real.to_string_lossy().to_string()]; + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", defaults), + Some(real.to_string_lossy().to_string()) + ); + assert_eq!( + first_readable_key_or(&[missing.clone()], "example.com", "ada", defaults), + None + ); + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", Vec::new), None ); - assert_eq!(first_readable_key_in(&[], "example.com", "ada"), None); std::fs::remove_dir_all(&dir).ok(); } From 609e1b04a6476d7dcd2527917546e5db138d9943 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:33:22 +0800 Subject: [PATCH 23/46] fix(terminal): let Ctrl-D close the typeahead record only on an empty line Readline, PSReadLine, a cooked tty and tty7's own editor all treat Ctrl-D as end of input only when the line is empty; with text on it Ctrl-D is an edit. Discarding a record that still holds unsubmitted text dropped the owed wipe, so text typed during a gap followed by Ctrl-D stayed on the shell's line and was glued to the front of the next command. Ctrl-D now discards only a record with no unsubmitted text, and taints otherwise. --- src/terminal/typeahead.rs | 37 +++++++++++++++++++++++++++++++ src/terminal/view.rs | 46 ++++++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 52b9887f..3bd99d59 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -19,6 +19,11 @@ pub enum RawInput<'a> { plain: bool, }, Interrupt, + /// Ctrl-D. Readers take it as end of input only on an empty line — on a + /// line with text it deletes a character, and a shell that reads the gap + /// later is still left holding that text — so it closes the record only + /// when nothing unsubmitted was typed. + EndOfInput, } impl Typeahead { @@ -29,7 +34,9 @@ impl Typeahead { pub fn observe(&mut self, input: RawInput, externally_owned: bool) { match input { RawInput::Interrupt => self.discard(), + RawInput::EndOfInput if self.text.is_empty() => self.discard(), _ if externally_owned => {} + RawInput::EndOfInput => self.taint(), RawInput::Text(s) => self.record_text(s), RawInput::Pasted(s) => { self.record_text(s); @@ -381,6 +388,36 @@ mod tests { assert_eq!(p.drain(), Some(String::new())); } + #[test] + fn end_of_input_on_an_empty_line_closes_the_record() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), None); + } + + #[test] + fn end_of_input_after_unsubmitted_text_still_owes_the_wipe() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("ab"), false); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), Some(String::new())); + } + #[test] fn submitting_a_recalled_exit_discards_taint_and_paste_provenance() { let mut t = Typeahead::new(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 753c1d81..435cc7ec 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2273,7 +2273,8 @@ impl TerminalView { let kitty = self.key_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; - let interrupt = is_typeahead_interrupt(ks.key.as_str(), m); + let boundary = typeahead_boundary(ks.key.as_str(), m); + let interrupt = boundary.is_some(); let shell_owns_prompt = self.shell_owns_prompt(); let held = plain && ks.key == "backspace" @@ -2290,11 +2291,11 @@ impl TerminalView { }; if !held { self.release_hold(); - if !shell_owns_prompt && interrupt { + if let Some(boundary) = boundary.filter(|_| !shell_owns_prompt) { // Ctrl-C interrupts and Ctrl-D can close the foreground reader. // Discard the gap before sending either so a prompt transition // cannot turn the pending record into a later Ctrl-U. - self.observe_typeahead(RawInput::Interrupt); + self.observe_typeahead(boundary); } self.terminal.write(bytes); if !shell_owns_prompt && !interrupt { @@ -6728,8 +6729,15 @@ impl TerminalView { } } -fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { - modifiers.control && !modifiers.alt && !modifiers.platform && matches!(key, "c" | "d") +fn typeahead_boundary(key: &str, modifiers: &Modifiers) -> Option> { + if !modifiers.control || modifiers.alt || modifiers.platform { + return None; + } + match key { + "c" => Some(RawInput::Interrupt), + "d" => Some(RawInput::EndOfInput), + _ => None, + } } fn sync_typeahead_owner_state( @@ -7871,8 +7879,8 @@ mod tests { use super::{ COMPLETION_MENU_MAX_W, LoopbackPlan, PortRoute, RawInput, SelectEndCopy, Typeahead, WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host, - display_width, is_typeahead_interrupt, link_path_style, loopback_plan, - observe_typeahead_for_owner, + display_width, link_path_style, loopback_plan, observe_typeahead_for_owner, + typeahead_boundary, }; use super::{SCROLL_ANIM_FRAME, scroll_anim_step}; use super::{ @@ -8050,17 +8058,23 @@ mod tests { control: true, ..Default::default() }; - assert!(is_typeahead_interrupt("c", &ctrl)); - assert!(is_typeahead_interrupt("d", &ctrl)); - assert!(!is_typeahead_interrupt("u", &ctrl)); + assert!(matches!( + typeahead_boundary("c", &ctrl), + Some(RawInput::Interrupt) + )); + assert!(matches!( + typeahead_boundary("d", &ctrl), + Some(RawInput::EndOfInput) + )); + assert!(typeahead_boundary("u", &ctrl).is_none()); let ctrl_alt = Modifiers { control: true, alt: true, ..Default::default() }; - assert!(!is_typeahead_interrupt("c", &ctrl_alt)); - assert!(!is_typeahead_interrupt("d", &ctrl_alt)); + assert!(typeahead_boundary("c", &ctrl_alt).is_none()); + assert!(typeahead_boundary("d", &ctrl_alt).is_none()); } fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { @@ -11014,16 +11028,18 @@ mod gpui_tests { #[gpui::test] fn passthrough_ctrl_c_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { - assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-c", 0x03); + assert_foreground_interrupt_does_not_wipe_prompt(cx, "agent input", "ctrl-c", 0x03); } #[gpui::test] fn passthrough_ctrl_d_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { - assert_foreground_interrupt_does_not_wipe_prompt(cx, "ctrl-d", 0x04); + // Ctrl-D only ends input on an empty line; with text it is an edit. + assert_foreground_interrupt_does_not_wipe_prompt(cx, "", "ctrl-d", 0x04); } fn assert_foreground_interrupt_does_not_wipe_prompt( cx: &mut TestAppContext, + pending: &str, chord: &str, byte: u8, ) { @@ -11031,7 +11047,7 @@ mod gpui_tests { window .update(cx, |view, window, cx| { assert!(!view.input_active(), "the foreground process owns input"); - view.typeahead.observe(RawInput::Text("agent input"), false); + view.typeahead.observe(RawInput::Text(pending), false); view.typeahead.observe( RawInput::Key { key: "up", From 1f189e2ee0b91cea8fdaaaa09ff5a7c9433fabac Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:35:15 +0800 Subject: [PATCH 24/46] fix(window): keep the title bar row in fullscreen, drop only its window buttons Hiding the whole title bar took the tab strip with it: with tabs on top every chip, the New Tab tile and the panel/menu tiles vanished in fullscreen, the docked document header (drawn only over the spanning bar) disappeared, and the strip's drop band kept claiming a row that was now terminal. What is actually dead in fullscreen is minimize/maximize/close. The row now stays; in fullscreen off macOS the strip goes into a plain row of the same geometry instead of `TitleBar`, which always draws those buttons, and the room reserved for them (strip width, chrome band over the panel, document header padding) comes back. The notice text says the window buttons are hidden rather than the title bar, and the keymap test whose premise was the bar disappearing is replaced by one pinning the controls width. --- src/ui/app.rs | 116 +++++++++++++++++++++++++++++++------------- src/ui/i18n/en.rs | 2 +- src/ui/i18n/ja.rs | 4 +- src/ui/i18n/zh.rs | 2 +- src/ui/keymap.rs | 49 ------------------- src/ui/tab_strip.rs | 13 ++--- 6 files changed, 94 insertions(+), 92 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 2a2c7a57..0bce6917 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1170,17 +1170,26 @@ fn clear_window_override_values(config: &mut Config, backdrop_is_local: bool) { /// it and leaving takes it away. struct FullscreenHint; -/// Whether fullscreen takes the title bar away on this platform. +/// Whether the title bar carries minimize, maximize and close right now. /// -/// Not on macOS, where fullscreen belongs to the system rather than to the app. -/// The traffic lights live on that bar, and revealing the menu bar draws a -/// translucent strip over the same band, which the bar absorbs; without it the -/// strip lands on the terminal instead and covers its first row. There is also -/// nothing there to fix: `WindowControls` draws no minimize, maximize or close -/// on macOS — the three that are dead in fullscreen elsewhere are the system's -/// there, and it hides them itself. So the bar is not broken chrome on macOS, -/// it is part of how the system dresses a fullscreen window. -const FULLSCREEN_TAKES_THE_TITLE_BAR: bool = !cfg!(target_os = "macos"); +/// Not in fullscreen. A fullscreen window has no caption: Windows clears +/// `WS_CAPTION` and answers `HTCLIENT` along the whole top edge, so the three +/// buttons would draw, light up under the pointer and do nothing when clicked. +/// The row they sit at the end of stays, because it is also the tab strip. +/// +/// Never on macOS, which draws no buttons of its own: those are the system's +/// traffic lights, and the system hides them itself. +pub(crate) fn window_controls_drawn(fullscreen: bool) -> bool { + !cfg!(target_os = "macos") && !fullscreen +} + +/// How much of the title bar's trailing end the window buttons take. +pub(crate) fn window_controls_w(fullscreen: bool) -> f32 { + match window_controls_drawn(fullscreen) { + true => WINDOW_CONTROLS_W, + false => 0., + } +} impl Tty7App { pub fn for_workspace( @@ -3545,7 +3554,7 @@ impl Tty7App { /// Toggle fullscreen, and say how to leave it on the way in. /// /// Only on the way in, and only from the action: entering is an instant in - /// which the title bar disappears, and a window that starts fullscreen + /// which the window buttons disappear, and a window that starts fullscreen /// because the setting says so is not a surprise anybody needs explaining. /// The chord comes from the keymap rather than from a string, because it is /// `F11` on Windows and Linux, `Cmd+Enter` on macOS, and either of them may @@ -3563,14 +3572,14 @@ impl Tty7App { let entering = !window.is_fullscreen(); window.toggle_fullscreen(); window.remove_notification::(cx); - // Nothing disappeared where the bar stays, so there is nothing to - // explain. - if !entering || !FULLSCREEN_TAKES_THE_TITLE_BAR { + // Nothing disappeared where there were no buttons to begin with, so + // there is nothing to explain. + if !entering || !window_controls_drawn(false) { return; } let hint = match crate::ui::home::key_hint("ToggleFullscreen", cx) { Some(chord) => t_fmt(L10nKey::AppFullscreenEntered, &[("key", &chord)]), - // Rebound to nothing at all: still worth saying the bar is gone, + // Rebound to nothing at all: still worth saying the buttons are gone, // just without naming a key that would not work. None => t(L10nKey::AppFullscreenEnteredNoKey).to_string(), }; @@ -7665,22 +7674,41 @@ impl Render for Tty7App { } }; - // No title bar in fullscreen. The bar is window chrome — a caption to - // drag the window by and the three controls at its end — and a - // fullscreen window has none of that to offer: it has no caption for - // the platform to hit-test, so the buttons draw, light up under the - // pointer and do nothing at all when clicked. Drawing chrome that - // cannot work is worse than drawing none, and taking it away is also - // what the mode is for. - let fullscreen = window.is_fullscreen(); - let bar_is_gone = fullscreen && FULLSCREEN_TAKES_THE_TITLE_BAR; - let title_bar = (!bar_is_gone).then(|| { - TitleBar::new() - .h(px(TITLE_BAR_HEIGHT)) - .bg(cx.theme().transparent) - .border_color(cx.theme().transparent) - .child(strip) - }); + // No window buttons in fullscreen, where they cannot work: the window + // has no caption for the platform to hit-test, so they would draw, + // light up under the pointer and do nothing when clicked. `TitleBar` + // always draws them, so the strip goes into a plain row of the same + // geometry instead — the row itself stays, since it holds the tabs, + // the chrome tiles and the docked document's header. + let title_bar = + if window_controls_drawn(window.is_fullscreen()) || cfg!(target_os = "macos") { + TitleBar::new() + .h(px(TITLE_BAR_HEIGHT)) + .bg(cx.theme().transparent) + .border_color(cx.theme().transparent) + .child(strip) + .into_any_element() + } else { + div() + .flex_shrink_0() + .flex() + .flex_row() + .items_center() + .h(px(TITLE_BAR_HEIGHT)) + .pl(px(TITLE_BAR_LEAD)) + .border_b_1() + .border_color(cx.theme().transparent) + .child( + div() + .flex() + .flex_row() + .items_center() + .h_full() + .flex_1() + .child(strip), + ) + .into_any_element() + }; let body_area = div() .flex_1() .relative() @@ -7784,9 +7812,9 @@ impl Render for Tty7App { let panel_below_title_bar = (right_panel.is_some() || document_column.is_some()) && !cfg!(target_os = "macos"); let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { - (None, title_bar) + (None, Some(title_bar)) } else { - (title_bar, None) + (Some(title_bar), None) }; let (column_overlays, hoisted_overlays) = if panel_below_title_bar { (Vec::new(), overlays) @@ -7882,7 +7910,9 @@ impl Render for Tty7App { .right(px(panel_px)) .w(px(document_px)) .when(panel_px <= 0., |d| { - d.pr(px(crate::ui::tab_strip::trailing_chrome_w())) + d.pr(px(crate::ui::tab_strip::trailing_chrome_w( + window.is_fullscreen(), + ))) }) .child(header), ) @@ -9468,6 +9498,24 @@ mod tests { assert_eq!(band.size.height, px(TITLE_BAR_HEIGHT)); } + /// Fullscreen takes the window buttons and nothing else: the strip keeps + /// its row, and the room reserved for the buttons at its end comes back. + /// macOS never had any to take. + #[test] + fn fullscreen_drops_the_window_buttons_but_not_their_row() { + assert!(!super::window_controls_drawn(true)); + assert_eq!(super::window_controls_w(true), 0.); + assert_eq!( + super::window_controls_drawn(false), + !cfg!(target_os = "macos") + ); + assert_eq!(super::window_controls_w(false), super::WINDOW_CONTROLS_W); + assert_eq!( + crate::ui::tab_strip::trailing_chrome_w(true), + crate::ui::tab_strip::trailing_chrome_tiles_w() + ); + } + /// A surface narrower than its own shadow is only reachable mid-resize, but /// a negative width would make `Bounds::contains` answer for a rectangle /// that is inside out. diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 47f63081..fd769eba 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1582,7 +1582,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppTabsNotRestored => "{count} tabs from last time could not be reopened", L10nKey::AppFullscreenEntered => "Fullscreen — press {key} to leave", L10nKey::AppFullscreenEnteredNoKey => { - "Fullscreen — the title bar is hidden until you leave" + "Fullscreen — the window buttons are hidden until you leave" } L10nKey::LaunchWorkspacesLeftRunning => { "Only this window was restored — {count} workspaces are still running in the background. Reopen them from the sidebar." diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 59eefef9..b254e184 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1644,7 +1644,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppOpenTerminalFailed => "ターミナルを開けませんでした: {error}", L10nKey::AppTabsNotRestored => "前回のタブ {count} 個を開き直せませんでした", L10nKey::AppFullscreenEntered => "全画面表示 — 解除するには {key}", - L10nKey::AppFullscreenEnteredNoKey => "全画面表示 — 解除するまでタイトルバーは非表示です", + L10nKey::AppFullscreenEnteredNoKey => { + "全画面表示 — 解除するまでウィンドウボタンは非表示です" + } L10nKey::LaunchWorkspacesLeftRunning => { "このウィンドウだけを復元しました — あと {count} 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" } diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index efce02fb..faa8a4e0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1497,7 +1497,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppOpenTerminalFailed => "无法打开终端:{error}", L10nKey::AppTabsNotRestored => "上次的 {count} 个标签页没能重新打开", L10nKey::AppFullscreenEntered => "已进入全屏 —— 按 {key} 退出", - L10nKey::AppFullscreenEnteredNoKey => "已进入全屏 —— 标题栏在退出前会一直隐藏", + L10nKey::AppFullscreenEnteredNoKey => "已进入全屏 —— 窗口按钮在退出前会一直隐藏", L10nKey::LaunchWorkspacesLeftRunning => { "只恢复了这个窗口——还有 {count} 个工作区在后台运行,可从侧边栏重新打开。" } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 3acdbb31..6fb2d808 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1339,55 +1339,6 @@ mod tests { use super::*; use gpui::Action as _; - /// Everything the title bar offers a button for stays reachable from the - /// keyboard, because on Windows and Linux the title bar is not drawn in - /// fullscreen at all — it is window chrome there, and a fullscreen window - /// has no chrome for the platform to hit-test, so its buttons would light - /// up under the pointer and do nothing when clicked. (On macOS the bar - /// stays: fullscreen is the system's there, and the bar is where it puts - /// the traffic lights.) - /// - /// Reachable means either a chord of its own or a seat in the palette, - /// which has one; both are hands-free, and the palette is how the sidebar - /// toggle is reached, since it ships without a chord. What this pins is - /// that a control on that bar is never mouse-only — if one ever is, - /// hiding the bar would take a feature away with it, and this is where - /// that should be noticed. - #[test] - fn what_the_title_bar_offers_is_reachable_without_it() { - let defaults = default_bindings(); - let chord = |action: &str| { - defaults - .iter() - .any(|(name, keystroke)| *name == action && !keystroke.is_empty()) - }; - // The palette is the fallback, so it is the one that must not be. - assert!( - chord("TogglePalette"), - "the fallback needs a chord of its own" - ); - for action in [ - "NewTab", - "ToggleTabSidebar", - "OpenSettings", - "ToggleFullscreen", - "ToggleSwitcher", - ] { - assert!( - chord(action) || palette_lists(action), - "{action} would be mouse-only once the bar is hidden" - ); - } - } - - /// Whether the palette lists `action` under a name somebody wrote, which is - /// what having a real seat there means: `action_entry` answers for every - /// action, falling back to a name split on capitals, and a fallback name is - /// not evidence that anyone meant the action to be found. - fn palette_lists(action: &str) -> bool { - authored_entry(action).is_some() - } - /// The actions a keymap built from `action_bindings` dispatches for `keys` /// typed in `context`, in precedence order — the same lookup gpui performs /// on a real keypress. diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 48676609..7ef322b1 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -626,8 +626,8 @@ pub(crate) fn trailing_chrome_tiles_w() -> f32 { /// Anything else drawn into that end of the title bar has to stop short of it — /// which for the hoisted document header means the case where the detail panel /// is closed and the document column runs to the window's right edge. -pub(crate) fn trailing_chrome_w() -> f32 { - trailing_chrome_tiles_w() + crate::ui::app::WINDOW_CONTROLS_W +pub(crate) fn trailing_chrome_w(fullscreen: bool) -> f32 { + trailing_chrome_tiles_w() + crate::ui::app::window_controls_w(fullscreen) } pub(crate) fn chrome_tile_sized( @@ -1812,14 +1812,15 @@ impl Tty7App { // instead: that header carries no fill of its own, and a chip left // under it showed through the file name while staying clickable. let document_w = self.document_dock_px(window, cx).unwrap_or(0.); + let controls_w = crate::ui::app::window_controls_w(window.is_fullscreen()); let strip_w = if cfg!(target_os = "macos") { (window.viewport_size().width - px(80. + panel_w + document_w)).max(px(160.)) } else { - (window.viewport_size().width - px(114.)).max(px(140.)) + (window.viewport_size().width - px(crate::ui::app::TITLE_BAR_LEAD + controls_w)) + .max(px(140.)) }; - let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)).then(|| { - (self.right_panel_px(window, cx) - crate::ui::app::WINDOW_CONTROLS_W - 1.).max(0.) - }); + let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)) + .then(|| (self.right_panel_px(window, cx) - controls_w - 1.).max(0.)); // `corner_w` reserves the trailing window chrome. With the panel open on // macOS that chrome belongs to the panel's own header, which the strip // now stops short of, so reserving for it here would charge the chips From 04878af279330a9a4d03f83223dbe73ab8d81cd3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:40:04 +0800 Subject: [PATCH 25/46] fix(terminal): hold one steady bell flash through a burst of bells Every visual bell armed its own 150 ms clear timer, so when bells arrived faster than that - holding Backspace on an empty bash prompt, or Tab with nothing to complete, rings at key-repeat rate - an older bell's timer blanked the flash a newer bell had just lit, and the pane strobed for as long as the key was held. Only the timer armed by the latest bell clears the flash now. Fixes #874 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/view.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..a9996eed 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -381,6 +381,9 @@ pub struct TerminalView { /// time, however fast the pane is printing. pub(super) search_scan_armed: bool, pub bell_flash: bool, + /// Bumped by every bell, so only the timer armed by the latest one clears + /// the flash: a burst of bells holds one steady flash instead of strobing. + bell_epoch: u64, pub report_mouse: bool, last_at_prompt: bool, last_typeahead_blocked: bool, @@ -1577,6 +1580,7 @@ impl TerminalView { search_scan_epoch: 0, search_scan_armed: false, bell_flash: false, + bell_epoch: 0, last_at_prompt: false, last_typeahead_blocked: false, running_since: None, @@ -2980,6 +2984,8 @@ impl TerminalView { } fn flash_bell(&mut self, cx: &mut Context) { + self.bell_epoch += 1; + let epoch = self.bell_epoch; self.bell_flash = true; cx.notify(); cx.spawn(async move |this, cx| { @@ -2987,6 +2993,12 @@ impl TerminalView { .timer(std::time::Duration::from_millis(150)) .await; let _ = this.update(cx, |view, cx| { + // A bell rung since this one owns the flash now. Holding + // Backspace on an empty bash prompt rings at key-repeat rate, + // and clearing here would blank it every few frames (#874). + if view.bell_epoch != epoch { + return; + } view.bell_flash = false; cx.notify(); }); @@ -14310,6 +14322,40 @@ mod gpui_tests { .unwrap(); } + /// Holding Backspace on an empty bash prompt (or Tab with nothing to + /// complete) rings the bell at key-repeat rate. Every flash used to arm its + /// own clear timer, so the first bell's timer blanked a flash the fifth bell + /// had just re-lit, and the pane strobed for as long as the key was held + /// (#874). + #[gpui::test] + fn a_bell_rung_at_key_repeat_rate_holds_one_steady_flash(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + let lit = + |cx: &mut TestAppContext| window.update(cx, |view, _, _| view.bell_flash).unwrap(); + + let repeat = std::time::Duration::from_millis(33); + let mut dark = Vec::new(); + for i in 0..30 { + window + .update(cx, |view, _, cx| view.handle_event(AlacEvent::Bell, cx)) + .unwrap(); + cx.executor().advance_clock(repeat); + cx.run_until_parked(); + if !lit(cx) { + dark.push(i); + } + } + assert!( + dark.is_empty(), + "the flash went dark between bells after repeats {dark:?}" + ); + + cx.executor() + .advance_clock(std::time::Duration::from_millis(300)); + cx.run_until_parked(); + assert!(!lit(cx), "the flash outlived the last bell"); + } + #[gpui::test] fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); From 37c2ef2176f74956102c733f08e8ddb4611cca22 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:39:11 +0800 Subject: [PATCH 26/46] test(keymap): pin additive keybinding semantics (#868) Regression tests for issue #868: a chord added in config.json must join the action's default chord rather than replace it, an empty string or list must still unbind, a list replaces the chord set, the tmux preset composes, a user chord wins a tie with another action's default, and Settings recordings write the exact-set shape. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/config.rs | 19 ++++ src/ui/app.rs | 53 ++++++++-- src/ui/keymap.rs | 158 ++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 7 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index fce87281..bdf715f2 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -1932,6 +1932,25 @@ mod tests { assert!(cfg.keybindings.is_empty()); } + #[test] + fn keybindings_take_a_chord_or_a_list_and_write_back_what_they_read() { + // A string is the shape every config written before #868 has, from the + // Settings page and by hand; a list is the one that replaces an + // action's chords outright. Both have to load, and a save must not turn + // one into the other — the two mean different things. + let written = serde_json::json!({ + "NextTab": "cmd-shift-]", + "AlternatePaste": "", + "PrevTab": ["cmd-shift-[", "ctrl-shift-tab"], + "SplitRight": [], + }); + let cfg: Config = + serde_json::from_value(serde_json::json!({ "keybindings": written.clone() })) + .expect("both shapes load"); + assert_eq!(cfg.keybindings.len(), 4); + assert_eq!(serde_json::to_value(&cfg.keybindings).unwrap(), written); + } + fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 54d5a7eb..5055666a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -10411,12 +10411,17 @@ mod keybinding_gpui_tests { }); } - fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) { + /// Waits for `action`'s entry in config to read `expected` — compared as the + /// JSON the file gets, since that is what the reader of `config.json` sees. + fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: serde_json::Value) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { vcx.background_executor.run_until_parked(); - let got = vcx.update(|_, cx| cx.global::().keybindings.get(action).cloned()); - if got.as_deref() == Some(expected) { + let got = vcx.update(|_, cx| { + serde_json::to_value(cx.global::().keybindings.get(action)) + .expect("a binding serializes") + }); + if got == expected { return; } assert!( @@ -10432,7 +10437,15 @@ mod keybinding_gpui_tests { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "NewTab"); vcx.simulate_keystrokes("secondary-shift-n"); - wait_for_binding(&mut vcx, "NewTab", "secondary-shift-n"); + // A list, because recording a shortcut on the Settings page *sets* it: + // the row showed one chord and now shows another. A bare string in + // config adds a chord beside the default (#868), which is not what + // the person at the row just did. + wait_for_binding( + &mut vcx, + "NewTab", + serde_json::json!(["secondary-shift-n"]), + ); let recording = app.update_in(&mut vcx, |app, _, _| { app.active_settings().map(|s| s.recording.is_some()) @@ -10450,7 +10463,33 @@ mod keybinding_gpui_tests { begin_capture(&app, &mut vcx, "CloseActiveTab"); vcx.simulate_keystrokes("secondary-b"); vcx.simulate_keystrokes("x"); - wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x"); + wait_for_binding( + &mut vcx, + "CloseActiveTab", + serde_json::json!(["secondary-b x"]), + ); + } + + #[gpui::test] + fn recording_a_chord_another_action_also_has_takes_only_that_chord( + cx: &mut TestAppContext, + ) { + let (app, mut vcx) = harness(cx); + vcx.update(|_, cx| { + cx.global_mut::().keybindings = + serde_json::from_value(serde_json::json!({ + "NextTab": ["ctrl-tab", "secondary-alt-n"], + })) + .expect("the binding loads"); + crate::ui::keymap::rebind(cx); + }); + begin_capture(&app, &mut vcx, "NewTab"); + vcx.simulate_keystrokes("secondary-alt-n"); + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-alt-n"])); + // Emptying the other action was right when an action had one chord. + // With two, it would take Ctrl+Tab away as well, for a keystroke that + // was never on it. + wait_for_binding(&mut vcx, "NextTab", serde_json::json!(["ctrl-tab"])); } #[gpui::test] @@ -10458,8 +10497,8 @@ mod keybinding_gpui_tests { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "NewTab"); vcx.simulate_keystrokes("alt-enter"); - wait_for_binding(&mut vcx, "NewTab", "alt-enter"); - wait_for_binding(&mut vcx, "InsertNewline", ""); + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["alt-enter"])); + wait_for_binding(&mut vcx, "InsertNewline", serde_json::json!([])); let note = app.update_in(&mut vcx, |app, _, _| { app.active_settings().and_then(|s| s.rebinding_note.clone()) diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6fb2d808..447a6734 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -2266,6 +2266,164 @@ mod gpui_tests { }); } + /// An app whose `config.json` reads `json`, keymap and all. Parsed from + /// text rather than built in Rust, because the file is the surface #868 is + /// about: what a hand-written line means is the thing under test. + fn running_on_json(cx: &mut gpui::App, json: &str) { + gpui_component::init(cx); + cx.set_global(Config( + serde_json::from_str(json).expect("the config parses"), + )); + init(cx); + } + + /// What the live keymap dispatches for `keys` typed in a terminal, best + /// match first — the first entry is the action a real keypress runs. + fn fired(cx: &gpui::App, keys: &str) -> Vec<&'static str> { + use gpui::Action as _; + let input: Vec = keys + .split(' ') + .map(|k| Keystroke::parse(k).expect("the typed keystroke parses")) + .collect(); + let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")]; + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &context) + .0 + .iter() + .map(|b| b.action().name()) + .collect() + } + + #[gpui::test] + fn a_chord_added_in_config_keeps_the_default_one(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + // The report, word for word: a second way to reach the tab switcher + // took the first one away (#868). + running_on_json( + cx, + r#"{"keybindings": {"NextTab": "ctrl-alt-]", "PrevTab": "ctrl-alt-["}}"#, + ); + assert_eq!( + fired(cx, "ctrl-tab").first(), + Some(&NextTab::name_for_type()), + "the default chord survives a chord added beside it" + ); + assert_eq!( + fired(cx, "ctrl-shift-tab").first(), + Some(&PrevTab::name_for_type()) + ); + assert_eq!( + fired(cx, "ctrl-alt-]").first(), + Some(&NextTab::name_for_type()), + "and the added chord works too" + ); + assert_eq!( + fired(cx, "ctrl-alt-[").first(), + Some(&PrevTab::name_for_type()) + ); + // The palette hint and the menus still name the chord the app + // ships with. + assert_eq!(effective_key("NextTab", cx).as_deref(), Some("ctrl-tab")); + }); + } + + #[gpui::test] + fn an_empty_chord_still_unbinds_the_action(cx: &mut TestAppContext) { + cx.update(|cx| { + // What config files already say to retire a default — the docs spell + // `"AlternatePaste": ""` — and what Settings used to write for an + // action that lost its chord. It has to keep meaning "no key". + running_on_json(cx, r#"{"keybindings": {"NextTab": ""}}"#); + assert!(fired(cx, "ctrl-tab").is_empty()); + assert_eq!(effective_key("NextTab", cx), None); + }); + } + + #[gpui::test] + fn a_list_is_the_whole_set_of_chords_for_an_action(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json( + cx, + r#"{"keybindings": {"NextTab": ["ctrl-alt-]", "ctrl-alt-n"]}}"#, + ); + assert!( + fired(cx, "ctrl-tab").is_empty(), + "a list replaces the default rather than joining it" + ); + for chord in ["ctrl-alt-]", "ctrl-alt-n"] { + assert_eq!( + fired(cx, chord).first(), + Some(&NextTab::name_for_type()), + "{chord} is in the list" + ); + } + }); + } + + #[gpui::test] + fn an_empty_list_unbinds_the_action(cx: &mut TestAppContext) { + cx.update(|cx| { + running_on_json(cx, r#"{"keybindings": {"NextTab": []}}"#); + assert!(fired(cx, "ctrl-tab").is_empty()); + assert_eq!(effective_key("NextTab", cx), None); + }); + } + + #[gpui::test] + fn a_chord_added_under_the_tmux_preset_joins_the_preset_chord(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json( + cx, + r#"{"keybinding_preset": "tmux", + "keybindings": {"NextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#, + ); + // The preset is a scheme, and it still replaces the default chord. + assert!(fired(cx, "ctrl-tab").is_empty()); + // A chord added on top of it is added to the preset's chord. + assert_eq!( + fired(cx, "ctrl-b n").first(), + Some(&NextTab::name_for_type()) + ); + assert_eq!( + fired(cx, "ctrl-alt-]").first(), + Some(&NextTab::name_for_type()) + ); + // A list replaces the preset's chord the way it replaces a default. + assert!(fired(cx, "ctrl-b %").is_empty()); + assert_eq!( + fired(cx, "ctrl-alt-d").first(), + Some(&SplitRight::name_for_type()) + ); + }); + } + + #[gpui::test] + fn a_chord_the_user_adds_wins_over_a_default_already_on_it(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + // `ctrl-tab` is `NextTab`'s default, and `NewTab` sits above it in the + // table. The keymap resolves a tie to the binding added last, so a + // user chord laid down in table order lost to the default whenever + // its action happened to come first — the line in config.json was + // read and did nothing. + running_on_json(cx, r#"{"keybindings": {"NewTab": "ctrl-tab"}}"#); + assert_eq!( + fired(cx, "ctrl-tab").first(), + Some(&NewTab::name_for_type()), + "the chord the user asked for is the one that runs" + ); + assert_eq!( + fired(cx, per_platform("secondary-t", "secondary-shift-t")).first(), + Some(&NewTab::name_for_type()), + "and NewTab keeps its own default" + ); + }); + } + #[gpui::test] fn repeated_rebinds_do_not_grow_the_keymap(cx: &mut TestAppContext) { cx.update(|cx| { From ef55505a6ad3c9e6d68b2ea89b76323bde60ef95 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:45:15 +0800 Subject: [PATCH 27/46] fix(agents): don't re-badge a turn the reader already saw when a pane is rebuilt Switching workspaces or reopening a window from the tray throws a pane's TerminalView away and builds a new one over the same daemon pane. The new view starts with no last status, so an agent that was already Done arrives as None -> Done, which poll_agent_status cannot tell from a turn finishing live, and every unfocused rebuilt pane got its unread badge back. The daemon now counts finished turns per agent session (turns, bumped on entering Done), and views leave an app-lifetime mark per (host, pane) of the session, turn count and badge the reader was last shown. A rebuilt view's first sight of Done takes the badge back from a matching mark instead of raising a new one; a turn that finished while the view was gone has no mark or a lower count, and still badges. Fixes #870 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/cli_agent.rs | 39 ++++ crates/tty7-core/src/daemon/control.rs | 1 + crates/tty7-core/src/daemon/pane.rs | 1 + crates/tty7-core/src/daemon/protocol.rs | 1 + src/terminal/remote.rs | 1 + src/terminal/view.rs | 267 +++++++++++++++++++++++- src/ui/app.rs | 2 +- 7 files changed, 303 insertions(+), 9 deletions(-) diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 952831e8..c4340f67 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -714,6 +714,12 @@ pub struct AgentSessionState { pub cwd: Option, #[serde(default)] pub activity: u64, + /// How many turns this session has finished: bumped each time it settles + /// into `Done`. The status alone cannot tell a client that attaches to a + /// `Done` pane whether that is the turn it already showed the reader or a + /// later one that finished while nobody was watching (#870). + #[serde(default)] + pub turns: u64, } impl AgentStatus { @@ -767,6 +773,9 @@ impl AgentSessionState { } } AgentEventKind::Stop => { + if self.status != AgentStatus::Done { + self.turns = self.turns.wrapping_add(1); + } self.status = AgentStatus::Done; self.message = ev.message.clone(); } @@ -1273,6 +1282,36 @@ mod tests { assert_eq!(s.activity, 4); } + #[test] + fn each_finished_turn_is_counted_once() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + assert_eq!(s.turns, 0, "a turn starting has not finished anything"); + + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1, "a repeated stop is the same turn"); + s.apply_event(&ev(AgentEventKind::Notification)); + assert_eq!(s.turns, 1); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 2, "a second turn is a second count"); + + s.apply_event(&ev(AgentEventKind::SessionEnd)); + assert_eq!(s.turns, 2); + } + #[test] fn session_state_tracks_and_releases_the_agent_cwd() { use std::path::PathBuf; diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index d8b01dd4..dc47d25c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1607,6 +1607,7 @@ mod tests { rich: true, cwd: Some("/work/api".into()), activity: 3, + turns: 1, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 29a0e542..7b6add69 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -5125,6 +5125,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 8bdad16f..0538c114 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1840,6 +1840,7 @@ mod tests { rich: true, cwd: Some("/repo/.claude/worktrees/fix-x".into()), activity: 12, + turns: 4, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index be4cee3e..4e561763 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -5630,6 +5630,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..93d27c88 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -191,6 +191,26 @@ pub struct ShellParts { pub(crate) owner: Option, } +/// What the reader was last shown of each pane's finished agent turn, kept for +/// the life of the app rather than of a view. +/// +/// A view is thrown away and built again over the same daemon pane whenever a +/// workspace is switched out and back or a window is reopened from the tray, +/// and a fresh view sees a `Done` agent arrive from nothing — exactly what a +/// turn finishing live looks like. This is how the new view tells the two apart +/// (#870). +#[derive(Default)] +struct AgentReadMarks(std::collections::HashMap<(crate::ui::host_ops::HostId, u64), AgentReadMark>); + +impl gpui::Global for AgentReadMarks {} + +#[derive(Clone)] +struct AgentReadMark { + session: (Option, Option>), + turns: u64, + unread: bool, +} + #[derive(Clone, Copy)] struct DragScroll { overshoot: f32, @@ -393,6 +413,9 @@ pub struct TerminalView { agent_was_rich: bool, agent_result_unread: bool, keep_unread_on_focus: bool, + /// Whether this view has seen its pane's agent status move at all. The + /// first move is where a rebuilt view consults [`AgentReadMarks`]. + agent_status_seen: bool, git_status_cwd: Option, last_agent_activity: u64, cmd: CmdEditor, @@ -1428,6 +1451,7 @@ impl TerminalView { view.keep_unread_on_focus = false; } else { view.agent_result_unread = false; + view.note_agent_result_unread(cx); } view.report_focus_change(true); cx.notify(); @@ -1588,6 +1612,7 @@ impl TerminalView { agent_was_rich: false, agent_result_unread: false, keep_unread_on_focus: false, + agent_status_seen: false, git_status_cwd: None, last_agent_activity: 0, cmd: CmdEditor::new(), @@ -1867,9 +1892,41 @@ impl TerminalView { self.agent_result_unread } - pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { + pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool, cx: &mut App) { self.agent_result_unread = true; self.keep_unread_on_focus = refocus_incoming; + self.note_agent_result_unread(cx); + } + + /// Leave what the reader has seen of this pane's agent where the pane's + /// next view will look for it — see [`AgentReadMarks`]. Anything but a + /// finished turn drops the mark: whatever finishes next is news. + fn record_agent_read_mark(&self, turns: u64, cx: &mut App) { + let key = (self.host_id, self.pane_id); + let marks = &mut cx.default_global::().0; + if self.last_agent_status == Some(crate::core::cli_agent::AgentStatus::Done) { + marks.insert( + key, + AgentReadMark { + session: self.last_agent_session.clone(), + turns, + unread: self.agent_result_unread, + }, + ); + } else { + marks.remove(&key); + } + } + + /// Carry a change to the badge alone into the mark the last status left. + fn note_agent_result_unread(&self, cx: &mut App) { + if !cx.has_global::() { + return; + } + let key = (self.host_id, self.pane_id); + if let Some(mark) = cx.global_mut::().0.get_mut(&key) { + mark.unread = self.agent_result_unread; + } } pub fn git_status(&self, cx: &App) -> Option { @@ -3909,6 +3966,30 @@ impl TerminalView { return false; } let prev = std::mem::replace(&mut self.last_agent_status, status); + let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); + let turns = session.as_ref().map_or(0, |s| s.turns); + + // A view built over a pane that already holds a finished turn sees + // `Done` arrive from nothing, the same as a turn finishing now. If the + // pane's previous view left a mark for this session at this turn count, + // it is the turn the reader was already shown: take their badge back as + // they left it instead of raising a new one (#870). A turn that + // finished after the old view went has no such mark, or a lower count. + if first_sight + && status == Some(AgentStatus::Done) + && let Some(mark) = cx + .try_global::() + .and_then(|marks| marks.0.get(&(self.host_id, self.pane_id))) + .filter(|mark| mark.session == self.last_agent_session && mark.turns == turns) + .cloned() + { + self.agent_result_unread = mark.unread && !self.focus_handle.is_focused(window); + self.keep_unread_on_focus = false; + self.record_agent_read_mark(turns, cx); + cx.notify(); + return false; + } + let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); match status { @@ -3928,6 +4009,7 @@ impl TerminalView { self.keep_unread_on_focus = false; } } + self.record_agent_read_mark(turns, cx); let rich = session.as_ref().is_some_and(|s| s.rich); let agent_name = self @@ -9870,6 +9952,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(daemon) .unwrap(); @@ -9925,6 +10008,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -9969,10 +10053,21 @@ mod gpui_tests { pane: &gpui::Entity, cx: &mut TestAppContext, daemon: &mut Stream, + ) { + report_agent_turn(status, 0, pane, cx, daemon); + } + + /// The same, for a session that has finished `turns` turns so far. + fn report_agent_turn( + status: crate::core::cli_agent::AgentStatus, + turns: u64, + pane: &gpui::Entity, + cx: &mut TestAppContext, + daemon: &mut Stream, ) { use crate::core::cli_agent::AgentSessionState; - DaemonMsg::AgentStatus(Some(AgentSessionState { + let state = AgentSessionState { status, message: None, session_id: Some("sid-abc".into()), @@ -9980,13 +10075,13 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, - })) - .encode(daemon) - .unwrap(); + turns, + }; + DaemonMsg::AgentStatus(Some(state.clone())) + .encode(daemon) + .unwrap(); for _ in 0..200 { - if cx.update(|cx| pane.read(cx).terminal.agent_session().map(|s| s.status)) - == Some(status) - { + if cx.update(|cx| pane.read(cx).terminal.agent_session()) == Some(state.clone()) { return; } std::thread::sleep(std::time::Duration::from_millis(5)); @@ -9994,6 +10089,161 @@ mod gpui_tests { panic!("the agent status never reached the pane"); } + /// Poll `pane`'s agent status inside `window` and read its badge back. + fn poll_unread( + window: gpui::WindowHandle, + pane: &gpui::Entity, + cx: &mut TestAppContext, + ) -> bool { + // Through the untyped handle: the typed one leases the root view, and + // `pane` may be that view. + cx.update_window(window.into(), |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + pane.agent_result_unread() + }) + }) + .unwrap() + } + + /// Switching workspaces, or reopening a window from the tray, throws the + /// pane's view away and builds a new one on the same daemon pane (#870). + /// The new view's first look at a `Done` agent is not a turn finishing — + /// the reader watched that one finish before the old view went. + #[gpui::test] + fn a_rebuilt_pane_does_not_re_badge_a_turn_the_reader_already_saw(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!( + !poll_unread(window, &before, cx), + "the reader watched it finish" + ); + + // The same daemon pane, rebuilt the way `tabs_from_session` rebuilds it, + // with the reader's focus somewhere else. + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + !poll_unread(window, &after, cx), + "rebuilding the pane is not a turn finishing" + ); + } + + /// What the rebuild must not swallow: a turn that was still running when + /// the view went away and finished before the new one arrived. + #[gpui::test] + fn a_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Working, 0, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "nobody saw this turn finish" + ); + } + + /// Nor a whole later turn: the reader saw turn one, the agent was sent + /// another and finished it while the pane was away. The status reads + /// `Done` both times; only the turn count tells them apart. + #[gpui::test] + fn a_later_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 2, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "the second turn finished unseen" + ); + } + + /// And a badge the reader had not cleared yet comes back with the pane. + #[gpui::test] + fn an_unread_turn_is_still_unread_after_the_pane_is_rebuilt(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + // Building another pane takes the window's focus off `before`. + let (_elsewhere, _elsewhere_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(5, window, cx)) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(poll_unread(window, &before, cx), "nobody was looking"); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "rebuilding the pane is not reading it" + ); + } + /// The badge answers "did the reader see this?", so it has to read the /// window's live focus rather than anything a focus callback left behind. /// @@ -10091,6 +10341,7 @@ mod gpui_tests { rich: true, cwd: Some(working_in.clone()), activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 54d5a7eb..254ec1f3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4834,7 +4834,7 @@ impl Tty7App { refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id()); leaf.update(cx, |view, cx| { if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) { - view.mark_agent_result_unread(refocus_incoming); + view.mark_agent_result_unread(refocus_incoming, cx); cx.notify(); } }); From 436ea04f33ab56e257d6ea8a27ec1f2ef2cf348b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:48:19 +0800 Subject: [PATCH 28/46] fix(keymap): a keybinding in config adds a chord instead of replacing the default (#868) effective_bindings kept one chord per action and set_binding overwrote that slot, so "NextTab": "cmd-shift-]" silently took Ctrl+Tab away. A string in keybindings now adds a chord beside the action's default (or preset) chord; "" still unbinds, as configs and the docs already rely on; a list is the exact chord set, [] unbinds. Configured chords are installed after every shipped one, so a chord the user names wins a tie with another action's default. The Settings page lists every chord of an action, and recording a shortcut writes the list shape (it sets the binding) and takes only the stolen chord from the action that had it. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/config.rs | 19 +++- docs/customization/keybindings.mdx | 17 ++- src/main.rs | 16 +-- src/ui/app.rs | 57 ++++++---- src/ui/keymap.rs | 168 +++++++++++++++++++++------- src/ui/settings.rs | 24 +++- 6 files changed, 226 insertions(+), 75 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index bdf715f2..4058a7b4 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -110,6 +110,23 @@ impl serde::Serialize for FontFeatures { } } +/// One action's line in `keybindings`. +/// +/// The two shapes mean different things, so a save writes back whichever one +/// was read. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(untagged)] +pub enum KeybindingOverride { + /// `"NextTab": "cmd-shift-]"` — a chord *beside* the ones the action + /// already has, the way VS Code, Zed and kitty read a line like it (#868). + /// Empty unbinds the action, which is what `""` has always meant here. + Add(String), + /// `"NextTab": ["cmd-shift-]"]` — exactly these chords, replacing the + /// default and the preset's. `[]` unbinds. This is what the Settings page + /// writes, because recording a shortcut there sets it. + Exact(Vec), +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Config { @@ -150,7 +167,7 @@ pub struct Config { pub window_backdrop: WindowBackdrop, #[serde(default = "default_true")] pub dim_inactive_panes: bool, - pub keybindings: HashMap, + pub keybindings: HashMap, #[serde(default = "default_preset")] pub keybinding_preset: String, #[serde(default = "default_prefix")] diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index ab36b884..63ad7344 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -39,13 +39,26 @@ the panel tabs. They are all in the command palette, and all bindable here. ```json { "keybindings": { - "SplitRight": "cmd-d", + "NextTab": "cmd-shift-]", + "SplitRight": ["cmd-d"], "ResizePaneLeft": "ctrl-alt-left", - "ToggleSftp": "cmd-shift-u" + "ToggleFullscreen": "" } } ``` +An action's value takes one of two shapes: + +| Value | Means | +|---|---| +| `"cmd-shift-]"` | **Add** this shortcut. The default keeps working — ⌃ ⇥ still switches tabs | +| `["cmd-d"]` | **Replace**: exactly these shortcuts, nothing else. List several to have several | +| `""` or `[]` | **Unbind** the action | + +Recording a shortcut on the Settings page replaces, so it writes the list +shape. A shortcut you add that another action already uses wins: it is the one +that runs. + The syntax is modifiers joined by `-`, then the key. Chords are separated by a space. diff --git a/src/main.rs b/src/main.rs index a0412c52..a5793199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -742,9 +742,10 @@ mod config_reload_tests { } fn bound_to_split_right(config: &mut Config, key: &str) { - config - .keybindings - .insert("SplitRight".to_string(), key.to_string()); + config.keybindings.insert( + "SplitRight".to_string(), + crate::core::config::KeybindingOverride::Add(key.to_string()), + ); } #[gpui::test] @@ -821,11 +822,10 @@ mod config_reload_tests { ); assert!(announced, "the breakage is announced"); assert_eq!( - cx.global::() - .keybindings - .get("SplitRight") - .map(String::as_str), - Some("ctrl-alt-9"), + cx.global::().keybindings.get("SplitRight"), + Some(&crate::core::config::KeybindingOverride::Add( + "ctrl-alt-9".to_string() + )), "the running config survives the broken file" ); assert_eq!( diff --git a/src/ui/app.rs b/src/ui/app.rs index 5055666a..b27e58c9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7194,16 +7194,32 @@ impl Tty7App { // only `same_chord` sees it. Compared as text, the displacement never // fires and both bindings survive onto that keystroke, where which one // wins is arbitrary (#750). - let displaced = crate::ui::keymap::effective_bindings(cx) + // + // Only that chord moves: the action that had it keeps any others it + // has, since an action can carry several (#868) and emptying it would + // take away keys that were never on this keystroke. An extra default — + // Alt+Enter beside Shift+Enter — follows its action's first chord rather + // than being one of its own, so its owner is unbound outright, as it + // always was. + use crate::ui::keymap::same_chord; + let displaced: Option<(String, Vec)> = crate::ui::keymap::effective_chords(cx) .into_iter() - .chain(crate::ui::keymap::extra_bindings(cx)) - .find(|(a, k)| *a != action && crate::ui::keymap::same_chord(k, &spec)) - .map(|(a, _)| a); + .find(|(a, chords)| *a != action && chords.iter().any(|k| same_chord(k, &spec))) + .map(|(a, chords)| { + let rest = chords.into_iter().filter(|k| !same_chord(k, &spec)); + (a, rest.collect()) + }) + .or_else(|| { + crate::ui::keymap::extra_bindings(cx) + .into_iter() + .find(|(a, k)| *a != action && same_chord(k, &spec)) + .map(|(a, _)| (a, Vec::new())) + }); // A trailing "…" on an action name marks a command that opens // something; it is not punctuation, and inside a sentence it reads as // the sentence trailing off — "Rename Tab… took the shortcut from". let in_prose = |name: &str| name.trim_end_matches('…').to_string(); - let note = displaced.as_ref().map(|other| { + let note = displaced.as_ref().map(|(other, _)| { t_fmt( L10nKey::AppKeybindingDisplacedNote, &[ @@ -7218,11 +7234,17 @@ impl Tty7App { ], ) }); + // Both written as lists. Recording a shortcut sets it — the row showed + // one chord and now shows another — and a bare string in config adds a + // chord beside the default instead (#868). self.update_config(cx, |cfg| { - if let Some(other) = &displaced { - cfg.keybindings.insert(other.clone(), String::new()); + use crate::core::config::KeybindingOverride; + if let Some((other, rest)) = &displaced { + cfg.keybindings + .insert(other.clone(), KeybindingOverride::Exact(rest.clone())); } - cfg.keybindings.insert(action, spec); + cfg.keybindings + .insert(action, KeybindingOverride::Exact(vec![spec])); }); crate::ui::keymap::rebind(cx); if let Some(s) = self.active_settings_mut() { @@ -10441,11 +10463,7 @@ mod keybinding_gpui_tests { // the row showed one chord and now shows another. A bare string in // config adds a chord beside the default (#868), which is not what // the person at the row just did. - wait_for_binding( - &mut vcx, - "NewTab", - serde_json::json!(["secondary-shift-n"]), - ); + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-shift-n"])); let recording = app.update_in(&mut vcx, |app, _, _| { app.active_settings().map(|s| s.recording.is_some()) @@ -10471,16 +10489,13 @@ mod keybinding_gpui_tests { } #[gpui::test] - fn recording_a_chord_another_action_also_has_takes_only_that_chord( - cx: &mut TestAppContext, - ) { + fn recording_a_chord_another_action_also_has_takes_only_that_chord(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); vcx.update(|_, cx| { - cx.global_mut::().keybindings = - serde_json::from_value(serde_json::json!({ - "NextTab": ["ctrl-tab", "secondary-alt-n"], - })) - .expect("the binding loads"); + cx.global_mut::().keybindings = serde_json::from_value(serde_json::json!({ + "NextTab": ["ctrl-tab", "secondary-alt-n"], + })) + .expect("the binding loads"); crate::ui::keymap::rebind(cx); }); begin_capture(&app, &mut vcx, "NewTab"); diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 447a6734..3cf3e14f 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1,7 +1,7 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; -use crate::core::config::Config; +use crate::core::config::{Config, KeybindingOverride}; use crate::terminal::view::{ AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, InsertNewlineFallback, PasteText, @@ -106,9 +106,9 @@ pub fn rebind(cx: &mut App) { /// own `save()`, which fires on a sidebar drag — so it compares the triple /// before and after and only rebinds when one of these actually moved; /// otherwise each save would rebuild the keymap for nothing (#548). -pub(crate) fn keybinding_config(cx: &App) -> (Vec<(String, String)>, String, String) { +pub(crate) fn keybinding_config(cx: &App) -> (Vec<(String, KeybindingOverride)>, String, String) { let cfg = cx.global::(); - let mut overrides: Vec<(String, String)> = cfg + let mut overrides: Vec<(String, KeybindingOverride)> = cfg .keybindings .iter() .map(|(a, k)| (a.clone(), k.clone())) @@ -881,28 +881,114 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { }) } -pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { - let cfg = cx.global::(); - let mut effective: Vec<(String, String)> = default_bindings() - .into_iter() - .map(|(a, k)| (a.to_string(), k.to_string())) - .collect(); - for (action, key) in preset_bindings(&cfg.keybinding_preset, &cfg.prefix) { - set_binding(&mut effective, &action, key); - } - for (action, key) in &cfg.keybindings { - set_binding(&mut effective, action, key.clone()); - } - effective +/// One action and the chords it answers to, split by where they came from: +/// the ones it ships with — its default, or the preset's in its place — and +/// the ones `config.json` names. +/// +/// Kept apart because they install apart. gpui resolves two bindings on one +/// chord to the one added last, and a chord the user asked for by name has to +/// be that one, wherever its action sits in the table. +struct ActionChords { + action: String, + shipped: Vec, + configured: Vec, } -fn set_binding(effective: &mut [(String, String)], action: &str, key: String) { - match effective.iter_mut().find(|(a, _)| a == action) { - Some(slot) => slot.1 = key, - // A hand-edited config.json with a typo used to vanish into this - // branch. The Keybindings page lists every name that works. - None => log::warn!("keybinding for unknown action {action:?} ignored"), +fn resolve_chords( + preset: &str, + prefix: &str, + overrides: &std::collections::HashMap, +) -> Vec { + let mut resolved: Vec = default_bindings() + .into_iter() + .map(|(action, key)| ActionChords { + action: action.to_string(), + shipped: [key] + .into_iter() + .filter(|k| !k.is_empty()) + .map(str::to_string) + .collect(), + configured: Vec::new(), + }) + .collect(); + // A preset is a scheme rather than an addition: its chord takes the + // default's place. + for (action, key) in preset_bindings(preset, prefix) { + if let Some(slot) = resolved.iter_mut().find(|s| s.action == action) { + slot.shipped = vec![key]; + } } + for (action, value) in overrides { + let Some(slot) = resolved.iter_mut().find(|s| s.action == *action) else { + // A hand-edited config.json with a typo used to vanish into this + // branch. The Keybindings page lists every name that works. + log::warn!("keybinding for unknown action {action:?} ignored"); + continue; + }; + match value { + // `""` has always been how a config retires a chord — the docs + // spell `"AlternatePaste": ""`, and Settings wrote it for an action + // whose chord was taken — so it goes on unbinding the action. + KeybindingOverride::Add(key) if key.is_empty() => { + slot.shipped.clear(); + } + // A chord beside the ones the action has, not in place of them + // (#868): `"NextTab": "cmd-shift-]"` is a second way to switch tabs, + // and Ctrl+Tab going dead because of it was the bug. + KeybindingOverride::Add(key) => { + if !slot.shipped.iter().any(|k| same_chord(k, key)) { + slot.configured.push(key.clone()); + } + } + KeybindingOverride::Exact(keys) => { + slot.shipped.clear(); + for key in keys { + if !key.is_empty() && !slot.configured.iter().any(|k| same_chord(k, key)) { + slot.configured.push(key.clone()); + } + } + } + } + } + resolved +} + +/// Every chord as an `(action, chord)` pair, in the order the keymap is built +/// from: all the shipped chords, then all the configured ones, so that a +/// configured chord landing on another action's default is the binding that +/// wins it. An action with no chord at all has no pair. +fn flatten_chords(resolved: &[ActionChords]) -> Vec<(String, String)> { + let pairs = |pick: fn(&ActionChords) -> &Vec| { + resolved + .iter() + .flat_map(move |s| pick(s).iter().map(|k| (s.action.clone(), k.clone()))) + }; + pairs(|s| &s.shipped) + .chain(pairs(|s| &s.configured)) + .collect() +} + +pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { + let cfg = cx.global::(); + flatten_chords(&resolve_chords( + &cfg.keybinding_preset, + &cfg.prefix, + &cfg.keybindings, + )) +} + +/// Every action in table order with all of its chords, shipped first — an +/// empty list for an action that has none. What a page listing the actions +/// reads; the keymap reads [`effective_bindings`]. +pub(crate) fn effective_chords(cx: &App) -> Vec<(String, Vec)> { + let cfg = cx.global::(); + resolve_chords(&cfg.keybinding_preset, &cfg.prefix, &cfg.keybindings) + .into_iter() + .map(|mut s| { + s.shipped.append(&mut s.configured); + (s.action, s.shipped) + }) + .collect() } fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> { @@ -1726,14 +1812,16 @@ mod tests { // one line in a `config.json`, so both are asserted against the whole // default table with that line applied — a bare one-entry table would // pass either assertion without the escape hatch working at all. - let mut retired = effective.clone(); - set_binding(&mut retired, "AlternatePaste", String::new()); + let with = |action: &str, value: KeybindingOverride| { + let overrides = std::collections::HashMap::from([(action.to_string(), value)]); + flatten_chords(&resolve_chords("default", "ctrl-b", &overrides)) + }; + let retired = with("AlternatePaste", KeybindingOverride::Add(String::new())); assert!( dispatched(&retired, "ctrl-v", "Terminal").is_empty(), "an emptied AlternatePaste gives Ctrl+V back to the shell" ); - let mut everywhere = effective.clone(); - set_binding(&mut everywhere, "PasteText", "ctrl-v".to_string()); + let everywhere = with("PasteText", KeybindingOverride::Add("ctrl-v".to_string())); for context in ["Terminal", "Terminal alt_screen"] { assert!( dispatched(&everywhere, "ctrl-v", context).contains(&PasteText::name_for_type()), @@ -2200,23 +2288,25 @@ mod gpui_tests { { let cfg = cx.global_mut::(); cfg.keybinding_preset = "tmux".to_string(); - cfg.keybindings - .insert("NewTab".to_string(), "secondary-shift-n".to_string()); + cfg.keybindings.insert( + "NewTab".to_string(), + KeybindingOverride::Add("secondary-shift-n".to_string()), + ); } rebind(cx); let eff = effective_bindings(cx); - let key_of = |action: &str| { + let keys_of = |action: &str| { eff.iter() - .find(|(a, _)| a == action) + .filter(|(a, _)| a == action) .map(|(_, k)| k.clone()) - .unwrap() + .collect::>() }; - assert_eq!(key_of("NewTab"), "secondary-shift-n"); - assert_eq!(key_of("SplitRight"), "ctrl-b %"); + assert_eq!(keys_of("NewTab"), ["ctrl-b c", "secondary-shift-n"]); + assert_eq!(keys_of("SplitRight"), ["ctrl-b %"]); assert_eq!( - key_of("TogglePalette"), - per_platform("secondary-p", "secondary-shift-p") + keys_of("TogglePalette"), + [per_platform("secondary-p", "secondary-shift-p")] ); cx.global_mut::().keybinding_preset = "default".to_string(); @@ -2251,9 +2341,10 @@ mod gpui_tests { assert_eq!(keybinding_config(cx), before); // A real binding edit moves it. - cx.global_mut::() - .keybindings - .insert("RenameTab".to_string(), "ctrl-shift-r".to_string()); + cx.global_mut::().keybindings.insert( + "RenameTab".to_string(), + KeybindingOverride::Add("ctrl-shift-r".to_string()), + ); assert_ne!(keybinding_config(cx), before); // So does the preset, and the prefix. @@ -2280,7 +2371,6 @@ mod gpui_tests { /// What the live keymap dispatches for `keys` typed in a terminal, best /// match first — the first entry is the action a real keypress runs. fn fired(cx: &gpui::App, keys: &str) -> Vec<&'static str> { - use gpui::Action as _; let input: Vec = keys .split(' ') .map(|k| Keystroke::parse(k).expect("the typed keystroke parses")) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 939f0f2d..0cf89b46 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -7200,7 +7200,7 @@ impl Tty7App { ) }; let tmux = preset == "tmux"; - let effective = crate::ui::keymap::effective_bindings(cx); + let effective = crate::ui::keymap::effective_chords(cx); let recording = self .active_settings() @@ -7308,7 +7308,7 @@ impl Tty7App { let filtering = !query.is_empty() && section_match_count(section, &query) > 0; let mut grouped: Vec<( crate::ui::palette::CommandGroup, - Vec<(String, String, String)>, + Vec<(String, Vec, String)>, )> = Vec::new(); for (action, key) in effective { if filtering && !keybinding_matches_query(&action, &query) { @@ -7330,7 +7330,7 @@ impl Tty7App { .position(|o| o == g) .unwrap_or(usize::MAX) }); - let rows: Vec<(String, String, String)> = grouped + let rows: Vec<(String, Vec, String)> = grouped .iter() .flat_map(|(_, rows)| rows.iter().cloned()) .collect(); @@ -7414,7 +7414,23 @@ impl Tty7App { .child("—") .into_any_element() } else { - keycaps(&key).into_any_element() + // An action can answer to more than one chord — its default and + // one added beside it in config.json (#868) — and this is the + // one page that lists them, so a row shows every one. + h_flex() + .flex_wrap() + .items_center() + .gap_2() + .children(key.iter().enumerate().map(|(n, spec)| { + h_flex() + .items_center() + .gap_2() + .when(n > 0, |d| { + d.child(div().text_xs().text_color(muted).child("/")) + }) + .child(keycaps(spec)) + })) + .into_any_element() }; let action_for_click = action.clone(); From 50957f312403302e84a651dfb5f3e5055d2711bd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:49:08 +0800 Subject: [PATCH 29/46] Keep SGR 2 text legible on light themes Faint text was painted as its ink at 66% alpha over the cell. On a light background that fixed fade collapses the WCAG ratio: Catppuccin Latte's foreground fell from 7.06:1 to 3.18:1, Rose Pine Dawn's to 3.08:1, and every bright-black the palette rescue had lifted to 4.5:1 fell back to ~2.5:1 on all four light builtins (#858). On a light cell the fade is now walked back toward the ink until it clears the 4.5:1 text floor, capped at the ink's own ratio. Dark cells, and the legible-palette switch turned off, keep the plain fade byte-for-byte. Fixes #858 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/element.rs | 207 +++++++++++++++++++++++++++++++++++++++- src/ui/presets.rs | 31 +++++- 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index c1a5b372..2d05b999 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -249,11 +249,46 @@ fn snapshot_cell( rc.selected = true; } if flags.contains(Flags::DIM) { - rc.fg.a *= DIM_OPACITY; + rc.fg = dim_fg(rc.fg, bgc, colors.legible_dim); } rc } +/// SGR 2's colour: the ink at `DIM_OPACITY` over its cell, or — on a light +/// cell where that fade would be illegible — the opaque ink +/// `presets::legible_dim` solved for. `legible` is Settings → Appearance's +/// palette switch; off keeps the plain fade everywhere. +fn dim_fg(fg: Hsla, under: Rgb, legible: bool) -> Hsla { + let mut faded = fg; + faded.a *= DIM_OPACITY; + if !legible { + return faded; + } + // A faint run is one (ink, background) pair repeated across the row, and + // the rescue is a contrast bisection — remember the last answer so a + // screen of dim text costs one solve per colour change, not one per cell. + thread_local! { + static LAST: std::cell::Cell)>> = + const { std::cell::Cell::new(None) }; + } + let (ink, bg) = (pack_rgb(super::palette::hsla_to_rgb(fg)), pack_rgb(under)); + let solved = LAST.with(|last| match last.get() { + Some((i, b, s)) if i == ink && b == bg => s, + _ => { + let s = crate::ui::presets::legible_dim(ink, bg, DIM_OPACITY); + last.set(Some((ink, bg, s))); + s + } + }); + match solved { + Some(c) => Hsla { + a: fg.a, + ..to_hsla(unpack_rgb(c)) + }, + None => faded, + } +} + fn active_selection_bg(cx: &gpui::App) -> Rgb { match cx.try_global::() { Some(a) => a.sel_bg, @@ -305,6 +340,9 @@ pub(super) struct PaintColors { current_match_bg: Hsla, fg_rgb: Rgb, bg_rgb: Rgb, + /// Whether faint text on a light cell is held at the text floor (see + /// `dim_fg`). Mirrors `theme_legible_palette`. + legible_dim: bool, } /// The under-colour a dimmed pane blends its content toward: the window @@ -444,6 +482,9 @@ impl PaintColors { current_match_bg, fg_rgb, bg_rgb, + legible_dim: cx + .try_global::() + .is_none_or(|c| c.theme_legible_palette), } } @@ -466,6 +507,7 @@ impl PaintColors { current_match_bg: blend_toward(self.current_match_bg, dim, under), fg_rgb: self.fg_rgb, bg_rgb: self.bg_rgb, + legible_dim: self.legible_dim, } } } @@ -2412,6 +2454,7 @@ mod tests { selection_bg: Hsla::default(), match_bg: Hsla::default(), current_match_bg: Hsla::default(), + legible_dim: true, fg_rgb: Rgb { r: 17, g: 17, @@ -3600,6 +3643,7 @@ mod tests { current_match_bg: wash(0.85), fg_rgb: fg, bg_rgb: bg, + legible_dim: true, } } @@ -3722,6 +3766,167 @@ mod tests { ); } + /// What a cell's foreground lands on screen as: its rgb composited over + /// the cell background by its alpha, the way the glyph is actually drawn. + fn on_screen(fg: Hsla, under: Rgb) -> u32 { + let c = Rgba::from(fg); + let ch = |v: f32, u: u8| ((v * c.a + (u as f32 / 255.) * (1. - c.a)) * 255.).round() as u32; + ch(c.r, under.r) << 16 | ch(c.g, under.g) << 8 | ch(c.b, under.b) + } + + /// The colours a pane on builtin `t` resolves cells with. + fn builtin_colors(t: &crate::ui::presets::Theme) -> (PaintColors, [Rgb; 256]) { + let (fg, bg) = (unpack_rgb(t.foreground), unpack_rgb(t.background_color())); + let mut colors = test_colors(); + colors.default_fg = to_hsla(fg); + colors.default_bg = to_hsla(bg); + colors.fg_rgb = fg; + colors.bg_rgb = bg; + let mut palette = super::super::palette::build(); + palette[..16].copy_from_slice(&t.active_palette(true).ansi16); + (colors, palette) + } + + /// Every colour faint text is commonly written in: the default foreground, + /// the sixteen palette slots, the 256-colour greys apps reach for as + /// "muted", and a truecolour grey. + fn faint_samples() -> Vec<(String, AnsiColor)> { + let mut v = vec![("fg".to_string(), AnsiColor::Named(NamedColor::Foreground))]; + for i in 0..16u8 { + v.push((format!("ansi{i}"), AnsiColor::Indexed(i))); + } + for i in [240u8, 244, 248, 250] { + v.push((format!("256:{i}"), AnsiColor::Indexed(i))); + } + v.push(( + "#999999".to_string(), + AnsiColor::Spec(Rgb { + r: 153, + g: 153, + b: 153, + }), + )); + v + } + + /// (plain, faint) on-screen colours of `color` as a pane on `t` paints them. + fn plain_and_faint( + colors: &PaintColors, + palette: &[Rgb; 256], + color: AnsiColor, + ) -> (RenderCell, RenderCell) { + let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); + let mut cell = Cell { + c: 'x', + fg: color, + ..Cell::default() + }; + let plain = snapshot_cell(&cell, point, palette, colors, None); + cell.flags = Flags::DIM; + let faint = snapshot_cell(&cell, point, palette, colors, None); + (plain, faint) + } + + #[test] + fn faint_text_keeps_the_text_floor_on_every_light_builtin() { + // #858: SGR 2 painted the ink at 66% over the cell, which on a light + // background took Catppuccin Latte's foreground to 3.2:1 and every + // bright-black the palette rescue had lifted to 4.5:1 back to ~2.5:1. + // Faint text on a light cell must now clear 4.5:1 — or, for an ink + // that never cleared it undimmed, stay at the ink's own ratio. + use crate::ui::presets::{builtins, contrast}; + let mut failures = Vec::new(); + eprintln!("| theme | colour | plain | faint |"); + for t in builtins().into_iter().filter(|t| !t.dark) { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + eprintln!("| {} | {name} | {plain:.2} | {faint:.2} |", t.id); + if faint < 4.5_f32.min(plain) - 0.05 { + failures.push(format!( + "{}/{name}: faint {faint:.2}:1 (plain {plain:.2}:1)", + t.id + )); + } + } + } + assert!( + failures.is_empty(), + "faint text under the floor:\n{}", + failures.join("\n") + ); + } + + #[test] + fn faint_text_on_dark_builtins_is_the_plain_fade() { + // The floor is a light-background rescue: every dark builtin must + // keep painting SGR 2 exactly as it did, as the ink at DIM_OPACITY. + for t in crate::ui::presets::builtins() + .into_iter() + .filter(|t| t.dark) + { + let (colors, palette) = builtin_colors(&t); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!( + faint.fg, fade, + "{}/{name}: dark theme faint text changed", + t.id + ); + } + } + } + + #[test] + fn faint_text_stays_fainter_than_plain_text() { + // The rescue buys legibility, not a restyle: faint text never gains + // contrast over its own ink, and an ink with room to spare above the + // floor still reads visibly fainter. + use crate::ui::presets::{builtins, contrast}; + for t in builtins() { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + assert!( + faint <= plain + 0.01, + "{}/{name}: faint {faint:.2} > plain {plain:.2}", + t.id + ); + if plain >= 6.0 { + assert!( + faint <= plain * 0.85, + "{}/{name}: faint {faint:.2} no longer reads fainter than {plain:.2}", + t.id + ); + } + } + } + } + + #[test] + fn faint_text_is_the_plain_fade_with_the_legibility_switch_off() { + // Settings → Appearance's palette switch off renders colours as + // authored; that includes faint text. + for t in crate::ui::presets::builtins() { + let (mut colors, palette) = builtin_colors(&t); + colors.legible_dim = false; + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!(faint.fg, fade, "{}/{name}: switch off still rescued", t.id); + } + } + } + #[test] fn blend_toward_mixes_in_rgb_space_and_keeps_alpha() { let under = Rgba { diff --git a/src/ui/presets.rs b/src/ui/presets.rs index f2f0a72f..2da3e61d 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -523,12 +523,41 @@ fn channel_distance(a: u32, b: u32) -> u32 { d(16).max(d(8)).max(d(0)) } -fn contrast(a: u32, b: u32) -> f32 { +pub(crate) fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; (hi + 0.05) / (lo + 0.05) } +/// The opaque ink SGR 2 (faint) text is painted in on a light cell, when the +/// plain fade would drop it under the text floor — `None` when the fade is +/// already legible and should stay a fade. +/// +/// Faint text is `opacity` of its ink over the cell. That costs a fixed share +/// of the ink's luminance distance, and on a light background the ratio that +/// distance buys collapses fast: Catppuccin Latte's own foreground fades from +/// 7.1:1 to 3.2:1, and every bright-black the palette rescue lifted to 4.5:1 +/// fades back to 2.5:1 — the "illegible secondary text" of #858. So the fade +/// is walked back toward the ink until it clears `TEXT_FLOOR` again, capped at +/// the ink's own ratio: text that was never above the floor is left as dim as +/// it would have been undimmed, not darkened past what the app asked for. +/// +/// Light backgrounds only. Dark themes read the same faint text at the same +/// ratios without complaint, and keeping them byte-for-byte as they were is +/// worth more than a symmetric rule nobody asked for. The test is the cell's +/// own background, so a light cell inside a dark theme is rescued too. +pub(crate) fn legible_dim(ink: u32, bg: u32, opacity: f32) -> Option { + if is_dark(bg) { + return None; + } + let faded = mix(bg, ink, opacity); + let floor = TEXT_FLOOR.min(contrast(ink, bg)); + if contrast(faded, bg) >= floor { + return None; + } + Some(bisect_contrast(faded, ink, bg, floor)) +} + fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 } From 6370fd4cdd7dd1a351ce9349f18fa32df4535f9c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:51:20 +0800 Subject: [PATCH 30/46] test(terminal): pin that a DECTCEM-hidden cursor paints no caret (#844) The issue reads `CursorShape::Hidden` as reachable only through DECSCUSR, but alacritty derives it from `TermMode::SHOW_CURSOR`, so `?25l` already suppresses the painted caret: focused bar/underline, the focused block's reverse-video cell, and the unfocused outline. Claude Code itself ends each frame with `?25h` at its input point, which is the caret the reporter saw. Route the paint decision through `GridSnapshot::painted_cursor` and pin it with a test that paints real frames for the reporter's stream shape (alt screen, 69 hides / 75 shows ending hidden): focused, unfocused, `?25h` restoring it, and a re-attach replay. With the hidden filter disabled the test fails with `Some((4, 12, Block))`. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/element.rs | 18 +++++- src/terminal/view.rs | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index c1a5b372..b3047b3b 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1583,6 +1583,20 @@ pub(super) struct GridSnapshot { history_size: usize, } +impl GridSnapshot { + /// The terminal caret this frame paints, if any. None while the program + /// has the cursor hidden: alacritty reports a DECTCEM-reset cursor + /// (`?25l`) as `CursorShape::Hidden`, and a TUI that hides it is usually + /// drawing a caret of its own that ours must not cover (#844). + pub(super) fn painted_cursor( + &self, + ) -> Option<(usize, usize, crate::core::config::CursorStyle)> { + self.cursor + .filter(|c| !c.hidden) + .map(|c| (c.row, c.col, c.style)) + } +} + impl TerminalElement { pub(super) fn build_grid( &self, @@ -2123,9 +2137,7 @@ impl Element for TerminalElement { let sliver = snap.sliver.as_ref(); let cursor_cell = cursor.map(|c| (c.row, c.ime_col)); - let render_cursor = cursor - .filter(|c| !c.hidden) - .map(|c| (c.row, c.col, c.style)); + let render_cursor = snap.painted_cursor(); // Reverse-video the block cursor's cell up front, so it rides the // normal background-then-glyph path instead of being tinted on top of diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..50fb8557 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -14822,6 +14822,125 @@ mod gpui_tests { ); } + /// #844: a TUI that resets DECTCEM and draws its own reverse-video caret + /// gets no terminal caret painted over it — focused, unfocused, and after + /// a re-attach replays its screen — and `?25h` brings the caret back. + /// + /// The stream is the reporter's shape: an alternate screen and 69 `?25l` + /// interleaved with 75 `?25h`, the last one a hide. What is checked is the + /// snapshot a real paint left behind, through the same `painted_cursor` + /// the element paints from. + #[gpui::test] + fn dectcem_reset_paints_no_terminal_caret_over_the_tuis_own(cx: &mut TestAppContext) { + use crate::core::config::CursorStyle; + + // An Ink-style frame: our own caret as one reverse-video cell at + // row 5 column 13, and the real cursor parked on it. + const FRAME: &[u8] = b"\x1b[5;1H\x1b[2K> type here \x1b[7m \x1b[27m\x1b[5;13H"; + let mut stream = b"\x1b[?1049h".to_vec(); + stream.extend(std::iter::repeat_n(&b"\x1b[?25h"[..], 7).flatten()); + for _ in 0..68 { + stream.extend_from_slice(b"\x1b[?25l"); + stream.extend_from_slice(FRAME); + stream.extend_from_slice(b"\x1b[?25h"); + } + stream.extend_from_slice(b"\x1b[?25l"); + stream.extend_from_slice(FRAME); + assert_eq!(stream.windows(6).filter(|w| w == b"\x1b[?25l").count(), 69); + assert_eq!(stream.windows(6).filter(|w| w == b"\x1b[?25h").count(), 75); + + type Painted = Option<(usize, usize, CursorStyle)>; + // Paints frames until the one the pane settles on matches `want`, and + // returns the last painted caret either way. + let paint_until = |window: &gpui::WindowHandle, + cx: &mut TestAppContext, + focused: bool, + want: &dyn Fn(Painted) -> bool| { + let mut painted = None; + for _ in 0..400 { + window + .update(cx, |view, window, cx| { + if focused { + window.activate_window(); + view.focus_handle.focus(window, cx); + } else { + window.blur(); + } + cx.notify(); + }) + .unwrap(); + let mut vcx = gpui::VisualTestContext::from_window((*window).into(), cx); + vcx.update(|window, _| window.refresh()); + vcx.run_until_parked(); + let (text, snap) = window + .update(cx, |view, window, _| { + assert_eq!(view.focus_handle.is_focused(window), focused); + use alacritty_terminal::grid::Dimensions as _; + use alacritty_terminal::index::{Column, Line}; + let term = view.terminal.term.lock(); + let row = &term.grid()[Line(4)]; + let text = (0..term.grid().columns()) + .map(|col| row[Column(col)].c) + .collect::(); + (text, view.grid_snap.as_ref().map(|s| s.painted_cursor())) + }) + .unwrap(); + if text.starts_with("> type here") + && let Some(p) = snap + { + painted = p; + if want(p) { + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + painted + }; + + let (window, mut daemon) = harness(cx); + DaemonMsg::Output(stream.clone()) + .encode(&mut daemon) + .unwrap(); + let focused = paint_until(&window, cx, true, &|p| p.is_none()); + assert_eq!( + focused, None, + "a focused pane painted its caret over a TUI that reset DECTCEM" + ); + let unfocused = paint_until(&window, cx, false, &|p| p.is_none()); + assert_eq!( + unfocused, None, + "an unfocused pane painted its hollow caret over a TUI that reset DECTCEM" + ); + + DaemonMsg::Output(b"\x1b[?25h".to_vec()) + .encode(&mut daemon) + .unwrap(); + let shown = paint_until(&window, cx, true, &|p| p.is_some()); + assert_eq!( + shown.map(|(row, col, _)| (row, col)), + Some((4, 12)), + "`?25h` must bring the caret back where the program parked it" + ); + + // Switching back to the tab: a brand new view replays the screen the + // daemon kept, then the prompt state, which says a program is running. + let (window, mut daemon) = harness(cx); + DaemonMsg::Snapshot(stream).encode(&mut daemon).unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + let replayed = paint_until(&window, cx, true, &|p| p.is_none()); + assert_eq!( + replayed, None, + "a re-attached pane painted its caret over a TUI that reset DECTCEM" + ); + } + #[gpui::test] fn child_exit_emits_the_close_event_but_disconnect_does_not(cx: &mut TestAppContext) { use std::cell::Cell; From eb5aae172d953ab53671aa869bc408181727deb6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:01:04 +0800 Subject: [PATCH 31/46] fix(agents): stop a reattach from badging every restored agent tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restarting the app leaves the daemon and every agent in it running, so each restored tab attaches to a pane whose turn ended long ago. The daemon replays that pane's stored agent status as an ordinary `AgentStatus` frame, and nothing on the wire said it was stored state — so the view read `None -> Done` as a turn finishing right then and put an unread badge on every agent tab that was not the focused one, for results the reader had already read. A relink did the same on every dropped link. Keep the distinction where the value lives: the shared slot now carries `replayed` alongside the status, under the same lock, so a reader cannot observe one without the other. The reader thread marks the first status frame on an attached link as the replay; the first frame of live output closes that window, which is what a pane with no session to replay needs (its next agent's first report is live, and must stay an edge). The view takes that mark once and adopts the status as its baseline instead of an edge. The notification path already required the previous status to be Working/Waiting, so only the badge was wrong. Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt --- src/terminal/remote.rs | 214 +++++++++++++++++++++++++++++++++++++++-- src/terminal/view.rs | 88 +++++++++++++++++ 2 files changed, 295 insertions(+), 7 deletions(-) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index be4cee3e..3ba287ae 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -16,7 +16,7 @@ use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursor use std::collections::VecDeque; -use crate::core::cli_agent::{AgentSessionState, CLIAgent}; +use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; use crate::core::config::CursorStyle as ConfigCursorStyle; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ @@ -64,12 +64,35 @@ struct ShellState { cycle: u64, } +/// The pane's agent status as the client last heard it, plus how it heard it. +/// +/// A reattach — the app restarting onto panes the daemon kept alive, or a +/// dropped link coming back — has the daemon replay the pane's *stored* status +/// as an ordinary `AgentStatus` frame ([`crate::daemon`]'s `replay_state`). +/// Nothing on the wire distinguishes it from a live transition, and a client +/// that reads it as one concludes that every restored agent finished its turn +/// in the instant the window opened. `replayed` is that distinction, kept in +/// the same lock as the value it describes so a reader can never observe the +/// status without also learning where it came from. +#[derive(Default)] +struct AgentSlot { + state: Option, + /// `state` arrived as an attach replay and no one has adopted it yet. + /// Cleared by the first taker — the view adopts it as a baseline rather + /// than as an edge. + replayed: bool, +} + struct ReaderSignals { cwd: Arc>>, shell: Arc>, remote: Arc>>, agent: Arc>>, - agent_session: Arc>>, + agent_session: Arc>, + /// Whether this link still owes us the attach replay. The reader keeps it + /// as a plain local: a link's replay is a property of that link's stream + /// position, and nothing outside the reader thread ever needs to read it. + awaiting_replay: bool, exited: Arc, child_exited: Arc, zle_reading: Arc, @@ -571,7 +594,7 @@ pub struct RemoteTerminal { ssh_user: Option, auto_supplied_password: bool, agent: Arc>>, - agent_session: Arc>>, + agent_session: Arc>, /// Kitty-graphics images placed on this pane's grid (issue #213). /// Written by the reader thread from out-of-band `Image`/`DeleteImage` /// frames, read by the paint path — only the client holds the grid the @@ -819,7 +842,8 @@ impl RemoteTerminal { } Err(e) => return Err(e), }; - let mut term = Self::from_stream_with(stream, size, buffered, PtySource::for_route(route))?; + let mut term = + Self::from_stream_parts(stream, size, buffered, PtySource::for_route(route), true)?; term.route = route.clone(); Ok(term) } @@ -891,6 +915,10 @@ impl RemoteTerminal { remote: self.remote_context.clone(), agent: self.agent.clone(), agent_session: self.agent_session.clone(), + // A relink attaches to the pane all over again, so the daemon + // replays its stored agent status down the new link just as it + // does on a cold attach. + awaiting_replay: true, exited: self.exited_flag.clone(), child_exited: self.child_exited.clone(), zle_reading: self.zle_reading.clone(), @@ -920,6 +948,20 @@ impl RemoteTerminal { Ok(()) } + /// A pane the client *reattached* to rather than spawned — the shape the + /// app restores last session's tabs in, where the head of the stream is the + /// daemon replaying state the pane already had. + #[cfg(test)] + pub(super) fn from_stream_reattached(stream: Stream, size: TermSize) -> anyhow::Result { + Self::from_stream_parts( + stream, + size, + Vec::new(), + PtySource::for_route(&PaneRoute::Local), + true, + ) + } + /// A pane on a pty of this machine's own — what the tests build, and what /// `spawn_on` narrows with the route it dialled. pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result { @@ -936,6 +978,22 @@ impl RemoteTerminal { size: TermSize, buffered: Vec, pty: PtySource, + ) -> anyhow::Result { + Self::from_stream_parts(stream, size, buffered, pty, false) + } + + /// `awaiting_replay` says this link is an attach rather than a spawn, and + /// so that the frames at the head of its stream describe a pane that was + /// already running — see [`AgentSlot`]. It has to be decided here rather + /// than set on the returned terminal: the reader starts inside this + /// function, and against a daemon that answers promptly the replay can be + /// parsed before the caller gets its value back. + fn from_stream_parts( + stream: Stream, + size: TermSize, + buffered: Vec, + pty: PtySource, + awaiting_replay: bool, ) -> anyhow::Result { let read_half = stream.try_clone()?; let write_half = stream; @@ -955,7 +1013,7 @@ impl RemoteTerminal { let shell_state: Arc> = Arc::new(Mutex::new(ShellState::default())); let remote_context: Arc>> = Arc::new(Mutex::new(None)); let agent: Arc>> = Arc::new(Mutex::new(None)); - let agent_session: Arc>> = Arc::new(Mutex::new(None)); + let agent_session: Arc> = Arc::new(Mutex::new(AgentSlot::default())); let exited_flag = Arc::new(AtomicBool::new(false)); let child_exited = Arc::new(AtomicBool::new(false)); let zle_reading = Arc::new(AtomicBool::new(false)); @@ -982,6 +1040,7 @@ impl RemoteTerminal { remote: remote_context.clone(), agent: agent.clone(), agent_session: agent_session.clone(), + awaiting_replay, exited: exited_flag.clone(), child_exited: child_exited.clone(), zle_reading: zle_reading.clone(), @@ -1092,6 +1151,7 @@ impl RemoteTerminal { remote, agent, agent_session, + awaiting_replay, exited: exited_flag, child_exited, zle_reading, @@ -1104,6 +1164,7 @@ impl RemoteTerminal { clipboard_write_busy, repair_cursor, } = signals; + let mut awaiting_replay = awaiting_replay; crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; let mut processor: ansi::Processor = ansi::Processor::new(); @@ -1327,6 +1388,16 @@ impl RemoteTerminal { proxy.send_event(AlacEvent::Wakeup); } DaemonMsg::Output(bytes) => { + // Live output only ever follows the whole + // replay (the daemon sends the stored status + // last, and the stream keeps that order), so + // the first frame here ends the window in which + // a status can still be a replayed one. Without + // this, a pane that had no agent session to + // replay would keep the window open until some + // agent it ran *later* reported for the first + // time, and that report would be discounted. + awaiting_replay = false; out_batch.extend_from_slice(&bytes); tr_frames += 1; } @@ -1508,7 +1579,11 @@ impl RemoteTerminal { DaemonMsg::AgentStatus(state) => { flush_batch!(); if let Ok(mut guard) = agent_session.lock() { - *guard = state; + guard.state = state; + // The first such frame on an attached link + // is the pane's stored status being + // replayed, not a turn changing state now. + guard.replayed = std::mem::take(&mut awaiting_replay); } proxy.send_event(AlacEvent::Wakeup); } @@ -1729,7 +1804,22 @@ impl RemoteTerminal { } pub fn agent_session(&self) -> Option { - self.agent_session.lock().ok().and_then(|g| g.clone()) + self.agent_session.lock().ok().and_then(|g| g.state.clone()) + } + + /// The status the daemon replayed when this link attached, handed out once. + /// + /// `Some(status)` means what [`Self::agent_session`] reports right now is + /// stored state from before this client existed — the caller should take it + /// as its starting point, not as something that just happened. Answering + /// only once is what keeps the very next live transition an edge again. + pub fn take_replayed_agent_status(&self) -> Option> { + let mut guard = self.agent_session.lock().ok()?; + if !guard.replayed { + return None; + } + guard.replayed = false; + Some(guard.state.as_ref().map(|s| s.status)) } pub fn zle_reading(&self) -> bool { @@ -5604,6 +5694,116 @@ mod tests { assert!(poll(None), "agent exit should clear it"); } + /// The daemon replays a reattached pane's stored agent status as an + /// ordinary report. Nothing on the wire says so, so the link has to + /// remember that the first report it hears is that replay — and that + /// everything after it is live. + #[test] + fn a_reattached_link_marks_only_its_first_status_report_as_replayed() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)).unwrap(); + assert_eq!( + term.take_replayed_agent_status(), + None, + "nothing replayed until the frame actually arrives" + ); + + let report = |status, daemon: &mut UnixStream| { + DaemonMsg::AgentStatus(Some(AgentSessionState { + status, + message: None, + session_id: Some("sid-1".into()), + launch_argv: None, + rich: true, + cwd: None, + activity: 0, + })) + .encode(daemon) + .unwrap(); + daemon.flush().unwrap(); + }; + let poll = |want: AgentStatus| { + for _ in 0..200 { + if term.agent_session().map(|s| s.status) == Some(want) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + report(AgentStatus::Done, &mut daemon_side); + assert!(poll(AgentStatus::Done), "the replayed status should land"); + assert_eq!( + term.take_replayed_agent_status(), + Some(Some(AgentStatus::Done)), + "the first report on a reattached link is stored state" + ); + assert_eq!( + term.take_replayed_agent_status(), + None, + "only one taker gets it" + ); + + report(AgentStatus::Working, &mut daemon_side); + assert!(poll(AgentStatus::Working), "the live status should land"); + assert_eq!( + term.take_replayed_agent_status(), + None, + "everything after the replay is something the client watched happen" + ); + } + + /// A pane with no agent session to replay sends no status frame at all, so + /// the replay window has to close on its own — otherwise the first report + /// from an agent launched *later* would be discounted as stored state. + #[test] + fn live_output_closes_the_replay_window() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)).unwrap(); + + DaemonMsg::Output(b"$ claude\r\n".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + DaemonMsg::AgentStatus(Some(AgentSessionState { + status: AgentStatus::Done, + message: None, + session_id: Some("sid-1".into()), + launch_argv: None, + rich: true, + cwd: None, + activity: 0, + })) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + for _ in 0..200 { + if term.agent_session().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!( + term.agent_session().map(|s| s.status), + Some(AgentStatus::Done), + "the status still lands" + ); + assert_eq!( + term.take_replayed_agent_status(), + None, + "a report that follows live output is live" + ); + } + #[test] fn agent_session_follows_daemon_status_reports() { use crate::core::cli_agent::{AgentSessionState, AgentStatus}; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d77f506..a35b3d97 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -3887,6 +3887,17 @@ impl TerminalView { ) -> bool { use crate::core::cli_agent::AgentStatus; + // Attaching to a pane the daemon kept alive — the app restarting onto + // last session's tabs, a dropped link coming back — has the daemon + // replay the pane's stored agent status as an ordinary report. It is a + // baseline, not an edge: the turn it describes ended before this view + // existed, often before this process did, and reading it as "a result + // just landed" is what used to bring every restored agent tab up + // wearing an unread badge for output its reader had long since read. + if let Some(restored) = self.terminal.take_replayed_agent_status() { + self.last_agent_status = restored; + } + let session = self.terminal.agent_session(); if session.as_ref().is_some_and(|s| s.rich) { self.agent_was_rich = true; @@ -9695,6 +9706,22 @@ pub(crate) fn quiet_test_pane( (view, daemon_side) } +/// The same pane, but reattached rather than spawned — what restoring last +/// session's tabs builds, and the only shape in which the daemon replays state +/// the pane already had. +#[cfg(test)] +pub(crate) fn quiet_reattached_test_pane( + pane_id: u64, + window: &mut Window, + cx: &mut gpui::App, +) -> (gpui::Entity, crate::daemon::transport::Stream) { + let (client_side, daemon_side) = test_stream_pair(); + let terminal = RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)) + .expect("quiet reattached test terminal"); + let view = cx.new(|cx| TerminalView::with_terminal(terminal, pane_id, window, cx)); + (view, daemon_side) +} + /// A quiet pane that was dialled by hand, with no saved host behind it. /// /// Ungated on purpose: the transport this hands back is already @@ -10039,6 +10066,67 @@ mod gpui_tests { .unwrap(); } + /// Reinstalling or restarting the app leaves the daemon — and every agent + /// in it — running, so each restored tab reattaches to a pane whose agent + /// finished its turn long ago. The daemon replays that status, and reading + /// it as a turn that just landed put an unread badge on every agent tab in + /// the window the moment it opened. + #[gpui::test] + fn a_restored_pane_does_not_badge_the_turn_it_reattached_to(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let (pane, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + report_agent_status(AgentStatus::Done, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + !pane.agent_result_unread(), + "the replayed status is where this pane starts, not a result that \ + just arrived" + ); + }); + }) + .unwrap(); + + // And the pane is still armed: the next turn it actually watches finish + // badges exactly as it would have without the reattach. + report_agent_status(AgentStatus::Working, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + }); + }) + .unwrap(); + report_agent_status(AgentStatus::Done, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + pane.agent_result_unread(), + "a turn that finished while the reader was elsewhere is unread" + ); + }); + }) + .unwrap(); + } + #[gpui::test] fn a_finished_turn_on_the_focused_pane_is_already_read(cx: &mut TestAppContext) { use crate::core::cli_agent::AgentStatus; From 7c0e339be45adeaae8164bade2a0f99396d131b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:01:51 +0800 Subject: [PATCH 32/46] docs(keymap): terminal shortcuts keep their chord; upgrading configs (#868) A chord added in config wins a tie only against another app-wide action. Copy, paste, find, clear scrollback and insert newline are bound in the Terminal context, which is deeper, so they keep their chord while a terminal is focused. Also tells people upgrading that a shortcut recorded in Settings up to 26.9.2 was saved as a string. It now adds instead of replacing, and a list brings back the old meaning. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- docs/customization/keybindings.mdx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index 63ad7344..cc699d7c 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -56,8 +56,14 @@ An action's value takes one of two shapes: | `""` or `[]` | **Unbind** the action | Recording a shortcut on the Settings page replaces, so it writes the list -shape. A shortcut you add that another action already uses wins: it is the one -that runs. +shape. A shortcut you add that another action already uses is the one that +runs. The exception is the terminal's own shortcuts: copy, paste, find, clear +scrollback and insert newline keep theirs while a terminal has focus. + +Up to 26.9.2 a string replaced the default, and the Settings page wrote +strings. So a shortcut you recorded in those versions now works *beside* the +default it was meant to replace. To drop the default again, record the shortcut again +or wrap it in a list: `"NewTab": ["cmd-shift-n"]`. The syntax is modifiers joined by `-`, then the key. Chords are separated by a space. From 0f046c95d8922ac93a16453c32f9044e227f9ffb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:03:17 +0800 Subject: [PATCH 33/46] fix(agents): a relink's replay is still a live report to the view A relink keeps the view and the status it saw before the link dropped, so that is already the baseline. Marking the relink's replay as stored state replaced a `Working` baseline with the daemon's `Done` and swallowed the turn that finished while the link was down: no badge, no notification. Only a cold attach, where the view has nothing to go on, adopts the replay. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/remote.rs | 19 ++++++++++--- src/terminal/view.rs | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 3ba287ae..22355864 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -890,6 +890,17 @@ impl RemoteTerminal { let read_half = stream.try_clone()?; + // A relink keeps the view, and the view keeps the status it last saw + // before the link dropped. That is already a baseline, and the better + // one: if the agent finished its turn while the link was down, the + // daemon's replayed `Done` against the view's `Working` is exactly the + // edge the reader must be told about. So the relink's replay is read + // as a live report, and a cold-attach mark nobody took yet is dropped + // rather than left to swallow that edge. + if let Ok(mut guard) = self.agent_session.lock() { + guard.replayed = false; + } + self.exited_flag.store(false, Ordering::SeqCst); self.exited = false; { @@ -915,10 +926,10 @@ impl RemoteTerminal { remote: self.remote_context.clone(), agent: self.agent.clone(), agent_session: self.agent_session.clone(), - // A relink attaches to the pane all over again, so the daemon - // replays its stored agent status down the new link just as it - // does on a cold attach. - awaiting_replay: true, + // The daemon does replay the stored status down this link, but + // the view already has a baseline from before the drop — see + // above. + awaiting_replay: false, exited: self.exited_flag.clone(), child_exited: self.child_exited.clone(), zle_reading: self.zle_reading.clone(), diff --git a/src/terminal/view.rs b/src/terminal/view.rs index a35b3d97..95ebfea1 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -10127,6 +10127,67 @@ mod gpui_tests { .unwrap(); } + /// A relink keeps the view and what it last saw. A turn that was running + /// when the link dropped and finished before it came back reaches the view + /// only as the daemon's replay, and that replay has to badge: nobody saw + /// the turn finish. + #[gpui::test] + fn a_turn_that_finished_while_the_link_was_down_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let (pane, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + report_agent_status(AgentStatus::Working, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!(!pane.agent_result_unread(), "the turn is still running"); + }); + }) + .unwrap(); + + let (new_client, mut new_daemon) = super::test_stream_pair(); + pane.update(cx, |pane, cx| { + pane.adopt_relink( + new_client, + Vec::new(), + &crate::terminal::PaneRoute::Local, + TermSize::new(80, 24), + 8, + 17, + cx, + ) + .expect("the swap itself cannot fail"); + }); + drop(daemon); + + report_agent_status(AgentStatus::Done, &pane, cx, &mut new_daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + pane.agent_result_unread(), + "the turn finished while the link was down, so nobody read it" + ); + }); + }) + .unwrap(); + } + #[gpui::test] fn a_finished_turn_on_the_focused_pane_is_already_read(cx: &mut TestAppContext) { use crate::core::cli_agent::AgentStatus; From dbee51396f578e7087087ff5cc6b666c2b958905 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:06:53 +0800 Subject: [PATCH 34/46] fix(agents): let #888's read marks decide a reattach this app already watched With both fixes on main, the replayed status adopted as a baseline made `status == last_agent_status` return before the read-mark lookup ran, so every rebuild (workspace switch, tray reopen, both of which reattach) skipped #888: an unread badge was dropped and a turn that finished while away never badged. A replay is now only a baseline when the pane has no mark, i.e. this app never watched an agent in it (the restart case #890 is about). Marks are kept for every status, not just Done, so a turn that was running when the old view went still badges, and the restore path checks the mark was a finished turn. Also adds `turns` to #890's unix-only remote.rs tests, which no longer compiled against #888's field. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/terminal/remote.rs | 2 + src/terminal/view.rs | 150 +++++++++++++++++++++++++++++++++-------- 2 files changed, 125 insertions(+), 27 deletions(-) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 69832f65..ec225a46 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -5732,6 +5732,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(daemon) .unwrap(); @@ -5792,6 +5793,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 93803eab..4bd53e5a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -207,6 +207,11 @@ impl gpui::Global for AgentReadMarks {} #[derive(Clone)] struct AgentReadMark { session: (Option, Option>), + /// The status the pane's last view saw. Kept for every status, not only + /// `Done`, so that a pane with no mark at all is one this app never watched + /// an agent in — the only case where a reattach's replayed status may be + /// taken as a baseline (see `poll_agent_status`). + status: Option, turns: u64, unread: bool, } @@ -1903,23 +1908,20 @@ impl TerminalView { } /// Leave what the reader has seen of this pane's agent where the pane's - /// next view will look for it — see [`AgentReadMarks`]. Anything but a - /// finished turn drops the mark: whatever finishes next is news. + /// next view will look for it — see [`AgentReadMarks`]. A mark left at any + /// status other than `Done` never vouches for a finished turn, but it still + /// records that this app was watching: a turn that was running when the + /// view went and finished before the next one came must badge. fn record_agent_read_mark(&self, turns: u64, cx: &mut App) { - let key = (self.host_id, self.pane_id); - let marks = &mut cx.default_global::().0; - if self.last_agent_status == Some(crate::core::cli_agent::AgentStatus::Done) { - marks.insert( - key, - AgentReadMark { - session: self.last_agent_session.clone(), - turns, - unread: self.agent_result_unread, - }, - ); - } else { - marks.remove(&key); - } + cx.default_global::().0.insert( + (self.host_id, self.pane_id), + AgentReadMark { + session: self.last_agent_session.clone(), + status: self.last_agent_status, + turns, + unread: self.agent_result_unread, + }, + ); } /// Carry a change to the badge alone into the mark the last status left. @@ -3957,15 +3959,31 @@ impl TerminalView { use crate::core::cli_agent::AgentStatus; // Attaching to a pane the daemon kept alive — the app restarting onto - // last session's tabs, a dropped link coming back — has the daemon - // replay the pane's stored agent status as an ordinary report. It is a - // baseline, not an edge: the turn it describes ended before this view - // existed, often before this process did, and reading it as "a result - // just landed" is what used to bring every restored agent tab up - // wearing an unread badge for output its reader had long since read. - if let Some(restored) = self.terminal.take_replayed_agent_status() { - self.last_agent_status = restored; - } + // last session's tabs above all — has the daemon replay the pane's + // stored agent status as an ordinary report. When this app has never + // watched the pane, that is a baseline, not an edge: the turn it + // describes ended before this view existed, often before this process + // did, and reading it as "a result just landed" is what used to bring + // every restored agent tab up wearing an unread badge for output its + // reader had long since read. + // + // When an earlier view of this app did watch the pane (a workspace + // switched out and back, a window reopened from the tray), its read + // mark knows more than the replay does, so the replay stays an edge and + // the mark decides below: the same finished turn takes its badge back + // as the reader left it, anything else is news (#870). + let adopted_baseline = match self.terminal.take_replayed_agent_status() { + Some(restored) + if !cx + .try_global::() + .is_some_and(|marks| marks.0.contains_key(&(self.host_id, self.pane_id))) => + { + self.last_agent_status = restored; + self.agent_status_seen = true; + true + } + _ => false, + }; let session = self.terminal.agent_session(); if session.as_ref().is_some_and(|s| s.rich) { @@ -3985,12 +4003,17 @@ impl TerminalView { } let status = session.as_ref().map(|s| s.status); + let turns = session.as_ref().map_or(0, |s| s.turns); + if adopted_baseline { + // From here on this app is watching the pane, so a later rebuild + // must find a mark rather than adopt its own replay. + self.record_agent_read_mark(turns, cx); + } if status == self.last_agent_status { return false; } let prev = std::mem::replace(&mut self.last_agent_status, status); let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); - let turns = session.as_ref().map_or(0, |s| s.turns); // A view built over a pane that already holds a finished turn sees // `Done` arrive from nothing, the same as a turn finishing now. If the @@ -4003,7 +4026,11 @@ impl TerminalView { && let Some(mark) = cx .try_global::() .and_then(|marks| marks.0.get(&(self.host_id, self.pane_id))) - .filter(|mark| mark.session == self.last_agent_session && mark.turns == turns) + .filter(|mark| { + mark.status == Some(AgentStatus::Done) + && mark.session == self.last_agent_session + && mark.turns == turns + }) .cloned() { self.agent_result_unread = mark.unread && !self.focus_handle.is_focused(window); @@ -10389,6 +10416,75 @@ mod gpui_tests { .unwrap(); } + /// Build `pane_id`'s first view (unfocused), let it watch `status` at + /// `turns`, then rebuild the pane the way restoring a workspace does — a + /// reattach, whose head is the daemon replaying `replayed` — and read the + /// rebuilt view's badge. + fn rebuild_by_reattach( + watched: (crate::core::cli_agent::AgentStatus, u64), + replayed: (crate::core::cli_agent::AgentStatus, u64), + cx: &mut TestAppContext, + ) -> bool { + let (window, _root_daemon) = harness(cx); + let focus_root = |cx: &mut TestAppContext| { + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + }; + let (before, mut before_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(2, window, cx)) + .unwrap(); + focus_root(cx); + report_agent_turn(watched.0, watched.1, &before, cx, &mut before_daemon); + poll_unread(window, &before, cx); + drop(before); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + focus_root(cx); + report_agent_turn(replayed.0, replayed.1, &after, cx, &mut daemon); + poll_unread(window, &after, cx) + } + + /// A reattach whose pane this app already watched is a rebuild, not a + /// restart: the read mark, not the replay, says whether the turn is news. + #[gpui::test] + fn a_reattached_pane_takes_back_the_badge_its_reader_left(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + assert!( + rebuild_by_reattach((AgentStatus::Done, 1), (AgentStatus::Done, 1), cx), + "the reader never cleared that badge; rebuilding the pane is not reading it" + ); + } + + #[gpui::test] + fn a_reattached_pane_badges_a_turn_that_was_running_when_its_view_went( + cx: &mut TestAppContext, + ) { + use crate::core::cli_agent::AgentStatus; + assert!( + rebuild_by_reattach((AgentStatus::Working, 0), (AgentStatus::Done, 1), cx), + "nobody saw this turn finish" + ); + } + + #[gpui::test] + fn a_reattached_pane_badges_a_later_turn_that_finished_while_away(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + // Turn one left a mark; turn two finishing before the reattach is a + // different count, so the mark does not vouch for it. + assert!( + rebuild_by_reattach((AgentStatus::Done, 1), (AgentStatus::Done, 2), cx), + "the second turn finished unseen" + ); + } + /// A relink keeps the view and what it last saw. A turn that was running /// when the link dropped and finished before it came back reaches the view /// only as the daemon's replay, and that replay has to badge: nobody saw From dd9446efe4991b6027f66fd3b0a4a4daa9455d94 Mon Sep 17 00:00:00 2001 From: ian Date: Thu, 17 Sep 2026 15:03:53 +0800 Subject: [PATCH 35/46] fix(terminal): preserve newline chords through conpty ConPTY translates bare LF into Ctrl+Enter, so native Codex drops the legacy Shift+Enter fallback and Ctrl+J. Preserve Ctrl+J for local console readers while retaining raw PTY and Kitty input behavior. Refs l0ng-ai/tty7#894 --- src/terminal/input.rs | 83 +++++++++++++++++++++++++++++++++++++++++- src/terminal/remote.rs | 42 ++++++++------------- src/terminal/view.rs | 65 ++++++++++++++++++++++++++++++--- 3 files changed, 157 insertions(+), 33 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 03ec4344..86ac5aeb 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -6,7 +6,7 @@ use super::view::TerminalView; use crate::core::config::Config; /// Everything about the terminal's current state that changes how a keystroke -/// is encoded: the kitty protocol flags, plus DECCKM (application cursor keys). +/// is encoded: keyboard modes and the PTY that receives the bytes. #[derive(Clone, Copy, Default)] pub(super) struct KeyFlags { disambiguate: bool, @@ -15,6 +15,7 @@ pub(super) struct KeyFlags { /// DECCKM. ncurses apps turn this on via `smkx` and then only recognise the /// SS3 form of the arrow keys, because that is what `kcuu1` & co. spell. app_cursor: bool, + local_conpty: bool, } impl KeyFlags { @@ -24,6 +25,14 @@ impl KeyFlags { report_all_keys: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), report_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), app_cursor: mode.contains(TermMode::APP_CURSOR), + local_conpty: false, + } + } + + pub(super) fn from_mode_with_local_conpty(mode: &TermMode, local_conpty: bool) -> Self { + Self { + local_conpty, + ..Self::from_mode(mode) } } @@ -31,6 +40,17 @@ impl KeyFlags { self.disambiguate || self.report_all_keys } + pub(super) fn legacy_newline_bytes(self) -> &'static [u8] { + if self.local_conpty { + // ConPTY decodes bare LF as Ctrl+Enter. Explicit Ctrl+J events + // preserve the key for native readers and still produce LF for + // VT readers such as ssh and WSL. + b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_" + } else { + b"\n" + } + } + pub(super) fn app_cursor(self) -> bool { self.app_cursor } @@ -374,6 +394,9 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option KeyFlags { @@ -558,6 +582,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, } } @@ -567,6 +592,7 @@ mod tests { report_all_keys: false, report_text: false, app_cursor: false, + local_conpty: false, } } @@ -582,6 +608,54 @@ mod tests { } } + #[test] + fn legacy_newline_preserves_ctrl_j_for_native_console_readers() { + let ctrl_j = Keystroke::parse("ctrl-j").unwrap(); + for (local_conpty, expected) in [ + (false, b"\n".as_slice()), + (true, b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_".as_slice()), + ] { + let flags = KeyFlags::from_mode_with_local_conpty(&TermMode::empty(), local_conpty); + assert_eq!(flags.legacy_newline_bytes(), expected); + assert_eq!( + keystroke_to_bytes(&ctrl_j, flags).as_deref(), + Some(expected) + ); + for (chord, expected) in [ + ("enter", b"\r".as_slice()), + ("ctrl-c", b"\x03".as_slice()), + ("alt-enter", b"\x1b\r".as_slice()), + ("ctrl-alt-j", b"\x1b\n".as_slice()), + ] { + assert_eq!( + keystroke_to_bytes(&Keystroke::parse(chord).unwrap(), flags).as_deref(), + Some(expected), + "{chord}, local_conpty={local_conpty}" + ); + } + } + } + + #[test] + fn kitty_newline_chords_take_precedence_over_conpty_encoding() { + for mode in [ + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + ] { + let flags = KeyFlags::from_mode_with_local_conpty(&mode, true); + for (chord, expected) in [ + ("ctrl-j", b"\x1b[106;5u".as_slice()), + ("shift-enter", b"\x1b[13;2u".as_slice()), + ] { + assert_eq!( + keystroke_to_bytes(&Keystroke::parse(chord).unwrap(), flags).as_deref(), + Some(expected), + "{chord}" + ); + } + } + } + #[test] fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() { let plain = Modifiers::default(); @@ -934,6 +1008,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; for flags in [kitty(), full] { assert_eq!( @@ -1128,6 +1203,7 @@ mod tests { report_all_keys: false, report_text: false, app_cursor: false, + local_conpty: false, } } @@ -1209,6 +1285,7 @@ mod tests { report_all_keys: true, report_text: false, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( @@ -1237,6 +1314,7 @@ mod tests { report_all_keys: true, report_text: false, app_cursor: false, + local_conpty: false, }; assert_eq!(tab_bytes(false, full), b"\x1b[9u".to_vec()); } @@ -1316,6 +1394,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; for key in ["f1", "f2", "f4", "f5", "f7", "f10", "f11", "f12"] { for mods in [none, shift, ctrl] { @@ -1352,6 +1431,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( @@ -1367,6 +1447,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index ec225a46..8862bbda 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -110,7 +110,7 @@ struct ReaderSignals { /// the reader puts back the cursor a repaint parked. Decided per pane from /// its [`PtySource`], and shared rather than copied because the reader can /// learn better mid-stream — see the `RemoteContext` arm. - repair_cursor: Arc, + local_conpty: Arc, } /// What kind of pty is at the far end of a pane's link, which is what decides @@ -146,20 +146,6 @@ impl PtySource { _ => PtySource::Raw, } } - - /// Whether the cursor a repaint parked has to be put back — see - /// [`crate::terminal::parked_cursor`]. - /// - /// Only conhost parks one. On a raw pty the application owns the cursor and - /// is free to end a repaint on the text it just wrote and then echo the - /// next keystroke straight after it, with no positioning of its own: vim - /// opens its command line that way, and putting the cursor back on the cell - /// the repaint hid it on drops the `wq!` typed next onto the row being - /// edited (#430, and #774 for the Windows client that reached a Linux host - /// and was repaired anyway). - fn repairs_parked_cursor(self) -> bool { - self == PtySource::LocalConpty - } } #[derive(Clone, Debug, PartialEq)] @@ -613,12 +599,12 @@ pub struct RemoteTerminal { /// flag under the term lock before every grid mutation, so once it is set /// the abandoned thread can only exit, never write. reader_quit: Arc, - /// Whether this pane's pty is a ConPTY, and so whether the reader repairs + /// Whether this pane's pty is a ConPTY, for input encoding and repairing /// the cursor a repaint parks. Held here so a relink hands the same answer /// to the reader it starts: a pane's pty does not change kind when the link /// to it is rebuilt, and the route a relink carries cannot tell a /// native-SSH pane from a local shell. - repair_cursor: Arc, + local_conpty: Arc, } /// The workspace id a spawn carries, so the pane's shell gets `$TTY7_WS` and a @@ -944,7 +930,7 @@ impl RemoteTerminal { // rebuilt from `route`: the pty on the far side is the same pty // it was before the link dropped, and only this value still // remembers what a `RemoteContext` taught the old reader. - repair_cursor: self.repair_cursor.clone(), + local_conpty: self.local_conpty.clone(), }, ); self.reader_thread = Some(reader); @@ -1038,7 +1024,7 @@ impl RemoteTerminal { let clipboard_write_busy = Arc::new(AtomicBool::new(false)); let reader_quit = Arc::new(AtomicBool::new(false)); - let repair_cursor = Arc::new(AtomicBool::new(pty.repairs_parked_cursor())); + let local_conpty = Arc::new(AtomicBool::new(pty == PtySource::LocalConpty)); let reader_thread = Self::spawn_reader( term.clone(), proxy.clone(), @@ -1062,7 +1048,7 @@ impl RemoteTerminal { images: images.clone(), clipboard_writes: clipboard_writes.clone(), clipboard_write_busy: clipboard_write_busy.clone(), - repair_cursor: repair_cursor.clone(), + local_conpty: local_conpty.clone(), }, ); @@ -1102,7 +1088,7 @@ impl RemoteTerminal { proxy, reader_thread: Some(reader_thread), reader_quit, - repair_cursor, + local_conpty, }) } @@ -1173,7 +1159,7 @@ impl RemoteTerminal { images, clipboard_writes, clipboard_write_busy, - repair_cursor, + local_conpty, } = signals; let mut awaiting_replay = awaiting_replay; crate::core::threads::promote_to_user_interactive(); @@ -1240,7 +1226,7 @@ impl RemoteTerminal { // emulator to the cut, act on the state that // sequence left behind, carry on. let mut cuts: Vec<(usize, CursorCut)> = Vec::new(); - if repair_cursor.load(Ordering::Relaxed) { + if local_conpty.load(Ordering::Relaxed) { cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c))); } { @@ -1561,7 +1547,7 @@ impl RemoteTerminal { .as_ref() .is_some_and(|c| c.kind == RemoteKind::NativeSsh) { - repair_cursor.store(false, Ordering::Relaxed); + local_conpty.store(false, Ordering::Relaxed); } if let Ok(mut guard) = remote.lock() { *guard = ctx; @@ -1692,6 +1678,10 @@ impl RemoteTerminal { self.child_exited.load(Ordering::SeqCst) } + pub(super) fn is_local_conpty(&self) -> bool { + self.local_conpty.load(Ordering::Relaxed) + } + /// Queues a keystroke — or a paste, or a mouse report — for the link. /// /// Callers are gpui event handlers on the UI thread, so this returns @@ -4084,6 +4074,7 @@ mod parked_cursor_tests { let (client_side, daemon_side) = socket_pair(); let term = RemoteTerminal::from_stream_with(client_side, size, Vec::new(), pty) .expect("a terminal over a socket pair"); + assert_eq!(term.is_local_conpty(), pty == PtySource::LocalConpty); (term, daemon_side) } @@ -4127,8 +4118,6 @@ mod parked_cursor_tests { PtySource::Raw }, ); - assert!(PtySource::LocalConpty.repairs_parked_cursor()); - assert!(!PtySource::Raw.repairs_parked_cursor()); } #[test] @@ -4280,6 +4269,7 @@ mod parked_cursor_tests { term.remote_context().is_some(), "the reader never applied the context" ); + assert!(!term.is_local_conpty()); DaemonMsg::Output(b"\x1b[6;4H".to_vec()) .encode(&mut daemon_side) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c82402e..d669ad1e 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2965,7 +2965,10 @@ impl TerminalView { } pub(super) fn key_flags(&self) -> super::input::KeyFlags { - super::input::KeyFlags::from_mode(self.terminal.term.lock().mode()) + super::input::KeyFlags::from_mode_with_local_conpty( + self.terminal.term.lock().mode(), + self.terminal.is_local_conpty(), + ) } fn tab_bytes(&self, shift: bool) -> Vec { @@ -4497,7 +4500,7 @@ impl TerminalView { { cx.propagate(); } else { - self.send_shortcut_bytes(b"\n", "enter", cx); + self.send_shortcut_bytes(self.key_flags().legacy_newline_bytes(), "enter", cx); } } @@ -9894,9 +9897,22 @@ mod gpui_tests { use super::*; use crate::daemon::protocol::{ClientMsg, DaemonMsg}; use crate::daemon::transport::Stream; + use crate::terminal::remote::PtySource; use gpui::{Entity, TestAppContext, point}; fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle, Stream) { + let pty = if cfg!(windows) { + PtySource::LocalConpty + } else { + PtySource::Raw + }; + harness_on(cx, pty) + } + + fn harness_on( + cx: &mut TestAppContext, + pty: PtySource, + ) -> (gpui::WindowHandle, Stream) { // Building a view reads the config. Whether that hit the real user // directory used to come down to which test happened to pin the // scratch dir first. @@ -9908,8 +9924,13 @@ mod gpui_tests { cx.set_global(Config::default()); }); let window = cx.add_window(|window, cx| { - let terminal = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)) - .expect("socketpair-backed terminal"); + let terminal = RemoteTerminal::from_stream_with( + client_side, + TermSize::new(80, 24), + Vec::new(), + pty, + ) + .expect("socketpair-backed terminal"); TerminalView::with_terminal(terminal, 1, window, cx) }); (window, daemon_side) @@ -12516,7 +12537,7 @@ mod gpui_tests { #[gpui::test] fn shift_enter_reaches_a_foreground_tui_with_kitty_encoding(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); - let (window, mut daemon) = harness(cx); + let (window, mut daemon) = harness_on(cx, PtySource::LocalConpty); cx.update(|cx| crate::ui::keymap::init(cx)); DaemonMsg::Output(b"\x1b[>1u".to_vec()) .encode(&mut daemon) @@ -12546,12 +12567,18 @@ mod gpui_tests { next_input_until_timeout(&mut daemon), Some(b"\x1b[13;2u".to_vec()) ); + + vcx.simulate_keystrokes("ctrl-j"); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b[106;5u".to_vec()) + ); } #[gpui::test] fn shift_enter_reaches_a_foreground_tui_as_lf_without_kitty(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); - let (window, mut daemon) = harness(cx); + let (window, mut daemon) = harness_on(cx, PtySource::Raw); cx.update(|cx| crate::ui::keymap::init(cx)); window .update(cx, |view, window, cx| { @@ -12565,6 +12592,32 @@ mod gpui_tests { vcx.simulate_keystrokes("shift-enter"); assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec())); + + vcx.simulate_keystrokes("ctrl-j"); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec())); + } + + #[gpui::test] + fn newline_chords_reach_conpty_as_ctrl_j_without_kitty(cx: &mut TestAppContext) { + let (window, mut daemon) = harness_on(cx, PtySource::LocalConpty); + cx.update(|cx| crate::ui::keymap::init(cx)); + window + .update(cx, |view, window, cx| { + assert!(!view.input_active()); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + for chord in ["shift-enter", "ctrl-j"] { + vcx.simulate_keystrokes(chord); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_".to_vec()), + "{chord} must preserve Ctrl+J for native console readers" + ); + } } #[gpui::test] From 2e9c7d714ce84a57b94d023d8d0db2390395c31e Mon Sep 17 00:00:00 2001 From: wick Date: Sat, 19 Sep 2026 09:07:49 +0800 Subject: [PATCH 36/46] Merge pull request #898 from wenlingang/fix/897-agents-row-note-width fix(settings): cap the Agents row note again so it stops crushing the label --- src/ui/settings.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 0cf89b46..66d90dfa 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -6430,8 +6430,19 @@ impl Tty7App { .when_some(row_note, |col, text| { col.child( div() - .max_w_80() - .max_w_full() + // One cap, not two: `max_w` holds a single + // length, so stating both left the note + // sized against a shrink-proof column that + // is itself sized to the note — no cap at + // all, and a long error (Codex's missing + // `codex` binary) inflated the row until + // the label column, `min_w_0`, came out one + // character per line. Stacked, the control + // column *is* the row, so a relative cap + // resolves; beside the label it cannot, and + // 320 is what the row has room for. + .when(stacked, |note| note.max_w_full()) + .when(!stacked, |note| note.max_w_80()) .text_xs() .when(!stacked, |note| note.text_right()) .text_color(muted_fg) From a723e71c9a31d58ce1474e561a2d419c8fc24cf6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:45 +0800 Subject: [PATCH 37/46] fix(keybindings): give Alt+1..9 a way out, and stop one bad line resetting the file (#901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+1..9 are vim's tab keys, and tty7 takes all nine for Go to Tab. Two things stood between the reporter and getting them back. **Nothing in the app could leave an action unbound.** Backspace on a Keybindings row that has recorded nothing *reset* the row — dropped the override so the action gets its shipped chord back. On a row nobody had overridden, which is every row the first time it is looked at, that is a no-op: pressing it over Alt+1 left Alt+1 sitting exactly where it was, which reads as the default restoring itself. `config.json` has spelled "no chord" as `[]` since #868, but no gesture wrote it. Backspace now writes that empty list. The row falls to `—` and grows the **Reset** button every overridden row has, which is the way back to the default. The capture hint names the key, and the docs say what it is for. **A keybinding line serde could not read failed the whole `Config`.** `keybindings` is a hand-edited map and was strict, so `"ActivateTab1": null` — or a number, or an object — quarantined `config.json` and started the app on built-in defaults. Every rebinding in the file then read as its shipped default, and the next settings write persisted those defaults over what the user had written. It now reads one entry at a time, like every other hand-edited nested key here: the lines that name a shortcut bind, a line that does not is logged and skipped. Tests, each failing on the unfixed code: - `ui::app::keybinding_gpui_tests::backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default` - `core::config::tests::a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it` and `ui::keymap::gpui_tests::alt_digits_can_be_moved_off_the_tab_actions_for_good` pins the merge and a save/reload round trip: a list replaces the shipped Alt+1, `[]` leaves nothing, and neither comes back after a restart. Fixes #901 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/config.rs | 68 ++++++++++++++++++++++++ docs/customization/keybindings.mdx | 26 ++++++++-- src/ui/app.rs | 80 ++++++++++++++++++++++++++++- src/ui/i18n/en.rs | 2 +- src/ui/i18n/ja.rs | 2 +- src/ui/i18n/zh.rs | 2 +- src/ui/keymap.rs | 50 ++++++++++++++++++ 7 files changed, 223 insertions(+), 7 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 4058a7b4..06d1f6e4 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -167,6 +167,15 @@ pub struct Config { pub window_backdrop: WindowBackdrop, #[serde(default = "default_true")] pub dim_inactive_panes: bool, + /// Lenient one entry at a time, for the same reason the nested keys below + /// are: this is hand-edited, and it used to be all-or-nothing. A single + /// value serde could not read — `"ActivateTab1": null`, a number, an object + /// — failed the whole `Config`, which quarantines `config.json` and starts + /// the app on built-in defaults; every rebinding in the file then read as + /// its shipped default, and the next settings write persisted those + /// defaults over what the user wrote (#901). A line that cannot be read is + /// skipped with a warning, and the rest of the map still binds. + #[serde(default, deserialize_with = "de_keybindings")] pub keybindings: HashMap, #[serde(default = "default_preset")] pub keybinding_preset: String, @@ -1213,6 +1222,37 @@ fn default_sidebar_width() -> f32 { pub const MAX_SCROLLBACK: usize = 100_000; +/// `keybindings`, read one line at a time. +/// +/// [`de_lenient`] is all-or-nothing per field, which for a map means one bad +/// line throwing away every good one. Here each entry stands on its own: the +/// ones that name a shortcut or a list of shortcuts bind, the ones that do not +/// are logged and dropped. A `keybindings` that is not an object at all falls +/// back to an empty map rather than failing the file. +fn de_keybindings<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let serde_json::Value::Object(entries) = value else { + log::warn!("ignoring `keybindings` {value}: expected an object of action name to shortcut"); + return Ok(HashMap::new()); + }; + let mut bindings = HashMap::with_capacity(entries.len()); + for (action, raw) in entries { + match KeybindingOverride::deserialize(&raw) { + Ok(binding) => { + bindings.insert(action, binding); + } + Err(e) => log::warn!( + "ignoring keybinding for {action:?}: {raw} is not a shortcut, \ + a list of shortcuts, or \"\" to unbind ({e})" + ), + } + } + Ok(bindings) +} + pub(crate) fn de_lenient<'de, D, T>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -1968,6 +2008,34 @@ mod tests { assert_eq!(serde_json::to_value(&cfg.keybindings).unwrap(), written); } + #[test] + fn a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it() { + // #901: one unreadable line used to fail the whole `Config`. The loader + // then quarantines config.json and starts on built-in defaults, so + // every rebinding in the file — the point of the file — came back as + // the shipped default, and the next settings write made that permanent. + let cfg: Config = serde_json::from_str( + r#"{"font_size": 20.0, + "keybindings": {"ActivateTab1": null, "ActivateTab2": 2, + "ActivateTab3": {"key": "alt-shift-3"}, + "ActivateTab4": [], "NextTab": "ctrl-alt-]"}}"#, + ) + .expect("a bad keybinding line must not fail the file"); + assert_eq!(cfg.font_size, 20.0, "the rest of the file still loads"); + assert_eq!( + serde_json::to_value(&cfg.keybindings).unwrap(), + serde_json::json!({"ActivateTab4": [], "NextTab": "ctrl-alt-]"}), + "the readable lines survive and the unreadable ones are dropped" + ); + + // And a `keybindings` that is not a map at all is no reason to hand + // the user back default fonts, themes and everything else. + let odd: Config = serde_json::from_str(r#"{"font_size": 20.0, "keybindings": []}"#) + .expect("a keybindings of the wrong shape must not fail the file"); + assert_eq!(odd.font_size, 20.0); + assert!(odd.keybindings.is_empty()); + } + fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index cc699d7c..26a4fa04 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -19,7 +19,13 @@ Click a shortcut and press the new keys. It saves after a brief pause. | Press keys | Set the binding | | Press more keys | Chain a sequence — ⌃ B then X | | Esc | Cancel | -| ⌫ | Remove the last key — or, pressed first, reset the shortcut to its default | +| ⌫ | Remove the last key — or, pressed first, leave the action with **no shortcut** | + +Leaving an action unbound is how you hand a key back to whatever is running in +the terminal: ⌥ 1…⌥ 9 jump between tabs by default, and +vim wants them for its own tabs. Clear the nine **Go to Tab** rows and the +digits go straight through. A cleared row shows `—` and grows a **Reset** +button, which puts the default back. **Restore all defaults** at the bottom undoes every rebinding at once. There is no undo for that one. @@ -74,8 +80,22 @@ space. | `cmd` · `ctrl` · `alt` · `shift` | Literal modifiers | | `ctrl-b n` | A two-key sequence | -An unknown action name or an invalid keystroke is skipped with a warning in the -log rather than breaking the rest of your bindings. +To keep a key for the program running in the terminal, unbind the action that +holds it. vim's tab keys, for instance: + +```json +{ + "keybindings": { + "ActivateTab1": [], "ActivateTab2": [], "ActivateTab3": [], + "ActivateTab4": [], "ActivateTab5": [], "ActivateTab6": [], + "ActivateTab7": [], "ActivateTab8": [], "ActivateTab9": [] + } +} +``` + +An unknown action name, an invalid keystroke, or a line that is neither a +shortcut nor a list of them is skipped with a warning in the log rather than +breaking the rest of your bindings. The full action list is on the [keyboard shortcuts](/reference/keyboard-shortcuts) page. diff --git a/src/ui/app.rs b/src/ui/app.rs index 717c7444..2022f520 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7134,7 +7134,7 @@ impl Tty7App { cx.notify(); } else { self.stop_recording(cx); - self.reset_keybinding(action, cx); + self.unbind_keybinding(action, cx); } return; } @@ -7253,6 +7253,34 @@ impl Tty7App { cx.notify(); } + /// Takes every chord off an action, and keeps it off. + /// + /// ⌫ on a row that has recorded nothing used to *reset* it — drop the + /// override so the action gets its shipped chord back. On a row nobody has + /// overridden, which is every row the first time it is looked at, that is a + /// no-op: someone pressing Backspace over Alt+1 to be rid of it watched + /// Alt+1 sit exactly where it was and read it as the default restoring + /// itself (#901). Nothing anywhere in the app said "this action should have + /// no key", though `config.json` has spelled it `[]` since #868. + /// + /// So ⌫ writes that empty list, and the **Reset** button beside the row — + /// which appears the moment an action is overridden, this way included — is + /// the way back to the default. + pub(crate) fn unbind_keybinding(&mut self, action: String, cx: &mut Context) { + self.update_config(cx, |cfg| { + cfg.keybindings.insert( + action, + crate::core::config::KeybindingOverride::Exact(Vec::new()), + ); + }); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + s.rebinding_note = None; + } + cx.notify(); + } + pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context) { self.update_config(cx, |cfg| { cfg.keybindings.remove(&action); @@ -10525,6 +10553,56 @@ mod keybinding_gpui_tests { ); } + /// #901, the half that happens in the UI: Alt+1…9 belongs to vim, and the + /// only gesture in the app that looks like "take this shortcut away" used + /// to *reset* the row instead — a no-op on a row nobody had overridden, + /// so the default appeared to restore itself however many times it was + /// pressed. + #[gpui::test] + fn backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default( + cx: &mut TestAppContext, + ) { + let (app, mut vcx) = harness(cx); + let shipped = vcx + .update(|_, cx| crate::ui::keymap::effective_key("ActivateTab1", cx)) + .expect("Go to Tab 1 ships with a chord"); + + begin_capture(&app, &mut vcx, "ActivateTab1"); + vcx.simulate_keystrokes("backspace"); + wait_for_binding(&mut vcx, "ActivateTab1", serde_json::json!([])); + + vcx.update(|_, cx| { + assert_eq!( + crate::ui::keymap::effective_key("ActivateTab1", cx), + None, + "the row has no chord left to show" + ); + let typed = [gpui::Keystroke::parse(&shipped).expect("the chord parses")]; + let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")]; + assert!( + cx.key_bindings() + .borrow() + .bindings_for_input(&typed, &context) + .0 + .is_empty(), + "{shipped} must reach the terminal now, not the tab switcher" + ); + }); + + // Reversible, and by the button that is already on the row: an + // overridden action — unbound counts — shows **Reset**. + app.update_in(&mut vcx, |app, _, cx| { + app.reset_keybinding("ActivateTab1".to_string(), cx) + }); + vcx.update(|_, cx| { + assert_eq!( + crate::ui::keymap::effective_key("ActivateTab1", cx).as_deref(), + Some(shipped.as_str()), + "Reset is the way back to the shipped chord" + ); + }); + } + #[gpui::test] fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a2f97082..631a1c49 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -628,7 +628,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C)." } L10nKey::SettingsPrefix => "Prefix", - L10nKey::SettingsPressKeys => "Press keys…", + L10nKey::SettingsPressKeys => "Press keys… · ⌫ for no shortcut", L10nKey::SettingsPauseToSaveEsc => "pause to save · Esc", L10nKey::SettingsKeybindingsIntroDesc => { "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets to default if pressed first." diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index ee452f64..17875bfb 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -635,7 +635,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "tmux では、ペイン/タブの操作をプレフィックスキーの後に行います(例: Ctrl-B の後に C)" } L10nKey::SettingsPrefix => "プレフィックスキー", - L10nKey::SettingsPressKeys => "キーを入力…", + L10nKey::SettingsPressKeys => "キーを入力… · ⌫ でショートカットなし", L10nKey::SettingsPauseToSaveEsc => "一時停止して保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとデフォルトに戻します" diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 17790008..d5aedfdb 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -554,7 +554,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "tmux 预设把窗格/标签页操作映射为前缀序列(例如 Ctrl-B 后按 C)。" } L10nKey::SettingsPrefix => "前缀", - L10nKey::SettingsPressKeys => "按下按键…", + L10nKey::SettingsPressKeys => "按下按键… · ⌫ 表示不设快捷键", L10nKey::SettingsPauseToSaveEsc => "暂停以保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则重置为默认。" diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 3cf3e14f..f7b77e86 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -2462,6 +2462,56 @@ mod gpui_tests { }); } + /// #901: Alt+1…9 is how vim switches tabs, and tty7's default was eating + /// the whole row of them. Both halves of what the reporter wanted have to + /// hold, and hold across a restart — the complaint was that the default + /// "keeps coming back". + #[gpui::test] + fn alt_digits_can_be_moved_off_the_tab_actions_for_good(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + let default_chord = per_platform("secondary-1", "alt-1"); + running_on_json( + cx, + r#"{"keybindings": {"ActivateTab1": ["alt-shift-1"], + "ActivateTab2": []}}"#, + ); + assert!( + fired(cx, default_chord).is_empty(), + "a list replaces the shipped chord, so the digit reaches the shell" + ); + assert!( + fired(cx, per_platform("secondary-2", "alt-2")).is_empty(), + "and an empty list leaves the action with no chord at all" + ); + assert_eq!( + fired(cx, "alt-shift-1").first(), + Some(&ActivateTab1::name_for_type()), + "the chord asked for in its place is live" + ); + assert_eq!(effective_key("ActivateTab2", cx), None); + + // The half that reads as "老是自动恢复": whatever the app saves has + // to load back as the same thing. A `Config` serialized and read + // again is exactly what a restart does with `config.json`. + let saved = + serde_json::to_string(&**cx.global::()).expect("the config serializes"); + cx.set_global(Config( + serde_json::from_str(&saved).expect("the saved config parses"), + )); + rebind(cx); + assert!( + fired(cx, default_chord).is_empty(), + "the shipped chord must not come back across a save and reload" + ); + assert_eq!( + fired(cx, "alt-shift-1").first(), + Some(&ActivateTab1::name_for_type()), + ); + assert_eq!(effective_key("ActivateTab2", cx), None); + }); + } + #[gpui::test] fn a_chord_added_under_the_tmux_preset_joins_the_preset_chord(cx: &mut TestAppContext) { use gpui::Action as _; From a645fd33f58b056c80514c518d0af1a62e0631fd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:44:15 +0800 Subject: [PATCH 38/46] fix(windows): link the Visual C++ runtime statically (#902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Windows binary tty7 has ever shipped imports `VCRUNTIME140.dll`. That file is not part of Windows; it arrives with the "Visual C++ 2015-2022 Redistributable", which Visual Studio, the GitHub runners and most developer machines install as a side effect of something else. On a machine that has never installed it the loader fails before `main` — no window, no log, no crash report, just The code execution cannot proceed because VCRUNTIME140.dll was not found. which is how 26.8.2 failed winget's install validation (microsoft/winget-pkgs#415841). Confirmed against the installed 26.x `tty7-app.exe` with `dumpbin /dependents`: `VCRUNTIME140.dll` plus nine `api-ms-win-crt-*` entries, and the same in `tty7.exe` and `tty7-updater.exe`. The UCRT half is in-box from Windows 10 on and never was the problem; VCRUNTIME140 is the one piece that has to come from the redistributable. The bundled ConPTY pair is already CRT-static, so it was never implicated — only our own three binaries are. `-C target-feature=+crt-static` for the MSVC targets, in `.cargo/config.toml` rather than in the release workflow, so CI's Windows `build & test` job compiles under the same flag a release does and a dependency that cannot link statically fails a pull request instead of a tag. The alternative — declaring `Microsoft.VCRedist.2015+.x64` in the winget manifest, or shipping the DLLs beside the exe — was rejected: it leaves the portable zip, the GitHub release and every non-winget install path broken, this repository publishes no winget manifest to carry the declaration, and the winget PR shows the declared dependency did not actually resolve the failure. Static linking removes the requirement instead of documenting it. The regression is invisible to everyone who could catch it, because every machine that builds tty7 has the redistributable, so `assert-no-vcruntime.ps1` reads the PE import and delay-load tables directly (no `dumpbin`, which would re-introduce the same "my machine has Visual Studio" assumption) and fails on any VC++ redistributable import. It runs in CI on the Windows debug build and, via `verify-windows-package.ps1`, over both shipped payloads in release and nightly. Verified on Windows 11 x86_64 with MSVC 14.44: a full `cargo build --release --locked --target x86_64-pc-windows-msvc` links cleanly, and `dumpbin /dependents` on the resulting `tty7-app.exe` shows 29 imports, all in-box — no `VCRUNTIME140.dll` and no `api-ms-win-crt-*` at all. Same for `tty7.exe` and the `--features updater` `tty7-updater.exe`. `cargo test --release -p tty7-core` under the flag: 1223 passed, 4 failed, those four being the `remote_link`/`router` tests that fail on a clean tree on this machine too. Fixes #902 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- .cargo/config.toml | 30 ++++ .github/scripts/assert-no-vcruntime.ps1 | 176 +++++++++++++++++++++ .github/scripts/verify-windows-package.ps1 | 18 +++ .github/workflows/ci.yml | 21 +++ docs/getting-started/installation.mdx | 5 + 5 files changed, 250 insertions(+) create mode 100644 .github/scripts/assert-no-vcruntime.ps1 diff --git a/.cargo/config.toml b/.cargo/config.toml index 39168d2b..4b314a32 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,3 +9,33 @@ dev = "run -- --config-dir .tty7-dev" # it (resumable, packed transfer), and is present on every platform we build on. [net] git-fetch-with-cli = true + +# Link the Visual C++ runtime statically on Windows (#902). +# +# The default MSVC target links the CRT dynamically, so every shipped binary +# imports VCRUNTIME140.dll — a file Windows does not carry in the box. On a +# machine without the "Visual C++ 2015-2022 Redistributable" installed, the +# loader fails before `main` with "VCRUNTIME140.dll was not found", which is +# what winget's install validation hit (microsoft/winget-pkgs#415841). +# +# The UCRT half (`api-ms-win-crt-*`) is in-box from Windows 10 onwards and is +# not the problem; VCRUNTIME140 is the one piece that has to come from the +# redistributable. `+crt-static` pulls both in statically, so the binaries have +# no CRT imports left at all. +# +# It lives here rather than in the release workflow on purpose: CI's Windows +# `build & test` job then compiles under the same flag as a release, so a +# dependency that cannot link statically fails a pull request instead of a tag. +# Keep in mind for future work: +# * `cc`-built C/C++ dependencies pick this up through +# CARGO_CFG_TARGET_FEATURE and switch to /MT, so the CRT choice stays +# consistent across the whole link. +# * It only holds while nothing sets the RUSTFLAGS environment variable for +# a Windows build — that variable replaces these flags wholesale rather +# than adding to them. `.github/scripts/assert-no-vcruntime.ps1` is the +# backstop: it fails the build if VCRUNTIME140/MSVCP140 reappear. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/scripts/assert-no-vcruntime.ps1 b/.github/scripts/assert-no-vcruntime.ps1 new file mode 100644 index 00000000..3d25a0f8 --- /dev/null +++ b/.github/scripts/assert-no-vcruntime.ps1 @@ -0,0 +1,176 @@ +# Fail the build if a shipped Windows binary imports the Visual C++ +# redistributable (#902). +# +# A default MSVC build links the CRT dynamically, which leaves VCRUNTIME140.dll +# (and, once any C++ is linked in, MSVCP140.dll) in the import table. Neither +# ships with Windows, so on a machine that has never installed the "Visual C++ +# 2015-2022 Redistributable" the loader fails before `main` with +# +# The code execution cannot proceed because VCRUNTIME140.dll was not found. +# +# That is what winget's install validation hit on 26.8.2 +# (microsoft/winget-pkgs#415841). `.cargo/config.toml` fixes it by linking the +# CRT statically; this script is the guard that keeps it fixed, because the +# failure is invisible on any developer or CI machine — they all have the +# redistributable, installed by Visual Studio or by some other package. +# +# The `api-ms-win-crt-*` imports are a different thing and are fine: those are +# the Universal CRT, an in-box Windows component since Windows 10. They are +# only present at all when the CRT is linked dynamically, so a statically +# linked binary has none of these imports either. +# +# Usage: assert-no-vcruntime.ps1 [ ...] +# Each path is a PE file, or a directory whose *.exe and *.dll are scanned. +# Microsoft's own redistributable ConPTY pair is skipped: it is prebuilt, we do +# not link it, and it is already statically linked against the CRT. +$ErrorActionPreference = 'Stop' + +$Forbidden = @('vcruntime140', 'vcruntime140_1', 'msvcp140', 'msvcp140_1', + 'msvcp140_2', 'concrt140', 'vcamp140', 'vcomp140', 'msvcr120', + 'msvcr110', 'msvcr100') +# Not ours to link, and already CRT-static (verified with dumpbin /dependents). +$Skip = @('conpty.dll', 'OpenConsole.exe') + +# Minimal PE import-table reader. Deliberately not `dumpbin`: that needs a +# Visual Studio installation on PATH, which is exactly the assumption this +# check exists to stop us making. +function Get-PEImportedModules([string]$Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 0x40) { throw "$Path is too small to be a PE file" } + if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) { throw "$Path is not a PE file (no MZ)" } + + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ([BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550) { + throw "$Path is not a PE file (no PE\0\0 at $peOffset)" + } + + $coff = $peOffset + 4 + $sectionCount = [BitConverter]::ToUInt16($bytes, $coff + 2) + $optionalSize = [BitConverter]::ToUInt16($bytes, $coff + 16) + $optional = $coff + 20 + + # PE32 keeps the data directories 16 bytes earlier than PE32+ does: the + # four ImageBase/Reserved fields differ in width between the two. + $magic = [BitConverter]::ToUInt16($bytes, $optional) + switch ($magic) { + 0x10B { $dataDirs = $optional + 96 } # PE32 + 0x20B { $dataDirs = $optional + 112 } # PE32+ + default { throw "$Path has an unknown optional header magic 0x$($magic.ToString('X'))" } + } + $dirCount = [BitConverter]::ToUInt32($bytes, $dataDirs - 4) + + $sections = @() + $sectionTable = $optional + $optionalSize + for ($i = 0; $i -lt $sectionCount; $i++) { + $s = $sectionTable + ($i * 40) + $sections += [pscustomobject]@{ + VirtualAddress = [BitConverter]::ToUInt32($bytes, $s + 12) + VirtualSize = [BitConverter]::ToUInt32($bytes, $s + 8) + RawSize = [BitConverter]::ToUInt32($bytes, $s + 16) + RawAddress = [BitConverter]::ToUInt32($bytes, $s + 20) + } + } + + # The import tables store addresses as RVAs; on disk we need file offsets. + function ConvertTo-FileOffset([uint32]$rva) { + foreach ($s in $sections) { + $span = [Math]::Max($s.VirtualSize, $s.RawSize) + if ($rva -ge $s.VirtualAddress -and $rva -lt ($s.VirtualAddress + $span)) { + return [int]($s.RawAddress + ($rva - $s.VirtualAddress)) + } + } + return -1 + } + + function Read-AsciiAt([int]$offset) { + if ($offset -lt 0 -or $offset -ge $bytes.Length) { return $null } + $end = $offset + while ($end -lt $bytes.Length -and $bytes[$end] -ne 0) { $end++ } + return [System.Text.Encoding]::ASCII.GetString($bytes, $offset, $end - $offset) + } + + $modules = New-Object System.Collections.Generic.List[string] + + # Directory 1 is the import table, directory 13 the delay-load table. A + # delay-loaded VCRUNTIME140 fails at first call rather than at load, which + # is worse to diagnose, not better — so both are checked. + # import descriptor: Name RVA at +12, 20-byte entries, zero-terminated + # delay descriptor: Name RVA at +4, 32-byte entries, zero-terminated + $tables = @( + [pscustomobject]@{ Index = 1; Stride = 20; NameAt = 12 }, + [pscustomobject]@{ Index = 13; Stride = 32; NameAt = 4 } + ) + foreach ($table in $tables) { + if ($dirCount -le $table.Index) { continue } + $rva = [BitConverter]::ToUInt32($bytes, $dataDirs + ($table.Index * 8)) + if ($rva -eq 0) { continue } + $cursor = ConvertTo-FileOffset $rva + if ($cursor -lt 0) { continue } + while ($true) { + if ($cursor + $table.Stride -gt $bytes.Length) { break } + $empty = $true + for ($b = 0; $b -lt $table.Stride; $b++) { + if ($bytes[$cursor + $b] -ne 0) { $empty = $false; break } + } + if ($empty) { break } + $nameRva = [BitConverter]::ToUInt32($bytes, $cursor + $table.NameAt) + # A bound delay-load descriptor can store a VA rather than an RVA; + # such an entry simply will not map, and is skipped. + $name = Read-AsciiAt (ConvertTo-FileOffset $nameRva) + if ($name) { $modules.Add($name) } + $cursor += $table.Stride + } + } + return $modules +} + +if ($args.Count -eq 0) { throw "usage: assert-no-vcruntime.ps1 [ ...]" } + +$targets = New-Object System.Collections.Generic.List[string] +foreach ($arg in $args) { + if (-not (Test-Path -LiteralPath $arg)) { throw "no such path: $arg" } + if (Test-Path -LiteralPath $arg -PathType Container) { + # Filtered with Where-Object rather than -Include: -Include is silently + # ignored alongside -LiteralPath, which would hand the PE reader the + # marker files and licence text sitting in the same directory. + Get-ChildItem -LiteralPath $arg -Recurse -File | + Where-Object { $_.Extension -in '.exe', '.dll' } | + ForEach-Object { $targets.Add($_.FullName) } + } else { + $targets.Add((Resolve-Path -LiteralPath $arg).Path) + } +} + +$scanned = 0 +$failures = New-Object System.Collections.Generic.List[string] +foreach ($target in $targets) { + $leaf = Split-Path -Leaf $target + if ($Skip -contains $leaf) { + Write-Output "skip $leaf (Microsoft's prebuilt redistributable ConPTY)" + continue + } + $scanned++ + $imports = Get-PEImportedModules $target + $bad = @($imports | Where-Object { + $Forbidden -contains [System.IO.Path]::GetFileNameWithoutExtension($_).ToLowerInvariant() + }) + if ($bad.Count -gt 0) { + $failures.Add("$leaf imports the Visual C++ redistributable: $($bad -join ', ')") + } else { + Write-Output "ok $leaf ($($imports.Count) imported modules, no VC++ redistributable)" + } +} + +# An empty scan must not pass: a mistyped path would otherwise report success +# without having looked at anything. +if ($scanned -eq 0) { throw "assert-no-vcruntime.ps1 found no PE files to check in: $($args -join ', ')" } + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Output "::error::$failure" } + throw ("these binaries need the Visual C++ Redistributable and will not start " + + "without it (#902); check that .cargo/config.toml's +crt-static still " + + "applies and that nothing set RUSTFLAGS for this build " + + "($($failures.Count) binary/binaries)") +} + +Write-Output "No Visual C++ redistributable imports in $scanned binary/binaries." diff --git a/.github/scripts/verify-windows-package.ps1 b/.github/scripts/verify-windows-package.ps1 index 25fdc259..a5714041 100644 --- a/.github/scripts/verify-windows-package.ps1 +++ b/.github/scripts/verify-windows-package.ps1 @@ -74,6 +74,22 @@ function Assert-ConptyPair([string]$directory, [string]$label) { } } +# A shipped binary that imports VCRUNTIME140.dll does not start on a Windows +# machine that has never installed the Visual C++ Redistributable: the loader +# fails before `main`, with no log and no window. That is #902, and it is how +# 26.8.2 failed winget's install validation +# (microsoft/winget-pkgs#415841) while running perfectly on every machine that +# had ever seen Visual Studio. `.cargo/config.toml` links the CRT statically; +# checked here, on the payload that actually ships, because neither the build +# nor the tests can observe the difference. +function Assert-NoVcRuntime([string]$directory, [string]$label) { + try { + & (Join-Path $PSScriptRoot 'assert-no-vcruntime.ps1') $directory + } catch { + Fail "$label would not start without the VC++ Redistributable: $($_.Exception.Message)" + } +} + # ---- Portable ZIP -------------------------------------------------------- # Update rules live in the updater's extractor; the ones that can be broken by # packaging alone are re-stated here. @@ -148,6 +164,7 @@ if (-not (Test-Path -LiteralPath $Zip)) { Assert-BinaryVersion (Join-Path $unzipped 'tty7-app.exe') 'the portable tty7-app.exe' Assert-BinaryVersion (Join-Path $unzipped 'tty7-updater.exe') 'the portable tty7-updater.exe' Assert-ConptyPair $unzipped 'the portable archive' + Assert-NoVcRuntime $unzipped 'the portable archive' } finally { Remove-Item -Recurse -Force $unzipped -ErrorAction SilentlyContinue } @@ -172,6 +189,7 @@ if (-not (Test-Path -LiteralPath $Stage -PathType Container)) { Assert-BinaryVersion (Join-Path $Stage 'tty7-app.exe') 'the installed tty7-app.exe' Assert-BinaryVersion (Join-Path $Stage 'tty7-updater.exe') 'the installed tty7-updater.exe' Assert-ConptyPair $Stage 'the Inno payload' + Assert-NoVcRuntime $Stage 'the Inno payload' } # ---- Setup executable ---------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5feeab63..d758a25d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,27 @@ jobs: timeout-minutes: 30 run: cargo build --locked --target ${{ matrix.target }} + # A binary that imports VCRUNTIME140.dll cannot start on a Windows + # machine that has never installed the Visual C++ Redistributable — the + # loader fails before `main`, which is what winget's install validation + # hit on 26.8.2 (#902). `.cargo/config.toml` links the CRT statically; + # this is the check that it still does. + # + # It runs here, on the debug build, rather than only at release time: + # every machine that builds tty7 already has the redistributable, so + # nothing else in the pipeline can notice the regression, and the + # dependency can come back from a single new `cc`-built dependency. + # Named files rather than the target directory — build scripts and + # proc-macro artifacts under it are host units, which are built without + # the target's rustflags and legitimately import the CRT dynamically. + - name: Assert the Windows binaries need no VC++ redistributable + if: runner.os == 'Windows' + shell: pwsh + run: | + & ./.github/scripts/assert-no-vcruntime.ps1 ` + "target/${{ matrix.target }}/debug/tty7-app.exe" ` + "target/${{ matrix.target }}/debug/tty7.exe" + # `cargo test` has no timeout of its own, so one hung test is # indistinguishable from a slow suite until the job hits GitHub's six-hour # limit. The suite intermittently hangs here — roughly one run in ten, on diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 4f5f3e59..afde3c07 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -46,6 +46,11 @@ AppImage bundles its own X11/Wayland/font libraries. tty7-app.exe --unregister-explorer-menu ``` + Nothing has to be installed first. The executables link the Visual C++ + runtime statically, so the "Visual C++ 2015–2022 Redistributable" is not a + prerequisite — releases up to and including 26.8.2 did need it, and failed + to start at all on a machine that had never installed it. + The Windows package also carries a Linux `tty7-server` binary so a WSL distro can be served locally instead of downloading one. See From 404b6afd1d28c1d2c9598731fd70ae9afba31998 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:47:13 +0800 Subject: [PATCH 39/46] fix: retire an OSC 0/2 title when the command that set it exits (#889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab went on reading "✳ fixing the switcher" long after Claude Code had quit and the pane was back at its own prompt in a real directory. An OSC 0/2 had no owner and no end — only another OSC 0/2 ever replaced it — so the last title any program wrote in a pane outlived it forever, and the `Osc` rung of the label ladder kept outranking the `Cwd` below it with a name for a session that no longer existed. The shell integration already says when a command starts and stops. A new `core::osc::TitleLifetime` reads OSC 133;C / 133;D alongside the titles: a title set *between* them belongs to that command and is retired by its `D`; a title set at a prompt — the shell's own, or one pinned by hand — belongs to nobody in particular and is left alone; a pane with no shell integration sees neither mark and keeps every title, exactly as before. Both readers run it over the same bytes, in stream order, so the tab strip and the switcher can never disagree about whether a title is still current: - the daemon's `OscSniffer` turns a retirement into the reset it already understood, clearing `PaneRecord::osc_title` for the switcher and CLI; - the window's pane reader sends `AlacEvent::ResetTitle` after the chunk the emulator just parsed, so its own terminal's title goes back to the pane's default and `stated_title` says nothing. Stream order is what keeps a re-titling shell whole: tty7's zsh helper prepends the `D` emitter to precmd and the PowerShell one titles inside its prompt function, so a shell's own OSC 0/2 lands after the `D` and is simply the last word rather than something to undo. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/core/osc.rs | 163 ++++++++++++++++++++++++++++ crates/tty7-core/src/daemon/pane.rs | 91 ++++++++++++++++ src/terminal/remote.rs | 38 ++++++- src/terminal/view.rs | 83 ++++++++++++++ 4 files changed, 374 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index 83504084..85eed400 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -147,6 +147,81 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> { None } +/// What a sequence did to the title a pane is showing — see +/// [`TitleLifetime`]. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum TitleEffect { + /// Nothing to do with the title. + None, + /// An OSC 0/2: the pane just named itself. + Set, + /// The command that set the title showing has finished, so the title + /// describes something that is no longer running and has to go. + Retire, +} + +/// How long a title a program set outlives the program (#889). +/// +/// An OSC 0/2 has no owner and no end: whatever a pane last named itself +/// stands until something else names it. That is right while the program that +/// wrote it is still running, and wrong the instant it exits — a tab that goes +/// on reading "✳ refactoring the parser" after Claude Code has quit is naming +/// a session that no longer exists, and the pane's directory (the rung below +/// it in the label ladder) would describe it far better. +/// +/// The shell integration already says when a command starts and stops: OSC +/// 133;C and 133;D. So a title set *between* them belongs to that command and +/// is retired by its `D`; a title set at a prompt — the shell's own, or one +/// the reader pinned by hand with a bare `printf '\e]0;…'` — belongs to +/// nobody in particular and is left alone. A pane with no shell integration +/// sees neither mark and so keeps every title, exactly as before. +/// +/// Both the daemon (which keeps `PaneRecord::osc_title` for the switcher and +/// the CLI) and the window (which keeps its own terminal's title for its tab +/// strip) run this over the same bytes, so the two can never disagree about +/// whether a title is still current. Feeding it in stream order is what makes +/// the answer right: a shell that re-titles itself in `precmd` emits its OSC +/// 0/2 *after* the `D`, and that [`Set`](TitleEffect::Set) is simply the last +/// word. +#[derive(Debug, Default, Clone, Copy)] +pub struct TitleLifetime { + /// A command owns the pane: a `C` has arrived and its `D` has not. + running: bool, + /// The title standing right now was set while a command owned the pane. + from_command: bool, +} + +impl TitleLifetime { + /// Reads one OSC payload — identifier included, as + /// [`OscTokenizer`] reports it — and says what it did to the title. + pub fn saw(&mut self, payload: &[u8]) -> TitleEffect { + if payload.starts_with(b"0;") || payload.starts_with(b"2;") { + self.from_command = self.running; + return TitleEffect::Set; + } + let Some(rest) = payload.strip_prefix(b"133;") else { + return TitleEffect::None; + }; + match rest.first() { + Some(b'C') => self.running = true, + Some(b'D') => { + let retire = self.running && self.from_command; + // Whatever stands after this mark was not written by a + // command that is still running, whoever wrote it. + self.running = false; + self.from_command = false; + if retire { + return TitleEffect::Retire; + } + } + // `A` and `B` only draw a prompt. They must not retire anything: + // a shell's own `precmd` title lands between the `D` and the `A`. + _ => {} + } + TitleEffect::None + } +} + #[cfg(test)] mod tests { use super::*; @@ -271,6 +346,94 @@ mod tests { assert_eq!(got, vec![(7, b"777;notify;x".to_vec())]); } + /// Feeds a stream through the tokenizer the way both readers do and + /// reports what the title ended up being: `Some(t)` for a title that + /// stands, `None` for one that was retired or never set. + fn showing(stream: &[u8]) -> Option { + let mut tok = OscTokenizer::new(&[b"0", b"2", b"133"]); + let mut life = TitleLifetime::default(); + let mut title = None; + tok.feed(stream, |payload| match life.saw(payload) { + TitleEffect::Set => { + let body = payload.split(|&b| b == b';').nth(1).unwrap_or(b""); + title = (!body.is_empty()).then(|| String::from_utf8_lossy(body).into_owned()); + } + TitleEffect::Retire => title = None, + TitleEffect::None => {} + }); + title + } + + /// The bug in #889: Claude Code names the tab, quits, and the name stays + /// on a pane that is back at its own prompt in a real directory. + #[test] + fn a_title_a_command_set_is_retired_when_that_command_finishes() { + assert_eq!( + showing(b"\x1b]133;C;claude\x07\x1b]0;refactoring the parser\x07\x1b]133;D;0\x07"), + None, + "the program that named the tab has exited" + ); + } + + /// The case the title is *supposed* to stick for: a TUI that names itself + /// once and keeps running. + #[test] + fn a_title_of_a_command_still_running_stands() { + assert_eq!( + showing(b"\x1b]133;C;vim\x07\x1b]0;vim \xe2\x80\x94 main.rs\x07").as_deref(), + Some("vim — main.rs"), + ); + } + + /// A shell that re-titles itself in `precmd` writes its OSC 0/2 after the + /// `D` and before the `A` (tty7's own zsh helper prepends the `D` emitter + /// for exactly that reason, and the PowerShell one titles between them). + /// Reading the stream in order is what keeps that title. + #[test] + fn a_title_the_shell_writes_at_its_next_prompt_is_the_last_word() { + assert_eq!( + showing( + b"\x1b]133;C;claude\x07\x1b]0;claude\x07\ + \x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07\x1b]133;B\x07" + ) + .as_deref(), + Some("me@box:~/dev"), + ); + } + + /// A title nobody's command set — the shell's, or one pinned by hand at a + /// prompt — is not a command's to retire. + #[test] + fn a_title_set_at_a_prompt_survives_the_next_command() { + assert_eq!( + showing( + b"\x1b]133;A\x07\x1b]0;my tab\x07\x1b]133;B\x07\ + \x1b]133;C;ls\x07\x1b]133;D;0\x07" + ) + .as_deref(), + Some("my tab"), + ); + } + + /// Without shell integration there are no marks at all, and a title is + /// kept the way it always was. + #[test] + fn a_pane_with_no_marks_keeps_every_title() { + assert_eq!(showing(b"\x1b]2;anything\x07").as_deref(), Some("anything")); + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"7;file://h/x"), TitleEffect::None); + assert_eq!(life.saw(b"133;V;1"), TitleEffect::None); + } + + /// A `D` with no command before it reports the shell's own startup, not a + /// command that ended; there is nothing of anyone's to retire. + #[test] + fn a_d_mark_with_no_command_before_it_retires_nothing() { + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"0;pinned"), TitleEffect::Set); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::None); + } + #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { assert_eq!( diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 7b6add69..7ece7ed2 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -3301,6 +3301,9 @@ struct SniffSignals { struct OscSniffer { tok: OscTokenizer, shell: ShellState, + /// Whether the title the pane is showing still belongs to something that + /// is running — see [`crate::core::osc::TitleLifetime`] (#889). + title_life: crate::core::osc::TitleLifetime, } impl OscSniffer { @@ -3308,13 +3311,20 @@ impl OscSniffer { Self { tok: OscTokenizer::new(&[b"0", b"2", b"7", b"133", b"9", b"777"]), shell: ShellState::default(), + title_life: crate::core::osc::TitleLifetime::default(), } } fn feed(&mut self, bytes: &[u8]) -> SniffSignals { let mut signals = SniffSignals::default(); let shell = &mut self.shell; + let title_life = &mut self.title_life; self.tok.feed(bytes, |payload| { + // In stream order, so that a shell which re-titles itself right + // after the `D` mark gets the last word over the retirement. + if title_life.saw(payload) == crate::core::osc::TitleEffect::Retire { + signals.title = Some(String::new()); + } if let Some(path) = parse_osc7(payload) { signals.cwd = Some(path); } else if let Some(title) = parse_osc_title(payload) { @@ -4377,6 +4387,87 @@ mod tests { assert_eq!(st.osc_title, None, "an empty title clears, not blanks"); } + /// #889: the tab went on reading "✳ fixing the switcher" long after Claude + /// Code had exited and the pane was back at its own prompt, because + /// nothing in the pane path ever retired an OSC 0/2 — only another one + /// replaced it. The `D` mark says the command that wrote it is over. + #[test] + fn a_title_a_command_set_is_retired_when_that_command_finishes() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + + let mut read = |st: &mut PaneState, bytes: &[u8]| apply_signals(st, s.feed(bytes)); + + read( + &mut st, + b"\x1b]7;file://h/work/tty7\x07\x1b]133;A\x07\x1b]133;B\x07", + ); + read(&mut st, b"\x1b]133;C;claude\x07"); + read(&mut st, b"\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\"); + assert_eq!( + st.osc_title.as_deref(), + Some("✳ fixing the switcher"), + "a running command names the tab" + ); + + // The user's precmd chain can take hundreds of ms, which is why the + // `D` emitter is prepended to it — so the mark routinely lands in a + // read of its own, ahead of anything the next prompt writes. + read(&mut st, b"\x1b]133;D;0\x07"); + assert_eq!( + st.osc_title, None, + "the program that wrote the title has exited" + ); + assert_eq!( + st.cwd.as_deref(), + Some(std::path::Path::new("/work/tty7")), + "and the rung below it in the label ladder still knows the place" + ); + } + + /// The two cases the retirement must not touch: a program that is still + /// running, and a shell that titles its own prompt. + #[test] + fn a_running_program_and_a_shells_own_prompt_title_both_keep_theirs() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + + apply_signals(&mut st, s.feed(b"\x1b]133;C;vim\x07\x1b]2;vim\x1b\\")); + assert_eq!( + st.osc_title.as_deref(), + Some("vim"), + "nothing said the command ended" + ); + + // A shell that re-titles itself does it between the `D` and the `A` + // (tty7's zsh helper prepends the `D`; the PowerShell one titles in + // its prompt function), so the title is the last word in the read. + apply_signals( + &mut st, + s.feed(b"\x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07"), + ); + assert_eq!( + st.osc_title.as_deref(), + Some("me@box:~/dev"), + "the prompt's own title outranks the retirement it follows" + ); + + // And that prompt title is nobody's command to retire. + apply_signals(&mut st, s.feed(b"\x1b]133;C;ls\x07\x1b]133;D;0\x07")); + assert_eq!(st.osc_title.as_deref(), Some("me@box:~/dev")); + } + + /// A pane with no shell integration never sees a mark, so nothing changes + /// for it: whatever it last called itself is all tty7 has. + #[test] + fn a_pane_without_shell_integration_keeps_its_title() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + apply_signals(&mut st, s.feed(b"\x1b]0;user@host:~/dev\x07")); + apply_signals(&mut st, s.feed(b"lots of output\r\n")); + assert_eq!(st.osc_title.as_deref(), Some("user@host:~/dev")); + } + #[test] fn sniff_osc133_prompt() { let mut s = OscSniffer::new(); diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 8862bbda..59e2fb9e 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -18,7 +18,7 @@ use std::collections::VecDeque; use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; use crate::core::config::CursorStyle as ConfigCursorStyle; -use crate::core::osc::OscTokenizer; +use crate::core::osc::{OscTokenizer, TitleEffect, TitleLifetime}; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, LoopbackForward, LoopbackForwardRequest, ManagedForward, NativeSshSpec, PaneProcs, @@ -1168,6 +1168,13 @@ impl RemoteTerminal { let mut osc = OscNotifyScanner::default(); let mut mode_tok = OscTokenizer::new(&[b"133"]); let mut zle_tok = OscTokenizer::new(&[b"133"]); + // #889: an OSC 0/2 the emulator above has already adopted, read + // a second time only to learn whether the program that wrote it + // has since exited. `TitleLifetime` is the daemon's rule too, + // so a window's tab strip and the switcher reading the tree can + // never disagree about whether a title is still current. + let mut title_tok = OscTokenizer::new(&[b"0", b"2", b"133"]); + let mut title_life = TitleLifetime::default(); let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); let mut pending: Vec = buffered; @@ -1216,6 +1223,25 @@ impl RemoteTerminal { let mut out_batch: Vec = Vec::new(); + // Whether this chunk ends with the title the pane is showing + // belonging to a command that has finished. The emulator has + // already parsed the same bytes, so a `true` here is sent on + // as a `ResetTitle` *after* every `Title` the chunk produced — + // which is the whole ordering question: a shell that re-titles + // itself in `precmd` writes its OSC 0/2 after the `D`, and + // that title is the last word rather than a thing to undo. + macro_rules! chunk_retires_the_title { + ($bytes:expr) => {{ + let mut retire = false; + title_tok.feed($bytes, |payload| match title_life.saw(payload) { + TitleEffect::Retire => retire = true, + TitleEffect::Set => retire = false, + TitleEffect::None => {} + }); + retire + }}; + } + 'main: loop { macro_rules! flush_batch { () => { @@ -1305,6 +1331,9 @@ impl RemoteTerminal { } } }); + if chunk_retires_the_title!(&out_batch) { + proxy.send_event(AlacEvent::ResetTitle); + } proxy.send_event(AlacEvent::Wakeup); out_batch.clear(); } @@ -1382,6 +1411,13 @@ impl RemoteTerminal { } }); proxy.replaying.store(false, Ordering::Relaxed); + // The replay carries the pane's recent marks, + // so reading it is what lets a reattached + // window know whether the title it just + // adopted belongs to anything still running. + if chunk_retires_the_title!(&bytes) { + proxy.send_event(AlacEvent::ResetTitle); + } proxy.send_event(AlacEvent::Wakeup); } DaemonMsg::Output(bytes) => { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d669ad1e..e41e3220 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -15934,6 +15934,89 @@ mod prompt_handover_tests { panic!("never settled: {what}"); } + /// Bytes from the pane's pty, the way the daemon forwards them. + fn output(daemon: &mut Stream, bytes: &[u8]) { + DaemonMsg::Output(bytes.to_vec()).encode(daemon).unwrap(); + } + + /// Waits for the tab's reading of what the pane calls itself to become + /// `expect`, running out the wait a new title is held for on each pass. + fn titled( + cx: &mut TestAppContext, + window: &gpui::WindowHandle, + expect: Option<&str>, + ) { + for _ in 0..300 { + cx.run_until_parked(); + cx.executor().advance_clock(TITLE_SETTLE * 2); + cx.run_until_parked(); + let showing = window + .update(cx, |view, _, _| view.stated_title().map(str::to_string)) + .unwrap(); + if showing.as_deref() == expect { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the tab never came to read {expect:?}"); + } + + /// #889: a tab went on reading the name Claude Code had left on it long + /// after Claude exited and the pane was back at its own prompt in a real + /// directory. An OSC 0/2 had no end — only another OSC 0/2 replaced it — + /// so the last title any program wrote in a pane outlived it forever. + #[gpui::test] + fn a_title_a_command_set_is_retired_when_that_command_finishes(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + output(&mut daemon, b"\x1b]133;A\x07\x1b]133;B\x07"); + output( + &mut daemon, + b"\x1b]133;C;claude\x07\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\", + ); + titled(cx, &window, Some("✳ fixing the switcher")); + + // Claude exits and the shell reports the command finished. Nothing + // titles the pane after it, so the tab has to fall back down the + // label ladder — `stated_title` saying nothing is how it does that. + output(&mut daemon, b"\x1b]133;D;0\x07"); + titled(cx, &window, None); + } + + /// The two cases a title is *supposed* to outlive: a program that is still + /// running, and a shell that titles its own prompt (which it does between + /// the `D` and the `A`, so it is the last word rather than a thing undone). + #[gpui::test] + fn a_running_program_and_a_shells_own_prompt_title_both_keep_theirs(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + output( + &mut daemon, + b"\x1b]133;C;vim\x07\x1b]2;vim \xe2\x80\x94 main.rs\x1b\\", + ); + titled(cx, &window, Some("vim — main.rs")); + + output( + &mut daemon, + b"\x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07\x1b]133;B\x07", + ); + titled(cx, &window, Some("me@box:~/dev")); + settle(cx, &window, "the prompt is reading", |view| { + view.terminal.zle_reading() + }); + + // And that prompt title is nobody's command to retire. Both marks are + // chased by an edge the pane reports, so the assertion below cannot + // pass on bytes that have not landed yet. + output(&mut daemon, b"\x1b]133;C;ls\x07"); + settle(cx, &window, "a command takes the pane", |view| { + !view.terminal.zle_reading() + }); + output(&mut daemon, b"\x1b]133;D;0\x07\x1b]133;B\x07"); + settle(cx, &window, "the prompt comes back", |view| { + view.terminal.zle_reading() + }); + titled(cx, &window, Some("me@box:~/dev")); + } + /// Printable text arrives the way the platform delivers it — through the /// text-input path, which is what the gap hold and the typeahead record see. fn type_text(window: &gpui::WindowHandle, cx: &mut TestAppContext, text: &str) { From 829d0f02eaeb31fa2d3a9de9366daba6ac005d0c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:11:24 +0800 Subject: [PATCH 40/46] fix(menu): shorten the Close Pane / Tab menu label to Close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-click menu and the File menu sit next to Split / New Tab items, so the surrounding context already says what closes; plain "Close" (⌘W) matches the macOS convention. The palette and Keybindings page keep the full name, where there is no surrounding menu to disambiguate. --- src/ui/i18n/en.rs | 2 +- src/ui/i18n/ja.rs | 2 +- src/ui/i18n/zh.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a2f97082..212073e4 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1769,7 +1769,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory", L10nKey::AppMenuCopySessionId => "Copy Session ID", L10nKey::AppMenuForkSession => "Fork Session", - L10nKey::AppMenuClosePaneTab => "Close Pane / Tab", + L10nKey::AppMenuClosePaneTab => "Close", L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs", L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right", L10nKey::AppMenuReopenClosedTab => "Reopen Closed Tab", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index ee452f64..63416bfd 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1846,7 +1846,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::AppMenuCopySessionId => "セッション ID をコピー", L10nKey::AppMenuForkSession => "セッションをフォーク", - L10nKey::AppMenuClosePaneTab => "ペイン / タブを閉じる", + L10nKey::AppMenuClosePaneTab => "閉じる", L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる", L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる", L10nKey::AppMenuReopenClosedTab => "閉じたタブをもう一度開く", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 17790008..b5f8e845 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1671,7 +1671,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录", L10nKey::AppMenuCopySessionId => "复制会话 ID", L10nKey::AppMenuForkSession => "Fork 会话", - L10nKey::AppMenuClosePaneTab => "关闭窗格 / 标签页", + L10nKey::AppMenuClosePaneTab => "关闭", L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页", L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页", L10nKey::AppMenuReopenClosedTab => "重新打开已关闭的标签页", From 3c3c069650d5bb31e8f89cb59929f8b5f63cf5f8 Mon Sep 17 00:00:00 2001 From: wenlingang Date: Tue, 22 Sep 2026 16:13:32 +0800 Subject: [PATCH 41/46] fix(terminal): let a flick's momentum tail keep the gesture it started as (#912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS stamps every event of a momentum tail with the modifiers held at delivery, so grabbing ⌘ while a two-finger flick was still coasting turned the rest of a plain scroll into a zoom — dozens of font steps in a few frames, down to FONT_SIZE_MIN and saved to config.json, which is why it outlived a relaunch and read as the font randomly shrinking. A trackpad gesture now answers "scroll or zoom?" once, at its first event, and holds that answer until the stream dies. A wheel has no gesture to belong to and still decides notch by notch. The latch is symmetric: a zoom gesture that outlives its modifier keeps zooming rather than dumping its tail into the scrollback. Co-Authored-By: Claude Opus 5 (1M context) --- src/terminal/view.rs | 81 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c82402e..4bfe914e 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -336,6 +336,10 @@ pub struct TerminalView { scroll_anim: Option, scroll_anim_epoch: u64, gesture_until: Option, + /// Whether the trackpad gesture in flight is zooming, latched at its first + /// event. `None` between gestures, and never set for a wheel, which has no + /// gesture to belong to and decides notch by notch. + gesture_zoom: Option, pub title: String, /// A title the pane has been told about but has not adopted yet — see /// `set_title_when_settled`. `None` means the tab is showing the newest @@ -1579,6 +1583,7 @@ impl TerminalView { scroll_anim: None, scroll_anim_epoch: 0, gesture_until: None, + gesture_zoom: None, title: DEFAULT_TITLE.to_string(), pending_title: None, default_title: DEFAULT_TITLE.to_string(), @@ -5469,12 +5474,31 @@ impl TerminalView { } fn on_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + let gesturing = self.track_scroll_gesture(ev.touch_phase); // One modifier turns the wheel into a zoom, the way it does in a // browser. Which one is the user's to say, because the default is the // platform modifier and on macOS that is a key half the world is // already holding for something else (#668). - if zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers) { - self.zoom_scroll(ev, window, cx); + let wants_zoom = zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers); + // A trackpad gesture answers "scroll or zoom?" once, on its first + // event, and keeps that answer until the stream dies — momentum tail + // included. The tail is why: those events are the system's, not the + // hand's, yet each one is stamped with whatever modifiers happen to be + // down as it is delivered. Reaching for ⌘ during a flick's coast — + // ⌘-Tab, ⌘-C, anything — would otherwise turn hundreds of coasting + // lines into zoom steps and leave the font at its minimum, from a + // gesture that was never a zoom (#912). + let zoom = if gesturing { + *self.gesture_zoom.get_or_insert(wants_zoom) + } else { + self.gesture_zoom = None; + // Leftover travel belongs to the gesture that earned it; a new one + // must not start already part-way to a step. + self.zoom_debt = 0.; + wants_zoom + }; + if zoom { + self.zoom_scroll(ev, gesturing, window, cx); return; } let mult = cx.global::().mouse_scroll_multiplier; @@ -5483,7 +5507,6 @@ impl TerminalView { ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(), }; let delta = raw * mult; - let gesturing = self.track_scroll_gesture(ev.touch_phase); let quantized = !ev.modifiers.shift && { let mode = *self.terminal.term.lock().mode(); @@ -5519,7 +5542,13 @@ impl TerminalView { /// asked for the wheel. Steps go out as the same actions the keyboard and /// the View menu send, so the min/max clamp and the saved setting live in /// one place — [`Tty7App::change_font_size`](crate::ui::app::Tty7App). - fn zoom_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + fn zoom_scroll( + &mut self, + ev: &ScrollWheelEvent, + gesturing: bool, + window: &mut Window, + cx: &mut Context, + ) { // Whatever the scrollback still had in flight is dropped: it was // travelling in lines of a font that is about to change size. self.cancel_scroll_anim(); @@ -5527,7 +5556,6 @@ impl TerminalView { ScrollDelta::Lines(p) => p.y, ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(), }; - let gesturing = self.track_scroll_gesture(ev.touch_phase); let (steps, debt) = zoom_scroll_steps(lines, self.zoom_debt, gesturing); self.zoom_debt = debt; for _ in 0..steps.unsigned_abs() { @@ -13262,6 +13290,49 @@ mod gpui_tests { .unwrap(); } + /// The bug this guards: a two-finger flick coasts long after the fingers + /// are gone, and every coasting event carries whatever modifiers are down + /// when it lands. Grabbing ⌘ for something else mid-coast used to read as + /// a zoom and run the font down to its minimum in a blink (#912). + #[gpui::test] + fn a_modifier_pressed_mid_flick_does_not_hijack_it_into_a_zoom(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Started), w, cx); + let before = display_offset(view); + // The tail, now stamped with ⌘ the hand reached for. + let mut tail = wheel(view, -3., gpui::TouchPhase::Moved); + tail.modifiers = Modifiers::secondary_key(); + view.on_scroll(&tail, w, cx); + assert!( + display_offset(view) != before || view.scroll_anim.is_some(), + "the tail stayed a scroll, the way the gesture started" + ); + assert_eq!(view.zoom_debt, 0., "and never paid into the zoom"); + }) + .unwrap(); + } + + /// The same latch the other way round: a zoom gesture that outlives the + /// modifier keeps zooming rather than dumping its tail into the buffer. + #[gpui::test] + fn a_zoom_gesture_keeps_zooming_after_the_modifier_is_released(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut start = wheel(view, -0.5, gpui::TouchPhase::Started); + start.modifiers = Modifiers::secondary_key(); + view.on_scroll(&start, w, cx); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Moved), w, cx); + assert_eq!(display_offset(view), 10, "the grid never moved"); + assert!(view.scroll_anim.is_none(), "and nothing was queued for it"); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test] From 770f19151aa7e258d98d8416b90496e974f517b8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:40:34 +0800 Subject: [PATCH 42/46] docs(windows): every release through 26.9.2 needed the VC++ redistributable (#902) The install guide said only releases up to 26.8.2 needed it, but 26.8.3 through 26.9.2 were built the same way and import VCRUNTIME140.dll too. Also correct the CI comment: cc-built code follows +crt-static by itself; what can bring the import back is a prebuilt native library or RUSTFLAGS. --- .github/workflows/ci.yml | 3 ++- docs/getting-started/installation.mdx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d758a25d..aad6539f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,8 @@ jobs: # It runs here, on the debug build, rather than only at release time: # every machine that builds tty7 already has the redistributable, so # nothing else in the pipeline can notice the regression, and the - # dependency can come back from a single new `cc`-built dependency. + # dependency can come back from a single prebuilt native library or a stray + # RUSTFLAGS (`cc`-built code follows `+crt-static` on its own). # Named files rather than the target directory — build scripts and # proc-macro artifacts under it are host units, which are built without # the target's rustflags and legitimately import the CRT dynamically. diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index afde3c07..2f33d959 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -48,7 +48,7 @@ AppImage bundles its own X11/Wayland/font libraries. Nothing has to be installed first. The executables link the Visual C++ runtime statically, so the "Visual C++ 2015–2022 Redistributable" is not a - prerequisite — releases up to and including 26.8.2 did need it, and failed + prerequisite — releases up to and including 26.9.2 did need it, and failed to start at all on a machine that had never installed it. From 8839d89d57711d1692c8d4e8e380babd31a47d66 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:07:08 +0800 Subject: [PATCH 43/46] fix(i18n): keybindings intro no longer says Backspace resets to default (#901) --- src/ui/i18n/en.rs | 2 +- src/ui/i18n/ja.rs | 2 +- src/ui/i18n/zh.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 631a1c49..a4ce5e97 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -631,7 +631,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsPressKeys => "Press keys… · ⌫ for no shortcut", L10nKey::SettingsPauseToSaveEsc => "pause to save · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets to default if pressed first." + "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or, pressed first, leaves the action with no shortcut — Reset brings the default back." } L10nKey::SettingsPrefixNote => { "With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal." diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 17875bfb..2404401c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -638,7 +638,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsPressKeys => "キーを入力… · ⌫ でショートカットなし", L10nKey::SettingsPauseToSaveEsc => "一時停止して保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとデフォルトに戻します" + "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとショートカットなしになり、「リセット」でデフォルトに戻せます" } L10nKey::SettingsPrefixNote => { "プレフィックスが有効な場合、プレフィックスキーを単独で押すと約 1 秒後にシェルに渡され、プレフィックス + 未割り当てのキーはターミナルへそのまま送信されます" diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d5aedfdb..5abc8b57 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -557,7 +557,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsPressKeys => "按下按键… · ⌫ 表示不设快捷键", L10nKey::SettingsPauseToSaveEsc => "暂停以保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则重置为默认。" + "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则不设快捷键,点「重置」可恢复默认。" } L10nKey::SettingsPrefixNote => { "启用前缀后,单独按前缀键约 1 秒后会传给 shell,前缀 + 未绑定的按键会直接发送到终端。" From f3de9a3a0d9a0083debd93c0d0f7b5efbe8a55e8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:10:23 +0800 Subject: [PATCH 44/46] Drop TEXT_MONO imports left unused by the SCM row restyle --- src/ui/scm/detail.rs | 2 +- src/ui/scm/panel.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index 0a148518..f169122f 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -46,7 +46,7 @@ use tty7_core::core::git::status::DecoStatus; use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_plural}; -use crate::ui::right_panel::{META, META_MONO, ROW_INSET, TEXT, TEXT_MONO, git_badge, info_chip}; +use crate::ui::right_panel::{META, META_MONO, ROW_INSET, TEXT, git_badge, info_chip}; use crate::ui::scm::path::{relative_time, split_display_path}; use crate::ui::scm::state::{CommitDetailView, RepoKey}; use crate::ui::scm::status::{status_color, status_glyph}; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 8b7d6ced..7954aa25 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -32,7 +32,7 @@ use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App}; use crate::ui::host_ops::{HostId, SharedHost}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::{ - HEADING, META, META_MONO, ROW_INSET, SEARCH_H, TEXT_MONO, action_strip, git_badge, info_chip, + HEADING, META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip, }; use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners}; use crate::ui::scm::ScmIntent; From 0a32a6c38e4a4426904a5761854fe6a34c0f9706 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:12:11 +0800 Subject: [PATCH 45/46] fix(terminal): let the zoom latch die with its gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latch added for #912 was cleared only on an event that was not part of a gesture — but a new gesture's first event is `Started`, which `track_scroll_gesture` reports as live, so the previous gesture's answer was still sitting there for `get_or_insert` to find. That is the same bug wearing the other coat: one ⌘-zoom, and every later two-finger flick kept zooming with nothing held at all, until some wheel notch or a stray non-gesture event happened to clear it. Deterministic, where the original was merely likely. Reset the latch (and the leftover zoom debt) when fingers go back down, so each gesture answers "scroll or zoom?" for itself. Guard: `a_new_gesture_is_not_bound_by_what_the_last_one_answered`, verified red against the commit it fixes. Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt --- src/terminal/view.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 4bfe914e..43ef29d6 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -5488,13 +5488,20 @@ impl TerminalView { // ⌘-Tab, ⌘-C, anything — would otherwise turn hundreds of coasting // lines into zoom steps and leave the font at its minimum, from a // gesture that was never a zoom (#912). - let zoom = if gesturing { - *self.gesture_zoom.get_or_insert(wants_zoom) - } else { + // The answer is latched per gesture, not per stream: fingers going + // back down ask it again. Carrying it over would be the same bug + // wearing the other coat — one ⌘-zoom, and every later flick zooms + // with nothing held at all, because `Started` keeps the gesture live + // and would find the old answer still sitting there. + if !gesturing || matches!(ev.touch_phase, gpui::TouchPhase::Started) { self.gesture_zoom = None; // Leftover travel belongs to the gesture that earned it; a new one // must not start already part-way to a step. self.zoom_debt = 0.; + } + let zoom = if gesturing { + *self.gesture_zoom.get_or_insert(wants_zoom) + } else { wants_zoom }; if zoom { @@ -13333,6 +13340,30 @@ mod gpui_tests { .unwrap(); } + /// The latch is per gesture, not per stream: fingers going down again + /// start a fresh question. Carrying the last answer over would mean one + /// ⌘-zoom left every later flick zooming with no modifier held at all. + #[gpui::test] + fn a_new_gesture_is_not_bound_by_what_the_last_one_answered(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut zoom = wheel(view, -0.5, gpui::TouchPhase::Started); + zoom.modifiers = Modifiers::secondary_key(); + view.on_scroll(&zoom, w, cx); + assert_eq!(display_offset(view), 10, "that one was a zoom"); + // Fingers down again, nothing held: a plain scroll. + view.on_scroll(&wheel(view, -3., gpui::TouchPhase::Started), w, cx); + assert_ne!( + display_offset(view), + 10, + "the new gesture asked the question again" + ); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test] From 96e5f23eae36729af831519cfb9d807a50913614 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:13:34 +0800 Subject: [PATCH 46/46] fix: retire a replayed title whose C mark rolled out of the ring (#889) A window that reattaches to a pane mid-command (app restart during a long agent session) replays the daemon's 8 MiB output ring. Once the session outgrows it, the command's 133;C is gone while its OSC 0/2 titles remain, so TitleLifetime treated them as prompt titles and the command's D never retired them: the tab stayed stuck exactly as in #889, while the daemon's own record had already cleared. The replayed Prompt state already says a command owns the pane. When it does and the replay carried no prompt mark at all, everything replayed was written under that command, so seed the reader's TitleLifetime as running with a command-owned title. --- crates/tty7-core/src/core/osc.rs | 45 ++++++++++++++++++++++++++++++++ src/terminal/remote.rs | 13 +++++++++ src/terminal/view.rs | 38 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index 85eed400..82a3177d 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -189,6 +189,8 @@ pub struct TitleLifetime { running: bool, /// The title standing right now was set while a command owned the pane. from_command: bool, + /// Any `133` prompt mark (`A`–`D`) has been read at all. + marked: bool, } impl TitleLifetime { @@ -202,6 +204,9 @@ impl TitleLifetime { let Some(rest) = payload.strip_prefix(b"133;") else { return TitleEffect::None; }; + if matches!(rest.first(), Some(b'A'..=b'D')) { + self.marked = true; + } match rest.first() { Some(b'C') => self.running = true, Some(b'D') => { @@ -220,6 +225,24 @@ impl TitleLifetime { } TitleEffect::None } + + /// The stream was picked up partway through a command whose `C` mark is + /// not in it — a window reattaching to a pane whose replay ring rolled + /// past the `C` while a long session (an agent, an editor) ran on. + /// + /// If what was read carried no prompt mark at all, every byte of it was + /// written under that command, titles included, so the command's `D` + /// must retire them just as it would have on a link that saw the `C`. + /// Without this a reattached window keeps the dead program's title + /// forever — exactly #889 — while the daemon, which saw the whole stream, + /// has already dropped it. Any mark read settles the question on its own, + /// so this does nothing then. + pub fn joined_mid_command(&mut self) { + if !self.marked { + self.running = true; + self.from_command = true; + } + } } #[cfg(test)] @@ -434,6 +457,28 @@ mod tests { assert_eq!(life.saw(b"133;D;0"), TitleEffect::None); } + /// A reattach whose replay ring no longer holds the running command's `C`: + /// the titles in it are that command's, and its `D` retires them. + #[test] + fn a_stream_joined_mid_command_retires_its_title_at_the_d() { + let mut life = TitleLifetime::default(); + assert_eq!( + life.saw(b"2;\xe2\x9c\xb3 fixing the switcher"), + TitleEffect::Set + ); + life.joined_mid_command(); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::Retire); + + // A replay that carried marks already knows who owns the title: a + // title pinned at a prompt stays pinned through the next command. + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"133;B"), TitleEffect::None); + assert_eq!(life.saw(b"0;my tab"), TitleEffect::Set); + life.joined_mid_command(); + assert_eq!(life.saw(b"133;C;ls"), TitleEffect::None); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::None); + } + #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { assert_eq!( diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 59e2fb9e..d132c28c 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -1175,6 +1175,10 @@ impl RemoteTerminal { // never disagree about whether a title is still current. let mut title_tok = OscTokenizer::new(&[b"0", b"2", b"133"]); let mut title_life = TitleLifetime::default(); + // No live `Output` frame yet: whatever arrives now is the + // daemon's replay (an attach or a relink), which it sends + // entirely as `Snapshot`s followed by the stored state. + let mut replaying_state = true; let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); let mut pending: Vec = buffered; @@ -1431,6 +1435,7 @@ impl RemoteTerminal { // agent it ran *later* reported for the first // time, and that report would be discounted. awaiting_replay = false; + replaying_state = false; out_batch.extend_from_slice(&bytes); tr_frames += 1; } @@ -1539,6 +1544,14 @@ impl RemoteTerminal { last_exit, } => { flush_batch!(); + // The replay says a command owns the pane + // right now. If the ring it replayed had + // already rolled past that command's `C`, the + // title just adopted from it is the command's + // and its `D` has to retire it (#889). + if replaying_state && active && !at_prompt { + title_life.joined_mid_command(); + } if let Ok(mut guard) = shell.lock() { *guard = ShellState { active, diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e41e3220..d1fb47ab 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -16017,6 +16017,44 @@ mod prompt_handover_tests { titled(cx, &window, Some("me@box:~/dev")); } + /// #889 on a reattached window: a long session outran the daemon's replay + /// ring, so the replay carries the program's title but not the `C` that + /// started it. The replayed prompt state says a command owns the pane, + /// and that is enough to know the title is the command's to lose at `D`. + #[gpui::test] + fn a_reattached_window_retires_a_title_whose_c_rolled_out_of_the_replay( + cx: &mut TestAppContext, + ) { + crate::core::config::pin_test_config_dir(); + cx.executor().allow_parking(); + let (client_side, mut daemon) = test_stream_pair(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + }); + let window = cx.add_window(|window, cx| { + let terminal = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)) + .expect("reattached link-backed terminal"); + TerminalView::with_terminal(terminal, 1, window, cx) + }); + + DaemonMsg::Snapshot(b"\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\redraw".to_vec()) + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + titled(cx, &window, Some("✳ fixing the switcher")); + + output(&mut daemon, b"\x1b]133;D;0\x07"); + titled(cx, &window, None); + } + /// Printable text arrives the way the platform delivers it — through the /// text-input path, which is what the gap hold and the typeahead record see. fn type_text(window: &gpui::WindowHandle, cx: &mut TestAppContext, text: &str) {