feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
@@ -0,0 +1,4 @@
//! Auto-subagents — automatically run review/test subagents at the end of
//! each turn.
pub mod paths;
@@ -0,0 +1,8 @@
//! Auto-subagent path resolution.
use std::path::PathBuf;
/// Resolve paths for auto-subagent scripts.
pub fn auto_subagent_dir(base_dir: &PathBuf) -> PathBuf {
base_dir.join("auto-agents")
}
@@ -0,0 +1,47 @@
//! Subagent execution context — wraps the shared state needed by a subagent.
//!
//! Includes LLM connection parameters (base_url, api_key, model) so the
//! engine can construct an `LlmClient` without loading settings itself.
use crate::tools::ToolCtx;
/// Context for a single subagent execution.
///
/// Flow: constructed by the caller (e.g. `execute_primitive`) with resolved
/// LLM credentials → passed to `engine::run_agent` → used to create the
/// `LlmClient` for LLM interaction.
pub struct SubagentContext {
/// The directive/instruction the subagent should execute.
pub directive: String,
/// Shared tool execution context (workspaces, session, memory paths).
pub tool_ctx: ToolCtx,
/// Access tier as a string (used for logging/serialization).
pub access_tier: String,
/// Base URL for the LLM provider API.
pub base_url: String,
/// API key for the LLM provider.
pub api_key: String,
/// Model identifier for the LLM provider.
pub model: String,
}
impl SubagentContext {
/// Create a new subagent context with all required fields.
pub fn new(
directive: String,
tool_ctx: ToolCtx,
access_tier: String,
base_url: String,
api_key: String,
model: String,
) -> Self {
SubagentContext {
directive,
tool_ctx,
access_tier,
base_url,
api_key,
model,
}
}
}
@@ -0,0 +1,91 @@
//! Subagent division — access-tier tool filtering for subagent permissions.
//!
//! Flow: the calling code picks an `AccessTier` → `tools_for()` returns the
//! subset of all built-in tools allowed at that tier → those tools are passed
//! to `engine::run_agent` for the subagent's tool-execution loop.
use crate::tools::Tool;
/// Access tier for subagent tool permissions.
///
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
/// includes everything in `Write`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessTier {
/// Read-only: search, read, glob, utility tools (no mutations).
Read,
/// Read + Write: above plus write, edit, delete, git, memory.
Write,
/// Full: above plus bash, shell, LSP, workflow, plan tools.
Full,
}
/// Filter the available tools to match the given access tier.
///
/// Flow: `all_tools()` → filter by tier → return owned `Vec<Box<dyn Tool>>`.
///
/// Read tier: non-mutating introspection and utility tools only.
/// Write tier: everything except dangerous system/network/process tools.
/// Full tier: all 37 tools.
pub fn tools_for(access: &AccessTier) -> Vec<Box<dyn Tool>> {
let all = crate::tools::all_tools();
match access {
AccessTier::Read => all
.into_iter()
.filter(|t| {
let name = t.name();
matches!(
name,
"read"
| "grep"
| "glob"
| "pong"
| "todowrite"
| "todofinish"
| "dir_list"
| "dir_cache_update"
| "cd"
| "remember"
| "recall"
| "forget"
)
})
.collect(),
AccessTier::Write => all
.into_iter()
.filter(|t| {
let name = t.name();
!matches!(
name,
"bash"
| "bash_output"
| "bash_kill"
| "git_operator"
| "git_worktree"
| "git_cred"
| "shell"
| "workflow_run"
| "note_finding"
| "read_findings"
| "hive_mind"
| "spawn_agents"
| "spawn_pipeline"
| "plan_enter"
| "plan_ready"
| "sequential_think"
| "lsp_connect"
| "lsp_disconnect"
| "lsp_hover"
| "lsp_completion"
| "lsp_definition"
| "lsp_references"
| "lsp_diagnostics"
)
})
.collect(),
AccessTier::Full => all, // everything
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Subagent engine — runs an LLM-powered agent with tool execution loop.
//!
//! Flow: construct system message → call LLM → parse tool calls → execute
//! tools → continue until the model returns a final text response (no more
//! tool calls) or the iteration limit is reached.
use anyhow::Result;
use tracing::{debug, info};
use crate::llm::provider::LlmClient;
use crate::subagent::context::SubagentContext;
use crate::subagent::division::{tools_for, AccessTier};
use crate::tools::{tool_defs, ToolCtx};
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
use zesdex_domain::core::ChatMessage;
/// Maximum number of tool-call iterations before the engine gives up.
const MAX_ITERATIONS: u32 = 25;
/// Run an agent with a directive, access tier, and tool context.
///
/// Flow:
/// 1. Resolve allowed tools for the given `access` tier.
/// 2. Build a system prompt from the directive.
/// 3. Loop (up to `MAX_ITERATIONS`):
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
/// b. If the response has no tool calls → return the text content.
/// c. Otherwise execute each tool call and append the result as a
/// tool-role message.
/// d. If the response also contained text, append an assistant message.
/// 4. If the loop exits naturally, return the iteration-limit message.
pub async fn run_agent(
ctx: SubagentContext,
directive: &str,
access: AccessTier,
tool_ctx: ToolCtx,
) -> Result<String> {
info!("Subagent starting with directive: {directive}");
let tools = tools_for(&access);
let defs = tool_defs(&tools);
let mut messages = vec![ChatMessage::system(format!(
"You are a focused subagent.\n\nYour directive:\n{directive}\n\n\
Complete the directive autonomously using the tools available to you. \
Return your final answer when done."
))];
let client = LlmClient::new(
ctx.api_key.clone(),
ctx.model.clone(),
Some(ctx.base_url.clone()),
);
// Limited iteration loop so we don't run forever
for iteration in 0..MAX_ITERATIONS {
let (response_msg, _usage) = client.chat_with_tools_non_streaming(
&messages,
Some(defs.clone()),
Some(4096),
None,
None,
)?;
let content = response_msg.content.clone().unwrap_or_default();
let tool_calls = response_msg.tool_calls.unwrap_or_default();
// If no tool calls, we're done — return content
if tool_calls.is_empty() {
info!("Subagent completed after {iteration} iterations");
return Ok(content);
}
// Execute tool calls
for tc in &tool_calls {
let tool_name = &tc.function.name;
let args = sanitize_tool_arguments(&tc.function.arguments);
debug!("Subagent executing tool: {tool_name}");
let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
match tool.run(&tool_ctx, &args) {
Ok(output) => output,
Err(e) => format!("Error: {e}"),
}
} else {
format!("Unknown tool: {tool_name}")
};
messages.push(ChatMessage::tool(tc.id.clone(), result));
}
// Add assistant response if there was text content
if !content.is_empty() {
messages.push(ChatMessage::assistant(Some(content)));
}
}
Ok("Subagent reached iteration limit".to_string())
}
+27
View File
@@ -0,0 +1,27 @@
//! Subagent event types — events emitted during subagent execution.
/// Events emitted by a running subagent.
#[derive(Debug, Clone)]
pub enum SubagentEvent {
Started {
agent_id: String,
directive: String,
},
ToolCall {
agent_id: String,
tool_name: String,
},
ToolResult {
agent_id: String,
tool_name: String,
output: String,
},
Completed {
agent_id: String,
output: String,
},
Failed {
agent_id: String,
error: String,
},
}
@@ -0,0 +1,14 @@
//! Subagent gating — decide whether to run review/test/arch agents based
//! on the current context.
/// Determine whether an auto-review should be triggered after an edit.
pub fn should_review(edit_count: u32, consecutive_empty_reviews: u32, max_skip: u32) -> bool {
if edit_count == 0 {
return false;
}
// Skip review if we've had several consecutive empty reviews
if consecutive_empty_reviews >= max_skip {
return false;
}
true
}
+12
View File
@@ -0,0 +1,12 @@
//! Subagent spawning and execution engine — spawn managed sub-processes
//! for test generation, architecture review, security review, etc.
pub mod context;
pub mod division;
pub mod engine;
pub mod event;
pub mod gating;
pub mod provider;
pub mod spawn;
pub mod tools;
pub mod workspace;
@@ -0,0 +1,82 @@
//! Subagent LLM provider — resolves provider/model from settings and wraps
//! `LlmClient` in a higher-level API for subagent use.
//!
//! Flow: `resolve_subagent_provider` is called at startup to pick a provider
//! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use
//! inside the subagent engine loop.
use anyhow::Result;
use crate::llm::provider::LlmClient;
use crate::tools::{tool_defs, Tool};
use zesdex_domain::core::ChatMessage;
/// Provider wrapper for subagent LLM interactions.
///
/// Provides two convenience methods (`chat`, `chat_with_tools`) that
/// abstract away the raw `LlmClient` parameter plumbing so the engine
/// loop only deals with messages and tools.
///
/// The model identifier is already embedded in the `LlmClient` itself
/// (its `model` field), so `SubagentProvider` does not duplicate it.
pub struct SubagentProvider {
client: LlmClient,
}
impl SubagentProvider {
/// Wrap an existing `LlmClient` for higher-level use.
pub fn new(client: LlmClient) -> Self {
Self { client }
}
/// Send messages to the LLM without any tool definitions.
///
/// Use this for a plain text-in/text-out conversation.
pub fn chat(
&self,
messages: &[ChatMessage],
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
self.client
.chat_with_tools_non_streaming(messages, None, Some(4096), None, None)
}
/// Send messages with available tool definitions.
///
/// Automatically converts the `&[Box<dyn Tool>]` slice to
/// `Vec<ToolDef>` before passing to the underlying client.
pub fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: &[Box<dyn Tool>],
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
let defs = tool_defs(tools);
self.client
.chat_with_tools_non_streaming(messages, Some(defs), Some(4096), None, None)
}
}
/// Resolve subagent provider and model from settings.
///
/// Flow: reads `settings.provider` and `settings.model` → if model is empty,
/// falls back to the provider config's `default_model` → if that is also
/// empty, uses `"deepseek-v4-flash-free"` as the ultimate default.
pub fn resolve_subagent_provider(
settings: &zesdex_domain::cms::Settings,
app_config: &zesdex_domain::cms::AppConfig,
) -> (String, String) {
let provider = settings.provider.clone();
let model = settings.model.clone();
// Use the default model from the provider config if available
let model = if model.is_empty() {
app_config
.providers
.get(&provider)
.and_then(|p| p.default_model.clone())
.unwrap_or_else(|| "deepseek-v4-flash-free".to_string())
} else {
model
};
(provider, model)
}
+37
View File
@@ -0,0 +1,37 @@
//! Subagent spawning — launch a subagent on a background OS thread.
//!
//! Flow: creates a new tokio runtime on a dedicated OS thread, then
//! `block_on` the engine's `run_agent` future. Returns a
//! `JoinHandle<Result<String>>` the caller can `.join()`.
use std::thread;
use anyhow::Result;
use tracing::info;
use crate::subagent::context::SubagentContext;
use crate::subagent::division::AccessTier;
use crate::subagent::engine::run_agent;
use crate::tools::ToolCtx;
/// Spawn a subagent on a background OS thread.
///
/// The subagent runs inside its own tokio runtime so it can make async calls
/// without blocking the calling thread's runtime.
///
/// Flow: `thread::spawn` → create `tokio::runtime::Runtime` →
/// `runtime.block_on(run_agent(...))` → return.
///
/// Returns a `JoinHandle` the caller can `join()` to await the result.
pub fn spawn_subagent(
ctx: SubagentContext,
directive: String,
access: AccessTier,
tool_ctx: ToolCtx,
) -> thread::JoinHandle<Result<String>> {
info!("Spawning subagent: {directive}");
thread::spawn(move || {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(run_agent(ctx, &directive, access, tool_ctx))
})
}
+13
View File
@@ -0,0 +1,13 @@
//! Subagent tool helpers — wrap tool execution for subagent use.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
/// Execute a single tool call within a subagent context.
pub fn execute_tool_call(
tool: &dyn Tool,
ctx: &ToolCtx,
args: &serde_json::Value,
) -> Result<String> {
tool.run(ctx, args)
}
@@ -0,0 +1,10 @@
//! Subagent workspace management — create isolated workspaces for subagents.
use std::path::PathBuf;
/// Create an isolated workspace directory for a subagent.
pub fn create_subagent_workspace(base_dir: &PathBuf, agent_id: &str) -> anyhow::Result<PathBuf> {
let ws = base_dir.join("subagent-workspaces").join(agent_id);
std::fs::create_dir_all(&ws)?;
Ok(ws)
}