From 3847e4d015802688eafaa9a0c49aba20b940882a Mon Sep 17 00:00:00 2001 From: ayamir Date: Fri, 31 Jul 2026 21:21:23 +0800 Subject: [PATCH 1/2] fix(ui): show process names in sidebar titles --- crates/tty7-cli/src/testbed.rs | 1 + crates/tty7-core/src/core/machine.rs | 4 ++ crates/tty7-core/src/daemon/pane.rs | 5 +- crates/tty7-core/src/daemon/server.rs | 4 ++ crates/tty7-core/src/host/server.rs | 78 +++++++++++++++++++++++++-- src/ui/machine_mirror.rs | 23 +++++++- src/ui/tab_strip.rs | 19 ++++++- 7 files changed, 126 insertions(+), 8 deletions(-) diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 4357f269..71894e13 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -44,6 +44,7 @@ pub fn two_workspace_machine() -> Machine { let record = |id: u64, cwd: &str| PaneRecord { id, cwd: Some(cwd.to_string()), + title: String::new(), ssh_spec: None, agent: None, live: true, diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 7a0173cc..f5244563 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -258,6 +258,8 @@ pub struct PaneRecord { #[serde(default)] pub cwd: Option, #[serde(default)] + pub title: String, + #[serde(default)] pub ssh_spec: Option>, #[serde(default)] pub agent: Option, @@ -270,6 +272,7 @@ impl PaneRecord { PaneRecord { id, cwd: None, + title: String::new(), ssh_spec: None, agent: None, live: false, @@ -313,6 +316,7 @@ impl PaneSeed { PaneRecord { id: self.pane, cwd: self.cwd, + title: String::new(), ssh_spec: self.ssh_spec.map(|s| Box::new(s.without_secrets())), agent: self.agent, live, diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index a48ff2c2..e36129e6 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -1571,7 +1571,10 @@ fn agent_facts_changed( match (before, after) { (None, None) => false, (Some(a), Some(b)) => { - a.agent != b.agent || a.session_id != b.session_id || a.launch_argv != b.launch_argv + a.agent != b.agent + || a.session_id != b.session_id + || a.launch_argv != b.launch_argv + || a.status != b.status } _ => true, } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index dc930874..6328581e 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -84,6 +84,10 @@ impl crate::host::server::PaneDirectory for Registry { self.panes.lock().unwrap().len() as u64 } + fn panes(&self) -> Vec { + self.list() + } + fn agent_states(&self) -> Vec { let panes: Vec> = self.panes.lock().unwrap().values().cloned().collect(); let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect(); diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 1cb9e836..6354afd2 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -13,6 +13,7 @@ use crate::daemon::control::{ WireErrorKind, feature, server_started, }; use crate::daemon::duplex::{Duplex, Halves}; +use crate::daemon::protocol::PaneInfo; use crate::host::{Host, SearchHit, SharedHost, WatchSub}; pub const MAX_WORKERS: usize = 64; @@ -25,6 +26,7 @@ pub const LAYOUT_EVENT_QUEUE: usize = 1024; pub trait PaneDirectory: Send + Sync { fn pane_count(&self) -> u64; + fn panes(&self) -> Vec; fn agent_states(&self) -> Vec; } @@ -476,6 +478,26 @@ fn drop_unsendable_hits(hits: &mut Vec) { } } +fn machine_with_live_panes(conn: &Conn) -> io::Result { + let mut machine = conn.machine()?.machine(); + let Some(panes) = conn.panes.as_ref().map(|p| p.panes()) else { + return Ok(machine); + }; + for info in panes { + let record = match machine.panes.iter_mut().find(|p| p.id == info.pane_id) { + Some(record) => record, + None => { + machine.panes.push(machine::PaneRecord::new(info.pane_id)); + machine.panes.last_mut().expect("record was just inserted") + } + }; + record.cwd = info.cwd.map(|p| p.to_string_lossy().into_owned()); + record.title = info.title; + record.live = info.alive; + } + Ok(machine) +} + fn run_request( conn: &Arc, req_id: u64, @@ -590,7 +612,7 @@ fn run_request( } ControlRequest::MachineGet => ( - ReplyOk::MachineTree(Box::new(conn.machine()?.machine())), + ReplyOk::MachineTree(Box::new(machine_with_live_panes(conn)?)), Vec::new(), ), ControlRequest::WorkspaceTree { workspace } => ( @@ -1535,13 +1557,19 @@ mod aggregate_tests { use crate::host::local::LocalHost; use std::net::{TcpListener, TcpStream}; - struct ThreePanesOneAgent; + struct ThreePanesOneAgent { + panes: Vec, + } impl PaneDirectory for ThreePanesOneAgent { fn pane_count(&self) -> u64 { 3 } + fn panes(&self) -> Vec { + self.panes.clone() + } + fn agent_states(&self) -> Vec { vec![PaneAgentState { pane_id: 7, @@ -1574,7 +1602,7 @@ mod aggregate_tests { #[test] fn status_answers_with_this_servers_facts() { let services = Services { - panes: Some(Arc::new(ThreePanesOneAgent)), + panes: Some(Arc::new(ThreePanesOneAgent { panes: Vec::new() })), ..Services::none() }; let client = client_with(services); @@ -1601,7 +1629,7 @@ mod aggregate_tests { #[test] fn agent_states_are_the_pane_directorys_snapshot() { let services = Services { - panes: Some(Arc::new(ThreePanesOneAgent)), + panes: Some(Arc::new(ThreePanesOneAgent { panes: Vec::new() })), ..Services::none() }; let client = client_with(services); @@ -1616,6 +1644,48 @@ mod aggregate_tests { assert_eq!(states[0].state.session_id.as_deref(), Some("sess-7")); } + #[test] + fn machine_get_overlays_live_pane_titles() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + store + .tab_create( + ws.id, + None, + machine::PaneSeed { + pane: 7, + cwd: Some("/repo/tty7".into()), + ssh_spec: None, + agent: None, + }, + None, + None, + ) + .unwrap(); + + let services = Services { + machine: Some(store), + attachments: Arc::new(AttachRegistry::default()), + panes: Some(Arc::new(ThreePanesOneAgent { + panes: vec![PaneInfo { + pane_id: 7, + cwd: Some(PathBuf::from("/repo/tty7")), + title: "nvim".into(), + alive: true, + owner: None, + }], + })), + }; + let client = client_with(services); + let ReplyOk::MachineTree(machine) = client.call(ControlRequest::MachineGet).unwrap() else { + panic!("MachineGet must answer with a machine tree"); + }; + let pane = machine.panes.iter().find(|p| p.id == 7).unwrap(); + assert_eq!(pane.title, "nvim"); + assert!(pane.live); + } + #[test] fn aggregates_still_answer_when_this_process_serves_no_panes() { let client = client_with(Services::none()); diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 9e7ac0ad..f81cad5a 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -257,6 +257,9 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) { return name.to_string(); } + if let Some(title) = pane_title_of(ws, panes) { + return title.to_string(); + } subject_path_of(ws, panes) .and_then(|path| { std::path::Path::new(&path) @@ -267,6 +270,15 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { .unwrap_or_else(|| "Untitled".to_string()) } +fn pane_title_of<'a>(ws: &Workspace, panes: &'a [PaneRecord]) -> Option<&'a str> { + ws.tabs + .iter() + .flat_map(|t| t.root.pane_ids()) + .filter_map(|id| panes.iter().find(|p| p.id == id)) + .map(|p| p.title.trim()) + .find(|title| !title.is_empty()) +} + pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { let mut counts: Vec<(&str, usize)> = Vec::new(); for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) { @@ -501,15 +513,22 @@ mod tests { #[test] fn display_names_derive_from_the_tree_with_the_session_precedence() { let mut ws = Workspace::default(); - let panes = vec![PaneRecord { + let mut panes = vec![PaneRecord { cwd: Some("/home/me/scratch".into()), ..PaneRecord::new(1) }]; ws.tabs = vec![leaf_tab(1)]; assert_eq!(display_name_of(&ws, &panes), "scratch"); + panes[0].title = "nvim".into(); + assert_eq!(display_name_of(&ws, &panes), "nvim"); + ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); - assert_eq!(display_name_of(&ws, &panes), "tty7"); + assert_eq!( + display_name_of(&ws, &panes), + "nvim", + "a live process name is more distinctive than the cwd group" + ); ws.name = Some(" Release prep ".into()); assert_eq!(display_name_of(&ws, &panes), "Release prep"); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index e473c129..4326ec97 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -232,7 +232,11 @@ impl Tty7App { } crate::terminal::pane_liveness::sweep(cx); - let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace) + let current = self + .tabs + .get(self.active) + .and_then(|tab| workspace_osc_title(tab, cx).or_else(|| workspace_agent_title(tab, cx))) + .or_else(|| crate::ui::machine_mirror::display_name_for(cx, self.workspace)) .unwrap_or_else(|| "tty7".to_string()); let monogram: String = current .chars() @@ -1082,6 +1086,19 @@ impl Tty7App { } } +fn workspace_osc_title(tab: &Tab, cx: &App) -> Option { + let title = tab.leaf_title(None, cx); + let title = title.trim(); + if title.is_empty() || title == "tty7" || title.starts_with("tty7 — ") { + return None; + } + Some(title.to_string()) +} + +fn workspace_agent_title(tab: &Tab, cx: &App) -> Option { + tab.agent(cx).map(|agent| agent.display_name().to_string()) +} + #[cfg(test)] mod tests { use super::*; From 9fd84bf55577e09e3344a82095e1e06af605386f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:59:25 +0800 Subject: [PATCH 2/2] fix(ui): don't let a bare shell name outrank the cwd/repo title pane_title_of() picked any non-empty PaneRecord.title, but an idle terminal's foreground process is just the shell itself (zsh, bash, ...). That made almost every idle-shell workspace show up as "zsh" in the sidebar instead of the far more useful cwd/repo-derived name, which defeats the "cwd as final fallback" intent of this change. Skip bare shell process names when picking a pane title, so the process-name fallback only kicks in for genuinely distinctive foreground processes (nvim, an agent, ...). --- crates/tty7-core/src/core/shells.rs | 43 +++++++++++++++++++++++++++++ src/ui/machine_mirror.rs | 9 +++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index f91ab190..c84bc9cc 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -27,6 +27,37 @@ pub struct ShellInventory { pub default_name: String, } +/// Common interactive-shell process names, lowercase and without a +/// platform-specific extension. Anything that lands in here is what a pane +/// looks like at an idle prompt — not distinctive enough to stand in for a +/// pane's cwd/repo name when picking a display title. +const BARE_SHELL_NAMES: &[&str] = &[ + "sh", + "bash", + "zsh", + "fish", + "dash", + "ksh", + "tcsh", + "csh", + "nu", + "elvish", + "xonsh", + "pwsh", + "powershell", + "cmd", + "wsl", +]; + +/// True when `name` is a bare interactive-shell process name (e.g. the +/// foreground process of an idle terminal), so callers that want a +/// *distinctive* title should skip it and fall back to something else. +pub fn is_bare_shell_name(name: &str) -> bool { + let lower = name.trim().to_ascii_lowercase(); + let lower = lower.strip_suffix(".exe").unwrap_or(&lower); + BARE_SHELL_NAMES.contains(&lower) +} + pub fn inventory() -> ShellInventory { let configured = crate::core::config::shell_command(); ShellInventory { @@ -409,4 +440,16 @@ mod tests { assert!(!default_shell_name(None).is_empty()); assert!(!default_shell_name(Some(" ")).is_empty()); } + + #[test] + fn bare_shell_names_are_recognized_case_and_extension_insensitively() { + assert!(is_bare_shell_name("zsh")); + assert!(is_bare_shell_name("bash")); + assert!(is_bare_shell_name("PowerShell")); + assert!(is_bare_shell_name("pwsh.exe")); + assert!(is_bare_shell_name("CMD.EXE")); + assert!(!is_bare_shell_name("nvim")); + assert!(!is_bare_shell_name("claude")); + assert!(!is_bare_shell_name("")); + } } diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index f81cad5a..012fc902 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -276,7 +276,7 @@ fn pane_title_of<'a>(ws: &Workspace, panes: &'a [PaneRecord]) -> Option<&'a str> .flat_map(|t| t.root.pane_ids()) .filter_map(|id| panes.iter().find(|p| p.id == id)) .map(|p| p.title.trim()) - .find(|title| !title.is_empty()) + .find(|title| !title.is_empty() && !tty7_core::core::shells::is_bare_shell_name(title)) } pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { @@ -530,6 +530,13 @@ mod tests { "a live process name is more distinctive than the cwd group" ); + panes[0].title = "zsh".into(); + assert_eq!( + display_name_of(&ws, &panes), + "tty7", + "an idle shell prompt is not distinctive — cwd/repo name should win" + ); + ws.name = Some(" Release prep ".into()); assert_eq!(display_name_of(&ws, &panes), "Release prep");