mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add keyboard based fast switching between agents in TUI (#13923)
This commit is contained in:
committed by
Michael Bolin
Unverified
parent
12ee9eb6e0
commit
180a5820fc
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 1207
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Robie [explorer] 100% context left "
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 1210
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Status line content · Robie [explorer] "
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user