Update README with latest features (sub‑agent orchestration, parallel tool execution, runtime API, task queue, etc.)

This commit is contained in:
Hunter Bown
2026-02-26 14:01:32 -06:00
parent c69a73b644
commit b3e765cc70
14 changed files with 4391 additions and 256 deletions
Generated
+22
View File
@@ -651,6 +651,27 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "ctor"
version = "0.1.26"
@@ -740,6 +761,7 @@ dependencies = [
"clap_complete",
"colored",
"crossterm",
"csv",
"dirs",
"dotenvy",
"futures-util",
+1
View File
@@ -24,6 +24,7 @@ clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
colored = "3.0.0"
crossterm = "0.28"
csv = "1.4"
dotenvy = "0.15.7"
dirs = "6.0.0"
futures-util = "0.3.31"
+8 -4
View File
@@ -19,9 +19,9 @@ Three modes:
- **Plan** — design-first, proposes before acting
- **Agent** — multi-step autonomous tool use
- **YOLO** — full auto-approve, no guardrails
- **YOLO** — full auto-approve, no guardrails (preloads tools by default)
Sub-agent orchestration is in there too (background workers, parallel tool calls). Still shaking out the rough edges.
**Recent highlights**: subagent orchestration (background workers, parallel tool calls, dependencyaware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/subagent state, and model contextwindow suffix hints (`-32k`, `-256k`).
## Install
@@ -57,13 +57,17 @@ deepseek # interactive TUI
deepseek -p "explain this in 2 sentences" # one-shot prompt
deepseek --yolo # agent mode, all tools auto-approved
deepseek doctor # check your setup
deepseek models # list available models
deepseek serve --http # start HTTP/SSE API server
```
Within the TUI, use `/config`, `/models`, `/task`, and `Ctrl+K` command palette.
## Model IDs
Common model IDs: `deepseek-chat`, `deepseek-reasoner`.
Any valid `deepseek-*` model ID is accepted (including future releases). To see live IDs from your configured endpoint:
Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include contextwindow suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint:
```bash
deepseek models
@@ -83,4 +87,4 @@ Detailed docs are in the [docs/](docs/) folder — architecture, modes, MCP inte
## License
MIT
MIT
+2 -1
View File
@@ -1288,7 +1288,8 @@ impl Engine {
}
Op::ListSubAgents => {
let agents = {
let manager = self.subagent_manager.lock().await;
let mut manager = self.subagent_manager.lock().await;
manager.cleanup(Duration::from_secs(60 * 60));
manager.list()
};
let _ = self.tx_event.send(Event::AgentList { agents }).await;
+25 -18
View File
@@ -12,16 +12,25 @@ use std::path::Path;
pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt");
pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt");
pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt");
pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
fn mode_prompt(mode: AppMode) -> &'static str {
match mode {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent => AGENT_PROMPT,
AppMode::Yolo => YOLO_PROMPT,
AppMode::Plan => PLAN_PROMPT,
}
}
fn compose_mode_prompt(mode: AppMode) -> String {
format!("{}\n\n{}", BASE_PROMPT.trim(), mode_prompt(mode).trim())
}
/// Get the system prompt for a specific mode
pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
let text = match mode {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
};
SystemPrompt::Text(text.trim().to_string())
SystemPrompt::Text(compose_mode_prompt(mode))
}
/// Get the system prompt for a specific mode with project context
@@ -30,27 +39,21 @@ pub fn system_prompt_for_mode_with_context(
workspace: &Path,
working_set_summary: Option<&str>,
) -> SystemPrompt {
let base_prompt = match mode {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
};
let mode_prompt = compose_mode_prompt(mode);
// Load project context from workspace
let project_context = load_project_context_with_parents(workspace);
// Combine base prompt with project context
let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
format!("{}\n\n{}", base_prompt.trim(), project_block)
format!("{}\n\n{}", mode_prompt, project_block)
} else {
// Fallback: Generate an automatic project map summary
let summary = crate::utils::summarize_project(workspace);
let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt
format!(
"{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```",
base_prompt.trim(),
summary,
tree
mode_prompt, summary, tree
)
};
@@ -91,13 +94,17 @@ pub fn base_system_prompt() -> SystemPrompt {
}
pub fn normal_system_prompt() -> SystemPrompt {
SystemPrompt::Text(NORMAL_PROMPT.trim().to_string())
system_prompt_for_mode(AppMode::Normal)
}
pub fn agent_system_prompt() -> SystemPrompt {
SystemPrompt::Text(AGENT_PROMPT.trim().to_string())
system_prompt_for_mode(AppMode::Agent)
}
pub fn yolo_system_prompt() -> SystemPrompt {
system_prompt_for_mode(AppMode::Yolo)
}
pub fn plan_system_prompt() -> SystemPrompt {
SystemPrompt::Text(PLAN_PROMPT.trim().to_string())
system_prompt_for_mode(AppMode::Plan)
}
+26 -4
View File
@@ -78,21 +78,43 @@ TASK MANAGEMENT:
- note: Record important information
SUB-AGENTS:
- spawn_agent: Spawn a background sub-agent (agent_type, message/items)
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools)
- spawn_agents_on_csv: Batch-process CSV rows with one worker sub-agent per row
- report_agent_job_result: Worker-only job row report tool for spawn_agents_on_csv
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context)
- swarm_status: Check status for a previously started swarm (swarm_id)
- swarm_result: Get full results for a previously started swarm (swarm_id, optional block/timeout)
- agent_result: Get result from a sub-agent (agent_id, block, timeout_ms)
- send_input: Send input to a running sub-agent (agent_id, message, interrupt)
- wait: Wait for one or more sub-agents to complete (ids, timeout_ms)
- send_input: Send input to a running sub-agent (agent_id, message/items, interrupt)
- agent_assign / assign_agent: Update assignment objective/role and optionally push immediate guidance
- wait: Wait for one or more sub-agents to complete (ids optional, wait_mode:any|all, timeout_ms)
- agent_cancel: Cancel a running sub-agent (agent_id)
- close_agent: Close a running sub-agent (alias for cancel)
- resume_agent: Resume a previously closed/completed sub-agent
- agent_list: List all sub-agents and their status
If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user.
If you use agent_swarm, incorporate its aggregated results (or follow up with agent_result for any running agents) before responding.
Delegation protocol:
- Delegate only bounded, parallelizable work with a clear input, expected output, and tool limits.
- Prefer multiple sub-agents for independent steps to maximize parallelism.
- When spawning/delegating, include explicit assignment metadata: objective + role (worker/explorer/awaiter/default) or agent_type.
- Use agent_assign to retask active sub-agents instead of respawning when objective/role changes.
- After spawning, immediately track completion with wait (for groups), swarm_result (for non-blocking swarms), or agent_result (block: true) per agent.
- For full barriers, use wait with wait_mode="all" and a generous timeout (prefer >= 60000ms). Omit ids to wait on all currently running agents.
- For spawn_agents_on_csv workers: call report_agent_job_result exactly once per row item; missing reports are treated as failures.
- Workers may set stop=true in report_agent_job_result to cancel remaining unstarted CSV rows.
- If sub-agents are still running, wait for their outputs before presenting final conclusions unless the user asked a direct question that needs an immediate reply.
- Do not present final conclusions until required sub-agent results are collected and integrated.
- If an agent stalls or fails, retry once with a tighter prompt; otherwise cancel it and continue with an explicit fallback.
- Close idle agents with close_agent to free capacity; use resume_agent to continue paused/completed assignments when needed.
- Verify critical sub-agent claims with primary tool output before applying changes.
Planning and progress:
- For complex or multi-file work, call update_plan to publish a checklist.
- Keep exactly one plan step in_progress at a time.
- Use todo tools for granular progress when helpful.
- Prefer short progress notes over long narration.
- For long-running tasks, emit checkpoint updates every few actions with: done, next, and blockers.
- Re-baseline plan/todos at each checkpoint when scope shifts.
Git hygiene:
- Run git status early (to see the workspace state) and again before finishing.
+118
View File
@@ -27,6 +27,7 @@ use crate::models::{
compaction_threshold_for_model,
};
use crate::tools::plan::new_shared_plan_state;
use crate::tools::subagent::SubAgentStatus;
use crate::tools::todo::new_shared_todo_list;
use crate::tui::app::AppMode;
@@ -1746,6 +1747,123 @@ impl RuntimeThreadManager {
)
.await?;
}
EngineEvent::AgentSpawned { id, prompt } => {
let message = format!(
"Sub-agent {id} spawned: {}",
summarize_text(&prompt, SUMMARY_LIMIT)
);
let item = TurnItemRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
turn_id: turn_id.clone(),
kind: TurnItemKind::Status,
status: TurnItemLifecycleStatus::Completed,
summary: summarize_text(&message, SUMMARY_LIMIT),
detail: Some(message),
artifact_refs: Vec::new(),
started_at: Some(Utc::now()),
ended_at: Some(Utc::now()),
};
self.store.save_item(&item)?;
self.attach_item_to_turn(&turn_id, &item.id)?;
self.emit_event(
&thread_id,
Some(&turn_id),
Some(&item.id),
"agent.spawned",
json!({ "item": item, "agent_id": id }),
)
.await?;
}
EngineEvent::AgentProgress { id, status } => {
let message = format!("Sub-agent {id}: {status}");
let item = TurnItemRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
turn_id: turn_id.clone(),
kind: TurnItemKind::Status,
status: TurnItemLifecycleStatus::Completed,
summary: summarize_text(&message, SUMMARY_LIMIT),
detail: Some(message),
artifact_refs: Vec::new(),
started_at: Some(Utc::now()),
ended_at: Some(Utc::now()),
};
self.store.save_item(&item)?;
self.attach_item_to_turn(&turn_id, &item.id)?;
self.emit_event(
&thread_id,
Some(&turn_id),
Some(&item.id),
"agent.progress",
json!({ "item": item, "agent_id": id }),
)
.await?;
}
EngineEvent::AgentComplete { id, result } => {
let message = format!(
"Sub-agent {id} completed: {}",
summarize_text(&result, SUMMARY_LIMIT)
);
let item = TurnItemRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
turn_id: turn_id.clone(),
kind: TurnItemKind::Status,
status: TurnItemLifecycleStatus::Completed,
summary: summarize_text(&message, SUMMARY_LIMIT),
detail: Some(message),
artifact_refs: Vec::new(),
started_at: Some(Utc::now()),
ended_at: Some(Utc::now()),
};
self.store.save_item(&item)?;
self.attach_item_to_turn(&turn_id, &item.id)?;
self.emit_event(
&thread_id,
Some(&turn_id),
Some(&item.id),
"agent.completed",
json!({ "item": item, "agent_id": id }),
)
.await?;
}
EngineEvent::AgentList { agents } => {
let running = agents
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
.count();
let completed = agents
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Completed))
.count();
let message = format!(
"Sub-agent list refreshed: {} total ({running} running, {completed} completed)",
agents.len()
);
let item = TurnItemRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
turn_id: turn_id.clone(),
kind: TurnItemKind::Status,
status: TurnItemLifecycleStatus::Completed,
summary: summarize_text(&message, SUMMARY_LIMIT),
detail: Some(message),
artifact_refs: Vec::new(),
started_at: Some(Utc::now()),
ended_at: Some(Utc::now()),
};
self.store.save_item(&item)?;
self.attach_item_to_turn(&turn_id, &item.id)?;
self.emit_event(
&thread_id,
Some(&turn_id),
Some(&item.id),
"agent.list",
json!({ "item": item, "agents": agents }),
)
.await?;
}
EngineEvent::ApprovalRequired {
id,
tool_name,
+37 -4
View File
@@ -446,31 +446,64 @@ impl ToolRegistryBuilder {
runtime: super::subagent::SubAgentRuntime,
) -> Self {
use super::subagent::{
AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool,
AgentWaitTool, DelegateToAgentTool,
AgentAssignTool, AgentCancelTool, AgentCloseTool, AgentListTool, AgentResultTool,
AgentResumeTool, AgentSendInputTool, AgentSpawnTool, AgentWaitTool,
DelegateToAgentTool, ReportAgentJobResultTool, SpawnAgentsOnCsvTool,
};
use super::swarm::AgentSwarmTool;
use super::swarm::{AgentSwarmTool, SwarmResultTool, SwarmStatusTool};
self.with_tool(Arc::new(AgentSpawnTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(AgentSpawnTool::with_name(
manager.clone(),
runtime.clone(),
"spawn_agent",
)))
.with_tool(Arc::new(DelegateToAgentTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime)))
.with_tool(Arc::new(AgentSwarmTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(SpawnAgentsOnCsvTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(ReportAgentJobResultTool))
.with_tool(Arc::new(SwarmStatusTool::new(
runtime.context.workspace.clone(),
)))
.with_tool(Arc::new(SwarmResultTool::new(
runtime.context.workspace.clone(),
)))
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
.with_tool(Arc::new(AgentSendInputTool::new(
manager.clone(),
"send_input",
)))
.with_tool(Arc::new(AgentAssignTool::new(
manager.clone(),
"agent_assign",
)))
.with_tool(Arc::new(AgentAssignTool::new(
manager.clone(),
"assign_agent",
)))
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait")))
.with_tool(Arc::new(AgentSendInputTool::new(
manager.clone(),
"agent_send_input",
)))
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait")))
.with_tool(Arc::new(AgentResumeTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(AgentCloseTool::new(manager.clone())))
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
.with_tool(Arc::new(AgentListTool::new(manager)))
}
+2928 -152
View File
File diff suppressed because it is too large Load Diff
+887 -41
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -296,6 +296,10 @@ pub struct App {
pub max_subagents: usize,
/// Cached sub-agent snapshots for UI views.
pub subagent_cache: Vec<SubAgentResult>,
/// Last known per-agent progress text for running sub-agents.
pub agent_progress: HashMap<String, String>,
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
pub ui_theme: UiTheme,
// Onboarding
pub onboarding: OnboardingState,
@@ -574,6 +578,8 @@ impl App {
allow_shell,
max_subagents,
subagent_cache: Vec::new(),
agent_progress: HashMap::new(),
agent_activity_started_at: None,
ui_theme,
onboarding: if needs_onboarding {
if was_onboarded && needs_api_key {
+210 -32
View File
@@ -48,7 +48,7 @@ use crate::task_manager::{
use crate::tools::ReviewOutput;
use crate::tools::plan::StepStatus;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
use crate::tools::subagent::{SubAgentResult, SubAgentStatus};
use crate::tools::todo::TodoStatus;
use crate::tui::command_palette::{
CommandPaletteView, build_entries as build_command_palette_entries,
@@ -95,6 +95,7 @@ const UI_ACTIVE_POLL_MS: u64 = 16;
const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 120;
const UI_TYPING_INDICATOR_MS: u64 = 120;
const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS;
const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2;
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -620,38 +621,46 @@ async fn run_event_loop(
}
}
EngineEvent::AgentSpawned { id, prompt } => {
app.add_message(HistoryCell::System {
content: format!(
"Sub-agent {id} spawned: {}",
summarize_tool_output(&prompt)
),
});
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
let _ = engine_handle.send(Op::ListSubAgents).await;
let prompt_summary = summarize_tool_output(&prompt);
app.agent_progress
.insert(id.clone(), format!("starting: {prompt_summary}"));
if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
app.add_message(HistoryCell::System {
content: format!("Sub-agent {id} spawned: {}", prompt_summary),
});
let _ = engine_handle.send(Op::ListSubAgents).await;
}
EngineEvent::AgentProgress { id, status } => {
app.agent_progress
.insert(id.clone(), summarize_tool_output(&status));
if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
app.status_message = Some(format!("Sub-agent {id}: {status}"));
}
EngineEvent::AgentComplete { id, result } => {
app.agent_progress.remove(&id);
app.add_message(HistoryCell::System {
content: format!(
"Sub-agent {id} completed: {}",
summarize_tool_output(&result)
),
});
if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
let _ = engine_handle.send(Op::ListSubAgents).await;
}
let _ = engine_handle.send(Op::ListSubAgents).await;
}
EngineEvent::AgentList { agents } => {
app.subagent_cache = agents.clone();
if app.view_stack.update_subagents(&agents) {
let mut sorted = agents.clone();
sort_subagents_in_place(&mut sorted);
app.subagent_cache = sorted.clone();
reconcile_subagent_activity_state(app);
if app.view_stack.update_subagents(&sorted) {
app.status_message =
Some(format!("Sub-agents: {} total", agents.len()));
Some(format!("Sub-agents: {} total", sorted.len()));
} else {
app.add_message(HistoryCell::System {
content: format_subagent_list(&agents),
content: format_subagent_list(&sorted),
});
}
}
@@ -815,7 +824,8 @@ async fn run_event_loop(
handle_view_events(app, &engine_handle, events).await;
}
if app.is_loading
let has_running_agents = running_agent_count(app) > 0;
if (app.is_loading || has_running_agents)
&& last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS)
{
app.needs_redraw = true;
@@ -837,7 +847,7 @@ async fn run_event_loop(
app.needs_redraw = false;
}
let mut poll_timeout = if app.is_loading {
let mut poll_timeout = if app.is_loading || has_running_agents {
Duration::from_millis(UI_ACTIVE_POLL_MS)
} else {
Duration::from_millis(UI_IDLE_POLL_MS)
@@ -2140,6 +2150,84 @@ fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec<String>
(previews, queue_count > shown_count)
}
fn running_agent_count(app: &App) -> usize {
let mut ids: std::collections::HashSet<&str> =
app.agent_progress.keys().map(String::as_str).collect();
for agent in app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
{
ids.insert(agent.agent_id.as_str());
}
ids.len()
}
fn active_agent_rows(app: &App, limit: usize) -> Vec<(String, String)> {
if limit == 0 {
return Vec::new();
}
let mut rows = Vec::new();
let mut seen = std::collections::HashSet::new();
for agent in app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
{
let detail = app
.agent_progress
.get(&agent.agent_id)
.cloned()
.unwrap_or_else(|| summarize_tool_output(&agent.assignment.objective));
rows.push((agent.agent_id.clone(), summarize_tool_output(&detail)));
seen.insert(agent.agent_id.clone());
if rows.len() >= limit {
return rows;
}
}
let mut extras: Vec<(String, String)> = app
.agent_progress
.iter()
.filter(|(id, _)| !seen.contains(id.as_str()))
.map(|(id, status)| (id.clone(), summarize_tool_output(status)))
.collect();
extras.sort_by(|a, b| a.0.cmp(&b.0));
rows.extend(extras.into_iter().take(limit.saturating_sub(rows.len())));
rows
}
fn reconcile_subagent_activity_state(app: &mut App) {
let running_agents: Vec<(String, String)> = app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
.map(|agent| {
(
agent.agent_id.clone(),
summarize_tool_output(&agent.assignment.objective),
)
})
.collect();
let running_ids: std::collections::HashSet<String> =
running_agents.iter().map(|(id, _)| id.clone()).collect();
app.agent_progress
.retain(|id, _| running_ids.contains(id.as_str()));
for (id, objective) in running_agents {
app.agent_progress.entry(id).or_insert(objective);
}
if running_ids.is_empty() {
app.agent_activity_started_at = None;
} else if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
}
fn compute_status_layout(
app: &App,
terminal_height: u16,
@@ -2154,7 +2242,10 @@ fn compute_status_layout(
};
}
let fixed_rows = usize::from(app.is_loading) + usize::from(app.queued_draft.is_some());
let active_agents = running_agent_count(app);
let fixed_rows = usize::from(app.is_loading)
+ usize::from(app.queued_draft.is_some())
+ usize::from(active_agents > 0);
let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows);
let (queued_preview, preview_compacted) = if queue_rows_budget > 0 {
@@ -2168,7 +2259,12 @@ fn compute_status_layout(
} else {
0
};
let requested_rows = fixed_rows + queue_rows;
let mut requested_rows = fixed_rows + queue_rows;
if active_agents > 0 {
let detail_rows_budget = usize::from(status_budget).saturating_sub(requested_rows);
let detail_rows = detail_rows_budget.min(active_agents.min(MAX_ACTIVE_AGENT_STATUS_ROWS));
requested_rows += detail_rows;
}
let status_height =
u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget);
let queued_compacted = preview_compacted || (app.queued_message_count() > 0 && queue_rows == 0);
@@ -2605,15 +2701,10 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR),
SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED),
};
let agent_type = match agent.agent_type {
SubAgentType::General => "general",
SubAgentType::Explore => "explore",
SubAgentType::Plan => "plan",
SubAgentType::Review => "review",
SubAgentType::Custom => "custom",
};
let agent_type = agent.agent_type.as_str();
let role = agent.assignment.role.as_deref().unwrap_or("default");
let summary = format!(
"{} {agent_type} {status_label} ({} steps)",
"{} {agent_type}/{role} {status_label} ({} steps)",
truncate_line_to_width(&agent.agent_id, 10),
agent.steps_taken
);
@@ -2621,6 +2712,16 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
truncate_line_to_width(&summary, content_width.max(1)),
Style::default().fg(status_color),
)));
lines.push(Line::from(Span::styled(
format!(
" {}",
truncate_line_to_width(
&agent.assignment.objective,
content_width.saturating_sub(2).max(1)
)
),
Style::default().fg(palette::TEXT_DIM),
)));
}
let remaining = app.subagent_cache.len().saturating_sub(max_agents);
@@ -3030,7 +3131,13 @@ fn render_status_indicator(
queued: &[String],
queued_compacted: bool,
) {
let mut lines = Vec::with_capacity(1 + queued.len() + usize::from(app.queued_draft.is_some()));
let max_rows = usize::from(area.height);
if max_rows == 0 {
return;
}
let mut lines = Vec::with_capacity(
2 + queued.len() + usize::from(app.queued_draft.is_some()) + MAX_ACTIVE_AGENT_STATUS_ROWS,
);
if app.is_loading {
let header = if app.show_thinking {
@@ -3083,6 +3190,49 @@ fn render_status_indicator(
lines.push(Line::from(spans));
}
let active_agent_total = running_agent_count(app);
if active_agent_total > 0 && lines.len() < max_rows {
let available = area.width as usize;
let spinner_start = app.agent_activity_started_at.or(app.turn_started_at);
let spinner = deepseek_squiggle(spinner_start);
let header = format!("AGENTS {active_agent_total} running | /agents");
lines.push(Line::from(vec![
Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::raw(" "),
Span::styled(
truncate_line_to_width(&header, available.saturating_sub(2).max(1)),
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
),
]));
let preview_limit = max_rows
.saturating_sub(lines.len())
.min(MAX_ACTIVE_AGENT_STATUS_ROWS);
let active_rows = active_agent_rows(app, preview_limit);
for (id, status) in &active_rows {
if lines.len() >= max_rows {
break;
}
let id_short = truncate_line_to_width(id, 12);
let status_single_line = status.lines().next().unwrap_or(status.as_str());
let prefix = format!(" {id_short}: ");
let detail_width = available.saturating_sub(prefix.width()).max(1);
let detail = truncate_line_to_width(status_single_line, detail_width);
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
Span::styled(detail, Style::default().fg(palette::TEXT_DIM)),
]));
}
let hidden = active_agent_total.saturating_sub(active_rows.len());
if hidden > 0 && lines.len() < max_rows {
lines.push(Line::from(Span::styled(
format!(" +{hidden} more running"),
Style::default().fg(palette::TEXT_MUTED),
)));
}
}
if let Some(draft) = app.queued_draft.as_ref() {
let available = area.width as usize;
let prefix = "Editing queued:";
@@ -3828,20 +3978,48 @@ fn extract_reasoning_header(text: &str) -> Option<String> {
}
}
fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
match status {
SubAgentStatus::Running => 0,
SubAgentStatus::Failed(_) => 1,
SubAgentStatus::Completed => 2,
SubAgentStatus::Cancelled => 3,
}
}
fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
agents.sort_by(|a, b| {
subagent_status_rank(&a.status)
.cmp(&subagent_status_rank(&b.status))
.then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
}
fn format_subagent_list(agents: &[SubAgentResult]) -> String {
if agents.is_empty() {
return "No sub-agents running.".to_string();
}
let mut sorted = agents.to_vec();
sort_subagents_in_place(&mut sorted);
let mut lines = Vec::new();
lines.push("Sub-agents:".to_string());
lines.push("----------------------------------------".to_string());
for agent in agents {
for agent in &sorted {
let status = format_subagent_status(&agent.status);
let role = agent.assignment.role.as_deref().unwrap_or("default");
let mut line = format!(
" {} ({:?}) - {} | steps: {} | {}ms",
agent.agent_id, agent.agent_type, status, agent.steps_taken, agent.duration_ms
" {} ({}/{}) - {} | steps: {} | {}ms\n objective: {}",
agent.agent_id,
agent.agent_type.as_str(),
role,
status,
agent.steps_taken,
agent.duration_ms,
summarize_tool_output(&agent.assignment.objective)
);
if matches!(agent.status, SubAgentStatus::Completed)
&& let Some(result) = agent.result.as_ref()
+105
View File
@@ -116,6 +116,111 @@ fn create_test_app() -> App {
App::new(options, &Config::default())
}
fn make_subagent(
id: &str,
status: crate::tools::subagent::SubAgentStatus,
) -> crate::tools::subagent::SubAgentResult {
crate::tools::subagent::SubAgentResult {
agent_id: id.to_string(),
agent_type: crate::tools::subagent::SubAgentType::General,
assignment: crate::tools::subagent::SubAgentAssignment {
objective: format!("objective-{id}"),
role: Some("worker".to_string()),
},
status,
result: None,
steps_taken: 0,
duration_ms: 0,
}
}
#[test]
fn sort_subagents_orders_running_before_terminal_statuses() {
let mut agents = vec![
make_subagent("agent_c", crate::tools::subagent::SubAgentStatus::Completed),
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
make_subagent(
"agent_b",
crate::tools::subagent::SubAgentStatus::Failed("boom".to_string()),
),
];
sort_subagents_in_place(&mut agents);
assert_eq!(agents[0].agent_id, "agent_a");
assert_eq!(agents[1].agent_id, "agent_b");
assert_eq!(agents[2].agent_id, "agent_c");
}
#[test]
fn running_agent_count_unions_cache_and_progress() {
let mut app = create_test_app();
app.subagent_cache = vec![
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
];
app.agent_progress
.insert("agent_c".to_string(), "planning".to_string());
assert_eq!(running_agent_count(&app), 2);
}
#[test]
fn compute_status_layout_reserves_rows_for_active_agents() {
let app = create_test_app();
let baseline = compute_status_layout(&app, 30, 3);
assert_eq!(baseline.status_height, 0);
let mut with_agents = create_test_app();
with_agents
.agent_progress
.insert("agent_a".to_string(), "running".to_string());
let active = compute_status_layout(&with_agents, 30, 3);
assert!(active.status_height >= 1);
}
#[test]
fn active_agent_rows_prefers_cache_order_and_progress_text() {
let mut app = create_test_app();
app.subagent_cache = vec![
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running),
];
app.agent_progress
.insert("agent_b".to_string(), "step 2".to_string());
app.agent_progress
.insert("agent_c".to_string(), "queued".to_string());
let rows = active_agent_rows(&app, 3);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].0, "agent_a");
assert!(rows[0].1.contains("objective-agent_a"));
assert_eq!(rows[1].0, "agent_b");
assert_eq!(rows[1].1, "step 2");
assert_eq!(rows[2].0, "agent_c");
}
#[test]
fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
let mut app = create_test_app();
app.subagent_cache = vec![
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
];
app.agent_progress
.insert("agent_stale".to_string(), "old".to_string());
reconcile_subagent_activity_state(&mut app);
assert!(app.agent_progress.contains_key("agent_a"));
assert!(!app.agent_progress.contains_key("agent_stale"));
assert!(app.agent_activity_started_at.is_some());
app.subagent_cache.clear();
reconcile_subagent_activity_state(&mut app);
assert!(app.agent_progress.is_empty());
assert!(app.agent_activity_started_at.is_none());
}
#[test]
fn format_token_count_compact_formats_units() {
assert_eq!(format_token_count_compact(999), "999");
+16
View File
@@ -1360,6 +1360,22 @@ fn append_subagent_group(
]));
}
if let Some(role) = agent.assignment.role.as_deref() {
let max_len = content_width.saturating_sub(14);
let role = truncate_view_text(role, max_len);
lines.push(Line::from(vec![
Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(role, Style::default().fg(palette::DEEPSEEK_SKY)),
]));
}
let max_len = content_width.saturating_sub(18);
let objective = truncate_view_text(&agent.assignment.objective, max_len);
lines.push(Line::from(vec![
Span::styled(" objective: ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(objective, Style::default().fg(palette::TEXT_DIM)),
]));
if let Some(result) = agent.result.as_ref() {
let max_len = content_width.saturating_sub(16);
let preview = truncate_view_text(result, max_len);