mirror of
https://github.com/openai/codex.git
synced 2026-11-14 06:48:26 +00:00
Add keyboard based fast switching between agents in TUI (#13923)
This commit is contained in:
1 parent
12ee9eb6e0
commit
180a5820fc
10 files changed
+752
-102
No files matched your search
+106
-54
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user