mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Improve sidebar chat selection and tab hover highlighting (#59928)
## Why The sidebar chooser used hard-coded keys and hints instead of configured list bindings, limiting navigation and making custom shortcuts inconsistent. ## What changed - Use configured list actions for chooser navigation, acceptance, and cancellation, including key chords, page movement, and jumps to the first or last choice. Show the configured shortcuts in footer and retry hints. - Place “New side chat” first and group existing chats into compact rows with a count and optional thread colors. Keep the selected chat visible in short panes. - Highlight inactive side-chat tabs on hover while preserving the selected tab's styling. Redraw when the hovered tab changes and suppress hover highlighting during pane resizing. ## Testing Extend interaction tests and snapshots to cover custom acceptance chords, `j`/`k` navigation, paging, and tab hover styles and redraws with either pane focused, including the fallback when terminal colors are unavailable. GitOrigin-RevId: bad992b3786215dcb5569acfc72af8841fad949d
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -71,6 +71,13 @@ impl ConversationFrame {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sidebar_tab_strip(&self) -> Option<Rect> {
|
||||
self.chat_strip.or_else(|| {
|
||||
self.chooser_area()
|
||||
.map(|area| side_pane::tab_and_body(area)[0])
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn divider_area(&self) -> Option<Rect> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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 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,
|
||||
};
|
||||
|
||||
@@ -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<ThreadId> {
|
||||
@@ -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),
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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?;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -13,4 +13,4 @@ expression: frame
|
||||
› parent draft │
|
||||
│
|
||||
gpt-test default │
|
||||
│ Enter open · Esc back · ↑/↓ move …
|
||||
│ f5 g open · esc back · ↑/↓ move …
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String> {
|
||||
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::<Vec<_>>();
|
||||
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<Option<ChooserAction>>) {
|
||||
let hint = |action| list_hint(keymap, action);
|
||||
let movement = [ListAction::MoveUp, ListAction::MoveDown]
|
||||
.into_iter()
|
||||
.filter_map(hint)
|
||||
.collect::<Vec<_>>()
|
||||
.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<ThreadId>) -> 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<ThreadId>,
|
||||
}
|
||||
|
||||
impl<'a> ChatTabs<'a> {
|
||||
pub(crate) fn choice_count(self) -> usize {
|
||||
self.ids.len() + 1
|
||||
}
|
||||
|
||||
pub(crate) fn choice(self, index: usize) -> Option<ThreadId> {
|
||||
self.ids.get(index.checked_sub(1)?).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn choice_index(self, id: ThreadId) -> Option<usize> {
|
||||
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<ThreadId>,
|
||||
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<usize>) {
|
||||
let height = if body_row(area, area.y).width < WIDE_WIDTH {
|
||||
2
|
||||
struct ChoiceRows {
|
||||
wide: bool,
|
||||
new_chat: Range<u16>,
|
||||
group: Option<u16>,
|
||||
chat_start: u16,
|
||||
chats: Range<usize>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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:<pad$}{detail}")).style(row_style),
|
||||
);
|
||||
paint(y, Line::from(format!("{title:<pad$}{detail}")).style(fill));
|
||||
} else {
|
||||
paint(y, Line::from(title).style(row_style));
|
||||
paint(y + 1, Line::from(format!(" {detail}")).style(row_style));
|
||||
paint(y, Line::from(title).style(fill));
|
||||
paint(y + 1, Line::from(format!(" {detail}")).style(fill));
|
||||
}
|
||||
}
|
||||
if footer_y > 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Item = (usize, Id, Rect)> + 'a, Rect) {
|
||||
pub(crate) fn strip_targets(
|
||||
self,
|
||||
area: Rect,
|
||||
) -> (impl Iterator<Item = (usize, Id, Rect)> + '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<TabTarget<Id>> {
|
||||
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<Id>,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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::<Vec<_>>().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 …
|
||||
|
||||
@@ -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<Position> {
|
||||
self.mouse.map(|mouse| (mouse.column, mouse.row).into())
|
||||
}
|
||||
|
||||
pub(crate) fn observe(&mut self, event: &TuiEvent) {
|
||||
match event {
|
||||
TuiEvent::Mouse(mouse) => {
|
||||
|
||||
@@ -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<std::time::Instant> {
|
||||
let (requester, requests) = super::FrameRequester::test_channel();
|
||||
tui.frame_requester = requester;
|
||||
requests
|
||||
}
|
||||
|
||||
pub(crate) fn make_test_tui() -> io::Result<Tui> {
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let terminal = Terminal::with_screen_size_and_cursor_position_for_test(
|
||||
|
||||
Reference in New Issue
Block a user