From 8044c6867ce14316876a6c7cec5efa44579f7eb7 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 7 Nov 2026 23:06:15 +0000 Subject: [PATCH] Add keyboard and mouse navigation for sidebar tabs (#60181) ## What changed - Add configurable `sidebar_up` and `sidebar_down` actions to cycle through tabs with wraparound while the sidebar has focus. Default leader shortcuts use `Shift-Tab` / `Tab`, `Up` / `Down`, and `k` / `j`. - Support mouse-wheel tab switching over the tab strip and clickable overflow arrows when tabs do not fit. - Show previous/next tab shortcuts in the leader HUD and keymap picker, and preserve pane zoom when switching tabs. ## Testing Extend sidebar interaction tests and snapshots to cover overflow arrows, mouse-wheel navigation, leader shortcuts, repeated custom bindings, draft preservation, and scroll handling where the leader HUD overlaps the tab strip. GitOrigin-RevId: 39ccb40f40560d1be850397f9597e765caa996a3 --- codex-rs/config/src/tui_keymap.rs | 4 + codex-rs/core/config.schema.json | 20 ++++ codex-rs/tui/src/app/input.rs | 3 + codex-rs/tui/src/app/side_focus.rs | 101 +++++++++++++----- codex-rs/tui/src/app/sidebar.rs | 7 ++ ...i__app__tests__side_chat_tab_overflow.snap | 6 ++ ...ui__app__tests__sidebar_tab_shortcuts.snap | 20 ++++ codex-rs/tui/src/app/tests.rs | 66 +++++++++++- codex-rs/tui/src/app/workspace_hud.rs | 24 +++-- .../rollout_history_tests.rs | 20 +++- codex-rs/tui/src/key_hint.rs | 3 + codex-rs/tui/src/keymap.rs | 26 +++++ codex-rs/tui/src/keymap/bindings.rs | 4 + codex-rs/tui/src/keymap/chords.rs | 10 +- .../tui/src/keymap/chords/leader_defaults.rs | 6 ++ codex-rs/tui/src/keymap/chords_tests.rs | 11 +- codex-rs/tui/src/keymap_setup/actions.rs | 2 + codex-rs/tui/src/sidebar_tab_strip.rs | 74 +++++++++---- ...__tests__keymap_picker_all_tab_search.snap | 4 +- 19 files changed, 342 insertions(+), 69 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__tests__side_chat_tab_overflow.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__tests__sidebar_tab_shortcuts.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 9f27bf75e9..2d7ae5d6d4 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -147,6 +147,10 @@ pub struct TuiGlobalKeymap { pub hide_pane: Option, /// Close the active sidebar tab. pub close_sidebar_tab: Option, + /// Switch to the previous sidebar tab. + pub sidebar_up: Option, + /// Switch to the next sidebar tab. + pub sidebar_down: Option, /// Focus the parent conversation. pub focus_pane_left: Option, /// Focus the open side conversation. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3d1ad5e7e9..dabd2be17d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5390,6 +5390,8 @@ "queue": null, "resize_panes": null, "select_transcript": null, + "sidebar_down": null, + "sidebar_up": null, "submit": null, "toggle_fast_mode": null, "toggle_pane_zoom": null, @@ -6374,6 +6376,22 @@ ], "description": "Enter keyboard selection mode for the transcript." }, + "sidebar_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Switch to the next sidebar tab." + }, + "sidebar_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Switch to the previous sidebar tab." + }, "submit": { "allOf": [ { @@ -6566,6 +6584,8 @@ "queue": null, "resize_panes": null, "select_transcript": null, + "sidebar_down": null, + "sidebar_up": null, "submit": null, "toggle_fast_mode": null, "toggle_pane_zoom": null, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 58d4803a83..03c9b5e190 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -267,6 +267,9 @@ impl App { }; } let mut contexts = contexts.with_pane_directions(); + if self.sidebar_has_focus() && self.sidebar_chooser_available() { + contexts = contexts.with_focused_sidebar(); + } if self.open_pane_focus().is_some() { contexts = contexts.with_open_pane(); } diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index 1db002f759..d113da06c3 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -27,13 +27,11 @@ impl App { } let pane = self.sidebar()?; let tabs = pane.chat_tabs().strip(); - let (TabTarget::Tab(id), _) = tabs.hit(strip, point)? else { - return None; - }; + let id = tabs.hit(strip, point)?.0.tab_id()?; if self.conversation_frame.chat_strip.is_some() { (tabs.selected != Some(id)).then_some(id) } else { - pane.creation.is_none().then_some(id) + (pane.creation.is_none() && pane.saved_chats_request.is_none()).then_some(id) } } @@ -90,6 +88,36 @@ impl App { Ok(()) } + async fn activate_sidebar_tab( + &mut self, + tui: &mut tui::Tui, + server: &mut AppServerSession, + id: ThreadId, + ) -> Result<()> { + let current = self.current_displayed_thread_id(); + if current == Some(id) { + return Ok(()); + } + let zoomed = current.is_some() && self.zoomed_thread == current; + let result = self.select_agent_thread(tui, server, id).await; + if zoomed && self.current_displayed_thread_id() == Some(id) { + self.zoomed_thread = Some(id); + } + result + } + + async fn step_sidebar_tab( + &mut self, + tui: &mut tui::Tui, + server: &mut AppServerSession, + step: isize, + ) -> Result<()> { + if let Some(id) = self.sidebar().and_then(|pane| pane.adjacent_chat(step)) { + self.activate_sidebar_tab(tui, server, id).await?; + } + Ok(()) + } + pub(super) async fn handle_sidebar_input( &mut self, tui: &mut tui::Tui, @@ -103,14 +131,22 @@ impl App { && matches!( mouse.kind, MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown ) { let point = (mouse.column, mouse.row).into(); let frame = &self.conversation_frame; + if frame.hud_contains(*mouse) { + return false; + } let chooser = frame.chooser_area(); let pane = self.sidebar(); - if (frame.chat_strip.is_some_and(|area| area.contains(point)) - || chooser.is_some_and(|area| area.contains(point))) + if ((matches!(mouse.kind, MouseEventKind::Down(_)) + && chooser.is_some_and(|area| area.contains(point))) + || frame + .sidebar_tab_strip() + .is_some_and(|area| area.contains(point))) && frame.sidebar_input != pane.map(sidebar::SidebarPane::input_state) { // A tab may have closed or the chooser scrolled since this frame was painted. @@ -125,24 +161,27 @@ impl App { { let target = pane.chat_tabs().strip().hit(strip, point); let middle = mouse.kind == MouseEventKind::Down(MouseButton::Middle); - let result = if let Some((TabTarget::Tab(id), tab)) = target - && (middle || crate::sidebar_chooser::close_cell(tab).contains(point)) - { - sidebar_close::request(self, tui, server, id, CloseTab).await - } else if !middle && matches!(target, Some((TabTarget::Add, _))) { - self.open_sidebar_tabs(tui, server).await - } else if let Some((TabTarget::Tab(id), _)) = target - && self.current_displayed_thread_id() != Some(id) - { - let zoomed = self.zoomed_thread.is_some() - && self.zoomed_thread == self.current_displayed_thread_id(); - let result = self.select_agent_thread(tui, server, id).await; - if zoomed && self.current_displayed_thread_id() == Some(id) { - self.zoomed_thread = Some(id); + let result = match (mouse.kind, target) { + (MouseEventKind::ScrollUp, _) => { + self.step_sidebar_tab(tui, server, /*step*/ -1).await } - result - } else { - return true; + (MouseEventKind::ScrollDown, _) => { + self.step_sidebar_tab(tui, server, /*step*/ 1).await + } + (_, Some((TabTarget::Tab(id), area))) + if middle || sidebar_chooser::close_cell(area).contains(point) => + { + sidebar_close::request(self, tui, server, id, CloseTab).await + } + (_, Some((TabTarget::Add, _))) if !middle => { + self.open_sidebar_tabs(tui, server).await + } + (_, Some((TabTarget::Tab(id) | TabTarget::Overflow { tab: id, .. }, _))) + if !middle => + { + self.activate_sidebar_tab(tui, server, id).await + } + _ => return true, }; if let Err(err) = result { self.chat_widget.add_error_message(err.to_string()); @@ -335,6 +374,14 @@ impl App { bindings.close_sidebar_tab.is_pressed(key) && self.side_threads.contains_key(id) }); let left = bindings.focus_pane_left.is_pressed(key); + let tab_step = if bindings.sidebar_up.is_pressed(key) { + Some(-1) + } else if bindings.sidebar_down.is_pressed(key) { + Some(1) + } else { + None + } + .filter(|_| self.sidebar_has_focus() && self.sidebar_chooser_available()); let right = bindings.focus_pane_right.is_pressed(key); let main = bindings.focus_main_composer.is_pressed(key) && self.main_composer_shortcut_available(); @@ -368,6 +415,7 @@ impl App { let resize = bindings.resize_panes.is_pressed(key) && can_resize; let resizing = resize || width_change != 0 || focused_percent.is_some(); if !(close.is_some() + || tab_step.is_some() || tabs || left || right @@ -392,6 +440,7 @@ impl App { // A matching repeat still belongs to the shortcut. Keep it out of the focused editor. return true; } + let direct_tab_switch = tab_step.is_some() && self.routed_key_activation.is_none(); let activation = self .routed_key_activation .take() @@ -404,6 +453,8 @@ impl App { || self.backtrack.overlay_preview_active); let result = if let Some(id) = close { sidebar_close::request(self, tui, app_server, id, CloseTab).await + } else if let Some(step) = tab_step { + self.step_sidebar_tab(tui, app_server, step).await } else if tabs { self.open_sidebar_tabs(tui, app_server).await } else if resize { @@ -464,10 +515,10 @@ impl App { let view_changed = leaves_transcript || self.current_displayed_thread_id() != previous_thread || (main && chooser == Some(PaneFocus::Sidebar)); - let suppression = if view_changed { + let suppression = if view_changed && !direct_tab_switch { ActivationKeySuppression::ViewTransition } else { - // A no-op leaves the editor in place, so prefer accepting fresh input. + // A direct tab shortcut can be pressed repeatedly to cycle through several tabs. ActivationKeySuppression::ReportedRepeats }; self.chat_widget.suppress_current_view_key_until_release( diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs index c77e552329..d2487a97ab 100644 --- a/codex-rs/tui/src/app/sidebar.rs +++ b/codex-rs/tui/src/app/sidebar.rs @@ -138,6 +138,13 @@ impl SidebarPane { self.visits.iter().rev().copied().find(|id| *id != target) } + pub(super) fn adjacent_chat(&self, step: isize) -> Option { + let selected = self.selected_chat()?; + let index = self.tabs.iter().position(|id| *id == selected)?; + let next = (index as isize + step).rem_euclid(self.tabs.len() as isize); + Some(self.tabs[next as usize]) + } + pub(super) fn selected_chat(&self) -> Option { self.visits.last().copied() } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__side_chat_tab_overflow.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__side_chat_tab_overflow.snap new file mode 100644 index 0000000000..de8e052567 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__side_chat_tab_overflow.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 4913 +expression: header.trim_end() +--- +‹ Fix cache× + diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__sidebar_tab_shortcuts.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__sidebar_tab_shortcuts.snap new file mode 100644 index 0000000000..06646491a5 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__sidebar_tab_shortcuts.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/tests.rs +expression: "panel_text(&app, &tui)" +--- +─ f12 · Sidebar ──────────────────────────────────────────────────────────────────────────────────── + + Panes This pane + c Main composer [ Select transcript + s Hide sidebar p Presets › + t Open in sidebar - Hide pane + z Zoom pane + Move ⇧h Divider left + ← / h Pane left ⇧l Divider right + ⇧tab / ↑ / k Previous tab = Equal widths + tab / ↓ / j Next tab r Resize panes + , Minimum width + . Maximum width + w Close tab + + Esc cancel diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 88e0a94a66..3ccd26aed3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4906,6 +4906,28 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_app_snapshot!("side_chat_tabs", header.trim_end()); assert_eq!(app.conversation_frame.chat_strip.unwrap().x, sidebar_x); + // At this width only one tab fits. Use the arrow to go back and the wheel to return. + let narrow = Size::new(24, even.height); + let narrow_frame = side_frame(&mut app, &mut tui, narrow)?; + let header = narrow_frame.lines().next().unwrap(); + insta::assert_snapshot!("side_chat_tab_overflow", header.trim_end()); + let arrow = header.chars().position(|c| c == '‹').expect("previous tab"); + for (kind, expected) in [ + (MouseEventKind::Down(MouseButton::Left), side_thread_id), + (MouseEventKind::ScrollDown, sibling), + ] { + let event = mouse( + kind, + arrow as u16, + app.conversation_frame.chat_strip.unwrap().y, + ); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, event)).await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + assert_eq!(app.current_displayed_thread_id(), Some(expected)); + side_frame(&mut app, &mut tui, narrow)?; + } + side_frame(&mut app, &mut tui, even)?; + keys!(prefix, chooser_key, up, enter); assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); assert_eq!(app.chat_widget.composer_text_with_pending(), child_draft); @@ -4921,6 +4943,44 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(draft, "sibling draft"); assert!(side_frame(&mut app, &mut tui, even)?.contains("sibling-24")); + tokio::time::pause(); + keys!(prefix); + tokio::time::advance(super::workspace_hud::REVEAL_DELAY).await; + side_frame(&mut app, &mut tui, even)?; + insta::assert_snapshot!("sidebar_tab_shortcuts", panel_text(&app, &tui)); + side_frame(&mut app, &mut tui, Size::new(even.width, /*height*/ 10))?; + let overlap = app + .conversation_frame + .leader_hud + .unwrap() + .intersection(app.conversation_frame.chat_strip.unwrap()); + assert!(!overlap.is_empty()); + let scroll = mouse(MouseEventKind::ScrollUp, overlap.x, overlap.y); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, scroll)).await?; + assert_eq!(app.current_displayed_thread_id(), Some(sibling)); + side_frame(&mut app, &mut tui, even)?; + keys!(KeyCode::Esc.into()); + tokio::time::resume(); + for (key, expected, text) in [ + (KeyCode::Tab, side_thread_id, child_draft.as_str()), + (KeyCode::BackTab, sibling, "sibling draft"), + (KeyCode::Down, side_thread_id, child_draft.as_str()), + (KeyCode::Char('k'), sibling, "sibling draft"), + (KeyCode::Char('j'), side_thread_id, child_draft.as_str()), + (KeyCode::Up, sibling, "sibling draft"), + ] { + keys!(prefix, key.into()); + assert_eq!(app.current_displayed_thread_id(), Some(expected)); + assert_eq!(app.chat_widget.composer_text_with_pending(), text); + } + app.local_settings.tui.keymap = + toml::from_str("[global]\nleader = 'f12'\nsidebar_down = 'f7'")?; + enable_side_retention(&mut app)?; + for expected in [side_thread_id, sibling] { + keys!(KeyCode::F(7).into()); + assert_eq!(app.current_displayed_thread_id(), Some(expected)); + } + let colors = crate::terminal_probe::DefaultColors { fg: (224, 224, 224), bg: (20, 20, 20), @@ -4931,14 +4991,16 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(app.current_displayed_thread_id(), Some(owner)); side_frame(&mut app, &mut tui, even)?; let strip = app.conversation_frame.chat_strip.unwrap(); - let (tabs, _) = app + let tabs = app .sidebar() .unwrap() .chat_tabs() .strip() .strip_targets(strip); let [inactive, selected] = tabs - .map(|(_, _, area)| area.x + 1) + .filter_map(|(target, area)| { + matches!(target, crate::sidebar_tab_strip::TabTarget::Tab(_)).then_some(area.x + 1) + }) .collect::>() .try_into() .expect("both side-chat tabs are visible"); diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs index 7b4d27abfa..8b7dde8949 100644 --- a/codex-rs/tui/src/app/workspace_hud.rs +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -65,6 +65,8 @@ impl App { (2, "Close tab") } "close_sidebar_tab" => continue, + "sidebar_up" => (1, "Previous tab"), + "sidebar_down" => (1, "Next tab"), "hide_pane" if side => (2, "Hide pane"), "focus_pane_left" if side && can_switch => (1, "Pane left"), "focus_pane_right" if !side && can_switch && self.open_pane_focus().is_some() => { @@ -77,21 +79,25 @@ impl App { "focus_conversation_composer" => (2, "Composer"), other => (2, other), }; - let key = keys.join(" / "); - groups[group].1.push( - vec![ - format!("{key:<7} ").fg(crate::style::accent_color()), - label.replace('_', " ").into(), - ] - .into(), - ); + let key = keys.join(" / ").fg(crate::style::accent_color()); + groups[group].1.push((key, label.replace('_', " "))); } let origin = if side { "Sidebar" } else { "Conversation" }; Some(ShortcutHud::leader( format!("{} · {origin}", leader.prefix.display_label()), groups .into_iter() - .filter(|(_, items)| !items.is_empty()) + .filter_map(|(title, items)| { + let width = items.iter().map(|(key, _)| key.width()).max()?.max(7); + let lines = items + .into_iter() + .map(|(key, label)| { + let gap = " ".repeat(width - key.width() + 1); + vec![key, gap.into(), label.into()].into() + }) + .collect(); + Some((title, lines)) + }) .collect(), )) } diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index ee3ea649a0..b1f7385dc7 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -22,8 +22,24 @@ async fn build_config(temp_dir: &TempDir) -> Config { .expect("config should build") } -#[tokio::test] -async fn remote_resume_restores_saved_server_profile_without_permission_overrides() -> Result<()> { +#[test] +fn remote_resume_restores_saved_server_profile_without_permission_overrides() -> Result<()> { + // The embedded server and client load full configurations on the same test thread. + const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; + std::thread::Builder::new() + .name("tui-resume-permissions".into()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(crate::boxed_future!(resume_with_saved_server_profile())) + })? + .join() + .expect("permission profile test thread panicked") +} + +async fn resume_with_saved_server_profile() -> Result<()> { let home = tempfile::tempdir()?; std::fs::write( home.path().join("config.toml"), diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 723ddc4e26..78312301c2 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -92,6 +92,9 @@ impl From for ShortcutHint { impl KeyBinding { pub(crate) fn display_label(&self) -> String { + if self.key == KeyCode::BackTab { + return Self::new(KeyCode::Tab, self.modifiers | KeyModifiers::SHIFT).display_label(); + } let mut label = String::new(); for (modifier, name) in MODIFIER_LABELS { if self.modifiers.contains(modifier) { diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 81ef969f50..5dd67dabd6 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -109,6 +109,8 @@ pub(crate) struct AppKeymap { pub(crate) focus_sidebar_tabs: Vec, pub(crate) hide_pane: Vec, pub(crate) close_sidebar_tab: Vec, + pub(crate) sidebar_up: Vec, + pub(crate) sidebar_down: Vec, pub(crate) focus_pane_left: Vec, pub(crate) focus_pane_right: Vec, pub(crate) minimize_pane_width: Vec, @@ -712,6 +714,8 @@ impl RuntimeKeymap { classic_keymap.global.focus_sidebar_tabs = None; classic_keymap.global.hide_pane = None; classic_keymap.global.close_sidebar_tab = None; + classic_keymap.global.sidebar_up = None; + classic_keymap.global.sidebar_down = None; classic_keymap.global.focus_pane_left = None; classic_keymap.global.focus_pane_right = None; &classic_keymap @@ -792,6 +796,16 @@ impl RuntimeKeymap { &[], "tui.keymap.global.close_sidebar_tab", )?, + sidebar_up: resolve_bindings( + keymap.global.sidebar_up.as_ref(), + &[], + "tui.keymap.global.sidebar_up", + )?, + sidebar_down: resolve_bindings( + keymap.global.sidebar_down.as_ref(), + &[], + "tui.keymap.global.sidebar_down", + )?, focus_pane_left: resolve_bindings( keymap.global.focus_pane_left.as_ref(), &[], @@ -1817,6 +1831,8 @@ impl RuntimeKeymap { focus_sidebar_tabs: Vec::new(), hide_pane: Vec::new(), close_sidebar_tab: Vec::new(), + sidebar_up: Vec::new(), + sidebar_down: Vec::new(), focus_pane_left: Vec::new(), focus_pane_right: Vec::new(), minimize_pane_width: Vec::new(), @@ -2198,6 +2214,8 @@ impl RuntimeKeymap { ("global.toggle_pane_zoom", &self.app.toggle_pane_zoom), ("global.hide_pane", &self.app.hide_pane), ("global.close_sidebar_tab", &self.app.close_sidebar_tab), + ("global.sidebar_up", &self.app.sidebar_up), + ("global.sidebar_down", &self.app.sidebar_down), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), ] { @@ -2249,6 +2267,8 @@ impl RuntimeKeymap { ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), ("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()), + ("sidebar_up", self.app.sidebar_up.as_slice()), + ("sidebar_down", self.app.sidebar_down.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2437,6 +2457,8 @@ impl RuntimeKeymap { ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), ("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()), + ("sidebar_up", self.app.sidebar_up.as_slice()), + ("sidebar_down", self.app.sidebar_down.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2597,6 +2619,8 @@ impl RuntimeKeymap { "global.close_sidebar_tab", self.app.close_sidebar_tab.as_slice(), ), + ("global.sidebar_up", self.app.sidebar_up.as_slice()), + ("global.sidebar_down", self.app.sidebar_down.as_slice()), ( "global.select_transcript", self.app.select_transcript.as_slice(), @@ -2664,6 +2688,8 @@ impl RuntimeKeymap { "global.close_sidebar_tab", self.app.close_sidebar_tab.as_slice(), ), + ("global.sidebar_up", self.app.sidebar_up.as_slice()), + ("global.sidebar_down", self.app.sidebar_down.as_slice()), ("global.focus_activity", self.app.focus_activity.as_slice()), ( "global.find_transcript", diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index e52d9434a7..7bb98cd5d8 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -112,6 +112,8 @@ impl KeymapActionId { | "focus_sidebar_tabs" | "hide_pane" | "close_sidebar_tab" + | "sidebar_up" + | "sidebar_down" | "focus_pane_left" | "focus_pane_right" | "minimize_pane_width" @@ -321,6 +323,8 @@ define_runtime_action_bindings! { focus_sidebar_tabs, hide_pane, close_sidebar_tab, + sidebar_up, + sidebar_down, focus_pane_left, focus_pane_right, minimize_pane_width, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index b492b439c2..ef3c26c4a8 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -61,6 +61,7 @@ const OPEN_PANE: u32 = 1 << 25; const FOCUSED_SIDE_PANE: u32 = 1 << 26; const SIDEBAR_CHOOSER: u32 = 1 << 27; const SIDEBAR_RESIZE: u32 = 1 << 28; +const FOCUSED_SIDEBAR: u32 = 1 << 29; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -183,8 +184,14 @@ impl KeymapContextSet { Self(self.0 | FOCUSED_SIDE_PANE) } + pub(crate) const fn with_focused_sidebar(self) -> Self { + Self(self.0 | FOCUSED_SIDEBAR) + } + pub(crate) const fn focused_sidebar_chooser() -> Self { - Self(SIDE_PANE | PANE_DIRECTION | MAIN_COMPOSER_FOCUS).with(KeymapContext::List) + Self(SIDE_PANE | PANE_DIRECTION | MAIN_COMPOSER_FOCUS) + .with_focused_sidebar() + .with(KeymapContext::List) } pub(crate) const fn with_sidebar_chooser(self) -> Self { @@ -205,6 +212,7 @@ impl KeymapContextSet { "focus_pane_left" | "focus_pane_right" => PANE_DIRECTION, "move_divider_left" | "move_divider_right" => FOCUSED_SIDE_PANE, "toggle_pane_zoom" => OPEN_PANE, + "sidebar_up" | "sidebar_down" => FOCUSED_SIDEBAR, "equalize_pane_widths" | "minimize_pane_width" | "maximize_pane_width" diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index 1e7831ce8c..fa6d555f57 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -49,6 +49,12 @@ pub(in crate::keymap) fn register( (Global, "maximize_pane_width", Char('.'), "."), (Global, "close_sidebar_tab", Char('w'), "w"), (Global, "focus_sidebar_tabs", Char('t'), "t"), + (Global, "sidebar_up", KeyCode::BackTab, "shift-tab"), + (Global, "sidebar_down", KeyCode::Tab, "tab"), + (Global, "sidebar_up", KeyCode::Up, "up"), + (Global, "sidebar_up", Char('k'), "k"), + (Global, "sidebar_down", KeyCode::Down, "down"), + (Global, "sidebar_down", Char('j'), "j"), ] { if remaining == 0 { break; diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs index bb6667c8be..089945cc82 100644 --- a/codex-rs/tui/src/keymap/chords_tests.rs +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -188,7 +188,7 @@ fn allows_shared_prefixes_with_distinct_completions() { RuntimeKeymap::from_config(&config).expect("prefix trie can branch on the second stroke"); - // With 16 leaders and 56 explicit completions, only eight more aliases fit per leader. + // Fill the budget immediately after the existing sidebar chooser shortcut. let mut config = TuiKeymap::default(); config.global.leader = Some(KeybindingsSpec::Many( (1..=16) @@ -201,7 +201,7 @@ fn allows_shared_prefixes_with_distinct_completions() { .flat_map(|modifier| { (1..=19).map(move |n| KeybindingSpec(format!("leader {modifier}f{n}"))) }) - .take(56) + .take(45) .collect(), )); let runtime = RuntimeKeymap::for_session(&config, /*workspace*/ true).unwrap(); @@ -215,12 +215,13 @@ fn allows_shared_prefixes_with_distinct_completions() { super::MAX_LEADER_CHORD_BINDINGS ); assert_eq!( - crate::keymap_setup::active_binding_specs(&runtime, "global", "focus_pane_left").unwrap(), - ["leader left", "leader h"] + crate::keymap_setup::active_binding_specs(&runtime, "global", "focus_sidebar_tabs") + .unwrap(), + ["leader t"] ); assert!( runtime - .primary_hint(KeymapContext::Global, "focus_pane_right") + .primary_hint(KeymapContext::Global, "sidebar_up") .is_none() ); } diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index ef6d869610..0f126cf368 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -123,6 +123,8 @@ define_keymap_actions! { focus_sidebar_content [Workspace] => "Focus the side conversation, or hide it when already focused.", hide_pane [Workspace] => "Hide the focused sidebar pane.", close_sidebar_tab [Workspace] => "Close the active sidebar tab.", + sidebar_up [Workspace] => "Switch to the previous sidebar tab.", + sidebar_down [Workspace] => "Switch to the next sidebar tab.", focus_pane_left [Workspace] => "Focus the parent conversation.", focus_pane_right [Workspace] => "Focus the open side conversation.", minimize_pane_width [Workspace] => "Minimize the focused pane width.", diff --git a/codex-rs/tui/src/sidebar_tab_strip.rs b/codex-rs/tui/src/sidebar_tab_strip.rs index 583435d7ff..b211c70800 100644 --- a/codex-rs/tui/src/sidebar_tab_strip.rs +++ b/codex-rs/tui/src/sidebar_tab_strip.rs @@ -25,9 +25,19 @@ pub(crate) struct TabStrip<'a, Id> { pub(crate) enum TabTarget { Tab(Id), + Overflow { tab: Id, glyph: &'static str }, Add, } +impl TabTarget { + pub(crate) fn tab_id(&self) -> Option { + match self { + Self::Tab(id) | Self::Overflow { tab: id, .. } => Some(*id), + Self::Add => None, + } + } +} + impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { pub(crate) fn selected_index(self) -> Option { self.ids.iter().position(|id| Some(*id) == self.selected) @@ -36,33 +46,41 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { pub(crate) fn strip_targets( self, area: Rect, - ) -> (impl Iterator + 'a, Rect) { + ) -> impl Iterator, Rect)> + 'a { let width = area.width.saturating_sub(1); let plus = width.min(3); let available = width - plus; let height = area.height.min(1); - let tab_width = available.min(self.tab_width); - let count = usize::from(available.checked_div(tab_width).unwrap_or_default()); + let overflow = self.ids.len() > usize::from((available / self.tab_width).max(1)); + // Reserve both arrow gutters so the tabs stay in place while switching. + let gutter = if overflow && available >= 7 { 2 } else { 0 }; + let space = available - 2 * gutter; + let tab_width = space.min(self.tab_width); + let count = usize::from(space.checked_div(tab_width).unwrap_or_default()); let first = self .selected_index() .map_or(0, |index| (index + 1).saturating_sub(count)); - let tabs = self.ids.iter().copied().enumerate().skip(first).take(count); - let targets = tabs.map(move |(index, id)| { - let x = area.x + (index - first) as u16 * tab_width; - (index, id, Rect::new(x, area.y, tab_width, height)) + let end = (first + count).min(self.ids.len()); + let rect = move |offset, width| Rect::new(area.x + offset, area.y, width, height); + let arrow = |chat, glyph, x| (TabTarget::Overflow { tab: chat, glyph }, rect(x, 1)); + let left = (gutter > 0 && first > 0).then(|| arrow(self.ids[first - 1], "‹", 0)); + let right = + (gutter > 0 && end < self.ids.len()).then(|| arrow(self.ids[end], "›", available - 1)); + let tabs = self.ids[first..end].iter().enumerate().map(move |(i, id)| { + ( + TabTarget::Tab(*id), + rect(gutter + i as u16 * tab_width, tab_width), + ) }); - (targets, Rect::new(area.x + available, area.y, plus, height)) + left.into_iter() + .chain(tabs) + .chain(right) + .chain([(TabTarget::Add, rect(available, plus))]) } pub(crate) fn hit(self, area: Rect, point: Position) -> Option<(TabTarget, Rect)> { - let (mut tabs, add) = self.strip_targets(area); - if add.contains(point) { - Some((TabTarget::Add, add)) - } else { - tabs.find_map(|(_, id, rect)| { - rect.contains(point).then_some((TabTarget::Tab(id), rect)) - }) - } + self.strip_targets(area) + .find(|(_, rect)| rect.contains(point)) } pub(crate) fn render( @@ -74,7 +92,6 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { hovered_tab: Option, paint: impl Fn(Id, Rect, Style, &mut Buffer), ) { - let (tabs, add) = self.strip_targets(area); let style = |state| match state { TabState::Active => crate::style::selection_style().not_underlined(), TabState::Inactive => crate::bottom_pane::active_tab_style(), @@ -83,10 +100,8 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { .not_underlined() .not_reversed(), }; - for (_, id, rect) in tabs { - let tab_style = if selected_state != TabState::Absent && Some(id) == self.selected { - style(selected_state) - } else if hovered_tab == Some(id) { + for (target, rect) in self.strip_targets(area) { + let quiet = if hovered_tab.is_some() && target.tab_id() == hovered_tab { let hover = crate::style::quiet_workspace_style() .fg(Color::Reset) .not_dim(); @@ -98,8 +113,21 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { } else { style(TabState::Absent) }; - paint(id, rect, tab_style, buf); + match target { + TabTarget::Tab(id) => { + let tab_style = + if Some(id) == self.selected && selected_state != TabState::Absent { + style(selected_state) + } else { + quiet + }; + paint(id, rect, tab_style, buf); + } + TabTarget::Overflow { glyph, .. } => { + Line::from(glyph).style(quiet).render(rect, buf) + } + TabTarget::Add => Line::from(" + ").style(style(add_state)).render(rect, buf), + } } - Line::from(" + ").style(style(add_state)).render(add, buf); } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 7ec1e91f4c..e8ccb8fdda 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -7,11 +7,11 @@ Focus Sidebar Tabs | leader t (ctrl-x t) | Global focus_sidebar_tabs Focus Sideb Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Sidebar Content Focus the side conversation, or hide it when already focused. leader s (ctrl-x s) Default Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default Close Sidebar Tab | leader w (ctrl-x w) | Global close_sidebar_tab Close Sidebar Tab Close the active sidebar tab. leader w (ctrl-x w) Default +Sidebar Up | leader shift-tab (ctrl-x shift-tab), leader up (ctrl-x up), leader k (ctrl-x k) | Global sidebar_up Sidebar Up Switch to the previous sidebar tab. leader shift-tab (ctrl-x shift-tab), leader up (ctrl-x up), leader k (ctrl-x k) Default +Sidebar Down | leader tab (ctrl-x tab), leader down (ctrl-x down), leader j (ctrl-x j) | Global sidebar_down Sidebar Down Switch to the next sidebar tab. leader tab (ctrl-x tab), leader down (ctrl-x down), leader j (ctrl-x j) Default Focus Pane Left | leader left (ctrl-x left), leader h (ctrl-x h) | Global focus_pane_left Focus Pane Left Focus the parent conversation. leader left (ctrl-x left), leader h (ctrl-x h) Default Focus Pane Right | leader right (ctrl-x right), leader l (ctrl-x l) | Global focus_pane_right Focus Pane Right Focus the open side conversation. leader right (ctrl-x right), leader l (ctrl-x l) Default Minimize Pane Width | leader , (ctrl-x ,) | Global minimize_pane_width Minimize Pane Width Minimize the focused pane width. leader , (ctrl-x ,) Default Maximize Pane Width | leader . (ctrl-x .) | Global maximize_pane_width Maximize Pane Width Maximize the focused pane width. leader . (ctrl-x .) Default Equalize Pane Widths | leader = (ctrl-x =) | Global equalize_pane_widths Equalize Pane Widths Equalize visible pane widths. leader = (ctrl-x =) Default Resize Panes | leader r (ctrl-x r) | Global resize_panes Resize Panes Enter keyboard resize mode for visible dividers. leader r (ctrl-x r) Default -Move Divider Left | leader shift-h (ctrl-x shift-h) | Global move_divider_left Move Divider Left Move the divider left when the side pane has focus. leader shift-h (ctrl-x shift-h) Default -Move Divider Right | leader shift-l (ctrl-x shift-l) | Global move_divider_right Move Divider Right Move the divider right when the side pane has focus. leader shift-l (ctrl-x shift-l) Default