Add keyboard based fast switching between agents in TUI (#13923)

This commit is contained in:
gabec-openai
2026-03-10 19:41:51 -07:00
committed by Michael Bolin
Unverified
parent 12ee9eb6e0
commit 180a5820fc
10 changed files with 752 additions and 102 deletions
+11
View File
@@ -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:
+106 -54
View File
@@ -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<ThreadId, ThreadEventChannel>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
agent_picker_threads: HashMap<ThreadId, AgentPickerThreadEntry>,
agent_navigation: AgentNavigationState,
active_thread_id: Option<ThreadId>,
active_thread_rx: Option<mpsc::Receiver<Event>>,
primary_thread_id: Option<ThreadId>,
@@ -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<ThreadId> {
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<PathBuf> {
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<ThreadId> = 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<SelectionItem> = agent_threads
let items: Vec<SelectionItem> = 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<String>,
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,
+324
View File
@@ -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<ThreadId, AgentPickerThreadEntry>,
/// Stable first-seen traversal order for picker rows and keyboard cycling.
order: Vec<ThreadId>,
}
/// 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<String>,
agent_role: Option<String>,
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<ThreadId>) -> 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<ThreadId>,
direction: AgentNavigationDirection,
) -> Option<ThreadId> {
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<ThreadId>,
primary_thread_id: Option<ThreadId>,
) -> Option<String> {
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<ThreadId> {
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())
);
}
}
+28 -16
View File
@@ -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<Line<'static>>,
status_line_enabled: bool,
// Agent label injected into the footer's contextual row when multi-agent mode is active.
active_agent_label: Option<String>,
}
#[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<String>) -> 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)
})
{
+132 -26
View File
@@ -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<i64>,
pub(crate) status_line_value: Option<Line<'static>>,
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<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -562,20 +577,10 @@ fn footer_from_props_lines(
show_shortcuts_hint: bool,
show_queue_hint: bool,
) -> Vec<Line<'static>> {
// 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<Line<'static>> {
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<CollaborationModeIndicator>,
@@ -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 =
+10
View File
@@ -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<String>) {
if self.composer.set_active_agent_label(active_agent_label) {
self.request_redraw();
}
}
}
#[cfg(not(target_os = "linux"))]
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1207
expression: terminal.backend()
---
" Robie [explorer] 100% context left "
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1210
expression: terminal.backend()
---
" Status line content · Robie [explorer] "
+12
View File
@@ -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<String>) {
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()
}
+117 -6
View File
@@ -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<String>,
/// Agent type shown in brackets when present, for example `worker`.
pub(crate) agent_role: Option<String>,
/// 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<Span<'static>> {
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")