//! The `Action` enum and its single dispatcher, `apply_action` — the //! chokepoint through which every key input, streaming event, and async //! background-thread result mutates `AppStateRest`. //! //! Flow: controllers/subagent threads construct `Action` values → the event //! loop calls `apply_action(&mut state, action)` → for turn-producing //! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS //! thread which drives `run_agent_turn` (stream to the LLM, gate and //! execute tool calls via `Harness`, archive messages to SQLite, log edits) //! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued //! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts, //! usage counters). //! //! Why: keeping all state mutation behind one function means callers only //! need to know how to *produce* actions, not how to update state safely; //! running turns on plain OS threads (rather than blocking the main loop) //! keeps the TUI responsive while the LLM streams. 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::{Origin, Overlay, Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; /// A single, well-typed event in the app — produced by key input, the /// streaming pipeline, or subagent threads — that mutates `AppStateRest` /// when applied via `apply_action`. /// /// Step bounds intentionally left unbounded (usize::MAX) so the agent can /// continue across as many turns as needed. Each iteration still honours /// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is /// observable and cancellable from the UI. #[derive(Debug, Clone)] pub enum Action { ForceQuit, SwitchMode(ModeKind), SubmitInput(String), DeleteChar, DeleteCharRight, CursorLeft, CursorRight, HistoryUp, HistoryDown, ScrollUp, ScrollDown, OpenOverlay(Overlay), CloseOverlay, SystemNote { kind: String, message: String, }, QuitConfirm, Resize(u16, u16), Tick, LessonExport { path: String, }, LessonImport { path: String, }, LessonAccept { name: String, }, LessonReject { name: String, }, StartOAuth { provider: String, }, OpenEditor { path: String, }, McpAdd { name: String, command: String, }, ModelList, AbortTurn, Compact, RunWorkflow { script: String, }, } /// Apply an `Action` to the application state. /// /// Flow: pattern-match the variant → mutate `state` (input buffer, scroll /// position, overlay, transcript, runtime, toasts, dirty flag, etc.) → /// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs /// (staleness sweep, pending-lesson commit). /// /// Why: the single chokepoint that turns every typed key and async event /// into a state change, so callers (controllers, subagent threads) only /// need to know how to *produce* actions. /// /// Return: nothing; `state` is mutated in place. pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::ForceQuit => { save_current_session(state); state.shutdown_lsp(); state.quit = true; } Action::SwitchMode(mode) => { state.misc.overlay = match mode { ModeKind::Chat | ModeKind::Bash => Overlay::None, ModeKind::Workflow => Overlay::Workflow, ModeKind::Help => Overlay::Help, ModeKind::Settings => Overlay::Settings, ModeKind::QuitConfirm => Overlay::QuitConfirm, ModeKind::KeyInput => Overlay::KeyInput, ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, ModeKind::Mcp => Overlay::Mcp, 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)); refresh_lesson_counters(&state.memory_dir, rt); } else { let _ = std::fs::create_dir_all(&state.memory_dir); } state.misc.thinking = true; spawn_turn(state); 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 => { state.scroll.scroll_up(5); state.dirty = true; } Action::ScrollDown => { state.scroll.scroll_down(5); state.dirty = true; } Action::OpenOverlay(overlay) => { state.misc.overlay = overlay; state.dirty = true; } Action::OpenEditor { path } => { let resolved = crate::tool::resolve_path(&state.workspace_roots, &path); match resolved { Ok(abs_path) => { let content = std::fs::read_to_string(&abs_path) .unwrap_or_default(); let lines: Vec = content.lines().map(|l| l.to_string()).collect(); let ed = crate::app::mode::editor::EditorState::open( abs_path.to_string_lossy().to_string(), Some(lines), ); state.misc.editor = Some(ed); state.misc.overlay = Overlay::Editor; state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path))); } Err(e) => { state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e))); } } state.dirty = true; } Action::McpAdd { name, command } => { let extra_args: Vec = command.split_whitespace().map(|s| s.to_string()).collect(); let cmd = extra_args.first().cloned().unwrap_or_default(); let args: Vec = extra_args.into_iter().skip(1).collect(); match state.mcp_manager.connect_stdio(&name, &cmd, &args) { Ok(_) => { let tool_count = state.mcp_manager.servers.last() .map(|s| s.tools.len()) .unwrap_or(0); state.push_toast(Toast::new(ToastKind::Success, format!("Connected MCP server '{}' ({} tools)", name, tool_count))); state.dirty = true; } Err(e) => { state.push_toast(Toast::new(ToastKind::Error, format!("MCP connect failed: {}", e))); } } } Action::ModelList => { state.misc.selected_index = 0; state.misc.overlay = Overlay::ModelSelector; state.dirty = true; } Action::CloseOverlay => { // If the overlay is the Editor, dismiss it properly first if state.misc.overlay == Overlay::Editor { crate::app::mode::editor::handle_editor_dismiss(state); } state.misc.overlay = Overlay::None; state.dirty = true; } 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::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::StartOAuth { provider } => { let turn_events = state.turn_events.clone(); let provider_clone = provider.clone(); std::thread::spawn(move || { let result = run_oauth_flow(&provider_clone); let message = match result { Ok(msg) => msg, Err(e) => format!("OAuth login failed: {}", e), }; if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "oauth".to_string(), message, }); } }); let toast = Toast::new(ToastKind::Info, format!("Opening browser for {} login...", provider)); state.push_toast(toast); state.dirty = true; } Action::Tick => { state.misc.tick_count = state.misc.tick_count.wrapping_add(1); let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); if state.misc.tick_count.is_multiple_of(10) { let todo_path = state.session_dir.join("todo.md"); if let Ok(content) = std::fs::read_to_string(&todo_path) { if content != state.misc.todo_content { state.misc.todo_content = content; state.dirty = true; } } else if !state.misc.todo_content.is_empty() { state.misc.todo_content.clear(); state.dirty = true; } } // Background API connectivity check — runs on a background thread // every ~1s while disconnected, every ~30s while connected, so the // status bar reflects real API availability without user input. let check_interval = if state.misc.api_connected { 600 } else { 20 }; if state.misc.tick_count.is_multiple_of(check_interval) { spawn_api_connectivity_check(state); } 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); } // Drain LSP provision progress messages into toast notifications. // Collect messages under the lock, then push toasts outside it to avoid // a borrow-conflict with state.push_toast (which also accesses state). let pending: Vec = state.lsp_provision_msgs.lock() .ok() .map(|mut q| q.drain(..).collect()) .unwrap_or_default(); for msg in &pending { let kind = if msg.contains("not available") || msg.contains("failed") { ToastKind::Warning } else if msg.contains("connected") || msg.contains("✓") { ToastKind::Success } else { ToastKind::Info }; state.push_toast(Toast::new(kind, msg.clone())); } 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) => { state.misc.thinking = false; state.misc.api_connected = true; 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, path } => { state.misc.thinking = false; let display_path = path.unwrap_or_default(); let display = if tool_name == "read" { let line_count = output.lines().count(); if !display_path.is_empty() { format!("read: {} ({} lines)", display_path, line_count) } else { format!("read: {} line(s)", line_count) } } else { format!("{}: {}", tool_name, output) }; state.push_transcript(ChatMessageDisplay::new( Role::Tool, display, )); 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 counted = if let Some(ref mut rt) = state.session_runtime { refresh_lesson_counters(&state.memory_dir, rt); true } else { false }; if let Some(ref mut rt) = state.session_runtime { if counted { rt.consecutive_empty_reviews = 0; } else { rt.consecutive_empty_reviews += 1; } } state.push_toast(Toast::new(ToastKind::Info, message)); } else if kind == "task_retry" { state.push_transcript(ChatMessageDisplay::new( crate::dto::chat::message::Role::System, message.clone(), )); state.push_toast(Toast::new(ToastKind::Info, "Auto-continuing unfinished tasks...".to_string())); if let Some(ref mut rt) = state.session_runtime { rt.push_message(crate::dto::chat::message::ChatMessage::system(message.clone())); } } else if kind == "connectivity" { state.misc.api_connected = message == "connected"; } else if kind == "workflow_done" { state.push_toast(Toast { kind: ToastKind::Success, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 10000, }); state.push_transcript(ChatMessageDisplay::new( crate::dto::chat::message::Role::System, format!("✓ {}", message), )); state.dirty = true; } else if kind == "workflow_error" { state.push_toast(Toast { kind: ToastKind::Error, message: message.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 12000, }); state.push_transcript(ChatMessageDisplay::new( crate::dto::chat::message::Role::System, format!("✗ {}", message), )); state.dirty = true; } else { state.push_toast(Toast::new(ToastKind::Info, message)); } } TurnEvent::StreamStart => { state.misc.thinking = false; state.misc.api_connected = true; state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new())); } TurnEvent::StreamToken(delta) => { if let Some(last) = state.transcript_cache.messages.last_mut() { if last.role == Role::Assistant { last.content.push_str(&delta); state.transcript_cache.dirty = true; } } } TurnEvent::StreamDone(msg) => { state.misc.thinking = false; if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::Usage { tokens_in, tokens_out } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in += tokens_in; rt.usage.tokens_out += tokens_out; rt.usage.last_tokens_in = tokens_in; rt.usage.last_tokens_out = tokens_out; rt.usage.api_calls += 1; } } TurnEvent::Error(msg) => { state.misc.api_connected = false; 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 => { state.misc.thinking = false; turn_finished = true; } TurnEvent::Compacted(new_msgs) => { if let Some(ref mut rt) = state.session_runtime { rt.messages = new_msgs; state.push_toast(Toast::new(ToastKind::Info, "History auto-compacted by AI.".to_string())); state.dirty = true; } } TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status } => { // Upsert the agent in the workflow engine roster. // Running agents are pushed as new entries; status // updates find the existing entry by id and replace it. use crate::app::workflow::engine::WorkflowAgent; if let Some(existing) = state.workflow_engine.agents .iter_mut() .find(|a| a.id == agent_id) { existing.status = status; } else { state.workflow_engine.agents.push(WorkflowAgent { id: agent_id, name: agent_name, status, }); } if state.misc.overlay != Overlay::Workflow { state.misc.overlay = Overlay::Workflow; } state.dirty = true; } } } if turn_finished { maybe_trigger_review(state); } if turn_finished || state.dirty { state.dirty = true; } } Action::AbortTurn => { state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst); state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); } Action::Compact => { let max_wire_tokens = state.app_config.model_roles.values() .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .and_then(|role| role.context_window) .unwrap_or(state.app_config.default_context_window) as usize; if let Some(ref mut rt) = state.session_runtime { let total_chars: usize = rt.messages.iter() .filter_map(|m| m.content.as_deref()) .map(|c| c.len()) .sum(); let token_estimate = total_chars / 4; rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None); state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string())); 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; } Action::RunWorkflow { script } => { // Open the Workflow overlay so the user can see progress. state.misc.overlay = Overlay::Workflow; state.dirty = true; // Reset engine state before starting. state.workflow_engine.agents.clear(); state.workflow_engine.findings.clear(); let turn_events = state.turn_events.clone(); let turn_events_live = state.turn_events.clone(); state.push_toast(Toast::new( ToastKind::Info, format!("Starting workflow: {}…", &script.chars().take(40).collect::()), )); let session_dir = state.session_dir.clone(); let workspace_roots = state.workspace_roots.clone(); std::thread::spawn(move || { use std::collections::HashMap; use std::sync::Arc; use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript}; use crate::app::workflow::engine::{LiveStateFn, AgentStatus}; // Parse the script string: // "prompt1 | prompt2 | prompt3" → Parallel of 3 agents // "prompt1 -> prompt2" → Pipeline of 2 stages // "prompt" → single Agent let parts_pipe: Vec<&str> = script.split('|').map(|s| s.trim()).collect(); let parts_arrow: Vec<&str> = script.split("->").map(|s| s.trim()).collect(); let primitive = if parts_pipe.len() > 1 { ScriptPrimitive::Parallel( parts_pipe.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect() ) } else if parts_arrow.len() > 1 { ScriptPrimitive::Pipeline( parts_arrow.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect() ) } else { ScriptPrimitive::Agent(script.clone()) }; let wf = WorkflowScript { name: script.chars().take(40).collect(), description: script.clone(), script: primitive, options: ScriptOptions::default(), }; // Build a live-state callback that pushes WorkflowAgentUpdate events // into the turn_events queue so the TUI panel updates in real time. let live: LiveStateFn = Arc::new(move |agent_id: String, status: AgentStatus| { let name = agent_id.chars().take(30).collect::(); if let Ok(mut q) = turn_events_live.lock() { q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { agent_id: agent_id.clone(), agent_name: name, status, }); } }); let args: HashMap = HashMap::new(); let result = crate::app::workflow::engine::run_workflow_tracked( &wf, &args, Some(live), &session_dir, &workspace_roots, ); let (kind, message) = match result { Ok(summary) => ("workflow_done".to_string(), summary), Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {}", e)), }; if let Ok(mut q) = turn_events.lock() { q.push_back(crate::app::state::runtime::TurnEvent::SystemNote { kind, message, }); } }); } } } /// Spawn a background thread that runs one full LLM turn. /// /// Flow: check that no turn is currently in-flight → bail if so → /// collect messages and config from state → determine API key (from /// settings, env var, or default) → resolve generation params from /// the current effort level → collect all tools (built-in + MCP) → /// build `TurnCtx` → spawn a thread running `run_agent_turn` → /// on any error, push a `TurnEvent::Error` → clear the in-flight flag /// when the thread exits. /// /// Why: runs on a plain OS thread so the async event loop stays responsive. /// /// Return: nothing; results flow through `state.turn_events`. 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 mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); let model = state.settings.model.clone(); let base_url = state.app_config.providers.get(&state.settings.provider) .map(|p| p.api_base.clone()); let context_window = state.app_config.model_roles.values() .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .and_then(|role| role.context_window) .unwrap_or(state.app_config.default_context_window) as usize; if api_key.is_empty() { if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { api_key = provider_cfg.api_key_env.as_ref() .and_then(|env| std::env::var(env).ok()) .or_else(|| provider_cfg.default_api_key.clone()) .unwrap_or_default(); } } if api_key.is_empty() { api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); } let (temperature, max_tokens) = crate::app::mode::effort::generation_params( state.misc.effort_level, state.settings.max_tokens, ); 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 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(); let abort_flag = state.abort_flag.clone(); abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); *in_flight_flag.lock().unwrap() = true; let events_q = turn_events.clone(); std::thread::spawn(move || { let db = crate::model::msglog::open_or_create(&edit_session_dir) .ok() .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); let tc = TurnCtx { client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url), tdefs: tool_defs, tools, ctx, context_window, workspace_roots, edit_log_session_dir: edit_session_dir, session_id, db, temperature, max_tokens, abort_flag, }; 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; } }); } /// Context bundle passed to `run_agent_turn` on its background thread. struct TurnCtx { client: crate::service::provider::LlmClient, tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, context_window: usize, workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, db: Option>>, temperature: f32, max_tokens: Option, abort_flag: std::sync::Arc, } /// Build an ASCII tree of the workspace directory structure for the /// system prompt, so the LLM can see the file layout. /// /// Flow: for each root, walk using `ignore::WalkBuilder` (respecting /// `.gitignore` and hidden files) → prefix `[DIR]` for directories → /// truncate after 1000 entries. /// /// Return: a formatted string with one entry per line. fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String { let mut out = String::new(); out.push_str("Current Workspace Directory Structure:\n"); for root in roots { out.push_str(&format!("Root: {}\n", root.display())); let walker = ignore::WalkBuilder::new(root) .hidden(true) .git_ignore(true) .build(); let mut count = 0; for entry in walker.flatten() { let path = entry.path(); if let Ok(rel) = path.strip_prefix(root) { if rel.as_os_str().is_empty() { continue; } let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let prefix = if is_dir { "[DIR] " } else { " " }; out.push_str(&format!(" {}{}\n", prefix, rel.display())); count += 1; if count > 1000 { out.push_str(" ... (truncated)\n"); break; } } } } out } /// Load all memory entries from `memory_dir` and format them as a compact /// section appended to the system prompt, so the AI is always aware of /// stored lessons and project knowledge. /// /// Flow: list memory slugs → for each, read + parse the file → collect /// entries whose lifecycle is not "stale" → cap total output at 3000 chars /// to avoid dominating the prompt budget. /// /// Why: previously, lessons existed on disk but the AI never saw them /// unless it explicitly called `recall()`. This makes the memory system /// actually useful by surfacing relevant knowledge automatically. /// /// Return: a formatted string (may be empty if no memory entries exist). fn build_memory_section(memory_dir: &std::path::Path) -> String { let names = crate::model::memory::Memory::list(memory_dir); if names.is_empty() { return String::new(); } let mut section = String::from("\n\n--- Persistent Memory ---\n"); section.push_str(&format!("Total entries: {}\n\n", names.len())); for name in &names { if section.len() > 3000 { section.push_str("... (more entries omitted, use recall() to see all)\n"); break; } if let Ok(mem) = crate::model::memory::Memory::read(memory_dir, name) { if mem.lifecycle == "stale" { continue; } section.push_str(&format!("## [{}] {}\n{}\n\n", mem.kind, mem.name, mem.content)); } } section.push_str("---"); section } /// Scan `memory_dir` and update every lesson counter in `SessionRuntime` /// from real on-disk data. /// /// Flow: list all memory slugs → read+parse each → increment the matching /// kind counter (user/feedback/project/reference), lifecycle counter /// (active/stale/contradicted), and the total. If a memory cannot be read /// (e.g. a race with deletion) it is silently skipped. /// /// Why: previously the UI showed all zeros because nothing ever set the /// breakdown counters. This runs on every user submit so the dashboard /// reflects actual memory state. fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::state::runtime::SessionRuntime) { let names = crate::model::memory::Memory::list(memory_dir); rt.lesson_count = 0; rt.lessons_user = 0; rt.lessons_feedback = 0; rt.lessons_project = 0; rt.lessons_reference = 0; rt.lessons_active = 0; rt.lessons_stale = 0; rt.lessons_contradicted = 0; for name in &names { if let Ok(mem) = crate::model::memory::Memory::read(memory_dir, name) { rt.lesson_count += 1; match mem.kind.as_str() { "user" => rt.lessons_user += 1, "feedback" => rt.lessons_feedback += 1, "project" => rt.lessons_project += 1, "reference" => rt.lessons_reference += 1, _ => {} } match mem.lifecycle.as_str() { "active" => rt.lessons_active += 1, "stale" => rt.lessons_stale += 1, "contradicted" => rt.lessons_contradicted += 1, _ => {} } } } } /// Persist a `ChatMessage` to the SQLite message log, if a database /// connection is available. /// /// Flow: if `db` is `Some`, lock the mutex and call `insert_message`. /// Errors are silently ignored. fn archive_message(db: &Option>>, session_id: &str, msg: &ChatMessage) { if let Some(ref arc) = db { if let Ok(conn) = arc.lock() { let _ = crate::model::msglog::insert_message(&conn, session_id, msg); } } } /// Execute one full agent turn: stream the conversation to the LLM, /// handle tool calls, and loop until the LLM produces a non-tool response /// or runs out of unfinished todo items. /// /// Flow: build system prompt with workspace tree → optionally shape /// (compact) messages via `shortsend` → call `chat_with_tools_streaming` /// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`, /// and `Usage` events → on streaming success, handle tool calls (gated /// through `Harness::gate_tool_call`) or unwrap the final assistant /// message → check for unfinished todo.md tasks (auto-retry with a /// system message if any remain) → finalise with `Done` and an `edits` /// SystemNote. /// /// On streaming failure: retry once with a non-streaming call → if that /// also fails and there are unfinished tasks, sleep 5s and loop back; /// otherwise return the error. /// /// Why: non-streaming fallback handles flaky connections without aborting /// the turn; todo.md polling lets the agent self-direct toward completeness. /// /// Return: `Ok(())` on successful completion, or an error from the LLM /// API after retries are exhausted. 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 mut prev_shaped = false; let tree_info = generate_workspace_tree(&tc.workspace_roots); let memory_section = build_memory_section(&tc.ctx.memory_dir); let system_text = format!( "{}\n\n{}\n\n{}{}", crate::resources::SYSTEM_PROMPT, crate::resources::SYSTEM_TOOLS, tree_info, memory_section, ); if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { let sys = ChatMessage::system(system_text); archive_message(&tc.db, &tc.session_id, &sys); msgs.insert(0, sys); } loop { let total_chars: usize = msgs.iter() .filter_map(|m| m.content.as_deref()) .map(|c| c.len()) .sum(); let token_estimate = total_chars / 4; let max_wire_tokens = tc.context_window; let wire_msgs = if crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) { prev_shaped = true; let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client)); // Dispatch the compacted messages to the main thread so the local session history // is permanently compacted and doesn't trigger shaping again immediately on next turn. if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Compacted(compacted.clone())); } // Also update our local `msgs` variable so the rest of the loop operates on the compacted version msgs = compacted.clone(); compacted } else { prev_shaped = false; msgs.clone() }; let mut stream_started = false; let mut reasoning_started = false; let mut reasoning_ended = false; let mut usage = None; let result = tc.client.chat_with_tools_streaming( &wire_msgs, if tc.tdefs.is_empty() { None } else { Some(tc.tdefs.clone()) }, Some(tc.temperature), tc.max_tokens, |event| -> bool { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { return false; } if let Ok(mut q) = events_q.lock() { match event { crate::app::runtime::stream::StreamEvent::Token(tok) => { if !stream_started { q.push_back(TurnEvent::StreamStart); stream_started = true; } if reasoning_started && !reasoning_ended { reasoning_ended = true; q.push_back(TurnEvent::StreamToken("\n\n\n".to_string())); } q.push_back(TurnEvent::StreamToken(tok.clone())); } crate::app::runtime::stream::StreamEvent::Reasoning(tok) => { if !stream_started { q.push_back(TurnEvent::StreamStart); stream_started = true; } if !reasoning_started { reasoning_started = true; q.push_back(TurnEvent::StreamToken("\n".to_string())); } q.push_back(TurnEvent::StreamToken(tok.clone())); } crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { usage = Some((*prompt_tokens, *completion_tokens)); } _ => {} } } true }, ); if reasoning_started && !reasoning_ended { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::StreamToken("\n\n\n".to_string())); } } let (response, final_usage) = match result { Ok((msg, u)) => (msg, u.or(usage)), Err(e) => { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error("Generation aborted by user".to_string())); } return Ok(()); } match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) { Ok((msg, usage_fb)) => (msg, usage_fb), Err(api_err) => { let todo_path = tc.ctx.session_dir.join("todo.md"); let mut has_unfinished = false; if let Ok(todo_text) = std::fs::read_to_string(&todo_path) { if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) { has_unfinished = true; } } if has_unfinished { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::SystemNote { kind: "task_retry".to_string(), message: format!("Network/API error: {}. Auto-retrying to finish tasks...", api_err), }); } std::thread::sleep(std::time::Duration::from_secs(5)); continue; } return Err(api_err); } } } }; if let Some((tok_in, tok_out)) = final_usage { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out }); } } 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(); archive_message(&tc.db, &tc.session_id, &response); msgs.push(response); for tool_call in tool_calls { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error("Turn aborted by user".to_string())); } return Ok(()); } 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, &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, &tool_call.id, &args, &tc.edit_log_session_dir, &tc.session_id, &tc.db, ) { Ok(result) => (result, false, is_edit_tool), Err(e) => (e.to_string(), true, false), }, Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false), }; if is_edit { edits_this_turn += 1; } let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); { 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, path: tool_path, }); } } let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); archive_message(&tc.db, &tc.session_id, &tool_msg); msgs.push(tool_msg); } } else { if !content.is_empty() { archive_message(&tc.db, &tc.session_id, &response); if let Ok(mut q) = events_q.lock() { if stream_started { q.push_back(TurnEvent::StreamDone(response.clone())); } else { q.push_back(TurnEvent::AssistantMessage(response.clone())); } } } let todo_path = tc.ctx.session_dir.join("todo.md"); let mut has_unfinished = false; if let Ok(todo_text) = std::fs::read_to_string(&todo_path) { if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) { has_unfinished = true; } } if has_unfinished { let sys_text = "You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished."; let msg = ChatMessage::system(sys_text); archive_message(&tc.db, &tc.session_id, &msg); msgs.push(msg); if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::SystemNote { kind: "task_retry".to_string(), message: sys_text.to_string(), }); } continue; } 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(()) } /// Execute a single tool call: find the tool by name, snapshot the file /// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for /// write/edit, and return the output. /// /// Flow: iterate tools → match by name → for write/edit, snapshot the /// pre-existing file content into the blob store → call `tool.run()` → /// for write/edit, compute SHA-256 of the new content and append an /// `EditLogEntry` → return the tool output string. /// /// Why: snapshots enable the rewind feature to restore previous content /// after a write/edit. /// /// Return: the tool's stdout string, or an error if no matching tool was /// found or the tool run itself failed. #[allow(clippy::too_many_arguments)] fn execute_one_tool( tools: &[Box], ctx: &crate::tool::ToolCtx, name: &str, tool_call_id: &str, args: &serde_json::Value, session_dir: &std::path::Path, session_id: &str, db: &Option>>, ) -> anyhow::Result { for tool in tools { if tool.name() == name { // Snapshot current file content before write/edit for rewind if (name == "write" || name == "edit") && !tool_call_id.is_empty() { if let Some(ref arc) = db { if let Ok(conn) = arc.lock() { let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) { if let Ok(bytes) = std::fs::read(&abs_path) { let _ = crate::model::msglog::store_blob( &conn, session_id, tool_call_id, &bytes, None, ); } } } } } 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) } /// Optionally push a review-available toast at the end of a turn that /// performed edits. /// /// Flow: skip if review is disabled → skip if `edit_count` is zero → /// push an info toast listing the number of modified files. /// /// Why: does not launch the review itself (that happens inside /// `should_trigger_review` on `Tick`), only informs the user that /// a review has material to examine. 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 session. Review available.", edit_count), )); } /// Persist the current session metadata and conversation to disk. /// /// Flow: build a `Session` object → save its metadata → write /// `rt.messages` as JSON to the conversation file → errors are silently /// ignored. /// /// Why: called on `ForceQuit` so the session can be resumed later. 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); } } } /// Run a browser-based OAuth PKCE flow for the given provider. /// /// Flow: look up config by provider name ("zen"/"opencode", "openai", /// or a custom provider via env vars) → bind a loopback server → generate /// a PKCE code verifier and challenge → build the authorisation URL → /// wait for the redirect code on the loopback server (with a 120s timeout) /// → exchange the code for a token → save the token to /// `~/.config/zesdex/oauth_{provider}.json`. /// /// Why: the `webbrowser::open` call is currently commented out; the user /// must open the auth URL manually until that line is reinstated. /// /// Return: a success message on completion, or an error if the flow fails /// at any step. fn run_oauth_flow(provider: &str) -> anyhow::Result { use crate::service::oauth::manager::{OAuthConfig, OAuthManager}; use crate::service::oauth::loopback::LoopbackServer; use crate::service::oauth::pkce::CodeVerifier; let config = match provider { "zen" | "opencode" => OAuthConfig { auth_url: "https://opencode.ai/zen/oauth/authorize".to_string(), token_url: "https://opencode.ai/zen/oauth/token".to_string(), client_id: std::env::var("ZEN_CLIENT_ID") .unwrap_or_else(|_| "zesdex".to_string()), client_secret: std::env::var("ZEN_CLIENT_SECRET").ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], }, "openai" => OAuthConfig { auth_url: "https://auth0.openai.com/authorize".to_string(), token_url: "https://auth0.openai.com/oauth/token".to_string(), client_id: std::env::var("OPENAI_CLIENT_ID") .unwrap_or_else(|_| "zesdex".to_string()), client_secret: std::env::var("OPENAI_CLIENT_SECRET").ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], }, other => { let auth_url = std::env::var(format!("{}_AUTH_URL", other.to_uppercase())) .map_err(|_| anyhow::anyhow!("unknown provider '{}'. Set {}_AUTH_URL env var.", other, other.to_uppercase()))?; let token_url = std::env::var(format!("{}_TOKEN_URL", other.to_uppercase())) .map_err(|_| anyhow::anyhow!("{}_TOKEN_URL not set", other.to_uppercase()))?; let client_id = std::env::var(format!("{}_CLIENT_ID", other.to_uppercase())) .unwrap_or_else(|_| "zesdex".to_string()); OAuthConfig { auth_url, token_url, client_id, client_secret: std::env::var(format!("{}_CLIENT_SECRET", other.to_uppercase())).ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], } } }; let server = LoopbackServer::bind()?; let redirect_uri = server.redirect_uri(); let verifier = CodeVerifier::new(); let challenge = verifier.challenge(); let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16))); let mut manager = OAuthManager::new(config.clone()); let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str()); if auth_url.is_empty() { tracing::warn!("[oauth] auth_url was empty for provider '{}'", provider); } else if webbrowser::open(&auth_url).is_err() { tracing::warn!( "[oauth] could not open browser for '{}'; user must open URL manually:\n{}", provider, auth_url ); } let code = server.wait_for_code(120_000, &state_token)?; manager.exchange_code(&code, &redirect_uri, verifier.as_str()) .map_err(|e| anyhow::anyhow!("{}", e))?; if let Some(ref token) = manager.token { let token_path = dirs::config_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("zesdex") .join(format!("oauth_{}.json", provider)); if let Some(parent) = token_path.parent() { let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default()) { tracing::warn!("[oauth] failed to persist token for '{}': {}", provider, e); } } Ok(format!("Successfully authenticated with {}.", provider)) } /// Spawn a background thread that checks API reachability via a lightweight HEAD /// request to `/models`, pushing the result as a `SystemNote` so the /// next `Tick` handler updates `api_connected`. /// /// Flow: resolve the provider's base URL → build a short-lived reqwest client /// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push /// a `connectivity` SystemNote with the result. /// /// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI. fn spawn_api_connectivity_check(state: &AppStateRest) { let base_url = state .app_config .providers .get(&state.settings.provider) .map(|p| p.api_base.clone()) .unwrap_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string()); let turn_events = state.turn_events.clone(); std::thread::spawn(move || { let url = format!("{}/models", base_url.trim_end_matches('/')); let connected = match reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(3)) .build() { Ok(client) => match client.head(&url).send() { Ok(resp) => { let s = resp.status(); // 401/403 means the server is reachable (just auth is wrong) s.is_success() || s.as_u16() == 401 || s.as_u16() == 403 } Err(_) => false, }, Err(_) => false, }; if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "connectivity".to_string(), message: if connected { "connected".to_string() } else { "disconnected".to_string() }, }); } }); } /// Generate `n` pseudo-random bytes from the system clock mixed with a monotonic /// counter, providing sufficient unpredictability for a per-flow OAuth state /// token without a `rand` dependency. /// /// Why: avoids pulling in a full RNG crate for the OAuth state token; /// the counter ensures sequential invocations produce different outputs even /// within the same clock tick, which is sufficient for a short-lived nonce. fn rand_bytes(n: usize) -> Vec { use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static COUNTER: AtomicU64 = AtomicU64::new(0); let counter = COUNTER.fetch_add(1, Ordering::Relaxed); let seed = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() as u64; let base = seed ^ counter; (0..n).map(|i| ((base >> ((i as u64 % 8) * 8)) ^ (i as u64 * 2654435761)) as u8).collect() }