From 9299927804124bab116baa19916114a325cd880e Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Fri, 17 Jul 2026 13:57:40 +0900 Subject: [PATCH] feat(app): derive dynamic tab title from foreground process and cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the shell has not set an OSC 0/2 title (and the user has not renamed the tab), fall back to a live title built from the focused pane's foreground process and OSC 7 cwd tail (`cargo — noa`), matching the sidebar card naming via the shared cwd_tail helper. A plain interactive shell collapses to just the cwd tail; with no information the title stays "Noa". User overrides and explicit OSC titles keep their existing priority. Foreground-process changes now request a window redraw when the session delta is applied so the title tracks them even with the sidebar closed; cwd and OSC title changes already ride the io thread's Redraw path. --- crates/noa-app/src/app/helpers/geometry.rs | 81 ++++++++++++++++++---- crates/noa-app/src/app/helpers/tests.rs | 64 +++++++++++++++-- crates/noa-app/src/app/render.rs | 29 +++++++- crates/noa-app/src/app/sidebar/state.rs | 11 +++ crates/noa-app/src/sidebar.rs | 2 +- 5 files changed, 165 insertions(+), 22 deletions(-) diff --git a/crates/noa-app/src/app/helpers/geometry.rs b/crates/noa-app/src/app/helpers/geometry.rs index 93d8ad8a..f1952675 100644 --- a/crates/noa-app/src/app/helpers/geometry.rs +++ b/crates/noa-app/src/app/helpers/geometry.rs @@ -133,24 +133,81 @@ pub(crate) fn visible_pane_ids(tree: &SplitTree, zoomed: Option) -> Vec< split_tree::zoom_decision(tree, zoomed, PaneRectApp::new(0, 0, 0, 0)).draw_panes } -pub(crate) fn tab_title(title: &str) -> String { - if title.is_empty() { - "Noa".to_string() - } else { - title.to_string() +/// The tab label to display, in descending priority (tab-title REQ-TTL-5): +/// +/// 1. a user-set override, verbatim — it masks any shell title; +/// 2. a non-empty shell-driven OSC 0/2 title, verbatim; +/// 3. a dynamic fallback built from the focused pane's live foreground +/// process and cwd (see [`dynamic_tab_title`]), so the label tracks state +/// even when the shell sets no title (Ghostty parity); +/// 4. `"Noa"` when nothing is known. +pub(crate) fn resolved_tab_title( + title_override: Option<&str>, + shell_title: &str, + cwd: Option<&str>, + process: Option<&str>, +) -> String { + if let Some(title) = title_override { + return title.to_string(); } + if !shell_title.is_empty() { + return shell_title.to_string(); + } + dynamic_tab_title(cwd, process).unwrap_or_else(|| "Noa".to_string()) } -/// The tab label to display: a user-set override verbatim when present -/// (tab-title REQ-TTL-5 — it masks any shell title), else the shell-driven -/// path with its `"Noa"` fallback. -pub(crate) fn resolved_tab_title(title_override: Option<&str>, shell_title: &str) -> String { - match title_override { - Some(title) => title.to_string(), - None => tab_title(shell_title), +/// Build the dynamic fallback title from the focused pane's live state, used +/// only when the shell has set no OSC 0/2 title. Mirrors the sidebar card's +/// naming (via the shared [`crate::sidebar::cwd_tail`]) so a tab and its card +/// read consistently: +/// +/// - a foreground process that is *not* the plain login shell leads, suffixed +/// with the cwd's tail segment when known (`cargo — noa`); +/// - a plain shell (or an unknown process) collapses to just the cwd tail +/// (`noa`), the identity the shell prompt itself would show; +/// - nothing known → `None` (the caller substitutes `"Noa"`). +fn dynamic_tab_title(cwd: Option<&str>, process: Option<&str>) -> Option { + let tail = cwd.and_then(crate::sidebar::cwd_tail); + let process = process + .map(str::trim) + .filter(|process| !process.is_empty() && !is_plain_shell(process)); + match (process, tail) { + (Some(process), Some(tail)) => Some(format!("{process} — {tail}")), + (Some(process), None) => Some(process.to_string()), + (None, Some(tail)) => Some(tail.to_string()), + (None, None) => None, } } +/// Whether a foreground process name is a plain interactive shell, in which +/// case the dynamic tab title shows the cwd tail instead of the shell name +/// (`zsh` in `~/noa` reads better as `noa`). Matched on the executable's +/// basename, case-insensitively, tolerating a login-shell `-` argv0 prefix. +fn is_plain_shell(process: &str) -> bool { + let base = process + .rsplit(['/', '\\']) + .next() + .unwrap_or(process) + .trim() + .trim_start_matches('-') + .to_ascii_lowercase(); + matches!( + base.as_str(), + "sh" | "bash" + | "zsh" + | "fish" + | "dash" + | "ksh" + | "tcsh" + | "csh" + | "nu" + | "elvish" + | "xonsh" + | "pwsh" + | "powershell" + ) +} + /// Titlebar proxy icon diff-cache (REQ-PXI-4): compares this frame's raw /// focused-pane cwd against the cached value from the last frame the setter /// actually ran for. Returns `None` (skip the native call) when unchanged, diff --git a/crates/noa-app/src/app/helpers/tests.rs b/crates/noa-app/src/app/helpers/tests.rs index d3586871..bc6e8dc3 100644 --- a/crates/noa-app/src/app/helpers/tests.rs +++ b/crates/noa-app/src/app/helpers/tests.rs @@ -1685,8 +1685,44 @@ fn toggle_tab_overview_dispatch_flips_visibility() { #[test] fn empty_terminal_title_falls_back_to_app_name() { - assert_eq!(tab_title(""), "Noa"); - assert_eq!(tab_title("shell"), "shell"); + // No override, no shell title, and no cwd/process to build a dynamic + // fallback from → the app name. + assert_eq!(resolved_tab_title(None, "", None, None), "Noa"); + // A non-empty shell title (OSC 0/2) is shown verbatim. + assert_eq!(resolved_tab_title(None, "shell", None, None), "shell"); +} + +#[test] +fn empty_shell_title_uses_dynamic_process_and_cwd_fallback() { + // cwd only → the cwd's tail segment (the repo/dir name). + assert_eq!( + resolved_tab_title(None, "", Some("/Users/me/repos/noa"), None), + "noa" + ); + // A non-shell process only (no cwd) → the process name. + assert_eq!(resolved_tab_title(None, "", None, Some("vim")), "vim"); + // Both → `process — cwdtail`. + assert_eq!( + resolved_tab_title(None, "", Some("/Users/me/repos/noa"), Some("cargo")), + "cargo — noa" + ); + // A plain shell as the foreground process collapses to just the cwd tail + // (a `zsh` prompt reads better as its directory name); a login-shell `-` + // argv0 prefix and a full path both still classify as the shell. + assert_eq!( + resolved_tab_title(None, "", Some("/Users/me/repos/noa"), Some("zsh")), + "noa" + ); + assert_eq!( + resolved_tab_title(None, "", Some("/Users/me/repos/noa"), Some("-zsh")), + "noa" + ); + assert_eq!( + resolved_tab_title(None, "", Some("/Users/me/repos/noa"), Some("/bin/bash")), + "noa" + ); + // A plain shell with no cwd has nothing to show → the app name. + assert_eq!(resolved_tab_title(None, "", None, Some("zsh")), "Noa"); } #[test] @@ -1786,10 +1822,26 @@ fn command_palette_snapshot_marks_unavailable_commands_disabled() { // existing shell-title/fallback path is untouched. #[test] fn resolved_tab_title_prefers_the_override_over_any_shell_title() { - assert_eq!(resolved_tab_title(Some("api server"), "vim"), "api server"); - assert_eq!(resolved_tab_title(Some("api server"), ""), "api server"); - assert_eq!(resolved_tab_title(None, "vim"), "vim"); - assert_eq!(resolved_tab_title(None, ""), "Noa"); + assert_eq!( + resolved_tab_title(Some("api server"), "vim", None, None), + "api server" + ); + assert_eq!( + resolved_tab_title(Some("api server"), "", None, None), + "api server" + ); + // The override even masks a dynamic process/cwd fallback (REQ-TTL-5). + assert_eq!( + resolved_tab_title(Some("api server"), "", Some("/Users/me/noa"), Some("cargo")), + "api server" + ); + assert_eq!(resolved_tab_title(None, "vim", None, None), "vim"); + // A non-empty OSC title wins over the dynamic fallback. + assert_eq!( + resolved_tab_title(None, "vim", Some("/Users/me/noa"), Some("cargo")), + "vim" + ); + assert_eq!(resolved_tab_title(None, "", None, None), "Noa"); } // AC-PXI-5: the diff-cache helper skips the setter when the raw cwd is diff --git a/crates/noa-app/src/app/render.rs b/crates/noa-app/src/app/render.rs index b8118881..a68fca0c 100644 --- a/crates/noa-app/src/app/render.rs +++ b/crates/noa-app/src/app/render.rs @@ -92,6 +92,20 @@ impl App { let search_preedit = self .modal_preedit_for(window_id, ModalImeTarget::SearchPrompt) .to_string(); + // The focused pane's detected foreground process name, read from the + // session store before the mutable `state` borrow below (the store is + // owned by `self`, not the per-window state). Feeds the dynamic + // tab-title fallback so the title tracks `cargo`/`vim`/… when the shell + // sets no OSC 0/2 title. + let focused_process = self + .windows + .get(&window_id) + .map(|state| state.focused_pane) + .and_then(|pane_id| { + self.session_store + .get(&Self::session_card_id(window_id, pane_id)) + .and_then(|card| card.process.clone()) + }); #[cfg(target_os = "macos")] let has_visible_background_image = self.background_image.has_visible_image(); let (Some(gpu), Some(state)) = (self.gpu.as_mut(), self.windows.get_mut(&window_id)) else { @@ -133,7 +147,7 @@ impl App { // (tab-title REQ-TTL-2/5); the shell path below only applies while // there is no override. let title_override = state.title_override.clone(); - let mut title = resolved_tab_title(title_override.as_deref(), ""); + let mut title = resolved_tab_title(title_override.as_deref(), "", None, None); // The focused pane's raw OSC 7 cwd diff-cache result, computed under // the same terminal lock the title read already takes (no extra lock // later) — feeds the titlebar proxy icon diff-apply below @@ -195,10 +209,19 @@ impl App { remote_state, &term.title, ); - title = resolved_tab_title(title_override.as_deref(), &remote_title); + // A remote pane's cwd/process aren't tracked locally; the + // remote `tab_title` helper carries its own fallback, so + // the dynamic path stays out of it (pass `None`). + title = + resolved_tab_title(title_override.as_deref(), &remote_title, None, None); focused_cwd_update = proxy_icon_update(&state.proxy_icon_cwd, None); } else { - title = resolved_tab_title(title_override.as_deref(), &term.title); + title = resolved_tab_title( + title_override.as_deref(), + &term.title, + term.cwd.as_deref(), + focused_process.as_deref(), + ); focused_cwd_update = proxy_icon_update(&state.proxy_icon_cwd, term.cwd.as_deref()); } diff --git a/crates/noa-app/src/app/sidebar/state.rs b/crates/noa-app/src/app/sidebar/state.rs index c3b245e1..1ff18b9b 100644 --- a/crates/noa-app/src/app/sidebar/state.rs +++ b/crates/noa-app/src/app/sidebar/state.rs @@ -94,6 +94,10 @@ impl App { // process-monitor overlay's rows (checked before `apply` moves the // delta) — a no-op when the overlay is closed. let is_metrics_delta = matches!(delta, SessionDelta::Metrics { .. }); + // Checked before `apply` moves the delta; drives the tab-title repaint + // below (a process change is otherwise invisible to the render path, + // which reads the store, not the delta stream). + let is_process_delta = matches!(delta, SessionDelta::Process { .. }); self.session_store.apply(delta); if is_metrics_delta { self.refresh_process_monitor(); @@ -107,6 +111,13 @@ impl App { ); } self.request_sidebar_redraw(); + // A foreground-process change also feeds the dynamic tab-title fallback + // (geometry::resolved_tab_title), so repaint the affected window even + // when its sidebar is closed. cwd (OSC 7) and OSC 0/2 title changes + // already ride the io thread's own Redraw, so they need no nudge here. + if is_process_delta { + self.request_window_redraw(window_id); + } if flags_overview_tile { self.mark_overview_tile_dirty(OverviewTileId::new(window_id, pane_id)); self.request_overview_redraw(); diff --git a/crates/noa-app/src/sidebar.rs b/crates/noa-app/src/sidebar.rs index 7650e09c..7d59f761 100644 --- a/crates/noa-app/src/sidebar.rs +++ b/crates/noa-app/src/sidebar.rs @@ -730,7 +730,7 @@ fn is_prompt_like_name(name: &str) -> bool { } /// The last non-empty path segment of `cwd`, if any. -fn cwd_tail(cwd: &str) -> Option<&str> { +pub(crate) fn cwd_tail(cwd: &str) -> Option<&str> { cwd.rsplit('/').find(|segment| !segment.is_empty()) }