diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3e393f2b54..564db4a29c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -859,7 +859,14 @@ impl App { tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } + let previous = tui.link_hover.position(); tui.link_hover.observe(&event); + let current = tui.link_hover.position(); + if previous != current + && self.hovered_sidebar_tab(previous) != self.hovered_sidebar_tab(current) + { + tui.frame_requester().schedule_frame(); + } if self.handle_pane_resize(tui, &event).await { return Ok(AppRunControl::Continue); } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 25bfb8ea03..b68d910e56 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -6,6 +6,7 @@ use super::*; use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; +use crate::keymap::KeymapContextSet; use crate::keymap::bindings_for_action; use crate::keymap::keymap_action_ids; @@ -18,6 +19,9 @@ impl App { key_event: KeyEvent, ) -> bool { let active_contexts = self.key_chord_contexts(tui); + if self.focused_sidebar_chooser(tui) { + return false; + } let rename_active = self .chat_widget .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) @@ -101,6 +105,7 @@ impl App { if (matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) || (tui.is_owned_screen() && self.overlay.is_none() + && !self.focused_sidebar_chooser(tui) && self.chat_widget.no_modal_or_popup_active() && self.transcript_view.owns_interaction_key(key_event) && (self.transcript_view.has_active_interaction() @@ -242,15 +247,19 @@ impl App { self.chat_widget.set_footer_hint_override(items); } + fn focused_sidebar_chooser(&self, tui: &tui::Tui) -> bool { + self.conversation_focus_available(tui) + && self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar) + && self.sidebar_chooser_available() + } + fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet { let contexts = self.active_keymap_contexts(); if !self.conversation_focus_available(tui) { return contexts; } - if self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar) - && self.sidebar_chooser_available() - { - let contexts = crate::keymap::KeymapContextSet::focused_sidebar_chooser(); + if self.focused_sidebar_chooser(tui) { + let contexts = KeymapContextSet::focused_sidebar_chooser(); return if self.can_nudge_side_width(tui) { contexts.with_sidebar_resize().with_focused_side_pane() } else { diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index ef2c5b71eb..7bc13b0b00 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -71,6 +71,13 @@ impl ConversationFrame { } } + pub(super) fn sidebar_tab_strip(&self) -> Option { + self.chat_strip.or_else(|| { + self.chooser_area() + .map(|area| side_pane::tab_and_body(area)[0]) + }) + } + pub(super) fn divider_area(&self) -> Option { self.sidebar.as_ref()?.divider_area() } @@ -281,6 +288,7 @@ impl App { } let highlight_divider = self.pane_resize.is_some() || tui.link_hover.shape == tui::PointerShape::Resize; + let hovered_tab = self.hovered_sidebar_tab(tui.link_hover.position()); // An input refresh opens a fresh terminal buffer. Carry the outer owner's // completed cells through it when only the conversation is being redrawn. let backdrop = caller_inset.map(|_| { @@ -347,7 +355,10 @@ impl App { if resize_hint.is_some() { message.clone() } else { - format!("{message}\nEnter retry · Esc back") + crate::sidebar_chooser::creation_failure_text( + message, + &self.keymap, + ) } } }; @@ -355,14 +366,35 @@ impl App { .wrap(ratatui::widgets::Wrap { trim: false }) .render(body, frame.buffer); } else if let Some(pane) = self.sidebar() { + let footer = if let Some(hint) = resize_hint { + let row = crate::sidebar_chooser::body_row(*sidebar, sidebar.y); + crate::sidebar_chooser::resize_hint(hint, row.width) + } else { + crate::sidebar_chooser::footer(&self.keymap, *sidebar, pane.choice) + .0 + .line + }; crate::sidebar_chooser::paint( focused, *sidebar, frame.buffer, - &self.keymap, + footer, pane.chat_tabs(), pane.choice, - resize_hint, + self.local_settings.tui.status_line_use_colors, + ); + let chooser = if focused { + TabState::Active + } else { + TabState::Inactive + }; + crate::sidebar_chooser::tab_strip( + *sidebar, + chooser, + TabState::Absent, + pane.chat_tabs(), + hovered_tab, + frame.buffer, ); } frame @@ -382,6 +414,7 @@ impl App { TabState::Absent, chat, pane.chat_tabs(), + hovered_tab, frame.buffer, ); } diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index ed63699a0e..76ed1a5988 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -6,16 +6,35 @@ use super::sidebar::SidebarContent; use super::*; use crate::activation_key_guard::ActivationKeySuppression; use crate::app_event::SideStartMode; +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; -enum ChooserAction { - Open(usize), - Back, - Hide, -} - impl App { + pub(super) fn hovered_sidebar_tab( + &self, + point: Option, + ) -> Option { + 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 TabTarget::Tab(id) = tabs.hit(strip, point)? else { + return None; + }; + if self.conversation_frame.chat_strip.is_some() { + (tabs.selected != Some(id)).then_some(id) + } else { + pane.creation.is_none().then_some(id) + } + } + pub(super) fn sidebar_chooser_available(&self) -> bool { !self.reconnect.offline && !self.conversation_frame.has_caller_inset() @@ -70,16 +89,12 @@ impl App { if let TuiEvent::Mouse(mouse) = event && mouse.kind == MouseEventKind::Down(MouseButton::Left) && let Some(pane) = self.sidebar() - && let Some(strip) = self.conversation_frame.chat_strip.or_else(|| { - let area = self.conversation_frame.chooser_area()?; - pane.creation - .is_none() - .then(|| Rect::new(area.x, area.y, area.width, 1)) - }) + && (self.conversation_frame.chat_strip.is_some() || pane.creation.is_none()) + && let Some(strip) = self.conversation_frame.sidebar_tab_strip() && strip.contains((mouse.column, mouse.row).into()) { let point = (mouse.column, mouse.row).into(); - let target = pane.chat_tabs().hit(strip, point); + let target = pane.chat_tabs().strip().hit(strip, point); let result = if matches!(target, Some(TabTarget::Add)) { self.open_sidebar_tabs(tui, server).await } else if let Some(TabTarget::Tab(id)) = target @@ -121,48 +136,65 @@ impl App { return true; } let choice = pane.choice; - let count = pane.chat_tabs().ids.len() + 1; + 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 = crate::sidebar_chooser::footer(&self.keymap, area); - match hints.items.iter().position(|range| range.contains(&column)) { - Some(0) => ChooserAction::Open(choice), - Some(1) => ChooserAction::Back, - Some(3) => ChooserAction::Hide, - _ => return true, - } + 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) if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { + 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 key.code { - KeyCode::Enter => ChooserAction::Open(pane.choice), - KeyCode::Esc => ChooserAction::Back, - KeyCode::Left | KeyCode::Right if pane.selected_chat().is_some() => { + 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 } - KeyCode::Up => { - pane.choice = pane.choice.saturating_sub(1); + _ => { + 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; } - KeyCode::Down => { - pane.choice = (pane.choice + 1).min(pane.chat_tabs().ids.len()); - return true; - } - _ => return true, }; - (action, Some(KeyBinding::from_event(*key))) + let activation = self + .routed_key_activation + .take() + .unwrap_or_else(|| KeyBinding::from_event(*key)); + (action, Some(activation)) } - _ => return matches!(event, TuiEvent::Key(_) | TuiEvent::Paste(_)), + _ => return matches!(event, TuiEvent::Paste(_)), }; let open = matches!(action, ChooserAction::Open(_)); @@ -195,7 +227,7 @@ impl App { } } else { let target = match action { - ChooserAction::Open(index) => pane.chat_tabs().ids.get(index).copied(), + ChooserAction::Open(index) => pane.chat_tabs().choice(index), ChooserAction::Back => pane.selected_chat(), ChooserAction::Hide => None, }; diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs index 9e750d5c0d..1273290dab 100644 --- a/codex-rs/tui/src/app/sidebar.rs +++ b/codex-rs/tui/src/app/sidebar.rs @@ -5,7 +5,7 @@ use super::App; use super::pane_motion::PaneMotion; use super::side_pane; use super::side_pane::SidePaneLayout; -use crate::sidebar_tab_strip::TabStrip; +use crate::sidebar_chooser::ChatTabs; use crate::tui::Tui; use codex_protocol::ThreadId; use ratatui::layout::Rect; @@ -74,7 +74,10 @@ impl SidebarPane { self.visits.push(id); } SidebarContent::Chooser if self.content != content || self.focus.is_none() => { - self.choice = self.chat_tabs().selected_index().unwrap_or(self.tabs.len()); + self.choice = self + .selected_chat() + .and_then(|id| self.chat_tabs().choice_index(id)) + .unwrap_or(0); } SidebarContent::Chooser => {} } @@ -82,8 +85,11 @@ impl SidebarPane { self.focus = Some(focus); } - pub(super) fn chat_tabs(&self) -> TabStrip<'_, ThreadId> { - crate::sidebar_chooser::chat_tabs(&self.tabs, self.selected_chat()) + pub(super) fn chat_tabs(&self) -> ChatTabs<'_> { + ChatTabs { + ids: &self.tabs, + selected: self.selected_chat(), + } } pub(super) fn selected_chat(&self) -> Option { @@ -120,9 +126,10 @@ impl SidebarPane { self.tabs.remove(index); self.tabs_revision += 1; self.visits.retain(|visited| *visited != id); - if index < self.choice || (index == self.choice && index == self.tabs.len()) { - self.choice = self.choice.saturating_sub(1); + if index + 1 < self.choice { + self.choice -= 1; } + self.choice = self.choice.min(self.tabs.len()); if self.content == SidebarContent::Chat(id) { self.content = match self.selected_chat() { Some(child) => SidebarContent::Chat(child), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index de38ae1b08..25300e230d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4506,7 +4506,8 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() // Open a sibling from the chooser, then revisit each retained conversation. let chooser_key = KeyEvent::from(KeyCode::Char('t')); - let [up, down, enter] = [KeyCode::Up, KeyCode::Down, KeyCode::Enter].map(KeyEvent::from); + let [up, down, enter] = + [KeyCode::Char('k'), KeyCode::Char('j'), KeyCode::Enter].map(KeyEvent::from); side_frame(&mut app, &mut tui, even)?; let sidebar_x = app.conversation_frame.chat_strip.unwrap().x; keys!(prefix, chooser_key, down, enter); @@ -4544,7 +4545,65 @@ 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")); - keys!(prefix, main); + let colors = crate::terminal_probe::DefaultColors { + fg: (224, 224, 224), + bg: (20, 20, 20), + }; + let mut tab_styles = Vec::new(); + let mut redraws = crate::tui::test_support::record_frame_requests(&mut tui); + for (focus, owner) in [("sidebar", sibling), ("main", parent_thread_id)] { + 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 + .sidebar() + .unwrap() + .chat_tabs() + .strip() + .strip_targets(strip); + let [inactive, selected] = tabs + .map(|(_, _, area)| area.x + 1) + .collect::>() + .try_into() + .expect("both side-chat tabs are visible"); + for (state, column, row, palette, redraw) in [ + ("rest", selected, strip.y + 1, Some(colors), None), + ("hover", inactive, strip.y, Some(colors), Some(true)), + ("fallback", inactive + 1, strip.y, None, Some(false)), + ("selected", selected, strip.y, Some(colors), Some(true)), + ("leave", selected, strip.y + 1, Some(colors), Some(false)), + ] { + while redraws.try_recv().is_ok() {} + let event = mouse(MouseEventKind::Moved, column, row); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, event)).await?; + if let Some(redraw) = redraw { + assert_eq!(redraws.try_recv().is_ok(), redraw, "{focus} {state}"); + } + crate::terminal_palette::with_test_default_colors(palette, || { + side_frame(&mut app, &mut tui, even) + })?; + let buffer = tui.terminal.previous_buffer(); + let style = |x| { + let cell = &buffer[(x, strip.y)]; + format!("{:?}/{:?}/{:?}", cell.fg, cell.bg, cell.modifier) + }; + tab_styles.push(format!( + "{focus} {state} inactive {} selected {}", + style(inactive), + style(selected), + )); + } + if owner == sibling { + keys!(prefix, KeyCode::Char('r').into()); + assert!(app.pane_resize.is_some()); + let event = mouse(MouseEventKind::Moved, inactive, strip.y); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, event)).await?; + assert_eq!(app.hovered_sidebar_tab(tui.link_hover.position()), None); + keys!(KeyCode::Esc.into(), prefix, main); + } + } + assert_app_snapshot!("side_chat_tab_hover", tab_styles.join("\n")); + app.chat_widget .dispatch_command(crate::slash_command::SlashCommand::Side); drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 4cf49daa3f..2e75cb5ae7 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1235,6 +1235,7 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> let (mut app, mut events, _ops) = make_test_app_with_channels().await; let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; + app.local_settings.tui.keymap = toml::from_str("[list]\naccept = ['f5 g']")?; super::enable_side_retention(&mut app)?; tui.set_owned_screen(/*owned*/ true)?; let parent = ThreadId::new(); @@ -1344,7 +1345,14 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; } } - Box::pin(press(&mut app, &mut tui, &mut server, key.into())).await?; + let strokes = if key == KeyCode::Esc { + &[KeyCode::Esc][..] + } else { + &[KeyCode::F(5), KeyCode::Char('g')] + }; + for stroke in strokes { + Box::pin(press(&mut app, &mut tui, &mut server, (*stroke).into())).await?; + } assert_eq!( app.chat_widget.composer_text_with_pending(), "parent draft!" @@ -1433,6 +1441,23 @@ prepared. draft.contains("first prompt") && draft.contains("second prompt"), "{draft}" ); + // The pinned New action leaves room for two scrolling chats. + let pane = app.sidebar_mut(); + for _ in 0..4 { + pane.chat_ready(ThreadId::new()); + } + pane.show( + crate::app::sidebar::SidebarContent::Chooser, + crate::app::sidebar::PaneFocus::Sidebar, + ); + pane.choice = 2; + let two_rows = ratatui::layout::Size::new(/*width*/ 120, /*height*/ 11); + tui.terminal = terminal_at(two_rows); + super::side_frame(&mut app, &mut tui, two_rows)?; + for (key, expected) in [(KeyCode::PageDown, 4), (KeyCode::PageUp, 2)] { + Box::pin(press(&mut app, &mut tui, &mut server, key.into())).await?; + assert_eq!(app.sidebar().unwrap().choice, expected); + } tokio::time::resume(); server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap index 973aa4f3e8..a5ef99c1cf 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap @@ -1,8 +1,9 @@ --- source: tui/src/app/tests/key_chords.rs +assertion_line: 1408 expression: narrow --- The side conversation could not be prepared. - Enter retry · Esc back + f5 g retry · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap index 76901b1069..2256e0d92e 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap @@ -13,4 +13,4 @@ expression: frame › parent draft │ │ gpt-test default │ - │ Enter open · Esc back · ↑/↓ move … + │ f5 g open · esc back · ↑/↓ move … diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b38f8d13ad..27d288a94b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -222,6 +222,7 @@ pub(crate) use picker_option::picker_option_list; pub(crate) use picker_option::picker_option_row; pub(crate) mod popup_consts; mod scroll_state; +pub(crate) use scroll_state::ScrollState; mod selection_popup_common; pub(crate) use selection_popup_common::menu_surface_padding_height; pub(crate) use selection_popup_common::render_menu_surface; diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index decedcce69..b492b439c2 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -184,7 +184,7 @@ impl KeymapContextSet { } pub(crate) const fn focused_sidebar_chooser() -> Self { - Self(SIDE_PANE | PANE_DIRECTION | MAIN_COMPOSER_FOCUS) + Self(SIDE_PANE | PANE_DIRECTION | MAIN_COMPOSER_FOCUS).with(KeymapContext::List) } pub(crate) const fn with_sidebar_chooser(self) -> Self { diff --git a/codex-rs/tui/src/sidebar_chooser.rs b/codex-rs/tui/src/sidebar_chooser.rs index dd731a5deb..01516c89a8 100644 --- a/codex-rs/tui/src/sidebar_chooser.rs +++ b/codex-rs/tui/src/sidebar_chooser.rs @@ -2,6 +2,7 @@ use crate::footer_hint; use crate::keymap::KeymapContext; +use crate::keymap::ListAction; use crate::keymap::RuntimeKeymap; use crate::sidebar_tab_strip::TabState; use crate::sidebar_tab_strip::TabStrip; @@ -10,12 +11,15 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Clear; use ratatui::widgets::Widget; +use std::ops::Range; const WIDE_WIDTH: u16 = 58; +const FIRST_CHOICE_ROW: u16 = 4; -fn body_row(area: Rect, y: u16) -> Rect { +pub(crate) fn body_row(area: Rect, y: u16) -> Rect { Rect::new(area.x + 1, y, area.width.saturating_sub(3), 1) } @@ -28,25 +32,94 @@ pub(crate) fn resize_hint(full: &str, width: u16) -> Line<'_> { } } -pub(crate) fn footer(keymap: &RuntimeKeymap, area: Rect) -> footer_hint::HintLine { +pub(crate) enum ChooserAction { + Open(usize), + Back, + Hide, +} + +fn list_hint(keymap: &RuntimeKeymap, action: ListAction) -> Option { + keymap + .list + .primary_hint(action) + .map(super::key_hint::ShortcutHint::display_label) +} + +pub(crate) fn creation_failure_text(message: &str, keymap: &RuntimeKeymap) -> String { + let hints = [(ListAction::Accept, "retry"), (ListAction::Cancel, "back")] + .into_iter() + .filter_map(|(action, label)| Some(format!("{} {label}", list_hint(keymap, action)?))) + .collect::>(); + if hints.is_empty() { + message.to_owned() + } else { + format!("{message}\n{}", hints.join(" · ")) + } +} + +pub(crate) fn footer( + keymap: &RuntimeKeymap, + area: Rect, + choice: usize, +) -> (footer_hint::HintLine, Vec>) { + let hint = |action| list_hint(keymap, action); + let movement = [ListAction::MoveUp, ListAction::MoveDown] + .into_iter() + .filter_map(hint) + .collect::>() + .join("/"); let hide = keymap .primary_hint(KeymapContext::Global, "hide_pane") - .map(|hint| footer_hint::shortcut(&hint.display_label(), "hide pane")); - let hints = [ - footer_hint::shortcut("Enter", "open"), - footer_hint::shortcut("Esc", "back"), - footer_hint::shortcut("↑/↓", "move"), - ]; - footer_hint::HintLine::new(hints.into_iter().chain(hide), body_row(area, area.y).width) + .map(super::key_hint::ShortcutHint::display_label); + let (hints, actions): (Vec<_>, Vec<_>) = [ + ( + hint(ListAction::Accept), + "open", + Some(ChooserAction::Open(choice)), + ), + (hint(ListAction::Cancel), "back", Some(ChooserAction::Back)), + ((!movement.is_empty()).then_some(movement), "move", None), + (hide, "hide pane", Some(ChooserAction::Hide)), + ] + .into_iter() + .filter_map(|(keys, label, action)| Some((footer_hint::shortcut(&keys?, label), action))) + .unzip(); + ( + footer_hint::HintLine::new(hints, body_row(area, area.y).width), + actions, + ) } const CHAT_TAB: &str = " Side chat "; -pub(crate) fn chat_tabs(ids: &[ThreadId], selected: Option) -> TabStrip<'_, ThreadId> { - TabStrip { - ids, - selected, - tab_width: CHAT_TAB.len() as u16, +#[derive(Clone, Copy, Default)] +pub(crate) struct ChatTabs<'a> { + pub(crate) ids: &'a [ThreadId], + pub(crate) selected: Option, +} + +impl<'a> ChatTabs<'a> { + pub(crate) fn choice_count(self) -> usize { + self.ids.len() + 1 + } + + pub(crate) fn choice(self, index: usize) -> Option { + self.ids.get(index.checked_sub(1)?).copied() + } + + pub(crate) fn choice_index(self, id: ThreadId) -> Option { + self.ids + .iter() + .position(|choice| *choice == id) + .map(|index| index + 1) + } + + pub(crate) fn strip(self) -> TabStrip<'a, ThreadId> { + TabStrip { + ids: self.ids, + selected: self.selected, + tab_width: CHAT_TAB.len() as u16, + } } } @@ -54,46 +127,74 @@ pub(crate) fn tab_strip( area: Rect, chooser: TabState, chat: TabState, - tabs: TabStrip<'_, ThreadId>, + tabs: ChatTabs<'_>, + hovered_tab: Option, buf: &mut Buffer, ) { - tabs.render(area, buf, chooser, chat, |index, _| { - if tabs.ids.len() == 1 { - Line::from(CHAT_TAB) - } else { - Line::from(format!(" Side {} ", index + 1)) - } - }); + tabs.strip() + .render(area, buf, chooser, chat, hovered_tab, |index, _| { + if tabs.ids.len() > 1 { + Line::from(format!(" Side {} ", index + 1)) + } else { + Line::from(CHAT_TAB) + } + }); } -fn choice_rows(area: Rect, count: usize, choice: usize) -> (u16, std::ops::Range) { - let height = if body_row(area, area.y).width < WIDE_WIDTH { - 2 +struct ChoiceRows { + wide: bool, + new_chat: Range, + group: Option, + chat_start: u16, + chats: Range, +} + +fn choice_rows(area: Rect, count: usize, choice: usize) -> ChoiceRows { + let wide = body_row(area, area.y).width >= WIDE_WIDTH; + let new_height = if wide { 1 } else { 2 }; + let footer = area.height.saturating_sub(1); + let new_end = (FIRST_CHOICE_ROW + new_height).min(footer); + let group = (count > 1 && footer > new_end + 2).then_some(new_end + 1); + // In short panes, let the New row scroll away so the selection remains visible. + let new_chat = FIRST_CHOICE_ROW..if choice == 0 || group.is_some() { + new_end.max(FIRST_CHOICE_ROW) } else { - 1 + FIRST_CHOICE_ROW }; - let visible = usize::from(area.height.saturating_sub(5).div_ceil(height)); - let start = (choice + 1).saturating_sub(visible); - (height, start..(start + visible).min(count)) + let chat_start = group.map_or(new_chat.end, |row| row + 1); + let visible = usize::from(footer.saturating_sub(chat_start)); + let first = (choice + 1).saturating_sub(visible).max(1); + ChoiceRows { + wide, + new_chat, + group, + chat_start, + chats: first..(first + visible).min(count), + } +} + +pub(crate) fn page_size(area: Rect, count: usize, choice: usize) -> usize { + choice_rows(area, count, choice).chats.len().max(1) } pub(crate) fn choice_at(area: Rect, count: usize, choice: usize, y: u16) -> Option { - if y >= area.bottom().saturating_sub(1) { - return None; + let mut rows = choice_rows(area, count, choice); + let offset = y.checked_sub(area.y)?; + if rows.new_chat.contains(&offset) { + return Some(0); } - let (height, rows) = choice_rows(area, count, choice); - let offset = y.checked_sub(area.y)?.checked_sub(4)?; - rows.into_iter().nth(usize::from(offset / height)) + rows.chats + .nth(usize::from(offset.checked_sub(rows.chat_start)?)) } pub(crate) fn paint( focused: bool, area: Rect, buf: &mut Buffer, - keymap: &RuntimeKeymap, - tabs: TabStrip<'_, ThreadId>, + footer: Line<'_>, + tabs: ChatTabs<'_>, choice: usize, - resize: Option<&str>, + use_theme_colors: bool, ) { Clear.render(area, buf); if area.is_empty() { @@ -104,12 +205,6 @@ pub(crate) fn paint( } else { crate::style::quiet_workspace_style() }; - let chooser = if focused { - TabState::Active - } else { - TabState::Inactive - }; - tab_strip(area, chooser, TabState::Absent, tabs, buf); let row = |y| body_row(area, y); let footer_y = area.bottom() - 1; let mut paint = |offset, line: Line<'_>| { @@ -119,44 +214,58 @@ pub(crate) fn paint( } }; paint(2, Line::from("Open in sidebar").bold()); - let (height, rows) = choice_rows(area, tabs.ids.len() + 1, choice); - for (offset, index) in rows.enumerate() { - let marker = if index == choice { '›' } else { ' ' }; - let row_style = if index == choice { - style + let count = tabs.choice_count(); + let rows = choice_rows(area, count, choice); + let row_style = |index| { + if index == choice { + ('›', style) } else { - Default::default() - }; - let (title, detail) = if index == tabs.ids.len() { - ( - format!("{marker} + New side chat"), - "Start from the main chat", - ) - } else { - let title = if tabs.ids.len() == 1 { - "Side chat".to_string() - } else { - format!("Side chat {}", index + 1) - }; - (format!("{marker} {title}"), "Reopen side chat") - }; - let y = 4 + offset as u16 * height; - if height == 1 { + (' ', Default::default()) + } + }; + let secondary = crate::style::secondary_text_style(); + if !rows.new_chat.is_empty() { + let y = rows.new_chat.start; + let (marker, fill) = row_style(0); + let title = format!("{marker} + New side chat"); + let detail = "Start from the main chat"; + if rows.wide { let pad = usize::from(row(area.y).width - 1) - detail.len(); - paint( - y, - Line::from(format!("{title: area.y { - let line = match resize { - Some(hint) => resize_hint(hint, row(footer_y).width), - None => footer(keymap, area).line, + if let Some(y) = rows.group { + let total = count - 1; + paint( + y, + Line::from(format!(" Side chats {total}")).style(secondary), + ); + } + for (offset, index) in rows.chats.enumerate() { + let id = tabs.ids[index - 1]; + let selected = index == choice && focused; + let (marker, fill) = row_style(index); + let title = if count == 2 { + "Side chat".to_string() + } else { + format!("Side chat {index}") }; - line.render(row(footer_y), buf); + let title = if use_theme_colors && !selected { + let identity = crate::thread_color::thread_color(id); + title.fg(crate::style::readable_color_on(identity, fill.bg)) + } else { + title.into() + }; + let symbol = Span::styled("○ ", if selected { style } else { secondary }); + paint( + rows.chat_start + offset as u16, + Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill), + ); + } + if footer_y > area.y { + footer.render(row(footer_y), buf); } } diff --git a/codex-rs/tui/src/sidebar_tab_strip.rs b/codex-rs/tui/src/sidebar_tab_strip.rs index 8096bb4ace..3b41a3ddb7 100644 --- a/codex-rs/tui/src/sidebar_tab_strip.rs +++ b/codex-rs/tui/src/sidebar_tab_strip.rs @@ -4,6 +4,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::text::Line; use ratatui::widgets::Widget; @@ -31,7 +32,10 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { self.ids.iter().position(|id| Some(*id) == self.selected) } - fn targets(self, area: Rect) -> (impl Iterator + 'a, Rect) { + pub(crate) fn strip_targets( + self, + area: Rect, + ) -> (impl Iterator + 'a, Rect) { let width = area.width.saturating_sub(1); let plus = width.min(3); let available = width - plus; @@ -50,7 +54,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { } pub(crate) fn hit(self, area: Rect, point: Position) -> Option> { - let (mut tabs, add) = self.targets(area); + let (mut tabs, add) = self.strip_targets(area); if add.contains(point) { Some(TabTarget::Add) } else { @@ -64,21 +68,34 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { buf: &mut Buffer, add_state: TabState, selected_state: TabState, + hovered_tab: Option, label: impl Fn(usize, Id) -> Line<'a>, ) { - let (tabs, add) = self.targets(area); + 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(), - TabState::Absent => crate::style::secondary_text_style(), + TabState::Absent => crate::style::secondary_text_style() + .bg(Color::Reset) + .not_underlined() + .not_reversed(), }; for (index, id, rect) in tabs { - let state = if add_state == TabState::Absent && Some(id) == self.selected { - selected_state + let tab_style = if selected_state != TabState::Absent && Some(id) == self.selected { + style(selected_state) + } else if hovered_tab == Some(id) { + let hover = crate::style::quiet_workspace_style() + .fg(Color::Reset) + .not_dim(); + if hover.bg == Some(Color::Reset) { + hover.underlined() + } else { + hover + } } else { - TabState::Absent + style(TabState::Absent) }; - label(index, id).style(style(state)).render(rect, buf); + label(index, id).style(tab_style).render(rect, buf); } Line::from(" + ").style(style(add_state)).render(add, buf); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tab_hover.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tab_hover.snap new file mode 100644 index 0000000000..ab706a1664 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tab_hover.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 4599 +expression: "tab_styles.join(\"\\n\")" +--- +sidebar rest inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(0, 0, 46)/Rgb(99, 168, 248)/BOLD +sidebar hover inactive Reset/Rgb(31, 31, 31)/NONE selected Rgb(0, 0, 46)/Rgb(99, 168, 248)/BOLD +sidebar fallback inactive Reset/Reset/UNDERLINED selected Reset/Reset/BOLD | REVERSED +sidebar selected inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(0, 0, 46)/Rgb(99, 168, 248)/BOLD +sidebar leave inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(0, 0, 46)/Rgb(99, 168, 248)/BOLD +main rest inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(224, 224, 224)/Rgb(76, 76, 76)/BOLD +main hover inactive Reset/Rgb(31, 31, 31)/NONE selected Rgb(224, 224, 224)/Rgb(76, 76, 76)/BOLD +main fallback inactive Reset/Reset/UNDERLINED selected Reset/Reset/BOLD | UNDERLINED +main selected inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(224, 224, 224)/Rgb(76, 76, 76)/BOLD +main leave inactive Rgb(142, 142, 142)/Reset/NONE selected Rgb(224, 224, 224)/Rgb(76, 76, 76)/BOLD diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap index 3849a1ad1e..6e020e8c19 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap @@ -1,17 +1,16 @@ --- source: tui/src/app/tests.rs -assertion_line: 3644 expression: "chooser.lines().filter_map(|line|\nline.split_once('│').map(|(_, sidebar)|\nsidebar.trim_end())).collect::>().join(\"\\n\")" --- Side chat + Open in sidebar - › Side chat - Reopen side chat + New side chat Start from the main chat + Side chats 1 + › ○ Side chat @@ -20,4 +19,4 @@ expression: "chooser.lines().filter_map(|line|\nline.split_once('│').map(|(_, - Enter open · Esc back … + enter open · esc back … diff --git a/codex-rs/tui/src/tui/link_pointer.rs b/codex-rs/tui/src/tui/link_pointer.rs index 984b27f992..ce4d253456 100644 --- a/codex-rs/tui/src/tui/link_pointer.rs +++ b/codex-rs/tui/src/tui/link_pointer.rs @@ -10,6 +10,7 @@ use std::sync::atomic::Ordering; use codex_terminal_detection::TerminalName; use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; +use ratatui::layout::Position; use super::Tui; use super::TuiEvent; @@ -30,6 +31,10 @@ pub(crate) struct LinkHover { } impl LinkHover { + pub(crate) fn position(&self) -> Option { + self.mouse.map(|mouse| (mouse.column, mouse.row).into()) + } + pub(crate) fn observe(&mut self, event: &TuiEvent) { match event { TuiEvent::Mouse(mouse) => { diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs index c3f9f6bc3b..b5acf7d527 100644 --- a/codex-rs/tui/src/tui/test_support.rs +++ b/codex-rs/tui/src/tui/test_support.rs @@ -9,6 +9,14 @@ use super::Tui; use super::terminal_stderr::TerminalStderrGuard; use crate::custom_terminal::Terminal; +pub(crate) fn record_frame_requests( + tui: &mut Tui, +) -> tokio::sync::mpsc::UnboundedReceiver { + let (requester, requests) = super::FrameRequester::test_channel(); + tui.frame_requester = requester; + requests +} + pub(crate) fn make_test_tui() -> io::Result { let backend = CrosstermBackend::new(stdout()); let terminal = Terminal::with_screen_size_and_cursor_position_for_test(