diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index e41c14e0..81b042e7 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixed - Config startup and reload now warn about unknown top-level table sections, including a `[toast]` hint that points to `[ui.toast]`, instead of silently ignoring them. - Claude Code session restore now accepts real `/clear`, `/resume`, and compacted session identity changes while still ignoring nested `claude -p` startup sessions that inherit the pane environment. (#620) +- Auto-named tab labels now stay compact after closing, moving, or creating tabs while public tab ids remain stable. - F1-F4 key presses sent as `ESC[11~` through `ESC[14~` now reach pane apps instead of being dropped. (#574) ## [0.6.10] - 2026-06-11 diff --git a/src/app/actions.rs b/src/app/actions.rs index 82aaa8f7..b033b372 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -219,8 +219,9 @@ pub fn notification_context( let mut context = format!("{} · {}", workspace_label, ws_idx + 1); if ws.tabs.len() > 1 { if let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) { - let tab = &ws.tabs[tab_idx]; - context.push_str(&format!(" · {}", tab.display_name())); + if let Some(label) = ws.tab_display_name(tab_idx) { + context.push_str(&format!(" · {label}")); + } } } context @@ -450,7 +451,9 @@ impl AppState { fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow { let ws = &self.workspaces[ws_idx]; let tab = &ws.tabs[tab_idx]; - let label = tab.display_name(); + let label = ws + .tab_display_name(tab_idx) + .unwrap_or_else(|| (tab_idx + 1).to_string()); let (status, seen) = tab_aggregate_state(tab, &self.terminals); let activity = tab_activity_summary(tab, &self.terminals); let pane_count = tab.panes.len(); diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 32dec32a..2b83d027 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -677,7 +677,10 @@ mod tests { panic!("expected layout apply response"); }; assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.workspaces[0].tabs[0].display_name(), "dev"); + assert_eq!( + app.state.workspaces[0].tab_display_name(0).as_deref(), + Some("dev") + ); let LayoutNode::Split { direction, ratio, diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index 881020b8..cd7d1818 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -196,7 +196,7 @@ impl App { ws_idx, workspace, self.public_tab_id(ws_idx, tab_idx), - Some(tab.display_name()), + ws.tab_display_name(tab_idx), focused_pane, correlation_id, )) @@ -257,7 +257,7 @@ impl App { let workspace = self.workspace_info(ws_idx); let tab_idx = ws.active_tab_index(); let tab_id = self.public_tab_id(ws_idx, tab_idx); - let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let tab_label = ws.tab_display_name(tab_idx); let focused_pane = ws .focused_pane_id() .and_then(|pane_id| self.pane_info(ws_idx, pane_id)); @@ -283,7 +283,7 @@ impl App { .find_tab_index_for_pane(pane_id) .unwrap_or_else(|| ws.active_tab_index()); let tab_id = self.public_tab_id(ws_idx, tab_idx); - let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let tab_label = ws.tab_display_name(tab_idx); let focused_pane = self.pane_info(ws_idx, pane_id); self.plugin_context_from_parts( ws_idx, diff --git a/src/app/creation.rs b/src/app/creation.rs index e53bce00..1d9b0f37 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -237,7 +237,7 @@ impl App { tab_id: self.public_tab_id(ws_idx, tab_idx)?, workspace_id: self.public_workspace_id(ws_idx), number: tab.number, - label: tab.display_name(), + label: ws.tab_display_name(tab_idx)?, focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx, pane_count: tab.panes.len(), agent_status: pane_agent_status(agg_state, seen), diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index cff760bf..a8ffef7e 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -355,7 +355,7 @@ fn next_new_tab_default_name(state: &AppState) -> String { state .active .and_then(|i| state.workspaces.get(i)) - .map(|ws| ws.next_public_tab_number.to_string()) + .map(|ws| (ws.tabs.len() + 1).to_string()) .unwrap_or_else(|| "1".to_string()) } @@ -453,9 +453,14 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { if let Some(ws) = state.workspaces.get_mut(ws_idx) { let workspace_id = ws.id.clone(); let active_tab = ws.active_tab; + let keep_auto_name = ws + .tabs + .get(active_tab) + .is_some_and(|tab| tab.is_auto_named()) + && ws + .tab_display_name(active_tab) + .is_some_and(|name| new_name == name); if let Some(tab) = ws.active_tab_mut() { - let keep_auto_name = - tab.is_auto_named() && new_name == tab.number.to_string(); if !new_name.is_empty() && !keep_auto_name { tab.set_custom_name(new_name); let tab_id = ws @@ -1296,7 +1301,7 @@ mod tests { } #[test] - fn closing_first_auto_tab_keeps_remaining_auto_tab_number_and_next_prompt() { + fn closing_first_auto_tab_compacts_remaining_auto_tab_label_and_next_prompt() { let mut state = state_with_workspaces(&["test"]); open_new_tab_dialog(&mut state); handle_rename_key( @@ -1311,11 +1316,14 @@ mod tests { state.workspaces[0].close_tab(0); state.workspaces[0].switch_tab(0); - assert_eq!(state.workspaces[0].tabs[0].display_name(), "2"); + assert_eq!( + state.workspaces[0].tab_display_name(0).as_deref(), + Some("1") + ); assert!(state.workspaces[0].tabs[0].custom_name.is_none()); open_new_tab_dialog(&mut state); - assert_eq!(state.name_input, "3"); + assert_eq!(state.name_input, "2"); } #[test] @@ -1332,7 +1340,10 @@ mod tests { assert_eq!(state.mode, Mode::Terminal); assert!(state.workspaces[0].tabs[1].custom_name.is_none()); - assert_eq!(state.workspaces[0].tabs[1].display_name(), "2"); + assert_eq!( + state.workspaces[0].tab_display_name(1).as_deref(), + Some("2") + ); } #[test] diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index c4bd52d9..3ab5f8f5 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -1298,9 +1298,10 @@ mod tests { let labels: Vec<_> = app.state.workspaces[0] .tabs .iter() - .map(|tab| tab.display_name()) + .enumerate() + .map(|(tab_idx, _)| app.state.workspaces[0].tab_display_name(tab_idx).unwrap()) .collect(); - assert_eq!(labels, vec!["foo", "3", "1"]); + assert_eq!(labels, vec!["foo", "2", "3"]); assert_eq!( app.state.workspaces[0].tabs[0].custom_name.as_deref(), Some("foo") diff --git a/src/app/mod.rs b/src/app/mod.rs index d45e085b..1b90f157 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2951,7 +2951,7 @@ mod tests { assert_eq!(tab.tab_id, format!("{}:t3", app.state.workspaces[0].id)); assert_eq!(tab.number, 3); - assert_eq!(tab.label, "3"); + assert_eq!(tab.label, "2"); } #[test] diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index a9dc977f..d074b68f 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -330,13 +330,13 @@ fn render_header_status( } fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { - let tab_number = ws - .public_tab_number(ws.active_tab) - .unwrap_or(ws.active_tab + 1); + let tab_label = ws + .tab_display_name(ws.active_tab) + .unwrap_or_else(|| (ws.active_tab + 1).to_string()); if ws.tabs.len() <= 1 { - format!("tab {tab_number}") + format!("tab {tab_label}") } else { - format!("tab {tab_number} · {}/{}", ws.active_tab + 1, ws.tabs.len()) + format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len()) } } @@ -524,10 +524,13 @@ fn render_mobile_switcher_content( for (idx, tab) in ws.tabs.iter().enumerate() { let active = idx == ws.active_tab; let bg = mobile_item_bg(false, active, p); + let display_name = ws + .tab_display_name(idx) + .unwrap_or_else(|| (idx + 1).to_string()); let label = if tab.is_auto_named() { - format!("tab {}", tab.display_name()) + format!("tab {display_name}") } else { - format!("{} · {}", tab.number, tab.display_name()) + format!("{} · {display_name}", idx + 1) }; let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), @@ -976,18 +979,18 @@ mod tests { } #[test] - fn mobile_tab_status_uses_public_tab_number_and_position() { + fn mobile_tab_status_uses_compact_tab_label_and_position() { let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); let removed_tab = workspace.test_add_tab(None); workspace.test_add_tab(None); assert!(workspace.close_tab(removed_tab)); workspace.active_tab = 1; - assert_eq!(mobile_tab_status(&workspace), "tab 3 · 2/2"); + assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2"); } #[test] - fn mobile_switcher_uses_stable_public_tab_number_for_auto_tab_labels() { + fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() { let mut app = crate::app::state::AppState::test_new(); let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); let removed_tab = workspace.test_add_tab(None); @@ -1017,8 +1020,8 @@ mod tests { .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) .collect::(); - assert!(row.contains("tab 3"), "mobile tab row: {row:?}"); - assert!(!row.contains("tab 2"), "mobile tab row: {row:?}"); + assert!(row.contains("tab 2"), "mobile tab row: {row:?}"); + assert!(!row.contains("tab 3"), "mobile tab row: {row:?}"); } #[cfg(unix)] diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index b0414f98..882f5c10 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -354,7 +354,11 @@ fn tab_detail( }; let mut parts = vec![ ws.display_name_from(&app.terminals, terminal_runtimes), - format!("tab: {}", tab.display_name()), + format!( + "tab: {}", + ws.tab_display_name(tab_idx) + .unwrap_or_else(|| (tab_idx + 1).to_string()) + ), format!("{} panes", tab.panes.len()), ]; let rows = app.navigator_rows_from(terminal_runtimes); @@ -384,7 +388,11 @@ fn pane_detail( }; let mut parts = vec![ws.display_name_from(&app.terminals, terminal_runtimes)]; if ws.tabs.len() > 1 { - parts.push(format!("tab: {}", tab.display_name())); + parts.push(format!( + "tab: {}", + ws.tab_display_name(tab_idx) + .unwrap_or_else(|| (tab_idx + 1).to_string()) + )); } if let Some(pane_number) = ws.public_pane_number(pane_id) { parts.push(format!("pane {pane_number}")); diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 0ce99b7b..f20e22f9 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -21,13 +21,15 @@ pub(crate) struct TabBarView { pub new_tab_hit_area: Rect, } -fn tab_width(tab: &crate::workspace::Tab) -> u16 { - (tab_chrome_label(tab).chars().count() as u16 + 4).max(MIN_TAB_WIDTH) +fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { + (tab_chrome_label(ws, tab_idx).chars().count() as u16 + 4).max(MIN_TAB_WIDTH) } -fn tab_chrome_label(tab: &crate::workspace::Tab) -> String { - let name = tab.display_name(); - if tab.zoomed { +fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String { + let name = ws + .tab_display_name(tab_idx) + .unwrap_or_else(|| (tab_idx + 1).to_string()); + if ws.tabs.get(tab_idx).is_some_and(|tab| tab.zoomed) { format!("{name} Z") } else { name @@ -46,7 +48,7 @@ fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: us if x >= right { break; } - let desired = tab_width(&ws.tabs[idx]); + let desired = tab_width(ws, idx); let remaining = right.saturating_sub(x); let width = desired.min(remaining).max(1); *rect = Rect::new(x, area.y, width, 1); @@ -333,7 +335,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { Style::default().fg(p.overlay1).bg(p.surface0) }; let width = rect.width as usize; - let name = tab_chrome_label(tab); + let name = tab_chrome_label(ws, idx); let text = format!(" {:width$}", name, width = width.saturating_sub(1)); frame.render_widget(Paragraph::new(text).style(style), rect); } @@ -427,8 +429,11 @@ mod tests { let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); assert!(row.contains(" 1 Z"), "tab row: {row:?}"); assert!(row.contains(" test Z"), "tab row: {row:?}"); - assert_eq!(app.workspaces[0].tabs[0].display_name(), "1"); - assert_eq!(app.workspaces[0].tabs[custom_tab].display_name(), "test"); + assert_eq!(app.workspaces[0].tab_display_name(0).as_deref(), Some("1")); + assert_eq!( + app.workspaces[0].tab_display_name(custom_tab).as_deref(), + Some("test") + ); } #[test] @@ -437,6 +442,6 @@ mod tests { ws.tabs[0].set_custom_name("abcdefgh".into()); ws.tabs[0].zoomed = true; - assert_eq!(tab_width(&ws.tabs[0]), 14); + assert_eq!(tab_width(&ws, 0), 14); } } diff --git a/src/workspace.rs b/src/workspace.rs index 02e9e9a6..8216001b 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -423,7 +423,16 @@ impl Workspace { } pub fn active_tab_display_name(&self) -> Option { - self.active_tab().map(Tab::display_name) + self.tab_display_name(self.active_tab) + } + + pub fn tab_display_name(&self, tab_idx: usize) -> Option { + let tab = self.tabs.get(tab_idx)?; + Some( + tab.custom_name + .clone() + .unwrap_or_else(|| (tab_idx + 1).to_string()), + ) } pub fn switch_tab(&mut self, idx: usize) { @@ -1581,8 +1590,10 @@ mod tests { assert!(ws.move_tab(0, ws.tabs.len())); - let labels: Vec<_> = ws.tabs.iter().map(|tab| tab.display_name()).collect(); - assert_eq!(labels, vec!["foo", "3", "1"]); + let labels: Vec<_> = (0..ws.tabs.len()) + .map(|tab_idx| ws.tab_display_name(tab_idx).unwrap()) + .collect(); + assert_eq!(labels, vec!["foo", "2", "3"]); assert_eq!(ws.tabs[0].custom_name.as_deref(), Some("foo")); assert!(ws.tabs[1].custom_name.is_none()); assert!(ws.tabs[2].custom_name.is_none()); diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index bf4c9408..d1fcf984 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -30,7 +30,12 @@ impl Tab { }) } - pub fn pane_details(&self, terminals: &HashMap) -> Vec { + fn pane_details( + &self, + terminals: &HashMap, + tab_idx: usize, + tab_label: &str, + ) -> Vec { self.layout .pane_ids() .iter() @@ -48,8 +53,8 @@ impl Tab { let presentation = terminal.effective_presentation(); Some(PaneDetail { pane_id: *id, - tab_idx: 0, - tab_label: self.display_name(), + tab_idx, + tab_label: tab_label.to_string(), label: agent_label.clone(), agent_label, agent: terminal.effective_known_agent(), @@ -100,12 +105,10 @@ impl Workspace { .iter() .enumerate() .flat_map(|(tab_idx, tab)| { - tab.pane_details(terminals) - .into_iter() - .map(move |mut detail| { - detail.tab_idx = tab_idx; - detail - }) + let tab_label = self + .tab_display_name(tab_idx) + .unwrap_or_else(|| (tab_idx + 1).to_string()); + tab.pane_details(terminals, tab_idx, &tab_label).into_iter() }) .map(|mut detail| { if multi_tab { diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 480bc329..3a339e57 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -185,12 +185,6 @@ impl Tab { )) } - pub fn display_name(&self) -> String { - self.custom_name - .clone() - .unwrap_or_else(|| self.number.to_string()) - } - pub fn is_auto_named(&self) -> bool { self.custom_name.is_none() }