mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
## 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
572 lines
23 KiB
Rust
572 lines
23 KiB
Rust
//! Composer and side-conversation focus shortcuts. Thread selection replaces the widget
|
|
//! immediately, but Park and Finish transfer transcript views through the app event queue.
|
|
|
|
use super::sidebar::PaneFocus;
|
|
use super::sidebar::SidebarContent;
|
|
use super::sidebar_close::Action::CloseTab;
|
|
use super::*;
|
|
use crate::activation_key_guard::ActivationKeySuppression;
|
|
use crate::app_event::SideStartMode;
|
|
use crate::app_server_session::SideChatStorage;
|
|
use crate::bottom_pane::ScrollState;
|
|
use crate::keymap::ListAction;
|
|
use crate::sidebar_chooser;
|
|
use crate::sidebar_chooser::ChooserAction;
|
|
use crate::sidebar_tab_strip::TabTarget;
|
|
use crate::transcript_view::TranscriptView;
|
|
|
|
impl App {
|
|
pub(super) fn hovered_sidebar_tab(
|
|
&self,
|
|
point: Option<ratatui::layout::Position>,
|
|
) -> Option<ThreadId> {
|
|
let point = point?;
|
|
let strip = self.conversation_frame.sidebar_tab_strip()?;
|
|
if self.pane_resize.is_some() || !strip.contains(point) {
|
|
return None;
|
|
}
|
|
let pane = self.sidebar()?;
|
|
let tabs = pane.chat_tabs().strip();
|
|
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() && pane.saved_chats_request.is_none()).then_some(id)
|
|
}
|
|
}
|
|
|
|
pub(super) fn sidebar_chooser_available(&self) -> bool {
|
|
!self.reconnect.offline
|
|
&& !self.conversation_frame.has_caller_inset()
|
|
&& !self.chat_widget.has_active_modal()
|
|
&& self.chat_widget.sidebar_chooser_available()
|
|
}
|
|
|
|
async fn open_sidebar_tabs(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
server: &mut AppServerSession,
|
|
) -> Result<()> {
|
|
if let Some(parent) = self.active_side_parent_thread_id() {
|
|
self.select_agent_thread(tui, server, parent).await?;
|
|
if self.current_displayed_thread_id() != Some(parent) {
|
|
return Ok(());
|
|
}
|
|
self.focus_returned_parent_composer(parent).await?;
|
|
}
|
|
self.zoomed_thread = None;
|
|
let parent = self.current_displayed_thread_id();
|
|
let pane = self.sidebar_mut();
|
|
pane.closing_chats.retain(|_, task| !task.is_finished());
|
|
let was_open = pane.visible_content() == Some(SidebarContent::Chooser);
|
|
pane.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
|
if !was_open
|
|
&& pane.creation.is_none()
|
|
&& pane.saved_chats_request.is_none()
|
|
&& let Some(parent) = parent
|
|
&& server.can_list_saved_side_chats(parent)
|
|
{
|
|
let request = uuid::Uuid::new_v4();
|
|
pane.set_saved_chats_request(Some(request));
|
|
let codex_home = self.config.codex_home.clone();
|
|
let mut view = server.request_view();
|
|
let tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let result = view
|
|
.list_saved_side_chats(parent, codex_home.as_path())
|
|
.await
|
|
.map_err(|err| format!("Failed to load saved side chats: {err}"));
|
|
tx.send(AppEvent::SavedSideChatsLoaded {
|
|
backend_id: view.backend_id(),
|
|
parent,
|
|
request,
|
|
result,
|
|
});
|
|
});
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
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,
|
|
server: &mut AppServerSession,
|
|
event: &TuiEvent,
|
|
) -> bool {
|
|
if !self.conversation_focus_available(tui) || !self.sidebar_chooser_available() {
|
|
return false;
|
|
}
|
|
if let TuiEvent::Mouse(mouse) = event
|
|
&& 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 ((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.
|
|
tui.frame_requester().schedule_frame();
|
|
return true;
|
|
}
|
|
if let Some(pane) = pane
|
|
&& (frame.chat_strip.is_some()
|
|
|| (pane.creation.is_none() && pane.saved_chats_request.is_none()))
|
|
&& let Some(strip) = frame.sidebar_tab_strip()
|
|
&& strip.contains(point)
|
|
{
|
|
let target = pane.chat_tabs().strip().hit(strip, point);
|
|
let middle = mouse.kind == MouseEventKind::Down(MouseButton::Middle);
|
|
let result = match (mouse.kind, target) {
|
|
(MouseEventKind::ScrollUp, _) => {
|
|
self.step_sidebar_tab(tui, server, /*step*/ -1).await
|
|
}
|
|
(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());
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
if self.chooser_focus().is_none() {
|
|
return false;
|
|
}
|
|
let (action, activation) = match event {
|
|
TuiEvent::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
|
|
tui.frame_requester().schedule_frame();
|
|
let Some(area) = self
|
|
.conversation_frame
|
|
.chooser_area()
|
|
.filter(|area| area.contains((mouse.column, mouse.row).into()))
|
|
else {
|
|
self.sidebar_mut()
|
|
.show(SidebarContent::Chooser, PaneFocus::Main);
|
|
return false;
|
|
};
|
|
let pane = self.sidebar_mut();
|
|
pane.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
|
if pane.creation.is_some() || pane.saved_chats_request.is_some() {
|
|
return true;
|
|
}
|
|
let choice = pane.choice;
|
|
let count = pane.chat_tabs().choice_count();
|
|
let action = if let Some(index) =
|
|
crate::sidebar_chooser::choice_at(area, count, choice, mouse.row)
|
|
{
|
|
ChooserAction::Open(index)
|
|
} else if mouse.row == area.bottom() - 1 && mouse.column > area.x {
|
|
let column = mouse.column - area.x - 1;
|
|
let (hints, actions) = sidebar_chooser::footer(&self.keymap, area, choice);
|
|
let Some(action) = hints
|
|
.items
|
|
.into_iter()
|
|
.zip(actions)
|
|
.find_map(|(range, action)| action.filter(|_| range.contains(&column)))
|
|
else {
|
|
return true;
|
|
};
|
|
action
|
|
} else {
|
|
return true;
|
|
};
|
|
(action, None)
|
|
}
|
|
_ if self.chooser_focus() != Some(PaneFocus::Sidebar) => return false,
|
|
TuiEvent::Key(key) => {
|
|
let Some(list_action) = self.keymap.list.action_for(*key) else {
|
|
return true;
|
|
};
|
|
let area = self.conversation_frame.chooser_area();
|
|
let pane = self.sidebar_mut();
|
|
tui.frame_requester().schedule_frame();
|
|
let action = match (list_action, key.kind) {
|
|
(ListAction::Accept, KeyEventKind::Press) => ChooserAction::Open(pane.choice),
|
|
(ListAction::Cancel, KeyEventKind::Press) => ChooserAction::Back,
|
|
(ListAction::MoveLeft | ListAction::MoveRight, KeyEventKind::Press)
|
|
if pane.selected_chat().is_some() =>
|
|
{
|
|
ChooserAction::Back
|
|
}
|
|
_ => {
|
|
let count = pane.chat_tabs().choice_count();
|
|
let visible = area.map_or(1, |area| {
|
|
sidebar_chooser::page_size(area, count, pane.choice)
|
|
});
|
|
let mut selection = ScrollState {
|
|
selected_idx: Some(pane.choice),
|
|
..Default::default()
|
|
};
|
|
selection.apply_vertical_action(list_action, count, visible);
|
|
pane.choice = selection.selected_idx.unwrap_or_default();
|
|
return true;
|
|
}
|
|
};
|
|
let activation = self
|
|
.routed_key_activation
|
|
.take()
|
|
.unwrap_or_else(|| KeyBinding::from_event(*key));
|
|
(action, Some(activation))
|
|
}
|
|
_ => return matches!(event, TuiEvent::Paste(_)),
|
|
};
|
|
|
|
let open = matches!(action, ChooserAction::Open(_));
|
|
let pane = self.sidebar_mut();
|
|
if open && pane.saved_chats_request.is_some() {
|
|
return true;
|
|
}
|
|
if let Some(creation) = &pane.creation {
|
|
match (&creation.state, open) {
|
|
(sidebar::CreationState::Waiting { .. }, true) => {}
|
|
(sidebar::CreationState::Waiting { .. }, false) => pane.hide(),
|
|
(sidebar::CreationState::Failed { cleanup, .. }, _) => {
|
|
if let Some(id) = *cleanup
|
|
&& !self.discard_side_thread(server, id).await
|
|
{
|
|
return true;
|
|
}
|
|
let message = self
|
|
.sidebar_mut()
|
|
.creation
|
|
.take()
|
|
.and_then(|creation| creation.message);
|
|
if open && let Some(parent_thread_id) = self.current_displayed_thread_id() {
|
|
self.app_event_tx.send(AppEvent::StartSide {
|
|
parent_thread_id,
|
|
user_message: message,
|
|
mode: SideStartMode::New,
|
|
});
|
|
} else {
|
|
self.restore_side_user_message(message);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let target = match action {
|
|
ChooserAction::Open(index) => pane.chat_tabs().choice(index),
|
|
ChooserAction::Back => pane.selected_chat(),
|
|
ChooserAction::Hide => None,
|
|
};
|
|
if let Some(id) = target {
|
|
let pane = self.sidebar_mut();
|
|
if pane
|
|
.closing_chats
|
|
.get(&id)
|
|
.is_some_and(|task| !task.is_finished())
|
|
{
|
|
self.chat_widget.add_info_message(
|
|
"Side chat is still closing. Try again in a moment.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
return true;
|
|
}
|
|
let saved = !pane.chat_tabs().ids.contains(&id);
|
|
if saved && let Some(parent) = self.current_displayed_thread_id() {
|
|
self.abandoned_side_threads.remove(&id);
|
|
self.side_threads.insert(
|
|
id,
|
|
side::SideThreadState {
|
|
storage: SideChatStorage::Attached,
|
|
..side::SideThreadState::new(parent)
|
|
},
|
|
);
|
|
}
|
|
if let Err(err) = self.select_agent_thread(tui, server, id).await {
|
|
self.chat_widget.add_error_message(err.to_string());
|
|
}
|
|
if saved && self.current_displayed_thread_id() != Some(id) {
|
|
self.side_threads.remove(&id);
|
|
}
|
|
} else if open && let Some(parent_thread_id) = self.chat_widget.thread_id() {
|
|
self.app_event_tx.send(AppEvent::StartSide {
|
|
parent_thread_id,
|
|
user_message: None,
|
|
mode: SideStartMode::New,
|
|
});
|
|
} else {
|
|
if open {
|
|
self.chat_widget
|
|
.dispatch_command(crate::slash_command::SlashCommand::Side);
|
|
}
|
|
self.sidebar_mut().hide();
|
|
}
|
|
}
|
|
// Selecting a chat replaces its widget. Guard the composer that will receive repeats.
|
|
if let Some(activation) = activation {
|
|
self.chat_widget.suppress_current_view_key_until_release(
|
|
activation,
|
|
Instant::now(),
|
|
ActivationKeySuppression::ViewTransition,
|
|
);
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(super) async fn handle_side_focus_key(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
app_server: &mut AppServerSession,
|
|
key: KeyEvent,
|
|
) -> bool {
|
|
let bindings = &self.keymap.app;
|
|
let close = self.current_displayed_thread_id().filter(|id| {
|
|
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();
|
|
let sidebar = bindings.focus_sidebar_content.is_pressed(key);
|
|
let tabs = bindings.focus_sidebar_tabs.is_pressed(key) && self.sidebar_chooser_available();
|
|
let hide = bindings.hide_pane.is_pressed(key);
|
|
let width_change = if bindings.move_divider_left.is_pressed(key) {
|
|
2
|
|
} else if bindings.move_divider_right.is_pressed(key) {
|
|
-2
|
|
} else {
|
|
0
|
|
};
|
|
let can_resize = self.can_resize_sidebar(tui);
|
|
let focused_percent = [
|
|
(&bindings.equalize_pane_widths, 50),
|
|
(&bindings.minimize_pane_width, 0),
|
|
(&bindings.maximize_pane_width, 100),
|
|
]
|
|
.into_iter()
|
|
.find_map(|(binding, percent)| binding.is_pressed(key).then_some(percent))
|
|
.filter(|_| can_resize);
|
|
let zoom_target = if bindings.toggle_pane_zoom.is_pressed(key) {
|
|
self.open_pane_focus()
|
|
} else {
|
|
None
|
|
};
|
|
if width_change != 0 && !self.can_nudge_side_width(tui) {
|
|
return false;
|
|
}
|
|
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
|
|
|| main
|
|
|| sidebar
|
|
|| hide
|
|
|| zoom_target.is_some()
|
|
|| resizing)
|
|
|| !self.conversation_focus_available(tui)
|
|
{
|
|
return false;
|
|
}
|
|
let child = self.switchable_side_child();
|
|
let chooser = self.chooser_focus();
|
|
if child.is_none()
|
|
&& ((hide && chooser != Some(PaneFocus::Sidebar))
|
|
|| (sidebar && !self.sidebar_chooser_available()))
|
|
{
|
|
return false;
|
|
}
|
|
if key.kind != KeyEventKind::Press {
|
|
// 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()
|
|
.unwrap_or_else(|| KeyBinding::from_event(key));
|
|
let previous_thread = self.current_displayed_thread_id();
|
|
let on_child = child.is_some_and(|id| previous_thread == Some(id));
|
|
let leaves_transcript = main
|
|
&& !on_child
|
|
&& (self.transcript_view.has_active_interaction()
|
|
|| 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 {
|
|
self.begin_pane_resize(tui)
|
|
} else if let Some(percent) = focused_percent {
|
|
self.set_focused_pane_width(tui, percent).await
|
|
} else if width_change != 0 {
|
|
self.nudge_side_width(tui, width_change).await
|
|
} else if let Some(target) = zoom_target {
|
|
self.zoomed_thread = (self.zoomed_thread != Some(target)).then_some(target);
|
|
tui.frame_requester().schedule_frame();
|
|
Ok(())
|
|
} else if chooser.is_some() {
|
|
if hide || (sidebar && chooser == Some(PaneFocus::Sidebar)) {
|
|
self.sidebar_mut().hide();
|
|
} else {
|
|
if main {
|
|
self.focus_current_parent_view(tui);
|
|
}
|
|
let focus = if main || left {
|
|
PaneFocus::Main
|
|
} else {
|
|
PaneFocus::Sidebar
|
|
};
|
|
self.sidebar_mut().show(SidebarContent::Chooser, focus);
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
Ok(())
|
|
} else if main {
|
|
self.focus_main_composer(tui, app_server).await
|
|
} else if (left && on_child)
|
|
|| (right && !on_child && child.is_some() && self.open_pane_focus().is_some())
|
|
{
|
|
// Switching threads parks and restores each pane's transcript interaction.
|
|
self.toggle_side_conversation(tui, app_server).await
|
|
} else if sidebar
|
|
&& !on_child
|
|
&& let Some(child) = child
|
|
{
|
|
let result = self.select_agent_thread(tui, app_server, child).await;
|
|
if result.is_ok() && self.current_displayed_thread_id() == Some(child) {
|
|
// The outgoing parent's view is still here until Park runs.
|
|
self.focus_current_parent_view(tui);
|
|
}
|
|
result
|
|
} else if sidebar && child.is_none() {
|
|
self.focus_current_parent_view(tui);
|
|
self.open_sidebar_tabs(tui, app_server).await
|
|
} else if hide || sidebar {
|
|
self.hide_focused_side_pane(tui, app_server).await
|
|
} else {
|
|
// A direction with no reachable neighbor leaves the current interaction intact.
|
|
Ok(())
|
|
};
|
|
if let Err(err) = result {
|
|
self.chat_widget.add_error_message(err.to_string());
|
|
}
|
|
let view_changed = leaves_transcript
|
|
|| self.current_displayed_thread_id() != previous_thread
|
|
|| (main && chooser == Some(PaneFocus::Sidebar));
|
|
let suppression = if view_changed && !direct_tab_switch {
|
|
ActivationKeySuppression::ViewTransition
|
|
} else {
|
|
// A direct tab shortcut can be pressed repeatedly to cycle through several tabs.
|
|
ActivationKeySuppression::ReportedRepeats
|
|
};
|
|
self.chat_widget.suppress_current_view_key_until_release(
|
|
activation,
|
|
Instant::now(),
|
|
suppression,
|
|
);
|
|
true
|
|
}
|
|
|
|
async fn focus_main_composer(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
app_server: &mut AppServerSession,
|
|
) -> Result<()> {
|
|
if let Some(parent) = self.active_side_parent_thread_id() {
|
|
self.select_agent_thread(tui, app_server, parent).await?;
|
|
if self.current_displayed_thread_id() == Some(parent) {
|
|
self.focus_returned_parent_composer(parent).await?;
|
|
}
|
|
} else {
|
|
self.focus_current_parent_view(tui);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn focus_current_parent_view(&mut self, tui: &mut tui::Tui) {
|
|
self.cancel_transcript_browsing(tui);
|
|
clear_transcript_interaction(&mut self.transcript_view, &self.transcript_cells);
|
|
self.cancel_pending_key_chord();
|
|
}
|
|
|
|
pub(super) async fn focus_returned_parent_composer(&mut self, parent: ThreadId) -> Result<()> {
|
|
// Finish will restore this view. The live transcript still belongs to the child.
|
|
if let Some(companion) = self.side_companions.get_mut(&parent) {
|
|
companion.view.set_highlight(/*index*/ None);
|
|
clear_transcript_interaction(&mut companion.view, &companion.cells);
|
|
}
|
|
self.cancel_pending_key_chord();
|
|
self.surface_pending_inactive_thread_interactive_requests()
|
|
.await
|
|
}
|
|
}
|
|
|
|
fn clear_transcript_interaction(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) {
|
|
view.end_select_mode();
|
|
view.end_selection(cells);
|
|
view.cancel_search();
|
|
view.clear_activity_focus();
|
|
}
|