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
This commit is contained in:
Felipe Coury
2026-11-07 23:18:39 +00:00
committed by copyberry
parent 1455fda735
commit 8044c6867c
19 changed files with 342 additions and 69 deletions
+4
View File
@@ -147,6 +147,10 @@ pub struct TuiGlobalKeymap {
pub hide_pane: Option<KeybindingsSpec>,
/// Close the active sidebar tab.
pub close_sidebar_tab: Option<KeybindingsSpec>,
/// Switch to the previous sidebar tab.
pub sidebar_up: Option<KeybindingsSpec>,
/// Switch to the next sidebar tab.
pub sidebar_down: Option<KeybindingsSpec>,
/// Focus the parent conversation.
pub focus_pane_left: Option<KeybindingsSpec>,
/// Focus the open side conversation.
+20
View File
@@ -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,
+3
View File
@@ -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();
}
+76 -25
View File
@@ -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(
+7
View File
@@ -138,6 +138,13 @@ impl SidebarPane {
self.visits.iter().rev().copied().find(|id| *id != target)
}
pub(super) fn adjacent_chat(&self, step: isize) -> Option<ThreadId> {
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<ThreadId> {
self.visits.last().copied()
}
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests.rs
assertion_line: 4913
expression: header.trim_end()
---
‹ Fix cache× +
@@ -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
+64 -2
View File
@@ -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::<Vec<_>>()
.try_into()
.expect("both side-chat tabs are visible");
+15 -9
View File
@@ -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(),
))
}
@@ -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"),
+3
View File
@@ -92,6 +92,9 @@ impl From<KeyBinding> 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) {
+26
View File
@@ -109,6 +109,8 @@ pub(crate) struct AppKeymap {
pub(crate) focus_sidebar_tabs: Vec<KeyBinding>,
pub(crate) hide_pane: Vec<KeyBinding>,
pub(crate) close_sidebar_tab: Vec<KeyBinding>,
pub(crate) sidebar_up: Vec<KeyBinding>,
pub(crate) sidebar_down: Vec<KeyBinding>,
pub(crate) focus_pane_left: Vec<KeyBinding>,
pub(crate) focus_pane_right: Vec<KeyBinding>,
pub(crate) minimize_pane_width: Vec<KeyBinding>,
@@ -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",
+4
View File
@@ -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,
+9 -1
View File
@@ -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"
@@ -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;
+6 -5
View File
@@ -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()
);
}
+2
View File
@@ -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.",
+51 -23
View File
@@ -25,9 +25,19 @@ pub(crate) struct TabStrip<'a, Id> {
pub(crate) enum TabTarget<Id> {
Tab(Id),
Overflow { tab: Id, glyph: &'static str },
Add,
}
impl<Id: Copy> TabTarget<Id> {
pub(crate) fn tab_id(&self) -> Option<Id> {
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<usize> {
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<Item = (usize, Id, Rect)> + 'a, Rect) {
) -> impl Iterator<Item = (TabTarget<Id>, 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<Id>, 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<Id>,
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);
}
}
@@ -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