mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fae2709320
## Why Multi-agent v2 identifies agents by canonical paths, but its tool handlers still emitted the larger legacy collaboration begin/end events built around nickname and role metadata. App-server, rollout-trace, analytics, and TUI consumers therefore lacked one compact path-based completion signal that behaved consistently across live events and replay. The TUI also needs a bounded `/agent` status surface for v2 agents. It should use recent local activity for previews, refresh liveness without loading full histories, and keep the legacy picker available when no path-backed v2 agent is known. ## What changed - Replace the v2 `spawn_agent`, `send_message`, `followup_task`, and `interrupt_agent` legacy lifecycle emissions with a success-only `SubAgentActivity` event. The event records the tool call ID, occurrence time, affected thread, canonical agent path, and `started`, `interacted`, or `interrupted` kind. - Expose the activity as a completion-only app-server v2 `subAgentActivity` thread item in live notifications and reconstructed history, regenerate the protocol schemas, and count it in sub-agent tool analytics. - Track canonical paths from live activity and loaded-thread metadata in the TUI, and render the activity in live and replayed transcripts. - Make `/agent` list running path-backed agents with summaries from bounded local event buffers. Each summary is capped at 240 graphemes, the scan is capped at six recent items, only the last three wrapped lines are shown, and command output is omitted. Liveness falls back to metadata-only `thread/read` when local turn state is unavailable. - Persist the activity as a terminal rollout-trace runtime payload and reduce it to the corresponding spawn, send, follow-up, or close interaction edge. `interrupt_agent` is classified as a close-edge operation. - Preserve the legacy picker when no path-backed v2 agent is known. ## Compatibility App-server v2 clients that consumed `collabAgentToolCall` begin/end pairs for these tools must handle the new completion-only `subAgentActivity` item. Legacy v1 collaboration behavior is unchanged. ## Screenshot <img width="684" height="288" alt="Screenshot 2026-06-08 at 15 40 47" src="https://github.com/user-attachments/assets/194b3cd0-619d-45fb-b587-cf3e2b1b8a1d" /> ## Testing - `just test -p codex-app-server-protocol` - `just test -p codex-rollout-trace` - Added focused coverage for activity analytics, terminal trace serialization, spawn-edge reduction, `interrupt_agent` classification, TUI status rendering without aggregated command output, and clearing stale running state after a completed turn.
954 lines
33 KiB
Rust
954 lines
33 KiB
Rust
//! 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;
|
|
use codex_app_server_protocol::CollabAgentState;
|
|
use codex_app_server_protocol::CollabAgentStatus;
|
|
use codex_app_server_protocol::CollabAgentTool;
|
|
use codex_app_server_protocol::CollabAgentToolCallStatus;
|
|
use codex_app_server_protocol::SubAgentActivityKind;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
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;
|
|
use std::collections::HashSet;
|
|
|
|
const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
|
|
const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160;
|
|
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>,
|
|
/// Canonical v2 agent path, when the thread was observed through v2 activity.
|
|
pub(crate) agent_path: Option<String>,
|
|
/// Whether the latest liveness refresh says the agent thread is actively working.
|
|
pub(crate) is_running: bool,
|
|
/// Whether the thread has emitted a close event and should render dimmed.
|
|
pub(crate) is_closed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct SubAgentActivityDisplay {
|
|
pub(crate) thread_id: ThreadId,
|
|
pub(crate) agent_path: String,
|
|
pub(crate) is_running_hint: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub(crate) struct AgentMetadata {
|
|
/// Human-friendly nickname shown in rendered tool-call rows.
|
|
pub(crate) agent_nickname: Option<String>,
|
|
/// Agent type shown in brackets when present, for example `worker`.
|
|
pub(crate) agent_role: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct AgentLabel<'a> {
|
|
thread_id: Option<ThreadId>,
|
|
nickname: Option<&'a str>,
|
|
role: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct SpawnRequestSummary {
|
|
pub(crate) model: String,
|
|
pub(crate) reasoning_effort: ReasoningEffortConfig,
|
|
}
|
|
|
|
pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec<Span<'static>> {
|
|
let dot = if is_closed {
|
|
"•".into()
|
|
} else {
|
|
"•".green()
|
|
};
|
|
vec![dot, " ".into()]
|
|
}
|
|
|
|
pub(crate) fn format_agent_picker_item_name(
|
|
agent_nickname: Option<&str>,
|
|
agent_role: Option<&str>,
|
|
is_primary: bool,
|
|
) -> String {
|
|
if is_primary {
|
|
return "Main [default]".to_string();
|
|
}
|
|
|
|
let agent_nickname = agent_nickname
|
|
.map(str::trim)
|
|
.filter(|nickname| !nickname.is_empty());
|
|
let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty());
|
|
match (agent_nickname, agent_role) {
|
|
(Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"),
|
|
(Some(agent_nickname), None) => agent_nickname.to_string(),
|
|
(None, Some(agent_role)) => format!("[{agent_role}]"),
|
|
(None, None) => "Agent".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 {
|
|
// Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of
|
|
// Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only
|
|
// enable this fallback when the composer is empty so draft editing retains the expected
|
|
// word-wise motion behavior.
|
|
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 {
|
|
// Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of
|
|
// Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only
|
|
// enable this fallback when the composer is empty so draft editing retains the expected
|
|
// word-wise motion behavior.
|
|
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_request_summary(item: &ThreadItem) -> Option<SpawnRequestSummary> {
|
|
match item {
|
|
ThreadItem::CollabAgentToolCall {
|
|
tool: CollabAgentTool::SpawnAgent,
|
|
model: Some(model),
|
|
reasoning_effort: Some(reasoning_effort),
|
|
..
|
|
} => Some(SpawnRequestSummary {
|
|
model: model.clone(),
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn tool_call_history_cell(
|
|
item: &ThreadItem,
|
|
cached_spawn_request: Option<&SpawnRequestSummary>,
|
|
mut agent_metadata: impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> Option<PlainHistoryCell> {
|
|
let ThreadItem::CollabAgentToolCall {
|
|
tool,
|
|
status,
|
|
receiver_thread_ids,
|
|
prompt,
|
|
agents_states,
|
|
..
|
|
} = item
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
let first_receiver = receiver_thread_ids
|
|
.first()
|
|
.and_then(|id| parse_thread_id(id));
|
|
let prompt = prompt.as_deref().unwrap_or_default();
|
|
|
|
match tool {
|
|
CollabAgentTool::SpawnAgent => {
|
|
if matches!(status, CollabAgentToolCallStatus::InProgress) {
|
|
return None;
|
|
}
|
|
let fallback_spawn_request = spawn_request_summary(item);
|
|
let spawn_request = cached_spawn_request.or(fallback_spawn_request.as_ref());
|
|
Some(spawn_end(
|
|
first_receiver,
|
|
prompt,
|
|
spawn_request,
|
|
&mut agent_metadata,
|
|
))
|
|
}
|
|
CollabAgentTool::SendInput => {
|
|
if matches!(status, CollabAgentToolCallStatus::InProgress) {
|
|
return None;
|
|
}
|
|
first_receiver.map(|receiver_thread_id| {
|
|
interaction_end(receiver_thread_id, prompt, &mut agent_metadata)
|
|
})
|
|
}
|
|
CollabAgentTool::ResumeAgent => first_receiver.map(|receiver_thread_id| {
|
|
if matches!(status, CollabAgentToolCallStatus::InProgress) {
|
|
resume_begin(receiver_thread_id, &mut agent_metadata)
|
|
} else {
|
|
let state = first_agent_state(receiver_thread_ids, agents_states);
|
|
resume_end(
|
|
receiver_thread_id,
|
|
state,
|
|
"Agent resume failed",
|
|
&mut agent_metadata,
|
|
)
|
|
}
|
|
}),
|
|
CollabAgentTool::Wait => {
|
|
if matches!(status, CollabAgentToolCallStatus::InProgress) {
|
|
Some(waiting_begin(receiver_thread_ids, &mut agent_metadata))
|
|
} else {
|
|
Some(waiting_end(
|
|
receiver_thread_ids,
|
|
agents_states,
|
|
&mut agent_metadata,
|
|
))
|
|
}
|
|
}
|
|
CollabAgentTool::CloseAgent => {
|
|
if matches!(status, CollabAgentToolCallStatus::InProgress) {
|
|
return None;
|
|
}
|
|
first_receiver
|
|
.map(|receiver_thread_id| close_end(receiver_thread_id, &mut agent_metadata))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sub_agent_activity_display(item: &ThreadItem) -> Option<SubAgentActivityDisplay> {
|
|
let ThreadItem::SubAgentActivity {
|
|
kind,
|
|
agent_thread_id,
|
|
agent_path,
|
|
..
|
|
} = item
|
|
else {
|
|
return None;
|
|
};
|
|
Some(SubAgentActivityDisplay {
|
|
thread_id: parse_thread_id(agent_thread_id)?,
|
|
agent_path: agent_path.clone(),
|
|
is_running_hint: !matches!(kind, SubAgentActivityKind::Interrupted),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn sub_agent_activity_history_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
|
let ThreadItem::SubAgentActivity {
|
|
kind, agent_path, ..
|
|
} = item
|
|
else {
|
|
return None;
|
|
};
|
|
Some(collab_event(
|
|
sub_agent_activity_title(*kind, agent_path),
|
|
Vec::new(),
|
|
))
|
|
}
|
|
|
|
pub(crate) fn sub_agent_activity_summary(kind: SubAgentActivityKind, agent_path: &str) -> String {
|
|
match kind {
|
|
SubAgentActivityKind::Started => format!("Started `{agent_path}`"),
|
|
SubAgentActivityKind::Interacted => format!("Interacted with `{agent_path}`"),
|
|
SubAgentActivityKind::Interrupted => format!("Interrupted `{agent_path}`"),
|
|
}
|
|
}
|
|
|
|
fn sub_agent_activity_title(kind: SubAgentActivityKind, agent_path: &str) -> Line<'static> {
|
|
let (prefix, path) = match kind {
|
|
SubAgentActivityKind::Started => ("Started ", agent_path),
|
|
SubAgentActivityKind::Interacted => ("Interacted with ", agent_path),
|
|
SubAgentActivityKind::Interrupted => ("Interrupted ", agent_path),
|
|
};
|
|
title_spans_line(vec![
|
|
Span::from(prefix).bold(),
|
|
Span::from(format!("`{path}`")).cyan(),
|
|
])
|
|
}
|
|
|
|
fn spawn_end(
|
|
new_thread_id: Option<ThreadId>,
|
|
prompt: &str,
|
|
spawn_request: Option<&SpawnRequestSummary>,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
let title = match new_thread_id {
|
|
Some(thread_id) => title_with_agent(
|
|
"Spawned",
|
|
agent_label(thread_id, &agent_metadata(thread_id)),
|
|
spawn_request,
|
|
),
|
|
None => title_text("Agent spawn failed"),
|
|
};
|
|
|
|
let mut details = Vec::new();
|
|
if let Some(line) = prompt_line(prompt) {
|
|
details.push(line);
|
|
}
|
|
collab_event(title, details)
|
|
}
|
|
|
|
fn interaction_end(
|
|
receiver_thread_id: ThreadId,
|
|
prompt: &str,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
let title = title_with_agent(
|
|
"Sent input to",
|
|
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
|
|
/*spawn_request*/ None,
|
|
);
|
|
|
|
let mut details = Vec::new();
|
|
if let Some(line) = prompt_line(prompt) {
|
|
details.push(line);
|
|
}
|
|
collab_event(title, details)
|
|
}
|
|
|
|
fn waiting_begin(
|
|
receiver_thread_ids: &[String],
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
let receiver_agents = receiver_thread_ids
|
|
.iter()
|
|
.filter_map(|thread_id| parse_thread_id(thread_id))
|
|
.map(|thread_id| (thread_id, agent_metadata(thread_id)))
|
|
.collect::<Vec<_>>();
|
|
|
|
let title = match receiver_agents.as_slice() {
|
|
[(thread_id, metadata)] => title_with_agent(
|
|
"Waiting for",
|
|
agent_label(*thread_id, metadata),
|
|
/*spawn_request*/ None,
|
|
),
|
|
[] => title_text("Waiting for agents"),
|
|
_ => title_text(format!("Waiting for {} agents", receiver_agents.len())),
|
|
};
|
|
|
|
let details = if receiver_agents.len() > 1 {
|
|
receiver_agents
|
|
.iter()
|
|
.map(|(thread_id, metadata)| agent_label_line(agent_label(*thread_id, metadata)))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
collab_event(title, details)
|
|
}
|
|
|
|
fn waiting_end(
|
|
receiver_thread_ids: &[String],
|
|
agents_states: &std::collections::HashMap<String, CollabAgentState>,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
let details = wait_complete_lines(receiver_thread_ids, agents_states, agent_metadata);
|
|
collab_event(title_text("Finished waiting"), details)
|
|
}
|
|
|
|
fn close_end(
|
|
receiver_thread_id: ThreadId,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
collab_event(
|
|
title_with_agent(
|
|
"Closed",
|
|
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
|
|
/*spawn_request*/ None,
|
|
),
|
|
Vec::new(),
|
|
)
|
|
}
|
|
|
|
fn resume_begin(
|
|
receiver_thread_id: ThreadId,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
collab_event(
|
|
title_with_agent(
|
|
"Resuming",
|
|
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
|
|
/*spawn_request*/ None,
|
|
),
|
|
Vec::new(),
|
|
)
|
|
}
|
|
|
|
fn resume_end(
|
|
receiver_thread_id: ThreadId,
|
|
status: Option<&CollabAgentState>,
|
|
fallback_error: &str,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> PlainHistoryCell {
|
|
collab_event(
|
|
title_with_agent(
|
|
"Resumed",
|
|
agent_label(receiver_thread_id, &agent_metadata(receiver_thread_id)),
|
|
/*spawn_request*/ None,
|
|
),
|
|
vec![status_summary_line(status, fallback_error)],
|
|
)
|
|
}
|
|
|
|
fn collab_event(title: Line<'static>, details: Vec<Line<'static>>) -> PlainHistoryCell {
|
|
let mut lines: Vec<Line<'static>> = vec![title];
|
|
if !details.is_empty() {
|
|
lines.extend(prefix_lines(details, " └ ".dim(), " ".into()));
|
|
}
|
|
PlainHistoryCell::new(lines)
|
|
}
|
|
|
|
fn title_text(title: impl Into<String>) -> Line<'static> {
|
|
title_spans_line(vec![Span::from(title.into()).bold()])
|
|
}
|
|
|
|
fn title_with_agent(
|
|
prefix: &str,
|
|
agent: AgentLabel<'_>,
|
|
spawn_request: Option<&SpawnRequestSummary>,
|
|
) -> Line<'static> {
|
|
let mut spans = vec![Span::from(format!("{prefix} ")).bold()];
|
|
spans.extend(agent_label_spans(agent));
|
|
spans.extend(spawn_request_spans(spawn_request));
|
|
title_spans_line(spans)
|
|
}
|
|
|
|
fn title_spans_line(mut spans: Vec<Span<'static>>) -> Line<'static> {
|
|
let mut title = Vec::with_capacity(spans.len() + 1);
|
|
title.push(Span::from("• ").dim());
|
|
title.append(&mut spans);
|
|
title.into()
|
|
}
|
|
|
|
fn parse_thread_id(thread_id: &str) -> Option<ThreadId> {
|
|
ThreadId::from_string(thread_id).ok()
|
|
}
|
|
|
|
fn agent_label(thread_id: ThreadId, metadata: &AgentMetadata) -> AgentLabel<'_> {
|
|
AgentLabel {
|
|
thread_id: Some(thread_id),
|
|
nickname: metadata.agent_nickname.as_deref(),
|
|
role: metadata.agent_role.as_deref(),
|
|
}
|
|
}
|
|
|
|
fn agent_label_line(agent: AgentLabel<'_>) -> Line<'static> {
|
|
agent_label_spans(agent).into()
|
|
}
|
|
|
|
fn agent_label_spans(agent: AgentLabel<'_>) -> Vec<Span<'static>> {
|
|
let mut spans = Vec::new();
|
|
let nickname = agent
|
|
.nickname
|
|
.map(str::trim)
|
|
.filter(|nickname| !nickname.is_empty());
|
|
let role = agent.role.map(str::trim).filter(|role| !role.is_empty());
|
|
|
|
if let Some(nickname) = nickname {
|
|
spans.push(Span::from(nickname.to_string()).cyan().bold());
|
|
} else if let Some(thread_id) = agent.thread_id {
|
|
spans.push(Span::from(thread_id.to_string()).cyan());
|
|
} else {
|
|
spans.push(Span::from("agent").cyan());
|
|
}
|
|
|
|
if let Some(role) = role {
|
|
spans.push(Span::from(" ").dim());
|
|
spans.push(Span::from(format!("[{role}]")));
|
|
}
|
|
|
|
spans
|
|
}
|
|
|
|
fn spawn_request_spans(spawn_request: Option<&SpawnRequestSummary>) -> Vec<Span<'static>> {
|
|
let Some(spawn_request) = spawn_request else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let model = spawn_request.model.trim();
|
|
if model.is_empty() && spawn_request.reasoning_effort == ReasoningEffortConfig::default() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let details = if model.is_empty() {
|
|
format!("({})", spawn_request.reasoning_effort)
|
|
} else {
|
|
format!("({model} {})", spawn_request.reasoning_effort)
|
|
};
|
|
|
|
vec![Span::from(" ").dim(), Span::from(details).magenta()]
|
|
}
|
|
|
|
fn prompt_line(prompt: &str) -> Option<Line<'static>> {
|
|
let trimmed = prompt.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Line::from(Span::from(truncate_text(
|
|
trimmed,
|
|
COLLAB_PROMPT_PREVIEW_GRAPHEMES,
|
|
))))
|
|
}
|
|
}
|
|
|
|
fn wait_complete_lines(
|
|
receiver_thread_ids: &[String],
|
|
agents_states: &std::collections::HashMap<String, CollabAgentState>,
|
|
agent_metadata: &mut impl FnMut(ThreadId) -> AgentMetadata,
|
|
) -> Vec<Line<'static>> {
|
|
let mut seen = HashSet::new();
|
|
let mut entries = receiver_thread_ids
|
|
.iter()
|
|
.filter_map(|thread_id| {
|
|
let parsed_thread_id = parse_thread_id(thread_id)?;
|
|
let status = agents_states.get(thread_id)?;
|
|
seen.insert(parsed_thread_id);
|
|
Some((parsed_thread_id, agent_metadata(parsed_thread_id), status))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut extras = agents_states
|
|
.iter()
|
|
.filter_map(|(thread_id, status)| {
|
|
let parsed_thread_id = parse_thread_id(thread_id)?;
|
|
(!seen.contains(&parsed_thread_id))
|
|
.then(|| (parsed_thread_id, agent_metadata(parsed_thread_id), status))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
extras.sort_by_key(|entry| entry.0.to_string());
|
|
entries.extend(extras);
|
|
|
|
if entries.is_empty() {
|
|
vec![Line::from(Span::from("No agents completed yet"))]
|
|
} else {
|
|
entries
|
|
.into_iter()
|
|
.map(|(thread_id, metadata, status)| {
|
|
let mut spans = agent_label_spans(agent_label(thread_id, &metadata));
|
|
spans.push(Span::from(": ").dim());
|
|
spans.extend(status_summary_spans(status));
|
|
spans.into()
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn first_agent_state<'a>(
|
|
receiver_thread_ids: &[String],
|
|
agents_states: &'a std::collections::HashMap<String, CollabAgentState>,
|
|
) -> Option<&'a CollabAgentState> {
|
|
receiver_thread_ids
|
|
.iter()
|
|
.find_map(|thread_id| agents_states.get(thread_id))
|
|
.or_else(|| {
|
|
agents_states
|
|
.iter()
|
|
.min_by(|left, right| left.0.cmp(right.0))
|
|
.map(|(_, status)| status)
|
|
})
|
|
}
|
|
|
|
fn status_summary_line(status: Option<&CollabAgentState>, fallback_error: &str) -> Line<'static> {
|
|
match status {
|
|
Some(status) => status_summary_spans(status).into(),
|
|
None => error_summary_spans(fallback_error).into(),
|
|
}
|
|
}
|
|
|
|
fn status_summary_spans(status: &CollabAgentState) -> Vec<Span<'static>> {
|
|
match status.status {
|
|
CollabAgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
|
|
CollabAgentStatus::Running => vec![Span::from("Running").cyan().bold()],
|
|
// Allow `.yellow()`
|
|
#[allow(clippy::disallowed_methods)]
|
|
CollabAgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()],
|
|
CollabAgentStatus::Completed => {
|
|
let mut spans = vec![Span::from("Completed").green()];
|
|
if let Some(message) = status.message.as_ref() {
|
|
let message_preview = truncate_text(
|
|
&message.split_whitespace().collect::<Vec<_>>().join(" "),
|
|
COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES,
|
|
);
|
|
if !message_preview.is_empty() {
|
|
spans.push(Span::from(" - ").dim());
|
|
spans.push(Span::from(message_preview));
|
|
}
|
|
}
|
|
spans
|
|
}
|
|
CollabAgentStatus::Errored => {
|
|
error_summary_spans(status.message.as_deref().unwrap_or("Agent errored"))
|
|
}
|
|
CollabAgentStatus::Shutdown => vec![Span::from("Shutdown")],
|
|
CollabAgentStatus::NotFound => vec![Span::from("Not found").red()],
|
|
}
|
|
}
|
|
|
|
fn error_summary_spans(error: &str) -> Vec<Span<'static>> {
|
|
let mut spans = vec![Span::from("Error").red()];
|
|
let error_preview = truncate_text(
|
|
&error.split_whitespace().collect::<Vec<_>>().join(" "),
|
|
COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES,
|
|
);
|
|
if !error_preview.is_empty() {
|
|
spans.push(Span::from(" - ").dim());
|
|
spans.push(Span::from(error_preview));
|
|
}
|
|
spans
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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;
|
|
use ratatui::style::Modifier;
|
|
use std::collections::HashMap;
|
|
|
|
#[test]
|
|
fn collab_events_snapshot() {
|
|
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")
|
|
.expect("valid sender thread id");
|
|
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
|
|
.expect("valid robie thread id");
|
|
let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003")
|
|
.expect("valid bob thread id");
|
|
|
|
let spawn = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-spawn".to_string(),
|
|
tool: CollabAgentTool::SpawnAgent,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: Some("Compute 11! and reply with just the integer result.".to_string()),
|
|
model: Some("gpt-5".to_string()),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
agents_states: HashMap::from([(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::PendingInit, /*message*/ None),
|
|
)]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, bob_id),
|
|
)
|
|
.expect("spawn item renders");
|
|
|
|
let send = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-send".to_string(),
|
|
tool: CollabAgentTool::SendInput,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: Some("Please continue and return the answer only.".to_string()),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::from([(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::Running, /*message*/ None),
|
|
)]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, bob_id),
|
|
)
|
|
.expect("send-input item renders");
|
|
|
|
let waiting = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-wait".to_string(),
|
|
tool: CollabAgentTool::Wait,
|
|
status: CollabAgentToolCallStatus::InProgress,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::new(),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, bob_id),
|
|
)
|
|
.expect("wait begin item renders");
|
|
|
|
let finished = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-wait".to_string(),
|
|
tool: CollabAgentTool::Wait,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string(), bob_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::from([
|
|
(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::Completed, Some("39916800")),
|
|
),
|
|
(
|
|
bob_id.to_string(),
|
|
agent_state(CollabAgentStatus::Errored, Some("tool timeout")),
|
|
),
|
|
]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, bob_id),
|
|
)
|
|
.expect("wait end item renders");
|
|
|
|
let close = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-close".to_string(),
|
|
tool: CollabAgentTool::CloseAgent,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::from([(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::Completed, Some("39916800")),
|
|
)]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, bob_id),
|
|
)
|
|
.expect("close item renders");
|
|
|
|
let snapshot = [spawn, send, waiting, finished, close]
|
|
.iter()
|
|
.map(cell_to_text)
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
assert_snapshot!("collab_agent_transcript", snapshot);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn agent_shortcut_matches_option_arrow_word_motion_fallbacks_only_when_allowed() {
|
|
assert!(previous_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ false,
|
|
));
|
|
assert!(next_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ false,
|
|
));
|
|
assert!(previous_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ true,
|
|
));
|
|
assert!(next_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ true,
|
|
));
|
|
assert!(!previous_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ false,
|
|
));
|
|
assert!(!next_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
|
|
/*allow_word_motion_fallback*/ false,
|
|
));
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[test]
|
|
fn agent_shortcut_matches_option_arrows_only() {
|
|
assert!(previous_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::ALT,),
|
|
/*allow_word_motion_fallback*/ false
|
|
));
|
|
assert!(next_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::ALT,),
|
|
/*allow_word_motion_fallback*/ false
|
|
));
|
|
assert!(!previous_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('b'), crossterm::event::KeyModifiers::ALT,),
|
|
/*allow_word_motion_fallback*/ false
|
|
));
|
|
assert!(!next_agent_shortcut_matches(
|
|
KeyEvent::new(KeyCode::Char('f'), crossterm::event::KeyModifiers::ALT,),
|
|
/*allow_word_motion_fallback*/ false
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn title_styles_nickname_and_role() {
|
|
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")
|
|
.expect("valid sender thread id");
|
|
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
|
|
.expect("valid robie thread id");
|
|
let cell = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-spawn".to_string(),
|
|
tool: CollabAgentTool::SpawnAgent,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: Some(String::new()),
|
|
model: Some("gpt-5".to_string()),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
agents_states: HashMap::from([(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::PendingInit, /*message*/ None),
|
|
)]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, ThreadId::new()),
|
|
)
|
|
.expect("spawn item renders");
|
|
|
|
let lines = cell.display_lines(/*width*/ 200);
|
|
let title = &lines[0];
|
|
assert_eq!(title.spans[2].content.as_ref(), "Robie");
|
|
assert_eq!(title.spans[2].style.fg, Some(Color::Cyan));
|
|
assert!(title.spans[2].style.add_modifier.contains(Modifier::BOLD));
|
|
assert_eq!(title.spans[4].content.as_ref(), "[explorer]");
|
|
assert_eq!(title.spans[4].style.fg, None);
|
|
assert!(!title.spans[4].style.add_modifier.contains(Modifier::DIM));
|
|
assert_eq!(title.spans[6].content.as_ref(), "(gpt-5 high)");
|
|
assert_eq!(title.spans[6].style.fg, Some(Color::Magenta));
|
|
}
|
|
|
|
#[test]
|
|
fn collab_resume_interrupted_snapshot() {
|
|
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")
|
|
.expect("valid sender thread id");
|
|
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
|
|
.expect("valid robie thread id");
|
|
|
|
let cell = tool_call_history_cell(
|
|
&ThreadItem::CollabAgentToolCall {
|
|
id: "call-resume".to_string(),
|
|
tool: CollabAgentTool::ResumeAgent,
|
|
status: CollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: sender_thread_id.to_string(),
|
|
receiver_thread_ids: vec![robie_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::from([(
|
|
robie_id.to_string(),
|
|
agent_state(CollabAgentStatus::Interrupted, /*message*/ None),
|
|
)]),
|
|
},
|
|
/*cached_spawn_request*/ None,
|
|
|thread_id| metadata_for(thread_id, robie_id, ThreadId::new()),
|
|
)
|
|
.expect("resume item renders");
|
|
|
|
assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));
|
|
}
|
|
|
|
fn agent_state(status: CollabAgentStatus, message: Option<&str>) -> CollabAgentState {
|
|
CollabAgentState {
|
|
status,
|
|
message: message.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
fn metadata_for(thread_id: ThreadId, robie_id: ThreadId, bob_id: ThreadId) -> AgentMetadata {
|
|
if thread_id == robie_id {
|
|
AgentMetadata {
|
|
agent_nickname: Some("Robie".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
}
|
|
} else if thread_id == bob_id {
|
|
AgentMetadata {
|
|
agent_nickname: Some("Bob".to_string()),
|
|
agent_role: Some("worker".to_string()),
|
|
}
|
|
} else {
|
|
AgentMetadata::default()
|
|
}
|
|
}
|
|
|
|
fn cell_to_text(cell: &PlainHistoryCell) -> String {
|
|
cell.display_lines(/*width*/ 200)
|
|
.iter()
|
|
.map(line_to_text)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn line_to_text(line: &Line<'static>) -> String {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
}
|