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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 56238bf3bb31375c6127c10aff5528006593d2ef Mon Sep 17 00:00:00 2001 From: hhdebb Date: Thu, 10 Sep 2026 21:32:43 +0800 Subject: [PATCH 07/10] 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 08/10] 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 09/10] 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 10/10] 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!(