Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 69 additions & 12 deletions crates/noa-app/src/app/helpers/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,24 +133,81 @@ pub(crate) fn visible_pane_ids(tree: &SplitTree, zoomed: Option<PaneId>) -> 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<String> {
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,
Expand Down
64 changes: 58 additions & 6 deletions crates/noa-app/src/app/helpers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions crates/noa-app/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment on lines +100 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep process title independent of session cards

When a pane has not produced any PTY output yet, such as noa -e sleep 60 or another silent explicit command, no SessionDelta::Upsert has created a SessionCard; SessionStore::apply drops SessionDelta::Process updates for missing cards, and the process poller will not resend the same name. Because the new fallback reads the foreground process only through self.session_store.get(...), the title remains Noa/cwd instead of showing the foreground process until output occurs or the process changes. Store the latest process independently of the card, or let process deltas create/update the card before relying on it for the title.

Useful? React with 👍 / 👎.

});
#[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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down
11 changes: 11 additions & 0 deletions crates/noa-app/src/app/sidebar/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/noa-app/src/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down