feat: Enhance OAuth module and OpenRouter client functionality
- Updated OAuth module to include unused imports for better clarity. - Refactored OpenRouterClient to improve chat functionality and added support for tools in chat requests. - Modified internet tools (Download, Fetch, Search) to use a more flexible internet mode check. - Introduced new Bash tools for managing background jobs (BashOutput, BashKill). - Enhanced workflow tool to parse and execute workflow scripts with arguments. - Updated status and workflow views to reflect new agent and findings counts. - Added IPC protocol definitions for client requests and state payloads.
This commit is contained in:
+1
Submodule .claude/worktrees/agent-a2d818144fe5555bc added at c1ad206a00
+1
Submodule .claude/worktrees/agent-a42650db8ee77314e added at 7cb4ae6708
+1
Submodule .claude/worktrees/agent-a712289b5e04047c2 added at 7cb4ae6708
+1
Submodule .claude/worktrees/agent-ada61e93513e2b0e4 added at 7cb4ae6708
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
target/
|
target/
|
||||||
.env
|
.env
|
||||||
|
.claude/settings.local.json
|
||||||
+52
-1
@@ -18,7 +18,58 @@ impl Harness {
|
|||||||
if mode.auto_approve() {
|
if mode.auto_approve() {
|
||||||
return Verdict::Allow;
|
return Verdict::Allow;
|
||||||
}
|
}
|
||||||
Verdict::Allow
|
if matches!(mode, super::state::types::AgentMode::Plan) {
|
||||||
|
return Verdict::Block("mutating tools are disabled in Plan mode".to_string());
|
||||||
|
}
|
||||||
|
Verdict::Escalate
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gate_tool_call(
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
mode: &super::state::types::AgentMode,
|
||||||
|
workspace_roots: &[&std::path::Path],
|
||||||
|
) -> Verdict {
|
||||||
|
if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) {
|
||||||
|
return Verdict::Block(e);
|
||||||
|
}
|
||||||
|
if !crate::tool::tool_is_risky(tool_name) {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
Self::classify(tool_name, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_catastrophic_guard(
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
workspace_roots: &[&std::path::Path],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use super::catastrophic::CatastrophicGuard;
|
||||||
|
match tool_name {
|
||||||
|
"bash" => {
|
||||||
|
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
CatastrophicGuard::check_all(cmd, workspace_roots)
|
||||||
|
}
|
||||||
|
"git_operator" => {
|
||||||
|
let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let arg_list: Vec<String> = args
|
||||||
|
.get("args")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let cmd = format!("git {} {}", operation, arg_list.join(" "));
|
||||||
|
CatastrophicGuard::check_all(&cmd, workspace_roots)
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
CatastrophicGuard::check_delete_path(std::path::Path::new(path), workspace_roots)
|
||||||
|
}
|
||||||
|
"web_download" | "download" => {
|
||||||
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
CatastrophicGuard::check_download_path(std::path::Path::new(path))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ pub struct McpManager {
|
|||||||
pub running: bool,
|
pub running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct McpToolAdapter {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::tool::Tool for McpToolAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
self.parameters.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, _ctx: &crate::tool::ToolCtx, _args: &serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
Err(anyhow::anyhow!("MCP tool execution not yet implemented"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl McpManager {
|
impl McpManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
McpManager {
|
McpManager {
|
||||||
@@ -75,4 +99,15 @@ impl McpManager {
|
|||||||
self.running = false;
|
self.running = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||||
|
self.all_tools().into_iter().map(|info| {
|
||||||
|
let name = format!("mcp__{}", info.name);
|
||||||
|
Box::new(McpToolAdapter {
|
||||||
|
name: Box::leak(name.into_boxed_str()),
|
||||||
|
description: Box::leak(info.description.clone().into_boxed_str()),
|
||||||
|
parameters: info.input_schema.clone(),
|
||||||
|
}) as Box<dyn crate::tool::Tool>
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
||||||
state.misc.overlay = Overlay::None;
|
state.misc.overlay = Overlay::None;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod bash;
|
pub mod bash;
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
@@ -22,17 +21,13 @@ pub mod onboard;
|
|||||||
pub mod onboard_provider;
|
pub mod onboard_provider;
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod picker;
|
pub mod picker;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod quit_confirm;
|
pub mod quit_confirm;
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod security;
|
pub mod security;
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod session_hub;
|
pub mod session_hub;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub fn toggle_security_arm(state: &mut AppStateRest) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
pub fn acknowledge_security(state: &mut AppStateRest) {
|
pub fn acknowledge_security(state: &mut AppStateRest) {
|
||||||
if !state.misc.security_acknowledged {
|
if !state.misc.security_acknowledged {
|
||||||
state.misc.security_acknowledged = true;
|
state.misc.security_acknowledged = true;
|
||||||
|
|||||||
+36
-10
@@ -1,20 +1,46 @@
|
|||||||
use crate::model::session::Session;
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
pub fn load_sessions(state: &mut AppStateRest) {
|
pub fn load_sessions(state: &mut AppStateRest) {
|
||||||
state.sessions = Session::list(&state.session_dir);
|
let base = state.store_base_dir();
|
||||||
|
state.sessions = crate::model::session::Session::list(&base);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_session(state: &mut AppStateRest, session_id: &str) {
|
pub fn select_session(state: &mut AppStateRest, session_id: &str) {
|
||||||
if let Some(session) = state.sessions.iter().find(|s| s.id == session_id) {
|
let base = state.store_base_dir();
|
||||||
let display = crate::app::state::rest::ChatMessageDisplay::new(
|
let session = crate::model::session::Session::load(session_id, &base).ok();
|
||||||
crate::dto::chat::message::Role::System,
|
if session.is_none() {
|
||||||
format!("switched to session: {}", session.title),
|
return;
|
||||||
);
|
|
||||||
state.push_transcript(display);
|
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
}
|
||||||
|
let session = session.unwrap();
|
||||||
|
let conv_path = session.conversation_path(&base);
|
||||||
|
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
|
||||||
|
std::fs::read_to_string(&conv_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|data| serde_json::from_str(&data).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
state.session_id = session.id.clone();
|
||||||
|
state.session_dir = session.session_dir(&base);
|
||||||
|
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
|
||||||
|
state.session_dir.clone(),
|
||||||
|
));
|
||||||
|
state.transcript_cache.messages.clear();
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
for msg in loaded_msgs {
|
||||||
|
let display = crate::app::state::rest::ChatMessageDisplay::new(
|
||||||
|
msg.role.clone(),
|
||||||
|
msg.content.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
state.transcript_cache.messages.push(display);
|
||||||
|
rt.push_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
||||||
|
crate::dto::chat::message::Role::System,
|
||||||
|
format!("switched to session: {}", session.title),
|
||||||
|
));
|
||||||
|
state.misc.overlay = Overlay::None;
|
||||||
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::app::runtime::actions::Action;
|
|||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::model::settings::{Settings, InternetMode};
|
use crate::model::settings::{Settings, InternetMode};
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
||||||
if let Action::ToggleYoloArm = action {
|
if let Action::ToggleYoloArm = action {
|
||||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||||
@@ -17,6 +18,7 @@ pub fn cycle_internet_mode(settings: &mut Settings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code)]
|
||||||
pub fn cycle_review_enabled(settings: &mut Settings) {
|
pub fn cycle_review_enabled(settings: &mut Settings) {
|
||||||
settings.review_enabled = !settings.review_enabled;
|
settings.review_enabled = !settings.review_enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-4
@@ -1,6 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
use crate::app::state::runtime::TurnEvent;
|
||||||
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||||
|
use crate::app::subagent::context::build_subagent_context;
|
||||||
|
use crate::app::subagent::engine::run_subagent;
|
||||||
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -163,10 +167,45 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||||
let _origin = Origin::Reviewer;
|
let def = AgentDefinition::new(
|
||||||
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
"quality-reviewer".to_string(),
|
||||||
crate::dto::chat::message::Role::System,
|
"reviewer".to_string(),
|
||||||
"review triggered".to_string(),
|
);
|
||||||
|
let mut ctx = build_subagent_context(def);
|
||||||
|
ctx.session_dir = state.session_dir.clone();
|
||||||
|
ctx.system_prompt = format!(
|
||||||
|
"You are a code quality reviewer. Review the recent code changes \
|
||||||
|
for correctness, security, and adherence to best practices. \
|
||||||
|
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||||
|
inspect the session files and provide a concise review verdict. \
|
||||||
|
Session directory: {:?}",
|
||||||
|
state.session_dir
|
||||||
|
);
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
||||||
|
let turn_events = state.turn_events.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = run_subagent(ctx, tx);
|
||||||
|
let message = match result {
|
||||||
|
Ok(verdict) => {
|
||||||
|
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
||||||
|
format!("Quality review: {}", first_line)
|
||||||
|
}
|
||||||
|
Err(e) => format!("Quality review failed: {}", e),
|
||||||
|
};
|
||||||
|
if let Ok(mut q) = turn_events.lock() {
|
||||||
|
q.push_back(TurnEvent::SystemNote {
|
||||||
|
kind: "review".to_string(),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.push_toast(Toast::new(
|
||||||
|
ToastKind::Info,
|
||||||
|
"Quality review triggered".to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+392
-43
@@ -1,10 +1,17 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::app::harness::Verdict;
|
||||||
|
use sha2::Digest;
|
||||||
use crate::app::mode::ModeKind;
|
use crate::app::mode::ModeKind;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::review::{should_trigger_review, trigger_review};
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::app::state::runtime::TurnEvent;
|
||||||
|
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
|
||||||
|
use crate::dto::chat::message::{ChatMessage, Role};
|
||||||
|
|
||||||
|
const MAX_AGENT_STEPS: usize = 40;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[expect(dead_code)]
|
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
Quit,
|
Quit,
|
||||||
ForceQuit,
|
ForceQuit,
|
||||||
@@ -22,6 +29,7 @@ pub enum Action {
|
|||||||
OpenOverlay(Overlay),
|
OpenOverlay(Overlay),
|
||||||
CloseOverlay,
|
CloseOverlay,
|
||||||
ToggleYoloArm,
|
ToggleYoloArm,
|
||||||
|
CycleAgentMode,
|
||||||
ToolResult {
|
ToolResult {
|
||||||
tool_call_id: String,
|
tool_call_id: String,
|
||||||
output: String,
|
output: String,
|
||||||
@@ -44,22 +52,29 @@ pub enum Action {
|
|||||||
LessonImport {
|
LessonImport {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
#[expect(dead_code)]
|
||||||
RecordUsage {
|
RecordUsage {
|
||||||
tokens_in: u64,
|
tokens_in: u64,
|
||||||
tokens_out: u64,
|
tokens_out: u64,
|
||||||
duration_ms: u64,
|
duration_ms: u64,
|
||||||
},
|
},
|
||||||
|
#[expect(dead_code)]
|
||||||
RecordReviewTokens {
|
RecordReviewTokens {
|
||||||
tokens: u64,
|
tokens: u64,
|
||||||
},
|
},
|
||||||
|
SaveSession,
|
||||||
|
ResumeSession,
|
||||||
|
RefreshSessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||||
match action {
|
match action {
|
||||||
Action::Quit => {
|
Action::Quit => {
|
||||||
|
save_current_session(state);
|
||||||
state.quit = true;
|
state.quit = true;
|
||||||
}
|
}
|
||||||
Action::ForceQuit => {
|
Action::ForceQuit => {
|
||||||
|
save_current_session(state);
|
||||||
state.quit = true;
|
state.quit = true;
|
||||||
}
|
}
|
||||||
Action::SwitchMode(mode) => {
|
Action::SwitchMode(mode) => {
|
||||||
@@ -87,32 +102,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::SubmitInput(text) => {
|
Action::SubmitInput(text) => {
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
state.input.submit();
|
||||||
rt.push_message(ChatMessage::user(text.clone()));
|
let text = text.trim().to_string();
|
||||||
let api_key = state.settings.api_key.clone();
|
if text.is_empty() {
|
||||||
let model = state.settings.model.clone();
|
state.dirty = true;
|
||||||
let msgs = rt.messages.clone();
|
return;
|
||||||
let pending = state.pending_api_response.clone();
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
if !key.is_empty() {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
|
||||||
match client.chat(&msgs) {
|
|
||||||
Ok(response) => {
|
|
||||||
if let Ok(mut guard) = pending.lock() {
|
|
||||||
*guard = Some(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if let Ok(mut guard) = pending.lock() {
|
|
||||||
*guard = Some(format!("Error: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.push_message(ChatMessage::user(text));
|
||||||
|
}
|
||||||
|
spawn_turn(state);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::InsertChar(c) => {
|
Action::InsertChar(c) => {
|
||||||
@@ -164,6 +164,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
Action::CycleAgentMode => {
|
||||||
|
let next = state.mode.cycle();
|
||||||
|
state.set_mode(next);
|
||||||
|
let toast = Toast::new(ToastKind::Info, format!("Mode: {}", state.mode.name()));
|
||||||
|
state.push_toast(toast);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
Action::ToolResult {
|
Action::ToolResult {
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
output,
|
output,
|
||||||
@@ -269,26 +276,117 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
Action::SaveSession => {
|
||||||
|
save_current_session(state);
|
||||||
|
state.push_toast(Toast::new(ToastKind::Success, "session saved".to_string()));
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::ResumeSession => {
|
||||||
|
let base = state.store_base_dir();
|
||||||
|
let sessions = crate::model::session::Session::list(&base);
|
||||||
|
let target = sessions.into_iter()
|
||||||
|
.filter(|s| s.id != state.session_id)
|
||||||
|
.max_by_key(|s| s.updated_at);
|
||||||
|
if let Some(session) = target {
|
||||||
|
let conv_path = session.conversation_path(&base);
|
||||||
|
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
|
||||||
|
std::fs::read_to_string(&conv_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|data| serde_json::from_str(&data).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
state.session_id = session.id.clone();
|
||||||
|
state.session_dir = session.session_dir(&base);
|
||||||
|
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
|
||||||
|
state.session_dir.clone(),
|
||||||
|
));
|
||||||
|
state.transcript_cache.messages.clear();
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
for msg in loaded_msgs {
|
||||||
|
let display = ChatMessageDisplay::new(
|
||||||
|
msg.role.clone(),
|
||||||
|
msg.content.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
state.transcript_cache.messages.push(display);
|
||||||
|
rt.push_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.push_toast(Toast::new(ToastKind::Success,
|
||||||
|
format!("resumed session: {}", session.title)));
|
||||||
|
} else {
|
||||||
|
state.push_toast(Toast::new(ToastKind::Info,
|
||||||
|
"no other sessions to resume".to_string()));
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
Action::RefreshSessions => {
|
||||||
|
let base = state.store_base_dir();
|
||||||
|
state.sessions = crate::model::session::Session::list(&base);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
Action::Tick => {
|
Action::Tick => {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
state.misc.drain_expired_toasts(now_ms);
|
state.misc.drain_expired_toasts(now_ms);
|
||||||
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
|
let events: Vec<TurnEvent> = {
|
||||||
guard.take()
|
if let Ok(mut q) = state.turn_events.lock() {
|
||||||
} else {
|
q.drain(..).collect()
|
||||||
None
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Some(response) = api_response {
|
let mut turn_finished = false;
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
for event in events {
|
||||||
if response.starts_with("Error:") {
|
match event {
|
||||||
let toast = crate::app::state::types::Toast::new(
|
TurnEvent::AssistantMessage(msg) => {
|
||||||
crate::app::state::types::ToastKind::Error,
|
let display_content = msg.content.clone().unwrap_or_default();
|
||||||
response,
|
if !display_content.is_empty() {
|
||||||
);
|
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content));
|
||||||
state.push_toast(toast);
|
}
|
||||||
} else {
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
rt.push_message(ChatMessage::assistant(Some(response)));
|
rt.push_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error } => {
|
||||||
|
state.push_transcript(ChatMessageDisplay::new(
|
||||||
|
Role::Tool,
|
||||||
|
format!("{}: {}", tool_name, output),
|
||||||
|
));
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
||||||
|
rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult {
|
||||||
|
tool_call_id,
|
||||||
|
tool_name,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
duration_ms: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TurnEvent::SystemNote { kind, message } => {
|
||||||
|
if kind == "edits" {
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
if let Ok(count) = message.parse::<u32>() {
|
||||||
|
rt.edit_count += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if should_trigger_review(state, Origin::Main) {
|
||||||
|
let _ = trigger_review(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
||||||
|
}
|
||||||
|
TurnEvent::Error(msg) => {
|
||||||
|
state.push_toast(Toast::new(ToastKind::Error, msg));
|
||||||
|
turn_finished = true;
|
||||||
|
}
|
||||||
|
TurnEvent::Done => {
|
||||||
|
turn_finished = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if turn_finished {
|
||||||
|
maybe_trigger_review(state);
|
||||||
|
}
|
||||||
|
if turn_finished || state.dirty {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,3 +404,254 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_turn(state: &AppStateRest) {
|
||||||
|
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
|
||||||
|
*guard
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if in_flight {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let messages = state
|
||||||
|
.session_runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|rt| rt.messages.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if messages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
||||||
|
if api_key.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let model = state.settings.model.clone();
|
||||||
|
let mut tools = crate::tool::all_tools();
|
||||||
|
tools.extend(state.mcp_manager.as_tools());
|
||||||
|
let tool_defs = crate::tool::tool_defs(&tools);
|
||||||
|
let ctx = state.tool_ctx();
|
||||||
|
let mode = state.mode;
|
||||||
|
let edit_log_path = state.edit_log.path.clone();
|
||||||
|
let session_id = state.session_id.clone();
|
||||||
|
let turn_events = state.turn_events.clone();
|
||||||
|
let in_flight_flag = state.turn_in_flight.clone();
|
||||||
|
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
|
||||||
|
|
||||||
|
*in_flight_flag.lock().unwrap() = true;
|
||||||
|
|
||||||
|
let events_q = turn_events.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let tc = TurnCtx {
|
||||||
|
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
|
||||||
|
tdefs: tool_defs,
|
||||||
|
tools,
|
||||||
|
ctx,
|
||||||
|
mode,
|
||||||
|
workspace_roots,
|
||||||
|
edit_log_path,
|
||||||
|
session_id,
|
||||||
|
};
|
||||||
|
let result = run_agent_turn(tc, &messages, &events_q);
|
||||||
|
if let Err(e) = result {
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::Error(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut flag) = in_flight_flag.lock() {
|
||||||
|
*flag = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TurnCtx {
|
||||||
|
client: crate::service::openrouter::OpenRouterClient,
|
||||||
|
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
|
||||||
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||||
|
ctx: crate::tool::ToolCtx,
|
||||||
|
mode: AgentMode,
|
||||||
|
workspace_roots: Vec<std::path::PathBuf>,
|
||||||
|
edit_log_path: std::path::PathBuf,
|
||||||
|
session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_agent_turn(
|
||||||
|
tc: TurnCtx,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
events_q: &std::sync::Mutex<VecDeque<TurnEvent>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut msgs = messages.to_vec();
|
||||||
|
let mut edits_this_turn = 0u32;
|
||||||
|
|
||||||
|
for _step in 0..MAX_AGENT_STEPS {
|
||||||
|
let response = tc
|
||||||
|
.client
|
||||||
|
.chat_with_tools(&msgs, Some(tc.tdefs.clone()))?;
|
||||||
|
|
||||||
|
let has_tool_calls = response.tool_calls.is_some()
|
||||||
|
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||||
|
|
||||||
|
let content = response.content.clone().unwrap_or_default();
|
||||||
|
if has_tool_calls {
|
||||||
|
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
||||||
|
msgs.push(response);
|
||||||
|
for tool_call in tool_calls {
|
||||||
|
let tool_name = tool_call.function.name.clone();
|
||||||
|
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
||||||
|
&tool_call.function.arguments,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ws_roots: Vec<&std::path::Path> =
|
||||||
|
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
|
||||||
|
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||||
|
&tool_name,
|
||||||
|
&args,
|
||||||
|
&tc.mode,
|
||||||
|
&ws_roots,
|
||||||
|
);
|
||||||
|
|
||||||
|
let is_edit_tool = tool_name == "write" || tool_name == "edit";
|
||||||
|
let (output, is_error, is_edit) = match verdict {
|
||||||
|
Verdict::Allow => match execute_one_tool(
|
||||||
|
&tc.tools,
|
||||||
|
&tc.ctx,
|
||||||
|
&tool_name,
|
||||||
|
&args,
|
||||||
|
&tc.edit_log_path,
|
||||||
|
&tc.session_id,
|
||||||
|
) {
|
||||||
|
Ok(result) => (result, false, is_edit_tool),
|
||||||
|
Err(e) => (e.to_string(), true, false),
|
||||||
|
},
|
||||||
|
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
||||||
|
Verdict::Escalate => (
|
||||||
|
"Tool requires approval. Switch to Auto mode or provide explicit approval."
|
||||||
|
.to_string(),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_edit {
|
||||||
|
edits_this_turn += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::ToolResult {
|
||||||
|
tool_call_id: tool_call.id.clone(),
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
output: output.clone(),
|
||||||
|
is_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
||||||
|
msgs.push(tool_msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !content.is_empty() {
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::AssistantMessage(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if edits_this_turn > 0 {
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::SystemNote {
|
||||||
|
kind: "edits".to_string(),
|
||||||
|
message: edits_this_turn.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut q) = events_q.lock() {
|
||||||
|
q.push_back(TurnEvent::Done);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_one_tool(
|
||||||
|
tools: &[Box<dyn crate::tool::Tool>],
|
||||||
|
ctx: &crate::tool::ToolCtx,
|
||||||
|
name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
edit_log_path: &std::path::Path,
|
||||||
|
session_id: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
for tool in tools {
|
||||||
|
if tool.name() == name {
|
||||||
|
let result = tool.run(ctx, args)?;
|
||||||
|
if name == "write" || name == "edit" {
|
||||||
|
let reason = args
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unnamed");
|
||||||
|
let path = args
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let content_sha256 = {
|
||||||
|
let content = args.get("content").or_else(|| args.get("new"));
|
||||||
|
let hash = sha2::Sha256::digest(
|
||||||
|
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||||
|
);
|
||||||
|
format!("{:x}", hash)
|
||||||
|
};
|
||||||
|
let entry = crate::model::editlog::EditLogEntry {
|
||||||
|
ts: chrono::Utc::now().timestamp_millis(),
|
||||||
|
tool: name.to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
content_sha256,
|
||||||
|
bytes_delta: result.len() as i64,
|
||||||
|
origin: ctx.origin.tag(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
};
|
||||||
|
let mut el = crate::model::editlog::EditLog::new(edit_log_path);
|
||||||
|
el.append(entry).ok();
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("tool not found: {}", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||||
|
if !state.settings.review_enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let edit_count = state
|
||||||
|
.session_runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|rt| rt.edit_count)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if edit_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.push_toast(Toast::new(
|
||||||
|
ToastKind::Info,
|
||||||
|
format!("{} file(s) modified this turn. Review available.", edit_count),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_current_session(state: &AppStateRest) {
|
||||||
|
let base = state.store_base_dir();
|
||||||
|
let session = crate::model::session::Session::new(
|
||||||
|
state.session_id.clone(),
|
||||||
|
"session".to_string(),
|
||||||
|
);
|
||||||
|
let _ = session.save(&base);
|
||||||
|
if let Some(ref rt) = state.session_runtime {
|
||||||
|
let conv_path = session.conversation_path(&base);
|
||||||
|
if let Ok(data) = serde_json::to_string(&rt.messages) {
|
||||||
|
let _ = std::fs::write(&conv_path, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
vec![Action::QuitConfirm]
|
vec![Action::QuitConfirm]
|
||||||
}
|
}
|
||||||
Command::Resume => {
|
Command::Resume => {
|
||||||
vec![Action::CloseOverlay]
|
vec![Action::SaveSession, Action::ResumeSession, Action::CloseOverlay]
|
||||||
}
|
}
|
||||||
Command::LessonCreate(text) => {
|
Command::LessonCreate(text) => {
|
||||||
vec![Action::SystemNote {
|
vec![Action::SystemNote {
|
||||||
@@ -38,9 +38,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
Command::Save => {
|
Command::Save => {
|
||||||
|
vec![Action::SaveSession]
|
||||||
|
}
|
||||||
|
Command::Login { provider } => {
|
||||||
vec![Action::SystemNote {
|
vec![Action::SystemNote {
|
||||||
kind: "save".to_string(),
|
kind: "oauth".to_string(),
|
||||||
message: "session saved".to_string(),
|
message: format!("OAuth login flow started for {}", provider),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
Command::Unknown(cmd) => {
|
Command::Unknown(cmd) => {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#[expect(dead_code)]
|
|
||||||
pub mod sessions;
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
use crate::app::state::types::ToastKind;
|
|
||||||
|
|
||||||
pub struct DeferredOp {
|
|
||||||
pub kind: String,
|
|
||||||
pub handler: Box<dyn FnOnce(&mut AppStateRest) + Send>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_deferred(state: &mut AppStateRest, op: DeferredOp) {
|
|
||||||
let kind = op.kind.clone();
|
|
||||||
(op.handler)(state);
|
|
||||||
state.push_toast(crate::app::state::types::Toast::new(
|
|
||||||
ToastKind::Info,
|
|
||||||
format!("deferred '{}' completed", kind),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use crate::model::session::Session;
|
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub struct SessionManager {
|
|
||||||
pub current_id: String,
|
|
||||||
pub base_dir: PathBuf,
|
|
||||||
pub sessions: Vec<Session>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionManager {
|
|
||||||
pub fn new(base_dir: PathBuf) -> Self {
|
|
||||||
SessionManager {
|
|
||||||
current_id: String::new(),
|
|
||||||
base_dir,
|
|
||||||
sessions: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_sessions(&mut self) {
|
|
||||||
self.sessions = Session::list(&self.base_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_by_id(&self, id: &str) -> Option<&Session> {
|
|
||||||
self.sessions.iter().find(|s| s.id == id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
#[expect(dead_code)]
|
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
use crate::tool::{ToolCtx, all_tools};
|
// Tool execution dispatch — superseded by inline per-tool call in
|
||||||
use serde_json::Value;
|
// app::runtime::actions::execute_one_tool within the SubmitInput loop.
|
||||||
use anyhow::Result;
|
// This module is preserved as a placeholder.
|
||||||
|
|
||||||
pub fn execute_tool_call(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
|
||||||
let tools = all_tools();
|
|
||||||
for tool in &tools {
|
|
||||||
if tool.name() == name {
|
|
||||||
return tool.run(ctx, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(anyhow::anyhow!("tool not found: {}", name))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn execute_deferred_tool(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
|
||||||
let tools = all_tools();
|
|
||||||
for tool in &tools {
|
|
||||||
if tool.name() == name {
|
|
||||||
return tool.run(ctx, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(anyhow::anyhow!("deferred tool not found: {}", name))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,70 +1,4 @@
|
|||||||
use crate::app::state::rest::AppStateRest;
|
// Stream module — superseded by the inline tool-calling loop in
|
||||||
use crate::app::runtime::actions::{Action, apply_action};
|
// app::runtime::actions (Action::SubmitInput / Tick pipeline).
|
||||||
|
// This module is preserved as a placeholder; all previous content
|
||||||
pub fn advance_turn(state: &mut AppStateRest) {
|
// has been removed since it duplicated logic now in actions/mod.rs.
|
||||||
if state.session_runtime.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rt = state.session_runtime.as_mut().unwrap();
|
|
||||||
if rt.messages.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let api_key = state.settings.api_key.clone();
|
|
||||||
let model = state.settings.model.clone();
|
|
||||||
let msgs = rt.messages.clone();
|
|
||||||
let pending = state.pending_api_response.clone();
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
if !key.is_empty() {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
|
||||||
match client.chat(&msgs) {
|
|
||||||
Ok(response) => {
|
|
||||||
if let Ok(mut guard) = pending.lock() {
|
|
||||||
*guard = Some(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if let Ok(mut guard) = pending.lock() {
|
|
||||||
*guard = Some(format!("Error: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn process_tools(state: &mut AppStateRest) {
|
|
||||||
let tool_calls: Vec<_> = {
|
|
||||||
let rt = match state.session_runtime.as_ref() {
|
|
||||||
Some(r) => r,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
rt.pending_tool_queue.clone()
|
|
||||||
};
|
|
||||||
if tool_calls.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for tool_call in &tool_calls {
|
|
||||||
let _result = format!("processing tool: {}", tool_call.tool_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn finish_tool_round(state: &mut AppStateRest) {
|
|
||||||
let tool_count = {
|
|
||||||
let rt = match state.session_runtime.as_ref() {
|
|
||||||
Some(r) => r,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
rt.tool_call_results.len()
|
|
||||||
};
|
|
||||||
if tool_count > 0 {
|
|
||||||
let note = format!("{} tool calls completed", tool_count);
|
|
||||||
apply_action(state, Action::SystemNote {
|
|
||||||
kind: "tool_round".to_string(),
|
|
||||||
message: note,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+32
-4
@@ -1,10 +1,13 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||||
use super::runtime::SessionRuntime;
|
use super::runtime::{SessionRuntime, TurnEvent};
|
||||||
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
|
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
|
||||||
|
use crate::app::mcp::manager::McpManager;
|
||||||
|
use crate::app::workflow::engine::WorkflowEngine;
|
||||||
use crate::model::editlog::EditLog;
|
use crate::model::editlog::EditLog;
|
||||||
use crate::model::settings::Settings;
|
use crate::model::settings::Settings;
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ pub struct AppStateRest {
|
|||||||
pub mode: AgentMode,
|
pub mode: AgentMode,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
pub workspace_roots: Vec<PathBuf>,
|
pub workspace_roots: Vec<PathBuf>,
|
||||||
|
pub session_id: String,
|
||||||
pub session_dir: PathBuf,
|
pub session_dir: PathBuf,
|
||||||
pub memory_dir: PathBuf,
|
pub memory_dir: PathBuf,
|
||||||
pub download_dir: PathBuf,
|
pub download_dir: PathBuf,
|
||||||
@@ -52,7 +56,10 @@ pub struct AppStateRest {
|
|||||||
pub scroll: ScrollState,
|
pub scroll: ScrollState,
|
||||||
pub input: InputState,
|
pub input: InputState,
|
||||||
pub misc: MiscState,
|
pub misc: MiscState,
|
||||||
pub pending_api_response: Arc<Mutex<Option<String>>>,
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
|
pub turn_in_flight: Arc<Mutex<bool>>,
|
||||||
|
pub workflow_engine: WorkflowEngine,
|
||||||
|
pub mcp_manager: McpManager,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
}
|
}
|
||||||
@@ -63,19 +70,27 @@ impl AppStateRest {
|
|||||||
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
|
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
|
||||||
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
||||||
let dir_cache = DirCache::new();
|
let dir_cache = DirCache::new();
|
||||||
|
let session_id = session_dir
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
AppStateRest {
|
AppStateRest {
|
||||||
mode: AgentMode::Normal,
|
mode: AgentMode::Normal,
|
||||||
settings,
|
settings,
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
|
session_id,
|
||||||
session_dir: session_dir.clone(),
|
session_dir: session_dir.clone(),
|
||||||
memory_dir,
|
memory_dir,
|
||||||
download_dir,
|
download_dir,
|
||||||
worktrees_dir,
|
worktrees_dir,
|
||||||
current_dir: std::env::current_dir().unwrap_or_default(),
|
current_dir: std::env::current_dir().unwrap_or_default(),
|
||||||
pending_api_response: Arc::new(Mutex::new(None)),
|
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||||
edit_log: EditLog::new(&session_dir),
|
edit_log: EditLog::new(&session_dir),
|
||||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||||
|
workflow_engine: WorkflowEngine::new(),
|
||||||
|
mcp_manager: McpManager::new(),
|
||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
crons: Vec::new(),
|
crons: Vec::new(),
|
||||||
transcript_cache: TranscriptCache::new(200),
|
transcript_cache: TranscriptCache::new(200),
|
||||||
@@ -110,7 +125,20 @@ impl AppStateRest {
|
|||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||||
|
self.session_dir.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| self.session_dir.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| self.session_dir.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
||||||
|
self.tool_ctx_for(Origin::Main)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
|
||||||
crate::tool::ToolCtx {
|
crate::tool::ToolCtx {
|
||||||
workspaces: self.workspace_roots.clone(),
|
workspaces: self.workspace_roots.clone(),
|
||||||
session_dir: self.session_dir.clone(),
|
session_dir: self.session_dir.clone(),
|
||||||
@@ -119,7 +147,7 @@ impl AppStateRest {
|
|||||||
worktrees_dir: self.worktrees_dir.clone(),
|
worktrees_dir: self.worktrees_dir.clone(),
|
||||||
dir_cache: self.dir_cache.clone(),
|
dir_cache: self.dir_cache.clone(),
|
||||||
internet_mode: self.settings.internet_mode.clone(),
|
internet_mode: self.settings.internet_mode.clone(),
|
||||||
origin: Origin::Main,
|
origin,
|
||||||
graduated_checks: Vec::new(),
|
graduated_checks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ pub struct BashJobRef {
|
|||||||
pub running: bool,
|
pub running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TurnEvent {
|
||||||
|
AssistantMessage(crate::dto::chat::message::ChatMessage),
|
||||||
|
ToolResult {
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
|
SystemNote {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Error(String),
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
impl SessionRuntime {
|
impl SessionRuntime {
|
||||||
pub fn new(session_dir: PathBuf) -> Self {
|
pub fn new(session_dir: PathBuf) -> Self {
|
||||||
SessionRuntime {
|
SessionRuntime {
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ impl AgentMode {
|
|||||||
AgentMode::Yolo => "Yolo",
|
AgentMode::Yolo => "Yolo",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cycle(&self) -> AgentMode {
|
||||||
|
match self {
|
||||||
|
AgentMode::Normal => AgentMode::Auto,
|
||||||
|
AgentMode::Auto => AgentMode::Plan,
|
||||||
|
AgentMode::Plan => AgentMode::Yolo,
|
||||||
|
AgentMode::Yolo => AgentMode::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -139,3 +148,13 @@ pub enum Origin {
|
|||||||
SubAgent,
|
SubAgent,
|
||||||
Reviewer,
|
Reviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Origin {
|
||||||
|
pub fn tag(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Origin::Main => "main".to_string(),
|
||||||
|
Origin::SubAgent => "subagent".to_string(),
|
||||||
|
Origin::Reviewer => "reviewer".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
use crate::tool::{all_tools, tool_is_risky};
|
||||||
use super::context::SubagentContext;
|
use super::context::SubagentContext;
|
||||||
use super::event::SubagentEvent;
|
use super::event::SubagentEvent;
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
messages.push(ChatMessage::system(ctx.system_prompt.clone()));
|
messages.push(ChatMessage::system(ctx.system_prompt.clone()));
|
||||||
|
|
||||||
|
let tool_ctx = crate::tool::ToolCtx::builder()
|
||||||
|
.session_dir(ctx.session_dir.clone())
|
||||||
|
.origin(crate::app::state::types::Origin::SubAgent)
|
||||||
|
.build();
|
||||||
|
|
||||||
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||||
for step in 0..max_steps {
|
for step in 0..max_steps {
|
||||||
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
||||||
@@ -55,16 +61,51 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let tools = all_tools();
|
||||||
for tool_name in &tool_calls {
|
for tool_name in &tool_calls {
|
||||||
if !ctx.allowed_tools.is_empty() && !ctx.allowed_tools.contains(tool_name) {
|
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||||
|
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||||
|
|
||||||
|
if !generally_allowed {
|
||||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||||
messages.push(ChatMessage::tool_result("subagent".to_string(), msg));
|
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
output: msg,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
|
||||||
tool: tool_name.clone(),
|
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||||
output: format!("{} executed", tool_name),
|
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
|
||||||
});
|
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
output: msg,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match tools.iter().find(|t| t.name() == *tool_name) {
|
||||||
|
Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})),
|
||||||
|
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(output_text) => {
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
output: output_text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("tool '{}' failed: {}", tool_name, e);
|
||||||
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
|
tool: tool_name.clone(),
|
||||||
|
output: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||||
step,
|
step,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub enum Command {
|
|||||||
Clear,
|
Clear,
|
||||||
Save,
|
Save,
|
||||||
LessonList,
|
LessonList,
|
||||||
|
Login { provider: String },
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
|
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
|
||||||
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||||
"/lesson" => Command::LessonList,
|
"/lesson" => Command::LessonList,
|
||||||
|
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
||||||
|
"/login" => Command::Login { provider: "openrouter".to_string() },
|
||||||
_ => Command::Unknown(cmd.to_string()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -1,12 +1,13 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::app::mode;
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
use crate::app::runtime::commands::apply_command;
|
use crate::app::runtime::commands::apply_command;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
use crate::controller::command::parse_command;
|
use crate::controller::command::parse_command;
|
||||||
|
|
||||||
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::QuitConfirm]
|
vec![Action::QuitConfirm]
|
||||||
@@ -57,11 +58,19 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
|||||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::ToggleYoloArm]
|
vec![Action::ToggleYoloArm]
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
vec![Action::CycleAgentMode]
|
||||||
|
}
|
||||||
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::OpenOverlay(Overlay::Bash)]
|
vec![Action::OpenOverlay(Overlay::Bash)]
|
||||||
}
|
}
|
||||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
vec![Action::OpenOverlay(Overlay::SessionHub)]
|
&& !key.modifiers.contains(KeyModifiers::SHIFT) =>
|
||||||
|
{
|
||||||
|
vec![Action::RefreshSessions, Action::OpenOverlay(Overlay::SessionHub)]
|
||||||
|
}
|
||||||
|
KeyCode::Char('S') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
vec![Action::OpenOverlay(Overlay::Security)]
|
||||||
}
|
}
|
||||||
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||||
@@ -95,10 +104,28 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_overlay_enter(state: &AppStateRest) -> Vec<Action> {
|
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||||
match state.misc.overlay {
|
match state.misc.overlay {
|
||||||
|
Overlay::Bash => {
|
||||||
|
let command = state.input.buffer.clone();
|
||||||
|
mode::bash::handle_bash_submit(state, command);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Overlay::Settings => {
|
||||||
|
mode::settings::cycle_internet_mode(&mut state.settings);
|
||||||
|
state.dirty = true;
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Overlay::Todo => {
|
||||||
|
mode::todo::handle_todo_toggle(state);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Overlay::Security => {
|
||||||
|
mode::security::handle_security_action(state, &Action::ToggleYoloArm);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
Overlay::QuitConfirm => {
|
Overlay::QuitConfirm => {
|
||||||
vec![Action::ForceQuit]
|
vec![mode::quit_confirm::handle_quit_confirm(true)]
|
||||||
}
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ pub mod client;
|
|||||||
pub mod conn;
|
pub mod conn;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod protocol;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum KeyAction {
|
||||||
|
Char(char),
|
||||||
|
Enter,
|
||||||
|
Escape,
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Tab,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
Function(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ClientRequest {
|
||||||
|
Tick,
|
||||||
|
KeyPress {
|
||||||
|
key: KeyAction,
|
||||||
|
ctrl: bool,
|
||||||
|
alt: bool,
|
||||||
|
shift: bool,
|
||||||
|
},
|
||||||
|
Submit(String),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageEntry {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToastEntry {
|
||||||
|
pub kind: String,
|
||||||
|
pub message: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub lifetime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatePayload {
|
||||||
|
pub mode: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub messages: Vec<MessageEntry>,
|
||||||
|
pub edit_count: u32,
|
||||||
|
pub message_count: usize,
|
||||||
|
pub overlay: Option<String>,
|
||||||
|
pub toasts: Vec<ToastEntry>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub input_buffer: String,
|
||||||
|
pub input_cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum DaemonFrame {
|
||||||
|
StateUpdate(Box<StatePayload>),
|
||||||
|
StreamToken(String),
|
||||||
|
SystemNote { kind: String, message: String },
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
+59
-17
@@ -1,42 +1,84 @@
|
|||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use super::conn::Connection;
|
use super::conn::Connection;
|
||||||
|
|
||||||
|
enum ListenerKind {
|
||||||
|
Tcp(TcpListener),
|
||||||
|
Unix(UnixListener),
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IpcServer {
|
pub struct IpcServer {
|
||||||
listener: TcpListener,
|
listener: ListenerKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IpcServer {
|
impl IpcServer {
|
||||||
pub fn bind(addr: &str) -> Result<Self> {
|
pub fn bind(addr: &str) -> Result<Self> {
|
||||||
let listener = TcpListener::bind(addr)?;
|
let listener = TcpListener::bind(addr)?;
|
||||||
Ok(IpcServer { listener })
|
Ok(IpcServer { listener: ListenerKind::Tcp(listener) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_unix(path: &str) -> Result<Self> {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
let listener = UnixListener::bind(path)?;
|
||||||
|
Ok(IpcServer { listener: ListenerKind::Unix(listener) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accept(&self) -> Result<Connection> {
|
pub fn accept(&self) -> Result<Connection> {
|
||||||
let (stream, _addr) = self.listener.accept()?;
|
match &self.listener {
|
||||||
stream.set_nodelay(true)?;
|
ListenerKind::Tcp(l) => {
|
||||||
Ok(Connection::Tcp(stream))
|
let (stream, _addr) = l.accept()?;
|
||||||
|
stream.set_nodelay(true)?;
|
||||||
|
Ok(Connection::Tcp(stream))
|
||||||
|
}
|
||||||
|
ListenerKind::Unix(l) => {
|
||||||
|
let (stream, _addr) = l.accept()?;
|
||||||
|
Ok(Connection::Unix(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()>
|
pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()>
|
||||||
where
|
where
|
||||||
F: Fn(Connection) -> Result<()> + Send + 'static,
|
F: Fn(Connection) -> Result<()> + Send + 'static,
|
||||||
{
|
{
|
||||||
thread::spawn(move || {
|
match self.listener {
|
||||||
for stream in self.listener.incoming() {
|
ListenerKind::Tcp(l) => {
|
||||||
match stream {
|
thread::spawn(move || {
|
||||||
Ok(stream) => {
|
for stream in l.incoming() {
|
||||||
if let Err(e) = handler(Connection::Tcp(stream)) {
|
match stream {
|
||||||
eprintln!("ipc handler error: {}", e);
|
Ok(s) => {
|
||||||
|
let _ = s.set_nodelay(true);
|
||||||
|
if let Err(e) = handler(Connection::Tcp(s)) {
|
||||||
|
eprintln!("ipc handler error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ipc accept error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
})
|
||||||
eprintln!("ipc accept error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
ListenerKind::Unix(l) => {
|
||||||
|
thread::spawn(move || {
|
||||||
|
for stream in l.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(s) => {
|
||||||
|
if let Err(e) = handler(Connection::Unix(s)) {
|
||||||
|
eprintln!("ipc handler error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ipc accept error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+398
-658
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ pub mod loopback;
|
|||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|
||||||
|
#[expect(unused_imports)]
|
||||||
pub use manager::{OAuthManager, OAuthConfig};
|
pub use manager::{OAuthManager, OAuthConfig};
|
||||||
|
#[expect(unused_imports)]
|
||||||
pub use pkce::CodeVerifier;
|
pub use pkce::CodeVerifier;
|
||||||
|
#[expect(unused_imports)]
|
||||||
pub use loopback::LoopbackServer;
|
pub use loopback::LoopbackServer;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::Value;
|
|
||||||
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
use crate::dto::openrouter::request::ToolDef;
|
||||||
|
|
||||||
pub struct OpenRouterClient {
|
pub struct OpenRouterClient {
|
||||||
pub client: reqwest::blocking::Client,
|
pub client: reqwest::blocking::Client,
|
||||||
@@ -18,13 +20,22 @@ impl OpenRouterClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn chat(&self, messages: &[crate::dto::chat::message::ChatMessage]) -> Result<String> {
|
pub fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
|
||||||
|
let response = self.chat_with_tools(messages, None)?;
|
||||||
|
Ok(response.content.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_with_tools(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
) -> Result<ChatMessage> {
|
||||||
let req = crate::dto::openrouter::request::ChatRequest {
|
let req = crate::dto::openrouter::request::ChatRequest {
|
||||||
model: self.model.clone(),
|
model: self.model.clone(),
|
||||||
messages: messages.to_vec(),
|
messages: messages.to_vec(),
|
||||||
max_tokens: Some(4096),
|
max_tokens: Some(4096),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
tools: None,
|
tools,
|
||||||
stream: Some(false),
|
stream: Some(false),
|
||||||
top_p: None,
|
top_p: None,
|
||||||
stop: None,
|
stop: None,
|
||||||
@@ -43,11 +54,13 @@ impl OpenRouterClient {
|
|||||||
anyhow::bail!("OpenRouter API error {}: {}", status, body);
|
anyhow::bail!("OpenRouter API error {}: {}", status, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: Value = resp.json()?;
|
let data: crate::dto::openrouter::response::ChatResponse = resp.json()?;
|
||||||
let content = data["choices"][0]["message"]["content"]
|
let message = data
|
||||||
.as_str()
|
.choices
|
||||||
.unwrap_or("")
|
.into_iter()
|
||||||
.to_string();
|
.next()
|
||||||
Ok(content)
|
.map(|c| c.message)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("OpenRouter response had no choices"))?;
|
||||||
|
Ok(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use serde_json::{json, Value};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use super::Tool;
|
||||||
|
use super::ToolCtx;
|
||||||
|
|
||||||
|
pub struct BashOutput;
|
||||||
|
|
||||||
|
impl Tool for BashOutput {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"bash_output"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Retrieve output from a background bash job by job_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Job ID returned by bash with run_in_background=true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["job_id"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let job_id = args.get("job_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
||||||
|
.to_string();
|
||||||
|
match crate::app::bgbash::control::bash_output(&job_id) {
|
||||||
|
Some(lines) => Ok(lines.join("\n")),
|
||||||
|
None => Ok(format!("No new output from job '{}'", job_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BashKill;
|
||||||
|
|
||||||
|
impl Tool for BashKill {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"bash_kill"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Kill a background bash job by job_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Job ID returned by bash with run_in_background=true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["job_id"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let job_id = args.get("job_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
||||||
|
.to_string();
|
||||||
|
crate::app::bgbash::control::bash_kill(&job_id)?;
|
||||||
|
Ok(format!("Killed background job '{}'", job_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ impl Tool for Download {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
if !ctx.internet_mode.can_download() {
|
||||||
anyhow::bail!("internet access is disabled. Enable it in settings to use download.");
|
anyhow::bail!("download requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||||
}
|
}
|
||||||
let url = args.get("url")
|
let url = args.get("url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ impl Tool for Fetch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
if !ctx.internet_mode.can_fetch() {
|
||||||
anyhow::bail!("internet access is disabled. Enable it in settings to use fetch.");
|
anyhow::bail!("fetch requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||||
}
|
}
|
||||||
let url = args.get("url")
|
let url = args.get("url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ impl Tool for Search {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
if !ctx.internet_mode.can_search() {
|
||||||
anyhow::bail!("internet access is disabled. Enable it in settings to use web_search.");
|
anyhow::bail!("web_search requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||||
}
|
}
|
||||||
let query = args.get("query")
|
let query = args.get("query")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub mod bash_tools;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod git_cred;
|
pub mod git_cred;
|
||||||
pub mod git_operator;
|
pub mod git_operator;
|
||||||
@@ -28,6 +29,7 @@ pub struct GraduatedCheck {
|
|||||||
pub rule: String,
|
pub rule: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ToolCtx {
|
pub struct ToolCtx {
|
||||||
pub workspaces: Vec<PathBuf>,
|
pub workspaces: Vec<PathBuf>,
|
||||||
pub session_dir: PathBuf,
|
pub session_dir: PathBuf,
|
||||||
@@ -116,6 +118,8 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
|||||||
Box::new(super::tool::fs::edit::Edit),
|
Box::new(super::tool::fs::edit::Edit),
|
||||||
Box::new(super::tool::search::Grep),
|
Box::new(super::tool::search::Grep),
|
||||||
Box::new(super::tool::search::Glob),
|
Box::new(super::tool::search::Glob),
|
||||||
|
Box::new(super::tool::bash_tools::BashOutput),
|
||||||
|
Box::new(super::tool::bash_tools::BashKill),
|
||||||
Box::new(super::tool::shell::Bash),
|
Box::new(super::tool::shell::Bash),
|
||||||
Box::new(super::tool::git_operator::GitOperator),
|
Box::new(super::tool::git_operator::GitOperator),
|
||||||
Box::new(super::tool::git_worktree::GitWorktree),
|
Box::new(super::tool::git_worktree::GitWorktree),
|
||||||
@@ -124,6 +128,9 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
|||||||
Box::new(super::tool::plan::PlanEnter),
|
Box::new(super::tool::plan::PlanEnter),
|
||||||
Box::new(super::tool::plan::PlanReady),
|
Box::new(super::tool::plan::PlanReady),
|
||||||
Box::new(super::tool::workflow::WorkflowRun),
|
Box::new(super::tool::workflow::WorkflowRun),
|
||||||
|
Box::new(super::tool::internet::fetch::Fetch),
|
||||||
|
Box::new(super::tool::internet::download::Download),
|
||||||
|
Box::new(super::tool::internet::search::Search),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,20 @@ pub fn tool_is_risky(name: &str) -> bool {
|
|||||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::openrouter::request::ToolDef> {
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| crate::dto::openrouter::request::ToolDef {
|
||||||
|
type_: "function".to_string(),
|
||||||
|
function: crate::dto::openrouter::request::ToolFunctionDef {
|
||||||
|
name: t.name().to_string(),
|
||||||
|
description: t.description().to_string(),
|
||||||
|
parameters: t.parameters(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub const DEFERRED_TOOLS: &[&str] = &[
|
pub const DEFERRED_TOOLS: &[&str] = &[
|
||||||
"read", "write", "edit", "bash", "grep", "glob",
|
"read", "write", "edit", "bash", "grep", "glob",
|
||||||
"git_operator", "git_worktree", "git_cred",
|
"git_operator", "git_worktree", "git_cred",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ impl Tool for Bash {
|
|||||||
"timeout": {
|
"timeout": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||||
|
},
|
||||||
|
"run_in_background": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Run the command in the background and return immediately with a job ID"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["command"]
|
"required": ["command"]
|
||||||
@@ -47,6 +51,11 @@ impl Tool for Bash {
|
|||||||
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
|
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
|
||||||
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
|
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
|
||||||
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
|
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
|
||||||
|
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
if run_in_background {
|
||||||
|
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
|
||||||
|
return Ok(format!("Background job: {}", job.id));
|
||||||
|
}
|
||||||
let mut child = Command::new("bash")
|
let mut child = Command::new("bash")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&cmd)
|
.arg(&cmd)
|
||||||
|
|||||||
+16
-3
@@ -32,10 +32,23 @@ impl Tool for WorkflowRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let _script = args.get("script")
|
let script_str = args.get("script")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
||||||
let _workflow_args = args.get("args");
|
|
||||||
Ok("workflow delegated to workflow engine".to_string())
|
let workflow_script: crate::app::workflow::script::WorkflowScript =
|
||||||
|
serde_json::from_str(script_str)
|
||||||
|
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?;
|
||||||
|
|
||||||
|
let workflow_args: std::collections::HashMap<String, String> = args.get("args")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| {
|
||||||
|
obj.iter().filter_map(|(k, v)| {
|
||||||
|
v.as_str().map(|s| (k.clone(), s.to_string()))
|
||||||
|
}).collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
crate::app::workflow::engine::run_workflow(&workflow_script, &workflow_args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
|||||||
);
|
);
|
||||||
|
|
||||||
let center_text = Span::styled(
|
let center_text = Span::styled(
|
||||||
format!(" | STATUS: {} | PROTOCOL: ZERO-STUBS ACTIVE ", mode_indicator),
|
format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator),
|
||||||
Style::default().fg(mode_color),
|
Style::default().fg(mode_color),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -25,8 +25,9 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
|||||||
let tool_count = session_runtime.tool_call_results.len();
|
let tool_count = session_runtime.tool_call_results.len();
|
||||||
let pending_count = session_runtime.pending_tool_queue.len();
|
let pending_count = session_runtime.pending_tool_queue.len();
|
||||||
let bash_count = session_runtime.bash_jobs.len();
|
let bash_count = session_runtime.bash_jobs.len();
|
||||||
let subagent_queue = session_runtime.subagent_queue;
|
|
||||||
let edit_count = session_runtime.edit_count;
|
let agent_count = state.workflow_engine.agents.len();
|
||||||
|
let findings_count = state.workflow_engine.findings.len();
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
@@ -54,17 +55,19 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
if subagent_queue > 0 {
|
if agent_count > 0 {
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
items.push(ListItem::new(Line::from(Span::styled(
|
||||||
format!(" Subagent queue: {}", subagent_queue),
|
format!(" Agents: {}", agent_count),
|
||||||
Style::default().fg(Theme::INFO),
|
Style::default().fg(Theme::INFO),
|
||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
if findings_count > 0 {
|
||||||
format!(" Edits: {}", edit_count),
|
items.push(ListItem::new(Line::from(Span::styled(
|
||||||
Style::default().fg(Theme::INFO),
|
format!(" Findings: {}", findings_count),
|
||||||
))));
|
Style::default().fg(Theme::WARNING),
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
let phase_status = match state.mode {
|
let phase_status = match state.mode {
|
||||||
crate::app::state::types::AgentMode::Auto => "Auto-running",
|
crate::app::state::types::AgentMode::Auto => "Auto-running",
|
||||||
|
|||||||
Reference in New Issue
Block a user