use crate::tui_internal::multi_agents::AgentPickerThreadEntry;
use crate::tui_internal::multi_agents::SubAgentActivityDisplay;
use crate::tui_internal::multi_agents::format_agent_picker_item_name;
use crate::tui_internal::multi_agents::next_agent_shortcut;
use crate::tui_internal::multi_agents::previous_agent_shortcut;
use lemurclaw_core::protocol::ThreadId;
use ratatui::text::Span;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, Default)]
pub(crate) struct AgentNavigationState {
threads: HashMap<ThreadId, AgentPickerThreadEntry>,
order: Vec<ThreadId>,
stopped_threads: HashSet<ThreadId>,
parent_owned_threads: HashSet<ThreadId>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AgentNavigationDirection {
Previous,
Next,
}
impl AgentNavigationState {
pub(crate) fn get(&self, thread_id: &ThreadId) -> Option<&AgentPickerThreadEntry> {
self.threads.get(thread_id)
}
pub(crate) fn is_parent_owned(&self, thread_id: ThreadId) -> bool {
self.parent_owned_threads.contains(&thread_id)
}
pub(crate) fn mark_parent_owned(&mut self, thread_id: ThreadId) {
self.parent_owned_threads.insert(thread_id);
}
pub(crate) fn is_empty(&self) -> bool {
self.threads.is_empty()
}
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);
}
let (previous_agent_path, previous_is_running) = self
.threads
.get(&thread_id)
.map(|entry| (entry.agent_path.clone(), entry.is_running))
.unwrap_or((None, false));
self.threads.insert(
thread_id,
AgentPickerThreadEntry {
agent_nickname,
agent_role,
agent_path: previous_agent_path,
is_running: previous_is_running && !is_closed,
is_closed,
},
);
}
pub(crate) fn record_sub_agent_activity(&mut self, activity: SubAgentActivityDisplay) {
if !self.threads.contains_key(&activity.thread_id) {
self.order.push(activity.thread_id);
}
let entry =
self.threads
.entry(activity.thread_id)
.or_insert_with(|| AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: None,
is_running: false,
is_closed: false,
});
entry.agent_path = Some(activity.agent_path);
if activity.is_running_hint
&& !entry.is_closed
&& !self.stopped_threads.contains(&activity.thread_id)
{
entry.is_running = true;
} else {
entry.is_running = false;
self.stopped_threads.insert(activity.thread_id);
}
}
pub(crate) fn mark_running(&mut self, thread_id: ThreadId) {
if self
.threads
.get(&thread_id)
.is_some_and(|entry| entry.is_closed)
{
return;
}
self.stopped_threads.remove(&thread_id);
self.set_running(thread_id, true);
}
pub(crate) fn mark_stopped(&mut self, thread_id: ThreadId) {
self.stopped_threads.insert(thread_id);
self.set_running(thread_id, false);
}
pub(crate) fn set_running(&mut self, thread_id: ThreadId, is_running: bool) {
if let Some(entry) = self.threads.get_mut(&thread_id) {
entry.is_running = is_running;
}
}
pub(crate) fn set_agent_path(&mut self, thread_id: ThreadId, agent_path: Option<String>) {
if let Some(agent_path) = agent_path
&& let Some(entry) = self.threads.get_mut(&thread_id)
{
entry.agent_path = Some(agent_path);
}
}
pub(crate) fn mark_closed(&mut self, thread_id: ThreadId) {
if let Some(entry) = self.threads.get_mut(&thread_id) {
entry.is_closed = true;
entry.is_running = false;
} else {
self.upsert(
thread_id, None, None,
true,
);
}
}
pub(crate) fn clear(&mut self) {
self.threads.clear();
self.order.clear();
self.stopped_threads.clear();
self.parent_owned_threads.clear();
}
pub(crate) fn remove(&mut self, thread_id: ThreadId) {
self.threads.remove(&thread_id);
self.order.retain(|candidate| *candidate != thread_id);
self.stopped_threads.remove(&thread_id);
self.parent_owned_threads.remove(&thread_id);
}
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)
}
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()
}
pub(crate) fn ordered_path_backed_subagent_threads(
&self,
primary_thread_id: Option<ThreadId>,
) -> Vec<(ThreadId, &AgentPickerThreadEntry)> {
self.ordered_threads()
.into_iter()
.filter(|(thread_id, entry)| {
Some(*thread_id) != primary_thread_id
&& entry
.agent_path
.as_deref()
.is_some_and(|agent_path| !agent_path.trim().is_empty())
})
.collect()
}
pub(crate) fn tracked_thread_ids(&self) -> Vec<ThreadId> {
self.ordered_threads()
.into_iter()
.map(|(thread_id, _)| thread_id)
.collect()
}
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)
}
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| {
if !is_primary
&& let Some(agent_path) = entry
.agent_path
.as_deref()
.filter(|agent_path| !agent_path.trim().is_empty())
{
return format!("`{agent_path}`");
}
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,
)
}),
)
}
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)]
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 parent_owned_state_is_removed_with_thread_metadata() {
let (mut state, _main_thread_id, first_agent_id, second_agent_id) = populated_state();
state.mark_parent_owned(first_agent_id);
assert!(state.is_parent_owned(first_agent_id));
state.remove(first_agent_id);
assert!(!state.is_parent_owned(first_agent_id));
state.mark_parent_owned(second_agent_id);
state.clear();
assert!(!state.is_parent_owned(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())
);
}
}