mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user