vtcode 0.106.0

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::Result;
use std::sync::Arc;
use vtcode_core::subagents::{
    BackgroundSubprocessEntry, BackgroundSubprocessSnapshot, BackgroundSubprocessStatus,
    SubagentController, SubagentStatus, SubagentStatusEntry, SubagentThreadSnapshot,
};
use vtcode_core::{CommandExecutionStatus, ThreadEvent, ThreadItemDetails, ToolCallStatus};
use vtcode_tui::app::{InlineHandle, LocalAgentEntry, LocalAgentKind};

pub(crate) async fn refresh_local_agents(
    handle: &InlineHandle,
    controller: &Arc<SubagentController>,
) -> Result<()> {
    let background_entries = controller.refresh_background_processes().await?;
    let delegated_entries = controller.status_entries().await;
    let local_agents =
        build_local_agent_entries(controller, delegated_entries, background_entries).await;
    handle.set_local_agents(local_agents);
    Ok(())
}

async fn build_local_agent_entries(
    controller: &Arc<SubagentController>,
    delegated_entries: Vec<SubagentStatusEntry>,
    background_entries: Vec<BackgroundSubprocessEntry>,
) -> Vec<LocalAgentEntry> {
    let mut entries = Vec::new();

    for entry in visible_delegated_local_agents(delegated_entries) {
        let snapshot = match controller.snapshot_for_thread(&entry.id).await {
            Ok(snapshot) => Some(snapshot),
            Err(err) => {
                tracing::debug!(
                    subagent_id = entry.id.as_str(),
                    "Failed to snapshot delegated agent for local-agents UI: {}",
                    err
                );
                None
            }
        };
        let preview = snapshot
            .as_ref()
            .map(|snapshot| delegated_local_agent_preview(&entry, snapshot))
            .unwrap_or_else(|| delegated_local_agent_preview_placeholder(&entry));
        let summary = snapshot
            .as_ref()
            .map(|snapshot| delegated_local_agent_summary(&entry, snapshot));
        entries.push((
            entry.updated_at,
            LocalAgentEntry {
                id: entry.id.clone(),
                display_label: entry.display_label.clone(),
                agent_name: entry.agent_name.clone(),
                color: entry.color.clone(),
                kind: LocalAgentKind::Delegated,
                status: entry.status.as_str().to_string(),
                summary,
                preview,
                transcript_path: entry.transcript_path.clone(),
            },
        ));
    }

    for entry in visible_background_local_agents(background_entries) {
        let snapshot = match controller.background_snapshot(&entry.id).await {
            Ok(snapshot) => Some(snapshot),
            Err(err) => {
                tracing::debug!(
                    subprocess_id = entry.id.as_str(),
                    "Failed to snapshot background subprocess for local-agents UI: {}",
                    err
                );
                None
            }
        };
        let preview = snapshot
            .as_ref()
            .map(background_local_agent_preview)
            .unwrap_or_else(|| background_local_agent_preview_placeholder(&entry));
        entries.push((
            entry.updated_at,
            LocalAgentEntry {
                id: entry.id.clone(),
                display_label: entry.display_label.clone(),
                agent_name: entry.agent_name.clone(),
                color: entry.color.clone(),
                kind: LocalAgentKind::Background,
                status: entry.status.as_str().to_string(),
                summary: Some(background_local_agent_summary(&entry)),
                preview,
                transcript_path: entry.transcript_path.clone().or(entry.archive_path.clone()),
            },
        ));
    }

    entries.sort_by(|left, right| right.0.cmp(&left.0));
    entries.into_iter().map(|(_, entry)| entry).collect()
}

pub(super) fn visible_delegated_local_agents(
    entries: Vec<SubagentStatusEntry>,
) -> Vec<SubagentStatusEntry> {
    let mut entries = entries
        .into_iter()
        .filter(|entry| {
            !matches!(
                entry.status,
                SubagentStatus::Completed | SubagentStatus::Closed
            )
        })
        .collect::<Vec<_>>();
    entries.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
    entries
}

pub(super) fn visible_background_local_agents(
    entries: Vec<BackgroundSubprocessEntry>,
) -> Vec<BackgroundSubprocessEntry> {
    let mut entries = entries
        .into_iter()
        .filter(|entry| {
            matches!(
                entry.status,
                BackgroundSubprocessStatus::Starting | BackgroundSubprocessStatus::Running
            ) || (entry.desired_enabled
                && matches!(entry.status, BackgroundSubprocessStatus::Error))
        })
        .collect::<Vec<_>>();
    entries.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
    entries
}

fn delegated_local_agent_summary(
    entry: &SubagentStatusEntry,
    snapshot: &SubagentThreadSnapshot,
) -> String {
    entry
        .summary
        .as_deref()
        .map(str::trim)
        .filter(|summary| !summary.is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| {
            if snapshot.snapshot.turn_in_flight {
                "Turn in flight; streaming live updates.".to_string()
            } else if matches!(entry.status, SubagentStatus::Failed) {
                entry
                    .error
                    .as_deref()
                    .map(str::trim)
                    .filter(|error| !error.is_empty())
                    .map(ToOwned::to_owned)
                    .unwrap_or_else(|| {
                        "Delegated agent failed before producing a summary.".to_string()
                    })
            } else if matches!(entry.status, SubagentStatus::Queued) {
                "Queued and waiting to start.".to_string()
            } else {
                "Running without a final summary yet.".to_string()
            }
        })
}

fn delegated_local_agent_preview(
    entry: &SubagentStatusEntry,
    snapshot: &SubagentThreadSnapshot,
) -> String {
    let preview = summarize_subagent_sidebar_preview(snapshot);
    if preview.trim().is_empty() {
        delegated_local_agent_preview_placeholder(entry)
    } else {
        preview
    }
}

pub(super) fn delegated_local_agent_preview_placeholder(entry: &SubagentStatusEntry) -> String {
    if matches!(entry.status, SubagentStatus::Queued) {
        "Agent is queued and has not emitted transcript output yet.".to_string()
    } else if matches!(entry.status, SubagentStatus::Failed) {
        entry
            .error
            .as_deref()
            .map(str::trim)
            .filter(|error| !error.is_empty())
            .map(ToOwned::to_owned)
            .unwrap_or_else(|| "Agent failed before emitting more transcript output.".to_string())
    } else {
        "Waiting for the next delegated transcript update.".to_string()
    }
}

fn background_local_agent_summary(entry: &BackgroundSubprocessEntry) -> String {
    entry
        .summary
        .as_deref()
        .map(str::trim)
        .filter(|summary| !summary.is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| match entry.status {
            BackgroundSubprocessStatus::Starting => {
                "Starting; waiting for subprocess output.".to_string()
            }
            BackgroundSubprocessStatus::Running => {
                "Running; waiting for transcript output.".to_string()
            }
            BackgroundSubprocessStatus::Stopped => "Stopped.".to_string(),
            BackgroundSubprocessStatus::Error => "Exited with an error.".to_string(),
        })
}

fn background_local_agent_preview(snapshot: &BackgroundSubprocessSnapshot) -> String {
    if snapshot.preview.trim().is_empty() {
        background_local_agent_preview_placeholder(&snapshot.entry)
    } else {
        snapshot.preview.clone()
    }
}

pub(super) fn background_local_agent_preview_placeholder(
    entry: &BackgroundSubprocessEntry,
) -> String {
    match entry.status {
        BackgroundSubprocessStatus::Starting => {
            "Waiting for the subprocess to emit output...".to_string()
        }
        BackgroundSubprocessStatus::Running => {
            "Subprocess is running; waiting for the next transcript update.".to_string()
        }
        BackgroundSubprocessStatus::Stopped => "Subprocess stopped.".to_string(),
        BackgroundSubprocessStatus::Error => "Subprocess ended with an error.".to_string(),
    }
}

fn summarize_subagent_sidebar_preview(snapshot: &SubagentThreadSnapshot) -> String {
    let live_preview = summarize_thread_event_preview(&snapshot.recent_events);
    if !live_preview.is_empty() {
        return live_preview;
    }

    snapshot
        .snapshot
        .messages
        .iter()
        .rev()
        .filter_map(|message| {
            let text = message.content.as_text();
            let preview = summarize_preview_text(text.as_ref())?;
            Some(format!("{:?}: {}", message.role, preview))
        })
        .take(16)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect::<Vec<_>>()
        .join("\n")
}

fn summarize_thread_event_preview(events: &[ThreadEvent]) -> String {
    let mut items = Vec::<(String, String)>::new();
    for event in events {
        let Some((item_id, line)) = thread_event_preview_line(event) else {
            continue;
        };
        if let Some((_, current)) = items.iter_mut().find(|(id, _)| id == &item_id) {
            *current = line;
        } else {
            items.push((item_id, line));
        }
    }

    items
        .into_iter()
        .map(|(_, line)| line)
        .rev()
        .take(16)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect::<Vec<_>>()
        .join("\n")
}

fn thread_event_preview_line(event: &ThreadEvent) -> Option<(String, String)> {
    let item = match event {
        ThreadEvent::ItemStarted(event) => &event.item,
        ThreadEvent::ItemUpdated(event) => &event.item,
        ThreadEvent::ItemCompleted(event) => &event.item,
        _ => return None,
    };

    let line = match &item.details {
        ThreadItemDetails::AgentMessage(message) => {
            format!("assistant: {}", summarize_preview_text(&message.text)?)
        }
        ThreadItemDetails::Reasoning(reasoning) => {
            format!("thinking: {}", summarize_preview_text(&reasoning.text)?)
        }
        ThreadItemDetails::ToolInvocation(tool) => {
            format!(
                "tool {}: {}",
                tool.tool_name,
                tool_status_label(tool.status.clone())
            )
        }
        ThreadItemDetails::ToolOutput(output) => summarize_preview_text(&output.output)
            .map(|text| format!("tool output: {}", text))
            .unwrap_or_else(|| {
                format!("tool output: {}", tool_status_label(output.status.clone()))
            }),
        ThreadItemDetails::CommandExecution(command) => {
            summarize_preview_text(&command.aggregated_output)
                .map(|text| format!("command {}: {}", command.command, text))
                .unwrap_or_else(|| {
                    format!(
                        "command {}: {}",
                        command.command,
                        command_status_label(command.status.clone())
                    )
                })
        }
        _ => return None,
    };

    Some((item.id.clone(), line))
}

fn tool_status_label(status: ToolCallStatus) -> &'static str {
    match status {
        ToolCallStatus::Completed => "completed",
        ToolCallStatus::Failed => "failed",
        ToolCallStatus::InProgress => "running",
    }
}

fn command_status_label(status: CommandExecutionStatus) -> &'static str {
    match status {
        CommandExecutionStatus::Completed => "completed",
        CommandExecutionStatus::Failed => "failed",
        CommandExecutionStatus::InProgress => "running",
    }
}

fn summarize_preview_text(text: &str) -> Option<String> {
    let preview = text
        .lines()
        .rev()
        .find_map(|line| {
            let collapsed = collapse_preview_whitespace(line);
            (!collapsed.is_empty()).then_some(collapsed)
        })
        .or_else(|| {
            let collapsed = collapse_preview_whitespace(text);
            (!collapsed.is_empty()).then_some(collapsed)
        })?;

    Some(truncate_preview_text(preview, 180))
}

fn collapse_preview_whitespace(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn truncate_preview_text(text: String, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text;
    }

    let mut truncated = text
        .chars()
        .take(max_chars.saturating_sub(1))
        .collect::<String>();
    truncated.push_str("...");
    truncated
}