use std::collections::VecDeque; use crate::app::harness::Verdict; use sha2::Digest; use crate::app::mode::ModeKind; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay}; 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)] pub enum Action { Quit, ForceQuit, SwitchMode(ModeKind), SubmitInput(String), InsertChar(char), DeleteChar, DeleteCharRight, CursorLeft, CursorRight, HistoryUp, HistoryDown, ScrollUp, ScrollDown, OpenOverlay(Overlay), CloseOverlay, ToggleYoloArm, CycleAgentMode, ToolResult { tool_call_id: String, output: String, is_error: bool, }, StreamToken(String), StreamDone, StreamError(String), SystemNote { kind: String, message: String, }, RunCommand(String), QuitConfirm, Resize(u16, u16), Tick, LessonExport { path: String, }, LessonImport { path: String, }, LessonAccept { name: String, }, LessonReject { name: String, }, SaveSession, ResumeSession, RefreshSessions, } pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::Quit => { save_current_session(state); auto_create_retrospective(state); state.quit = true; } Action::ForceQuit => { save_current_session(state); state.quit = true; } Action::SwitchMode(mode) => { state.misc.overlay = match mode { ModeKind::Chat | ModeKind::Agents | ModeKind::Bash | ModeKind::Workflow => Overlay::None, ModeKind::Help => Overlay::Help, ModeKind::Settings => Overlay::Settings, ModeKind::SessionHub => Overlay::SessionHub, ModeKind::QuitConfirm => Overlay::QuitConfirm, ModeKind::Onboard => Overlay::Onboard, ModeKind::OnboardProvider => Overlay::OnboardProvider, ModeKind::Picker => Overlay::Picker, ModeKind::KeyInput => Overlay::KeyInput, ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, ModeKind::Mcp => Overlay::Mcp, ModeKind::Security => Overlay::Security, ModeKind::Todo => Overlay::Todo, ModeKind::Rewind => Overlay::Rewind, ModeKind::Loading => Overlay::Loading, }; state.dirty = true; } Action::SubmitInput(text) => { state.input.submit(); let text = text.trim().to_string(); if text.is_empty() { state.dirty = true; return; } 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; } Action::InsertChar(c) => { state.input.insert(c); state.dirty = true; } Action::DeleteChar => { state.input.delete_left(); state.dirty = true; } Action::DeleteCharRight => { state.input.delete_right(); state.dirty = true; } Action::CursorLeft => { state.input.char_left(); } Action::CursorRight => { state.input.char_right(); } Action::HistoryUp => { state.input.history_up(); state.dirty = true; } Action::HistoryDown => { state.input.history_down(); state.dirty = true; } Action::ScrollUp => { let total = state.transcript_cache.messages.len(); state.scroll.scroll_up(); state.scroll.scroll_down(total); state.dirty = true; } Action::ScrollDown => { let total = state.transcript_cache.messages.len(); state.scroll.scroll_down(total); state.dirty = true; } Action::OpenOverlay(overlay) => { state.misc.overlay = overlay; state.dirty = true; } Action::CloseOverlay => { state.misc.overlay = Overlay::None; state.dirty = true; } Action::ToggleYoloArm => { state.misc.yolo_armed = !state.misc.yolo_armed; 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 { tool_call_id, output, is_error, } => { 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: String::new(), output, is_error, duration_ms: 0, }, ); } state.dirty = true; } Action::StreamToken(token) => { if let Some(ref mut rt) = state.session_runtime { let found = rt.messages.iter_mut().rev().find(|m| { matches!(m.role, crate::dto::chat::message::Role::Assistant) }); if let Some(last) = found { let current = last.content.take().unwrap_or_default(); last.content = Some(current + &token); } else { let mut msg = ChatMessage::assistant(None); msg.content = Some(token); rt.push_message(msg); } } state.dirty = true; } Action::StreamDone => { state.dirty = true; } Action::StreamError(msg) => { let toast = crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, msg, ); state.push_toast(toast); } Action::SystemNote { kind: _kind, message } => { let toast = crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Info, message, ); state.push_toast(toast); } Action::RunCommand(text) => { if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::user(text)); } state.dirty = true; } Action::QuitConfirm => { state.misc.overlay = Overlay::QuitConfirm; state.dirty = true; } Action::Resize(w, _h) => { state.scroll.set_max_visible(w as usize); state.dirty = true; } Action::LessonExport { path } => { let dest = std::path::Path::new(&path); if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); } match crate::model::memory::export_lessons(&state.memory_dir, dest) { Ok(_) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("lessons exported to {}", path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("export failed: {}", e), )); } } state.dirty = true; } Action::LessonImport { path } => { let src = std::path::Path::new(&path); match crate::model::memory::import_lessons(&state.memory_dir, src) { Ok(count) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("imported {} lessons from {}", count, path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("import failed: {}", e), )); } } 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 = 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 => { let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); crate::app::review::maybe_run_staleness_sweep(state); if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir); } let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() } else { Vec::new() } }; let mut turn_finished = false; for event in events { match event { TurnEvent::AssistantMessage(msg) => { let display_content = msg.content.clone().unwrap_or_default(); if !display_content.is_empty() { state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content)); } if let Some(ref mut rt) = state.session_runtime { 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::() { rt.edit_count += count; } } if should_trigger_review(state, Origin::Main) { let _ = trigger_review(state); } } else if kind == "review" { let lessons_found = if message.contains("lesson") || message.contains("Lesson") { message.rsplit(' ').next().and_then(|w| { w.trim_end_matches(')').trim_end_matches('s') .split('(').next_back() .and_then(|n| n.parse::().ok()) }).unwrap_or(0) } else { 0 }; if let Some(ref mut rt) = state.session_runtime { if lessons_found > 0 { rt.consecutive_empty_reviews = 0; rt.lesson_count += lessons_found; } else { rt.consecutive_empty_reviews += 1; } } state.push_toast(Toast::new(ToastKind::Info, message)); } else { state.push_toast(Toast::new(ToastKind::Info, message)); } } TurnEvent::Error(msg) => { let long_toast = Toast { kind: ToastKind::Error, message: msg.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 15000, }; state.push_toast(long_toast); state.push_transcript(ChatMessageDisplay::new( crate::dto::chat::message::Role::System, format!("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; } } Action::LessonAccept { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, &state.memory_dir, &name, true, ); } state.push_toast(Toast::new(ToastKind::Success, format!("accepted lesson: {}", name))); state.dirty = true; } Action::LessonReject { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, &state.memory_dir, &name, false, ); } state.push_toast(Toast::new(ToastKind::Info, format!("rejected lesson: {}", name))); state.dirty = true; } } } 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(); 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_session_dir = state.session_dir.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 = 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::provider::LlmClient::new(api_key, model), tdefs: tool_defs, tools, ctx, mode, workspace_roots, edit_log_session_dir: edit_session_dir, 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::provider::LlmClient, tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, mode: AgentMode, workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, } fn run_agent_turn( tc: TurnCtx, messages: &[ChatMessage], events_q: &std::sync::Mutex>, ) -> anyhow::Result<()> { let mut msgs = messages.to_vec(); let mut edits_this_turn = 0u32; let system_text = format!( "{}\n\n{}", crate::resources::SYSTEM_PROMPT, crate::resources::SYSTEM_TOOLS, ); if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { msgs.insert(0, ChatMessage::system(system_text)); } 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_session_dir, &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], ctx: &crate::tool::ToolCtx, name: &str, args: &serde_json::Value, session_dir: &std::path::Path, session_id: &str, ) -> anyhow::Result { 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 bytes_delta = if name == "write" { args.get("content") .and_then(|v| v.as_str()) .map(|s| s.len() as i64) .unwrap_or(0) } else { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); (new.len() as i64 - old.len() as i64).abs() }; 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, origin: ctx.origin.tag(), session_id: session_id.to_string(), }; let mut el = crate::model::editlog::EditLog::new(session_dir); 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); } } } fn auto_create_retrospective(state: &mut AppStateRest) { if state.session_runtime.is_none() { return; } let session = crate::model::session::Session::new( state.session_id.clone(), "session".to_string(), ); match crate::model::memory::auto_create_retrospective(&state.session_dir, &session) { Ok(Some(retro)) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Info, format!("Retrospective created: {}", retro.name), )); } Ok(None) => {} Err(e) => { let _ = e; } } let lessons: Vec = crate::model::memory::Memory::list(&state.memory_dir) .iter() .filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok()) .filter(|m| m.kind == "lesson") .collect(); if let Some(global_dir) = dirs::data_dir().map(|d| d.join("zesdex")) { for lesson in &lessons { if lesson.scope.as_deref() != Some("global") { let _ = crate::model::memory::promote_with_consensus(&global_dir, lesson); } } } }