From 180a5820fc1fa3ca398f088f8906cfe74f7c22a0 Mon Sep 17 00:00:00 2001 From: gabec-openai Date: Tue, 10 Mar 2026 19:41:51 -0700 Subject: [PATCH] Add keyboard based fast switching between agents in TUI (#13923) --- AGENTS.md | 11 + codex-rs/tui/src/app.rs | 160 ++++++--- codex-rs/tui/src/app/agent_navigation.rs | 324 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 44 ++- codex-rs/tui/src/bottom_pane/footer.rs | 158 +++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 10 + ...ter__tests__footer_active_agent_label.snap | 6 + ...r_status_line_with_active_agent_label.snap | 6 + codex-rs/tui/src/chatwidget.rs | 12 + codex-rs/tui/src/multi_agents.rs | 123 ++++++- 10 files changed, 752 insertions(+), 102 deletions(-) create mode 100644 codex-rs/tui/src/app/agent_navigation.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap diff --git a/AGENTS.md b/AGENTS.md index 09c32d02f..df6c3df3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,17 @@ In the codex-rs folder where the rust code lives: - After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught locally before CI. - Do not create small helper methods that are referenced only once. +- Avoid large modules: + - Prefer adding new modules instead of growing existing ones. + - Target Rust modules under 500 LoC, excluding tests. + - If a file exceeds roughly 800 LoC, add new functionality in a new module instead of extending + the existing file unless there is a strong documented reason not to. + - This rule applies especially to high-touch files that already attract unrelated changes, such + as `codex-rs/tui/src/app.rs`, `codex-rs/tui/src/bottom_pane/chat_composer.rs`, + `codex-rs/tui/src/bottom_pane/footer.rs`, `codex-rs/tui/src/chatwidget.rs`, + `codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules. + - When extracting code from a large module, move the related tests and module/type docs toward + the new implementation so the invariants stay close to the code that owns them. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index adb723d1d..75646d892 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,10 +26,10 @@ use crate::history_cell::UpdateAvailableHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; -use crate::multi_agents::AgentPickerThreadEntry; use crate::multi_agents::agent_picker_status_dot_spans; use crate::multi_agents::format_agent_picker_item_name; -use crate::multi_agents::sort_agent_picker_threads; +use crate::multi_agents::next_agent_shortcut_matches; +use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -111,8 +111,11 @@ use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; use toml::Value as TomlValue; +mod agent_navigation; mod pending_interactive_replay; +use self::agent_navigation::AgentNavigationDirection; +use self::agent_navigation::AgentNavigationState; use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -697,7 +700,7 @@ pub(crate) struct App { thread_event_channels: HashMap, thread_event_listener_tasks: HashMap>, - agent_picker_threads: HashMap, + agent_navigation: AgentNavigationState, active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, @@ -1097,7 +1100,7 @@ impl App { let short_id: String = thread_id.chars().take(8).collect(); format!("Agent ({short_id})") }; - if let Some(entry) = self.agent_picker_threads.get(&thread_id) { + if let Some(entry) = self.agent_navigation.get(&thread_id) { let label = format_agent_picker_item_name( entry.agent_nickname.as_deref(), entry.agent_role.as_deref(), @@ -1115,6 +1118,29 @@ impl App { } } + /// Returns the thread whose transcript is currently on screen. + /// + /// `active_thread_id` is the source of truth during steady state, but the widget can briefly + /// lag behind thread bookkeeping during transitions. The footer label and adjacent-thread + /// navigation both follow what the user is actually looking at, not whichever thread most + /// recently began switching. + fn current_displayed_thread_id(&self) -> Option { + self.active_thread_id.or(self.chat_widget.thread_id()) + } + + /// Mirrors the visible thread into the contextual footer row. + /// + /// The footer sometimes shows ambient context instead of an instructional hint. In multi-agent + /// sessions, that contextual row includes the currently viewed agent label. The label is + /// intentionally hidden until there is more than one known thread so single-thread sessions do + /// not spend footer space restating that the user is already on the main conversation. + fn sync_active_agent_label(&mut self) { + let label = self + .agent_navigation + .active_agent_label(self.current_displayed_thread_id(), self.primary_thread_id); + self.chat_widget.set_active_agent_label(label); + } + async fn thread_cwd(&self, thread_id: ThreadId) -> Option { let channel = self.thread_event_channels.get(&thread_id)?; let store = channel.store.lock().await; @@ -1322,6 +1348,7 @@ impl App { let thread_id = session.session_id; self.primary_thread_id = Some(thread_id); self.primary_session_configured = Some(session.clone()); + self.upsert_agent_picker_thread(thread_id, None, None, false); self.ensure_thread_channel(thread_id); self.activate_thread_channel(thread_id).await; self.enqueue_thread_event(thread_id, event).await?; @@ -1336,6 +1363,12 @@ impl App { Ok(()) } + /// Opens the `/agent` picker after refreshing cached labels for known threads. + /// + /// The picker state is derived from long-lived thread channels plus best-effort metadata + /// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by + /// historical id now" and converted into closed picker entries instead of deleting them, so + /// the stable traversal order remains intact for review and keyboard navigation. async fn open_agent_picker(&mut self) { let thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); for thread_id in thread_ids { @@ -1356,29 +1389,23 @@ impl App { } let has_non_primary_agent_thread = self - .agent_picker_threads - .keys() - .any(|thread_id| Some(*thread_id) != self.primary_thread_id); + .agent_navigation + .has_non_primary_thread(self.primary_thread_id); if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread { self.chat_widget.open_multi_agent_enable_prompt(); return; } - if self.agent_picker_threads.is_empty() { + if self.agent_navigation.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); return; } - let mut agent_threads: Vec<(ThreadId, AgentPickerThreadEntry)> = self - .agent_picker_threads - .iter() - .map(|(thread_id, entry)| (*thread_id, entry.clone())) - .collect(); - sort_agent_picker_threads(&mut agent_threads); - let mut initial_selected_idx = None; - let items: Vec = agent_threads + let items: Vec = self + .agent_navigation + .ordered_threads() .iter() .enumerate() .map(|(idx, (thread_id, entry))| { @@ -1410,7 +1437,7 @@ impl App { self.chat_widget.show_selection_view(SelectionViewParams { title: Some("Multi-agents".to_string()), - subtitle: Some("Select an agent to watch".to_string()), + subtitle: Some(AgentNavigationState::picker_subtitle()), footer_hint: Some(standard_popup_hint_line()), items, initial_selected_idx, @@ -1418,6 +1445,10 @@ impl App { }); } + /// Updates cached picker metadata and then mirrors any visible-label change into the footer. + /// + /// These two writes stay paired so the picker rows and contextual footer continue to describe + /// the same displayed thread after nickname or role updates. fn upsert_agent_picker_thread( &mut self, thread_id: ThreadId, @@ -1425,22 +1456,18 @@ impl App { agent_role: Option, is_closed: bool, ) { - self.agent_picker_threads.insert( - thread_id, - AgentPickerThreadEntry { - agent_nickname, - agent_role, - is_closed, - }, - ); + self.agent_navigation + .upsert(thread_id, agent_nickname, agent_role, is_closed); + self.sync_active_agent_label(); } + /// Marks a cached picker thread closed and recomputes the contextual footer label. + /// + /// Closing a thread is not the same as removing it: users can still inspect finished agent + /// transcripts, and the stable next/previous traversal order should not collapse around them. fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { - if let Some(entry) = self.agent_picker_threads.get_mut(&thread_id) { - entry.is_closed = true; - } else { - self.upsert_agent_picker_thread(thread_id, None, None, true); - } + self.agent_navigation.mark_closed(thread_id); + self.sync_active_agent_label(); } async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { @@ -1487,6 +1514,7 @@ impl App { tx }; self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); + self.sync_active_agent_label(); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -1517,12 +1545,13 @@ impl App { fn reset_thread_event_state(&mut self) { self.abort_all_thread_event_listeners(); self.thread_event_channels.clear(); - self.agent_picker_threads.clear(); + self.agent_navigation.clear(); self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; self.pending_primary_events.clear(); self.chat_widget.set_pending_thread_approvals(Vec::new()); + self.sync_active_agent_label(); } async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { @@ -1910,7 +1939,7 @@ impl App { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), - agent_picker_threads: HashMap::new(), + agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -3657,6 +3686,33 @@ impl App { } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + let allow_agent_word_motion_fallback = !self.enhanced_keys_supported + && self.chat_widget.composer_text_with_pending().is_empty(); + if self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) + { + if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( + self.current_displayed_thread_id(), + AgentNavigationDirection::Previous, + ) { + let _ = self.select_agent_thread(tui, thread_id).await; + } + return; + } + if self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) + { + if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( + self.current_displayed_thread_id(), + AgentNavigationDirection::Next, + ) { + let _ = self.select_agent_thread(tui, thread_id).await; + } + return; + } + match key_event { KeyEvent { code: KeyCode::Char('t'), @@ -3797,6 +3853,7 @@ mod tests { use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; + use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; use codex_core::CodexAuth; use codex_core::config::ConfigBuilder; @@ -4916,13 +4973,14 @@ mod tests { assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); assert_eq!( - app.agent_picker_threads.get(&thread_id), + app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: None, agent_role: None, is_closed: true, }) ); + assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]); Ok(()) } @@ -4932,20 +4990,18 @@ mod tests { let thread_id = ThreadId::new(); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(1)); - app.agent_picker_threads.insert( + app.agent_navigation.upsert( thread_id, - AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - is_closed: false, - }, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, ); app.open_agent_picker().await; assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); assert_eq!( - app.agent_picker_threads.get(&thread_id), + app.agent_navigation.get(&thread_id), Some(&AgentPickerThreadEntry { agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), @@ -5127,13 +5183,11 @@ mod tests { } app.thread_event_channels .insert(agent_thread_id, agent_channel); - app.agent_picker_threads.insert( + app.agent_navigation.upsert( agent_thread_id, - AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - is_closed: false, - }, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, ); app.refresh_pending_thread_approvals().await; @@ -5185,13 +5239,11 @@ mod tests { }, ), ); - app.agent_picker_threads.insert( + app.agent_navigation.upsert( agent_thread_id, - AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - is_closed: false, - }, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, ); app.enqueue_thread_event( @@ -5537,7 +5589,7 @@ mod tests { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), - agent_picker_threads: HashMap::new(), + agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -5597,7 +5649,7 @@ mod tests { windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), - agent_picker_threads: HashMap::new(), + agent_navigation: AgentNavigationState::default(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs new file mode 100644 index 000000000..a77a49d96 --- /dev/null +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -0,0 +1,324 @@ +//! Multi-agent picker navigation and labeling state for the TUI app. +//! +//! This module exists to keep the pure parts of multi-agent navigation out of [`crate::app::App`]. +//! It owns the stable spawn-order cache used by the `/agent` picker, keyboard next/previous +//! navigation, and the contextual footer label for the thread currently being watched. +//! +//! Responsibilities here are intentionally narrow: +//! - remember picker entries and their first-seen order +//! - answer traversal questions like "what is the next thread?" +//! - derive user-facing picker/footer text from cached thread metadata +//! +//! Responsibilities that stay in `App`: +//! - discovering threads from the backend +//! - deciding which thread is currently displayed +//! - mutating UI state such as switching threads or updating the footer widget +//! +//! The key invariant is that traversal follows first-seen spawn order rather than thread-id sort +//! order. Once a thread id is observed it keeps its place in the cycle even if the entry is later +//! updated or marked closed. + +use crate::multi_agents::AgentPickerThreadEntry; +use crate::multi_agents::format_agent_picker_item_name; +use crate::multi_agents::next_agent_shortcut; +use crate::multi_agents::previous_agent_shortcut; +use codex_protocol::ThreadId; +use ratatui::text::Span; +use std::collections::HashMap; + +/// Small state container for multi-agent picker ordering and labeling. +/// +/// `App` owns thread lifecycle and UI side effects. This type keeps the pure rules for stable +/// spawn-order traversal, picker copy, and active-agent labels together and separately testable. +/// +/// The core invariant is that `order` records first-seen thread ids exactly once, while `threads` +/// stores the latest metadata for those ids. Mutation is intentionally funneled through `upsert`, +/// `mark_closed`, and `clear` so those two collections do not drift semantically even if they are +/// temporarily out of sync during teardown races. +#[derive(Debug, Default)] +pub(crate) struct AgentNavigationState { + /// Latest picker metadata for each tracked thread id. + threads: HashMap, + /// Stable first-seen traversal order for picker rows and keyboard cycling. + order: Vec, +} + +/// Direction of keyboard traversal through the stable picker order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentNavigationDirection { + /// Move toward the entry that was seen earlier in spawn order, wrapping at the front. + Previous, + /// Move toward the entry that was seen later in spawn order, wrapping at the end. + Next, +} + +impl AgentNavigationState { + /// Returns the cached picker entry for a specific thread id. + /// + /// Callers use this when they already know which thread they care about and need the last + /// metadata captured for picker or footer rendering. If a caller assumes every tracked thread + /// must be present here, shutdown races can turn that assumption into a panic elsewhere, so + /// this stays optional. + pub(crate) fn get(&self, thread_id: &ThreadId) -> Option<&AgentPickerThreadEntry> { + self.threads.get(thread_id) + } + + /// Returns whether the picker cache currently knows about any threads. + /// + /// This is the cheapest way for `App` to decide whether opening the picker should show "No + /// agents available yet." rather than constructing picker rows from an empty state. + pub(crate) fn is_empty(&self) -> bool { + self.threads.is_empty() + } + + /// Inserts or updates a picker entry while preserving first-seen traversal order. + /// + /// The key invariant of this module is enforced here: a thread id is appended to `order` only + /// the first time it is seen. Later updates may change nickname, role, or closed state, but + /// they must not move the thread in the cycle or keyboard navigation would feel unstable. + pub(crate) fn upsert( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + is_closed: bool, + ) { + if !self.threads.contains_key(&thread_id) { + self.order.push(thread_id); + } + self.threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname, + agent_role, + is_closed, + }, + ); + } + + /// Marks a thread as closed without removing it from the traversal cache. + /// + /// Closed threads stay in the picker and in spawn order so users can still review them and so + /// next/previous navigation does not reshuffle around disappearing entries. If a caller "cleans + /// this up" by deleting the entry instead, wraparound navigation will silently change shape + /// mid-session. + pub(crate) fn mark_closed(&mut self, thread_id: ThreadId) { + if let Some(entry) = self.threads.get_mut(&thread_id) { + entry.is_closed = true; + } else { + self.upsert(thread_id, None, None, true); + } + } + + /// Drops all cached picker state. + /// + /// This is used when `App` tears down thread event state and needs the picker cache to return + /// to a pristine single-session state. + pub(crate) fn clear(&mut self) { + self.threads.clear(); + self.order.clear(); + } + + /// Returns whether there is at least one tracked thread other than the primary one. + /// + /// `App` uses this to decide whether the picker should be available even when the collaboration + /// feature flag is currently disabled, because already-existing sub-agent threads should remain + /// inspectable. + pub(crate) fn has_non_primary_thread(&self, primary_thread_id: Option) -> bool { + self.threads + .keys() + .any(|thread_id| Some(*thread_id) != primary_thread_id) + } + + /// Returns live picker rows in the same order users cycle through them. + /// + /// The `order` vector is intentionally historical and may briefly contain thread ids that no + /// longer have cached metadata, so this filters through the map instead of assuming both + /// collections are perfectly synchronized. + pub(crate) fn ordered_threads(&self) -> Vec<(ThreadId, &AgentPickerThreadEntry)> { + self.order + .iter() + .filter_map(|thread_id| self.threads.get(thread_id).map(|entry| (*thread_id, entry))) + .collect() + } + + /// Returns the adjacent thread id for keyboard navigation in stable spawn order. + /// + /// The caller must pass the thread whose transcript is actually being shown to the user, not + /// just whichever thread bookkeeping most recently marked active. If the wrong current thread + /// is supplied, next/previous navigation will jump in a way that feels nondeterministic even + /// though the cache itself is correct. + pub(crate) fn adjacent_thread_id( + &self, + current_displayed_thread_id: Option, + direction: AgentNavigationDirection, + ) -> Option { + let ordered_threads = self.ordered_threads(); + if ordered_threads.len() < 2 { + return None; + } + + let current_thread_id = current_displayed_thread_id?; + let current_idx = ordered_threads + .iter() + .position(|(thread_id, _)| *thread_id == current_thread_id)?; + let next_idx = match direction { + AgentNavigationDirection::Next => (current_idx + 1) % ordered_threads.len(), + AgentNavigationDirection::Previous => { + if current_idx == 0 { + ordered_threads.len() - 1 + } else { + current_idx - 1 + } + } + }; + Some(ordered_threads[next_idx].0) + } + + /// Derives the contextual footer label for the currently displayed thread. + /// + /// This intentionally returns `None` until there is more than one tracked thread so + /// single-thread sessions do not waste footer space restating the obvious. When metadata for + /// the displayed thread is missing, the label falls back to the same generic naming rules used + /// by the picker. + pub(crate) fn active_agent_label( + &self, + current_displayed_thread_id: Option, + primary_thread_id: Option, + ) -> Option { + if self.threads.len() <= 1 { + return None; + } + + let thread_id = current_displayed_thread_id?; + let is_primary = primary_thread_id == Some(thread_id); + Some( + self.threads + .get(&thread_id) + .map(|entry| { + format_agent_picker_item_name( + entry.agent_nickname.as_deref(), + entry.agent_role.as_deref(), + is_primary, + ) + }) + .unwrap_or_else(|| format_agent_picker_item_name(None, None, is_primary)), + ) + } + + /// Builds the `/agent` picker subtitle from the same canonical bindings used by key handling. + /// + /// Keeping this text derived from the actual shortcut helpers prevents the picker copy from + /// drifting if the bindings ever change on one platform. + pub(crate) fn picker_subtitle() -> String { + let previous: Span<'static> = previous_agent_shortcut().into(); + let next: Span<'static> = next_agent_shortcut().into(); + format!( + "Select an agent to watch. {} previous, {} next.", + previous.content, next.content + ) + } + + #[cfg(test)] + /// Returns only the ordered thread ids for focused tests of traversal invariants. + /// + /// This helper exists so tests can assert on ordering without embedding the full picker entry + /// payload in every expectation. + pub(crate) fn ordered_thread_ids(&self) -> Vec { + self.ordered_threads() + .into_iter() + .map(|(thread_id, _)| thread_id) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn populated_state() -> (AgentNavigationState, ThreadId, ThreadId, ThreadId) { + let mut state = AgentNavigationState::default(); + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); + let first_agent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread"); + let second_agent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread"); + + state.upsert(main_thread_id, None, None, false); + state.upsert( + first_agent_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + state.upsert( + second_agent_id, + Some("Bob".to_string()), + Some("worker".to_string()), + false, + ); + + (state, main_thread_id, first_agent_id, second_agent_id) + } + + #[test] + fn upsert_preserves_first_seen_order() { + let (mut state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); + + state.upsert( + first_agent_id, + Some("Robie".to_string()), + Some("worker".to_string()), + true, + ); + + assert_eq!( + state.ordered_thread_ids(), + vec![main_thread_id, first_agent_id, second_agent_id] + ); + } + + #[test] + fn adjacent_thread_id_wraps_in_spawn_order() { + let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); + + assert_eq!( + state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Next), + Some(main_thread_id) + ); + assert_eq!( + state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Previous), + Some(first_agent_id) + ); + assert_eq!( + state.adjacent_thread_id(Some(main_thread_id), AgentNavigationDirection::Previous), + Some(second_agent_id) + ); + } + + #[test] + fn picker_subtitle_mentions_shortcuts() { + let previous: Span<'static> = previous_agent_shortcut().into(); + let next: Span<'static> = next_agent_shortcut().into(); + let subtitle = AgentNavigationState::picker_subtitle(); + + assert!(subtitle.contains(previous.content.as_ref())); + assert!(subtitle.contains(next.content.as_ref())); + } + + #[test] + fn active_agent_label_tracks_current_thread() { + let (state, main_thread_id, first_agent_id, _) = populated_state(); + + assert_eq!( + state.active_agent_label(Some(first_agent_id), Some(main_thread_id)), + Some("Robie [explorer]".to_string()) + ); + assert_eq!( + state.active_agent_label(Some(main_thread_id), Some(main_thread_id)), + Some("Main [default]".to_string()) + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ba16a52b5..f314c5e47 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -166,6 +166,7 @@ use super::footer::footer_hint_items_width; use super::footer::footer_line_width; use super::footer::inset_footer_hint_area; use super::footer::max_left_width_for_right; +use super::footer::passive_footer_status_line; use super::footer::render_context_right; use super::footer::render_footer_from_props; use super::footer::render_footer_hint_items; @@ -173,6 +174,7 @@ use super::footer::render_footer_line; use super::footer::reset_mode_after_activity; use super::footer::single_line_footer_layout; use super::footer::toggle_shortcut_mode; +use super::footer::uses_passive_footer_status_layout; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use super::skill_popup::MentionItem; @@ -408,6 +410,8 @@ pub(crate) struct ChatComposer { windows_degraded_sandbox_active: bool, status_line_value: Option>, status_line_enabled: bool, + // Agent label injected into the footer's contextual row when multi-agent mode is active. + active_agent_label: Option, } #[derive(Clone, Debug)] @@ -528,6 +532,7 @@ impl ChatComposer { windows_degraded_sandbox_active: false, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -3189,6 +3194,7 @@ impl ChatComposer { context_window_used_tokens: self.context_window_used_tokens, status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, + active_agent_label: self.active_agent_label.clone(), } } @@ -3760,6 +3766,19 @@ impl ChatComposer { self.status_line_enabled = enabled; true } + + /// Replaces the contextual footer label for the currently viewed agent. + /// + /// Returning `false` means the value was unchanged, so callers can skip redraw work. This + /// field is intentionally just cached presentation state; `ChatComposer` does not infer which + /// thread is active on its own. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) -> bool { + if self.active_agent_label == active_agent_label { + return false; + } + self.active_agent_label = active_agent_label; + true + } } #[cfg(not(target_os = "linux"))] @@ -4193,26 +4212,19 @@ impl ChatComposer { }; let available_width = hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; - let status_line = footer_props - .status_line_value - .as_ref() - .map(|line| line.clone().dim()); - let status_line_candidate = footer_props.status_line_enabled - && match footer_props.mode { - FooterMode::ComposerEmpty => true, - FooterMode::ComposerHasDraft => !footer_props.is_task_running, - FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - | FooterMode::EscHint => false, - }; - let mut truncated_status_line = if status_line_candidate { - status_line.as_ref().map(|line| { + let status_line_active = uses_passive_footer_status_layout(&footer_props); + let combined_status_line = if status_line_active { + passive_footer_status_line(&footer_props).map(ratatui::prelude::Stylize::dim) + } else { + None + }; + let mut truncated_status_line = if status_line_active { + combined_status_line.as_ref().map(|line| { truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) }) } else { None }; - let status_line_active = status_line_candidate && truncated_status_line.is_some(); let left_mode_indicator = if status_line_active { None } else { @@ -4259,7 +4271,7 @@ impl ChatComposer { if status_line_active && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) && left_width > max_left - && let Some(line) = status_line.as_ref().map(|line| { + && let Some(line) = combined_status_line.as_ref().map(|line| { truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) }) { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2ad23272e..1e4d5459c 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -9,6 +9,15 @@ //! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is //! otherwise idle. //! +//! Terminology used in this module: +//! - "status line" means the configurable contextual row built from `/statusline` items such as +//! model, git branch, and context usage. +//! - "instructional footer" means a row that tells the user what to do next, such as quit +//! confirmation, shortcut help, or queue hints. +//! - "contextual footer" means the footer is free to show ambient context instead of an +//! instruction. In that state, the footer may render the configured status line, the active +//! agent label, or both combined. +//! //! Single-line collapse overview: //! 1. The composer decides the current `FooterMode` and hint flags, then calls //! `single_line_footer_layout` for the base single-line modes. @@ -69,6 +78,12 @@ pub(crate) struct FooterProps { pub(crate) context_window_used_tokens: Option, pub(crate) status_line_value: Option>, pub(crate) status_line_enabled: bool, + /// Active thread label shown when the footer is rendering contextual information instead of an + /// instructional hint. + /// + /// When both this label and the configured status line are available, they are rendered on the + /// same row separated by ` · `. + pub(crate) active_agent_label: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -562,20 +577,10 @@ fn footer_from_props_lines( show_shortcuts_hint: bool, show_queue_hint: bool, ) -> Vec> { - // If status line content is present, show it for passive composer states. - // Active draft states still prefer the queue hint over the passive status - // line so the footer stays actionable while a task is running. - if props.status_line_enabled - && let Some(status_line) = &props.status_line_value - && match props.mode { - FooterMode::ComposerEmpty => true, - FooterMode::ComposerHasDraft => !props.is_task_running, - FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - | FooterMode::EscHint => false, - } - { - return vec![status_line.clone().dim()]; + // Passive footer context can come from the configurable status line, the + // active agent label, or both combined. + if let Some(status_line) = passive_footer_status_line(props) { + return vec![status_line.dim()]; } match props.mode { FooterMode::QuitShortcutReminder => { @@ -618,6 +623,57 @@ fn footer_from_props_lines( } } +/// Returns the contextual footer row when the footer is not busy showing an instructional hint. +/// +/// The returned line may contain the configured status line, the currently viewed agent label, or +/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue +/// prompts deliberately return `None` so those call-to-action hints stay visible. +pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option> { + if !shows_passive_footer_line(props) { + return None; + } + + let mut line = if props.status_line_enabled { + props.status_line_value.clone() + } else { + None + }; + + if let Some(active_agent_label) = props.active_agent_label.as_ref() { + if let Some(existing) = line.as_mut() { + existing.spans.push(" · ".into()); + existing.spans.push(active_agent_label.clone().into()); + } else { + line = Some(Line::from(active_agent_label.clone())); + } + } + + line +} + +/// Whether the current footer mode allows contextual information to replace instructional hints. +/// +/// In practice this means the composer is idle, or it has a draft but is not currently running a +/// task, so the footer can spend the row on ambient context instead of "what to do next" text. +pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool { + match props.mode { + FooterMode::ComposerEmpty => true, + FooterMode::ComposerHasDraft => !props.is_task_running, + FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => { + false + } + } +} + +/// Whether callers should reserve the dedicated status-line layout for a contextual footer row. +/// +/// The dedicated layout exists for the configurable `/statusline` row. An agent label by itself +/// can be rendered by the standard footer flow, so this only becomes `true` when the status line +/// feature is enabled and the current mode allows contextual footer content. +pub(crate) fn uses_passive_footer_status_layout(props: &FooterProps) -> bool { + props.status_line_enabled && shows_passive_footer_line(props) +} + pub(crate) fn footer_line_width( props: &FooterProps, collaboration_mode_indicator: Option, @@ -1032,14 +1088,12 @@ mod tests { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - let status_line_active = props.status_line_enabled - && match props.mode { - FooterMode::ComposerEmpty => true, - FooterMode::ComposerHasDraft => !props.is_task_running, - FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - | FooterMode::EscHint => false, - }; + let status_line_active = uses_passive_footer_status_layout(props); + let passive_status_line = if status_line_active { + passive_footer_status_line(props) + } else { + None + }; let left_mode_indicator = if status_line_active { None } else { @@ -1051,8 +1105,7 @@ mod tests { props.mode, FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft ) { - props - .status_line_value + passive_status_line .as_ref() .map(|line| line.clone().dim()) .map(|line| truncate_line_with_ellipsis_if_overflow(line, available_width)) @@ -1095,8 +1148,7 @@ mod tests { if status_line_active && let Some(max_left) = max_left_width_for_right(area, right_width) && left_width > max_left - && let Some(line) = props - .status_line_value + && let Some(line) = passive_status_line .as_ref() .map(|line| line.clone().dim()) .map(|line| { @@ -1213,6 +1265,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1230,6 +1283,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1247,6 +1301,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1264,6 +1319,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1281,6 +1337,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1298,6 +1355,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1315,6 +1373,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1332,6 +1391,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1349,6 +1409,7 @@ mod tests { context_window_used_tokens: Some(123_456), status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1366,6 +1427,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }, ); @@ -1381,6 +1443,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }; snapshot_footer_with_mode_indicator( @@ -1409,6 +1472,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }; snapshot_footer_with_mode_indicator( @@ -1430,6 +1494,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + active_agent_label: None, }; snapshot_footer("footer_status_line_overrides_shortcuts", props); @@ -1446,6 +1511,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + active_agent_label: None, }; snapshot_footer("footer_status_line_yields_to_queue_hint", props); @@ -1462,6 +1528,7 @@ mod tests { context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + active_agent_label: None, }; snapshot_footer("footer_status_line_overrides_draft_idle", props); @@ -1478,6 +1545,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, // command timed out / empty status_line_enabled: true, + active_agent_label: None, }; snapshot_footer_with_mode_indicator( @@ -1499,6 +1567,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, + active_agent_label: None, }; snapshot_footer_with_mode_indicator( @@ -1520,6 +1589,7 @@ mod tests { context_window_used_tokens: None, status_line_value: None, status_line_enabled: true, + active_agent_label: None, }; // has status line and no collaboration mode @@ -1544,6 +1614,7 @@ mod tests { "Status line content that should truncate before the mode indicator".to_string(), )), status_line_enabled: true, + active_agent_label: None, }; snapshot_footer_with_mode_indicator( @@ -1552,6 +1623,40 @@ mod tests { &props, Some(CollaborationModeIndicator::Plan), ); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + status_line_value: None, + status_line_enabled: false, + active_agent_label: Some("Robie [explorer]".to_string()), + }; + + snapshot_footer("footer_active_agent_label", props); + + let props = FooterProps { + mode: FooterMode::ComposerEmpty, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + collaboration_modes_enabled: false, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + context_window_percent: None, + context_window_used_tokens: None, + status_line_value: Some(Line::from("Status line content".to_string())), + status_line_enabled: true, + active_agent_label: Some("Robie [explorer]".to_string()), + }; + + snapshot_footer("footer_status_line_with_active_agent_label", props); } #[test] @@ -1571,6 +1676,7 @@ mod tests { .to_string(), )), status_line_enabled: true, + active_agent_label: None, }; let screen = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 913b466bc..f9c34222f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1125,6 +1125,16 @@ impl BottomPane { self.request_redraw(); } } + + /// Updates the contextual footer label and requests a redraw only when it changed. + /// + /// This keeps the footer plumbing cheap during thread transitions where `App` may recompute + /// the label several times while the visible thread settles. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { + if self.composer.set_active_agent_label(active_agent_label) { + self.request_redraw(); + } + } } #[cfg(not(target_os = "linux"))] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap new file mode 100644 index 000000000..c70085026 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 1207 +expression: terminal.backend() +--- +" Robie [explorer] 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap new file mode 100644 index 000000000..3c05f9b60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 1210 +expression: terminal.backend() +--- +" Status line content · Robie [explorer] " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cd926590a..7f3831a40 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1084,6 +1084,14 @@ impl ChatWidget { self.bottom_pane.set_status_line(status_line); } + /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. + /// + /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the + /// user actually looking at?" and the footer stack remains a pure renderer of that decision. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { + self.bottom_pane.set_active_agent_label(active_agent_label); + } + /// Recomputes footer status-line content from config and current runtime state. /// /// This method is the status-line orchestrator: it parses configured item identifiers, @@ -3920,6 +3928,10 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn no_modal_or_popup_active(&self) -> bool { + self.bottom_pane.no_modal_or_popup_active() + } + pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 3a90f7741..c68bf1970 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -1,3 +1,9 @@ +//! Helpers for rendering and navigating multi-agent state in the TUI. +//! +//! This module owns the shared presentation contracts for multi-agent history rows, `/agent` picker +//! entries, and the fast-switch keyboard shortcuts. Higher-level coordination, such as deciding +//! which thread becomes active or when a thread closes, stays in [`crate::app::App`]. + use crate::history_cell::PlainHistoryCell; use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; @@ -13,6 +19,12 @@ use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +#[cfg(target_os = "macos")] +use crossterm::event::KeyEventKind; +#[cfg(target_os = "macos")] +use crossterm::event::KeyModifiers; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -25,8 +37,11 @@ const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AgentPickerThreadEntry { + /// Human-friendly nickname shown in picker rows and footer labels. pub(crate) agent_nickname: Option, + /// Agent type shown in brackets when present, for example `worker`. pub(crate) agent_role: Option, + /// Whether the thread has emitted a close event and should render dimmed. pub(crate) is_closed: bool, } @@ -73,12 +88,83 @@ pub(crate) fn format_agent_picker_item_name( } } -pub(crate) fn sort_agent_picker_threads(agent_threads: &mut [(ThreadId, AgentPickerThreadEntry)]) { - agent_threads.sort_by(|(left_id, left), (right_id, right)| { - left.is_closed - .cmp(&right.is_closed) - .then_with(|| left_id.to_string().cmp(&right_id.to_string())) - }); +pub(crate) fn previous_agent_shortcut() -> crate::key_hint::KeyBinding { + crate::key_hint::alt(KeyCode::Left) +} + +pub(crate) fn next_agent_shortcut() -> crate::key_hint::KeyBinding { + crate::key_hint::alt(KeyCode::Right) +} + +/// Matches the canonical "previous agent" binding plus platform-specific fallbacks that keep agent +/// navigation working when enhanced key reporting is unavailable. +pub(crate) fn previous_agent_shortcut_matches( + key_event: KeyEvent, + allow_word_motion_fallback: bool, +) -> bool { + previous_agent_shortcut().is_press(key_event) + || previous_agent_word_motion_fallback(key_event, allow_word_motion_fallback) +} + +/// Matches the canonical "next agent" binding plus platform-specific fallbacks that keep agent +/// navigation working when enhanced key reporting is unavailable. +pub(crate) fn next_agent_shortcut_matches( + key_event: KeyEvent, + allow_word_motion_fallback: bool, +) -> bool { + next_agent_shortcut().is_press(key_event) + || next_agent_word_motion_fallback(key_event, allow_word_motion_fallback) +} + +#[cfg(target_os = "macos")] +fn previous_agent_word_motion_fallback( + key_event: KeyEvent, + allow_word_motion_fallback: bool, +) -> bool { + // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events + // unless enhanced keyboard reporting is enabled. + allow_word_motion_fallback + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) +} + +#[cfg(not(target_os = "macos"))] +fn previous_agent_word_motion_fallback( + _key_event: KeyEvent, + _allow_word_motion_fallback: bool, +) -> bool { + false +} + +#[cfg(target_os = "macos")] +fn next_agent_word_motion_fallback(key_event: KeyEvent, allow_word_motion_fallback: bool) -> bool { + // macOS terminals often send Option+b/f as word-motion keys instead of Option+arrow events + // unless enhanced keyboard reporting is enabled. + allow_word_motion_fallback + && matches!( + key_event, + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) +} + +#[cfg(not(target_os = "macos"))] +fn next_agent_word_motion_fallback( + _key_event: KeyEvent, + _allow_word_motion_fallback: bool, +) -> bool { + false } pub(crate) fn spawn_end( @@ -485,6 +571,10 @@ fn status_summary_spans(status: &AgentStatus) -> Vec> { mod tests { use super::*; use crate::history_cell::HistoryCell; + #[cfg(target_os = "macos")] + use crossterm::event::KeyEvent; + #[cfg(target_os = "macos")] + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::style::Color; @@ -579,6 +669,27 @@ mod tests { assert_snapshot!("collab_agent_transcript", snapshot); } + #[cfg(target_os = "macos")] + #[test] + fn agent_shortcut_matches_option_arrow_word_motion_fallbacks() { + assert!(previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + true, + )); + assert!(next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + true, + )); + assert!(!previous_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + false, + )); + assert!(!next_agent_shortcut_matches( + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + false, + )); + } + #[test] fn title_styles_nickname_and_role() { let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")