From 017b2cebeaef37c5d2ca294b592d94da17b633f7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:34:35 +0800 Subject: [PATCH] feat(tabs): name a titleless tab after its working directory (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane that has reported where it is and nothing else was labelled "tty7" — the app's own name — for as long as it lived. That is not a rare corner: of the shells tty7 ships integration for, only PowerShell writes an OSC 0 title. bash, zsh and fish report their directory over OSC 7 and never title themselves, so on macOS and Linux the strip answered "which repo is this?" with the name of the terminal emulator. The switcher, meanwhile, already showed that directory. It reads a tab out of the machine tree through `TabView::label`, whose ladder — given name, title, agent, directory, process — has ranked a cwd above a bare process name since it was written, and its own doc comment said what that cost: Names a tab of a workspace this window does not own, matching what `Tty7App::tab_label` shows for local ones. The two read different sources and have to be talked into agreeing. They are not talked into it any more; there is one source. `tab_label` now builds a `TabView` of its own tab out of the live leaf (`Tab::label_view`) and renders it through the renderer the switcher was using, moved to `tab_strip::label_of`. The switcher, the strip and the tab sidebar climb the same ladder, and a tab cannot be one thing in the list and another in the row above it. No config key. The issue proposed `tab_label_fallback: "shell" | "cwd"` defaulting to today's behaviour, and a setting is the right shape when both answers are ones somebody would choose. Nobody chooses "tty7": the value it replaces carries no information about the tab at all, and the surface next to it has been showing the directory the whole time. A key here would be a permanent switch between a correct label and a placeholder, plus a settings row, to preserve a state no one asked for. What a pane is showing is read by `stated_title`: any title but the placeholder, which is wider than "arrived over OSC 0/2" on purpose. An SSH pane answers to the host it dialled (#438) and a workspace pane to its workspace's name; those are names tty7 gave the pane deliberately, they occupy the same rung a terminal title does, and a directory does not displace them. Nor does the "— process exited" state a finished pane is left holding. Only the placeholder gives way. Everything still comes off the one leaf the tab names itself after, so a title and the directory standing in for it cannot describe different panes (#580), and the hover tooltip now unshortens whichever of the two the label was actually made from. Fixes #740 --- src/terminal/view.rs | 53 +++++++++++++ src/ui/app.rs | 61 +++++++++++++++ src/ui/switcher.rs | 43 ++-------- src/ui/tab_sidebar.rs | 23 +++++- src/ui/tab_strip.rs | 177 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 297 insertions(+), 60 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 316d10cd..5632786e 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -163,6 +163,24 @@ pub struct NativeSshParts { /// What a pane is called when nothing running in it has said otherwise. pub(crate) const DEFAULT_TITLE: &str = "tty7"; +/// What a pane is *saying* about itself, if anything — the reading behind +/// [`TerminalView::stated_title`], split out so it can be pinned without a +/// live pane. +/// +/// Anything but the placeholder counts. That is wider than "arrived over OSC +/// 0/2" on purpose: an SSH pane answers to the host it dialled and a workspace +/// pane to its workspace's name, and those are names tty7 gave the pane +/// deliberately (#438) rather than the absence of one. The literal string +/// `tty7` is the only title that says nothing, because it is the app's own +/// name standing in for a pane that has never introduced itself. +pub(crate) fn stated_title(title: &str) -> Option<&str> { + match title.trim() { + "" => None, + t if t == DEFAULT_TITLE => None, + t => Some(t), + } +} + pub struct ShellParts { terminal: RemoteTerminal, pub(crate) pane_id: u64, @@ -1477,6 +1495,16 @@ impl TerminalView { self.terminal.foreground_cwd() } + /// The title this pane is showing, or `None` while it is still answering + /// to the app's own name — see [`stated_title`]. The label ladder reads + /// this where the machine tree reads + /// [`PaneRecord::osc_title`](tty7_core::core::machine::PaneRecord::osc_title), + /// which is what lets the tab strip and the switcher name a tab the same + /// way. + pub(crate) fn stated_title(&self) -> Option<&str> { + stated_title(&self.title) + } + /// Sets how opaque the pane wants this terminal painted; the pane leaf /// calls this every frame while rendering, and the terminal element /// blends its colours toward the window background during paint (see @@ -7222,6 +7250,31 @@ fn drag_scroll_step(overshoot: f32) -> i32 { #[cfg(test)] mod tests { + /// What the label ladder asks a pane: are you showing a name of your own, + /// or still standing under the app's? (#740) + #[test] + fn a_pane_states_a_title_whenever_it_is_not_the_placeholder() { + use super::stated_title; + + // Nothing has spoken — this is the pane a directory stands in for. + assert_eq!(stated_title("tty7"), None); + assert_eq!(stated_title(" tty7 "), None); + assert_eq!(stated_title(" "), None); + + // A title from the program running in it. + assert_eq!(stated_title("vim — main.rs"), Some("vim — main.rs")); + assert_eq!(stated_title(" user@host:~/repo "), Some("user@host:~/repo")); + // A default tty7 chose for the pane itself is a name, not the absence + // of one: an SSH pane answers to its host (#438) and a workspace pane + // to its workspace, and neither gives way to a directory. + assert_eq!(stated_title("prod-web"), Some("prod-web")); + // So does the state a finished pane is left showing. + assert_eq!( + stated_title("tty7 — process exited"), + Some("tty7 — process exited") + ); + } + #[test] fn an_unfocused_input_caret_is_always_a_steady_outline() { use super::{InputCaretPaint, input_caret_paint}; diff --git a/src/ui/app.rs b/src/ui/app.rs index dea55890..eae7e48b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -536,6 +536,67 @@ impl Tab { (leaf.title.clone(), leaf.display_home(cx)) } + /// This tab as the shared label ladder reads it, together with what a `~` + /// in whatever it ends up named would mean. + /// + /// [`TabView`](tty7_core::core::tab_view::TabView) is how a tab looks to + /// someone who is *not* the window showing it — the switcher listing + /// another window's workspace, `tty7 tab ls` on the far side of a socket. + /// Building one here from the live pane is what stops this window having a + /// second opinion: both sides then rank a given name, a title, an agent and + /// a directory through + /// [`TabView::label`](tty7_core::core::tab_view::TabView::label), so the + /// strip's answer to "which repo is this?" is the switcher's answer too. + /// + /// Everything comes off the one leaf the tab names itself after, so the + /// title and the directory standing in for it can never describe different + /// panes (#580). + pub(crate) fn label_view( + &self, + window: Option<&Window>, + cx: &App, + ) -> ( + tty7_core::core::tab_view::TabView, + Option, + ) { + let name = self.name.clone(); + let Some(leaf) = self.title_leaf(window, cx) else { + return ( + tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + title: String::new(), + osc_title: None, + cwd: None, + agent: None, + status: None, + live: false, + panes: 0, + }, + None, + ); + }; + let leaf = leaf.read(cx); + let view = tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + // The tree's `title` is the foreground process name — what it falls + // back on once a pane has said nothing about itself. A live pane's + // equivalent is the placeholder it answers to unprompted: any + // *other* default it was given (an SSH host, a workspace name) is a + // name tty7 chose for it deliberately, and `stated_title` hands + // those up as the title the pane is showing. + title: crate::terminal::view::DEFAULT_TITLE.to_string(), + osc_title: leaf.stated_title().map(str::to_string), + cwd: leaf.cwd().map(|p| p.display().to_string()), + agent: leaf.agent(), + status: leaf.agent_session().map(|s| s.status), + live: !leaf.terminal.exited, + panes: self.pane.terminals().len(), + }; + (view, leaf.display_home(cx)) + } + pub(crate) fn git_status( &self, window: Option<&Window>, diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 2468a40c..9d3f087d 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2956,47 +2956,20 @@ impl TabRow { } } -/// Names a tab of a workspace this window does not own, matching what -/// `Tty7App::tab_label` shows for local ones. +/// Names a tab of a workspace this window does not own. /// -/// The two read different sources and have to be talked into agreeing. A local -/// tab is named by its live terminal's OSC title, which shells set to the -/// working directory and agents overwrite with what they are doing. The tree -/// carries a copy of that title (`PaneRecord::osc_title`), which is what makes -/// the two columns agree; `PaneRecord::title` is the *foreground process name* -/// ("zsh") and only stands in when there is no title at all. +/// The two surfaces used to read different sources and had to be talked into +/// agreeing: a local tab was named by its live terminal's title, this one by +/// the tree's copy of it (`PaneRecord::osc_title`). They now go through the one +/// renderer, [`crate::ui::tab_strip::label_of`] — a local tab is turned into +/// the same [`TabView`](crate::ui::machine_mirror::TabView) this one already +/// is, so neither column can rank the evidence its own way. fn tab_view_label( view: &crate::ui::machine_mirror::TabView, index: usize, home: Option<&std::path::Path>, ) -> String { - let unnamed = || { - t_fmt( - L10nKey::TabUnnamedShell, - &[("n", &((index + 1).to_string()))], - ) - }; - // A path can shorten away to nothing (a bare "user@host:"), and the process - // name is still worth more than a number. - let shortened = |raw: &str| match crate::ui::tab_strip::short_title(raw, home) { - shortened if !shortened.trim().is_empty() => shortened, - _ => match view.title.trim() { - "" => unnamed(), - title => title.to_string(), - }, - }; - match view.label() { - crate::ui::machine_mirror::TabLabel::Named(name) => name.to_string(), - // Through `short_title` because the local strip puts its own titles - // through it too: the shell integration writes `user@host:~/dir`, and a - // tab that spelled that out in full where the strip says "…/dir" would - // be the same disagreement in a new place. - crate::ui::machine_mirror::TabLabel::Osc(title) => shortened(title), - crate::ui::machine_mirror::TabLabel::Agent(agent) => agent.display_name().to_string(), - crate::ui::machine_mirror::TabLabel::Cwd(cwd) => shortened(cwd), - crate::ui::machine_mirror::TabLabel::Process(title) => title.to_string(), - crate::ui::machine_mirror::TabLabel::Unknown => unnamed(), - } + crate::ui::tab_strip::label_of(view, index, home) } impl Group { diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 6324133e..cb093257 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -326,9 +326,24 @@ impl Tty7App { ); (shown, Some(full)) } else { - let (raw_title, home) = tab.leaf_title_and_home(Some(window), cx); - let title = strip_host_prefix(raw_title.trim()); - let raw = abbreviate_home(title, home.as_deref()); + // The ladder the strip and the switcher climb, read + // here for the name and not for the shortening: this + // column measures in pixels and lets a card expand the + // row back to the whole string, so it wants what + // `label_of` would have cut down rather than the cut. + use crate::ui::machine_mirror::TabLabel; + let (view, home) = tab.label_view(Some(window), cx); + let raw = match view.label() { + TabLabel::Osc(title) | TabLabel::Cwd(title) => { + abbreviate_home(strip_host_prefix(title.trim()), home.as_deref()) + .into_owned() + } + TabLabel::Agent(agent) => agent.display_name().to_string(), + // A tab holding a name got one above. + TabLabel::Named(name) => name.to_string(), + TabLabel::Process(title) => title.to_string(), + TabLabel::Unknown => String::new(), + }; if raw.trim().is_empty() { // Nothing to expand: the row is naming an unnamed // shell, not hiding a title behind an ellipsis. @@ -338,7 +353,7 @@ impl Tty7App { )); (placeholder, None) } else { - let full = SharedString::from(raw.as_ref()); + let full = SharedString::from(raw); let shown = elide_label( &window.text_system(), title_font, diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index eb4f5198..450ef36b 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -140,6 +140,57 @@ pub(crate) fn short_title(raw: &str, home: Option<&std::path::Path>) -> String { label } +/// The one place a tab gets its displayed name, whichever surface is asking. +/// +/// `label()` ranks the evidence — a given name, then the title the pane is +/// showing, then an agent, then the working directory, then the process it is +/// running — and this renders whatever came back. Both callers arrive with a +/// [`TabView`](crate::ui::machine_mirror::TabView): the switcher reads one out +/// of the machine tree for a window it does not own, and the strip builds one +/// from its own live panes in +/// [`Tab::label_view`](crate::ui::app::Tab::label_view). +/// +/// They used to rank their own evidence, and disagreed where it mattered most: +/// a pane with a working directory and no title — every non-PowerShell shell +/// tty7 ships integration for reports OSC 7 and no OSC 0 — was listed by the +/// switcher as `~/repo/tty7` and by the strip that owned it as "tty7", the +/// app's own name (#740). +pub(crate) fn label_of( + view: &crate::ui::machine_mirror::TabView, + index: usize, + home: Option<&std::path::Path>, +) -> String { + use crate::ui::machine_mirror::TabLabel; + + let unnamed = || { + t_fmt( + L10nKey::TabUnnamedShell, + &[("n", &((index + 1).to_string()))], + ) + }; + // A path can shorten away to nothing (a bare "user@host:"), and the process + // name is still worth more than a number. + let shortened = |raw: &str| match short_title(raw, home) { + shortened if !shortened.trim().is_empty() => shortened, + _ => match view.title.trim() { + "" => unnamed(), + title => title.to_string(), + }, + }; + match view.label() { + TabLabel::Named(name) => name.to_string(), + // Through `short_title` because a title is so often a path: the shell + // integration writes `user@host:~/dir`, and a tab spelling that out in + // full where the one beside it says "…/dir" would be the same + // disagreement in a new place. + TabLabel::Osc(title) => shortened(title), + TabLabel::Agent(agent) => agent.display_name().to_string(), + TabLabel::Cwd(cwd) => shortened(cwd), + TabLabel::Process(title) => title.to_string(), + TabLabel::Unknown => unnamed(), + } +} + /// Width of `text` shaped in `font` at `size`, in pixels. /// /// The window's text system caches shaped runs, so measuring the same labels @@ -1229,6 +1280,11 @@ impl Tty7App { /// read `…/a/b/c` with no way to find out which `a` that was. `None` when /// nothing was dropped, so tabs that already show their whole name stay /// quiet under the pointer. + /// + /// It has to unshorten whatever the label was *made of*, which is why it + /// reads the same [`TabView`](crate::ui::machine_mirror::TabView) the label + /// did: a tab named after its directory wants that directory spelled out, + /// not the title it never had. pub(crate) fn tab_title_tooltip( &self, tab: &Tab, @@ -1236,12 +1292,18 @@ impl Tty7App { window: Option<&Window>, cx: &App, ) -> Option { - if tab.name.as_ref().is_some_and(|n| !n.trim().is_empty()) { - return None; - } - let (raw, home) = tab.leaf_title_and_home(window, cx); + use crate::ui::machine_mirror::TabLabel; + + let (view, home) = tab.label_view(window, cx); + // The other rungs are never shortened: a given name and a process name + // are printed whole, and an agent's is a word. + let raw = match view.label() { + TabLabel::Osc(title) => title, + TabLabel::Cwd(cwd) => cwd, + _ => return None, + }; let raw = raw.trim(); - if raw.is_empty() || raw == self.tab_label(tab, index, window, cx) { + if raw.is_empty() || raw == label_of(&view, index, home.as_deref()) { return None; } Some(SharedString::from( @@ -1249,6 +1311,9 @@ impl Tty7App { )) } + /// What this window puts on a tab of its own — the same ladder, through the + /// same renderer, as the switcher uses for a tab of somebody else's window. + /// See [`label_of`]. pub(crate) fn tab_label( &self, tab: &Tab, @@ -1256,22 +1321,8 @@ impl Tty7App { window: Option<&Window>, cx: &App, ) -> String { - if let Some(name) = tab.name.as_ref() { - let trimmed = name.trim(); - if !trimmed.is_empty() { - return trimmed.to_string(); - } - } - let (raw, home) = tab.leaf_title_and_home(window, cx); - let label = short_title(&raw, home.as_deref()); - if label.trim().is_empty() { - t_fmt( - L10nKey::TabUnnamedShell, - &[("n", &((index + 1).to_string()))], - ) - } else { - label - } + let (view, home) = tab.label_view(window, cx); + label_of(&view, index, home.as_deref()) } /// The New Tab control: one `+` that drops the list of everything it could @@ -2500,4 +2551,88 @@ mod tests { assert_eq!(spec.args, ["--login"]); assert!(!spec.args_are_tty7_defaults); } + + /// A tab of this window as the strip reads it: `tab_label` is nothing but + /// [`label_of`] over the [`TabView`](crate::ui::machine_mirror::TabView) + /// that [`Tab::label_view`](crate::ui::app::Tab::label_view) builds from + /// the live leaf, so naming one here climbs the same ladder a real tab + /// climbs. `title` is the placeholder `label_view` fills that slot with — + /// the machine tree puts a process name there, a live pane has only the + /// name it answers to before anything has spoken. + fn strip_tab() -> crate::ui::machine_mirror::TabView { + crate::ui::machine_mirror::TabView { + id: tty7_core::core::machine::TabId::new(), + name: None, + title: crate::terminal::view::DEFAULT_TITLE.to_string(), + osc_title: None, + cwd: None, + agent: None, + status: None, + live: true, + panes: 1, + } + } + + /// The home the paths below are measured against — named rather than read + /// off this machine, so the assertions do not depend on who is running + /// them (#580). + fn home() -> &'static Path { + Path::new("/Users/x") + } + + #[test] + fn a_renamed_tab_keeps_its_name_over_every_other_answer() { + let mut tab = strip_tab(); + tab.name = Some(" build ".into()); + tab.osc_title = Some("vim — main.rs".into()); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "build"); + } + + #[test] + fn a_pane_showing_a_title_is_named_by_it_and_not_by_its_directory() { + let mut tab = strip_tab(); + tab.osc_title = Some("vim — main.rs".into()); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "vim — main.rs"); + + // Including the title an SSH pane answers to before the far shell has + // said anything (#438): `label_view` hands that up here, so a window + // full of them still reads as hosts rather than as directories. + tab.osc_title = Some("prod-web".into()); + assert_eq!(label_of(&tab, 0, Some(home())), "prod-web"); + } + + /// #740: every shell tty7 ships integration for except PowerShell reports + /// its directory over OSC 7 and never sets a title, which left the tab + /// reading "tty7" — the app's own name — while the switcher listing the + /// very same tab showed the directory. + #[test] + fn a_pane_that_has_only_said_where_it_is_is_named_after_that() { + let mut tab = strip_tab(); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "~/repo/tty7"); + // Through the same shortener as a title, so a deep directory is cut + // where a deep path in a title would be. + tab.cwd = Some("/Users/x/repo/tty7/crates/tty7-core/src".into()); + assert_eq!( + label_of(&tab, 0, Some(home())), + super::short_title("/Users/x/repo/tty7/crates/tty7-core/src", Some(home())), + ); + } + + #[test] + fn a_pane_with_nothing_to_say_falls_back_the_way_it_always_did() { + // No title and no directory: the placeholder, exactly as before. + let tab = strip_tab(); + assert_eq!(label_of(&tab, 0, Some(home())), "tty7"); + + // And a tab holding no live pane at all is still numbered. + let mut empty = strip_tab(); + empty.title = String::new(); + assert!(label_of(&empty, 2, Some(home())).contains('3')); + } }