//! Daemon request handler — processes IPC `ClientRequest` messages, //! applies `Action`s to application state, and pushes state updates back //! to the attached client. //! //! Also defines the [`Action`] enum and the [`apply_action`] dispatcher, //! as well as the [`handle_key`] function that translates `crossterm` //! key events into actions — adapting `controller::input::handle_key` //! from the legacy single-process backend. //! //! Flow: //! 1. `handle_daemon_client(conn, state)` loops reading `ClientRequest`s //! 2. Each request is translated into `Action`(s) via `handle_key` / //! direct action invocation //! 3. `apply_action` mutates `AppStateRest` in place //! 4. After each request, `send_daemon_update` pushes a full state //! snapshot back to the client use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tracing::info; use webbrowser; use zesdex_infrastructure::ipc::conn::Connection; use zesdex_infrastructure::ipc::protocol::{ ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry, }; use zesdex_infrastructure::utils::CastOr; use crate::key_code::key_action_to_code; use crate::state::{AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper}; // --------------------------------------------------------------------------- // Action enum // --------------------------------------------------------------------------- /// A single well-typed event that mutates `AppStateRest` when applied via /// [`apply_action`]. /// /// Produced by `handle_key` (key event → actions) or directly by the /// daemon's IPC handler. #[derive(Debug, Clone)] pub enum Action { /// Hard exit — immediately terminates the process. ForceQuit, /// Submit a user message to the LLM, starting a new agent turn. SubmitInput(String), /// Delete one character before the cursor in the input buffer. DeleteChar, /// Delete one character after the cursor in the input buffer. DeleteCharRight, /// Move the cursor one position left in the input buffer. CursorLeft, /// Move the cursor one position right in the input buffer. CursorRight, /// Navigate up through command history. HistoryUp, /// Navigate down through command history. HistoryDown, /// Scroll the transcript pane up. ScrollUp, /// Scroll the transcript pane down. ScrollDown, /// Open a named overlay. OpenOverlay(Overlay), /// Close the currently active overlay. CloseOverlay, /// Insert a system-generated note into the transcript. SystemNote { /// Note category: "error", "info", "clear", etc. kind: String, /// The message text to display. message: String, }, /// Show the quit-confirmation overlay. QuitConfirm, /// Terminal resize event — carries the new dimensions. Resize(u16, u16), /// Periodic timer tick — drains queued events and runs side jobs. Tick, /// Accept a lesson by name. LessonAccept { name: String }, /// Reject a lesson by name. LessonReject { name: String }, /// Delete a previously stored lesson by name. LessonDelete { name: String }, /// Start the OAuth device-code login flow for a named provider. StartOAuth { provider: String }, /// Open the inline file editor for `path`. OpenEditor { path: String }, /// Register a new MCP server by name and shell command. McpAdd { name: String, command: String }, /// Open the model-picker overlay. ModelList, /// Set the abort flag on the currently running turn. AbortTurn, /// Request AI-summary compaction of the conversation history. Compact, } // --------------------------------------------------------------------------- // apply_action // --------------------------------------------------------------------------- /// Apply an `Action` to the application state. /// /// Flow: pattern-match the variant → delegate to the corresponding handler /// → handler mutates `state` (input buffer, scroll position, overlay, /// transcript, toasts, dirty flag, etc.). /// /// Why: the single chokepoint that turns every typed key and async event /// into a state change. pub fn apply_action(state: &mut AppStateRest, action: Action) { tracing::debug!("apply_action: {:?}", action); match action { // ── Lifecycle ───────────────────────────────────────────────── Action::ForceQuit => handle_force_quit(state), Action::QuitConfirm => handle_quit_confirm(state), Action::Resize(w, _h) => handle_resize(state, w), Action::Tick => handle_tick(state), // ── Input / editing ─────────────────────────────────────────── Action::SubmitInput(text) => handle_submit_input(state, text), Action::DeleteChar => handle_delete_char(state), Action::DeleteCharRight => handle_delete_char_right(state), Action::CursorLeft => handle_cursor_left(state), Action::CursorRight => handle_cursor_right(state), Action::HistoryUp => handle_history_up(state), Action::HistoryDown => handle_history_down(state), // ── Scroll / navigation ─────────────────────────────────────── Action::ScrollUp => handle_scroll_up(state), Action::ScrollDown => handle_scroll_down(state), Action::OpenOverlay(overlay) => handle_open_overlay(state, overlay), Action::CloseOverlay => handle_close_overlay(state), // ── System / info ───────────────────────────────────────────── Action::SystemNote { kind: _kind, message, } => handle_system_note(state, message), Action::ModelList => handle_model_list(state), Action::AbortTurn => handle_abort_turn(state), Action::Compact => handle_compact(state), // ── Editor / MCP / OAuth ────────────────────────────────────── Action::OpenEditor { path } => handle_open_editor(state, path), Action::McpAdd { name, command } => handle_mcp_add(state, name, command), Action::StartOAuth { provider } => handle_start_oauth(state, provider), // ── Lessons ─────────────────────────────────────────────────── Action::LessonAccept { name } => handle_lesson_accept(state, name), Action::LessonReject { name } => handle_lesson_reject(state, name), Action::LessonDelete { name } => handle_lesson_delete(state, name), } } // --------------------------------------------------------------------------- // Action handlers // --------------------------------------------------------------------------- fn handle_force_quit(state: &mut AppStateRest) { state.quit = true; } fn handle_quit_confirm(state: &mut AppStateRest) { state.misc.overlay = Overlay::QuitConfirm; state.dirty = true; } fn handle_resize(state: &mut AppStateRest, w: u16) { tracing::debug!("terminal resize to width={}", w); state.dirty = true; } fn handle_tick(state: &mut AppStateRest) { let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); // Drain queued turn events FIRST (while holding the lock), then release // the lock and process events with mutable state access. let drained: Vec<_> = state .turn_events .lock() .map(|mut events| events.drain(..).collect()) .unwrap_or_else(|e| { tracing::error!("turn_events mutex poisoned, recovering"); e.into_inner().drain(..).collect() }); for event in drained { use zesdex_infrastructure::TurnEvent; match event { TurnEvent::SystemNote { kind, message } => { if kind == "hive_mind_converged" { if let Some(ref mut rt) = state.session_runtime { rt.hive_mind_converged = true; } } handle_system_note(state, message); } TurnEvent::AssistantMessage(_msg) => { // AssistantMessage is handled via StreamDone to avoid duplicates. state.dirty = true; } TurnEvent::StreamToken(_token) => { state.dirty = true; } TurnEvent::StreamDone(msg) => { state.push_transcript(ChatMessageDisplay::new( RoleWrapper::Assistant, msg.content.unwrap_or_default(), )); } TurnEvent::Error(e) => { state.toast_error(e); } TurnEvent::Usage { tokens_in, tokens_out, } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in); rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out); rt.usage.last_tokens_in = tokens_in; rt.usage.last_tokens_out = tokens_out; } } TurnEvent::ReviewUsage { tokens_in, tokens_out, } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in); rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out); } } TurnEvent::Done => { if let Ok(mut in_flight) = state.turn_in_flight.lock() { *in_flight = false; } state.dirty = true; } TurnEvent::ToolResult { .. } => { // Tool result events are logged but the content is already in // the session runtime messages — no transcript push needed here. state.dirty = true; } _ => { state.dirty = true; } } } state.misc.tick_count += 1; state.dirty = true; } fn handle_submit_input(state: &mut AppStateRest, text: String) { // Push the user message to the transcript. state.push_transcript(ChatMessageDisplay::new(RoleWrapper::User, text.clone())); // Save the input to history. if !text.is_empty() { state.input.history.push(text.clone()); if let Some(ref path) = state.input.history_file { let _ = std::fs::write(path, state.input.history.join("\n")); } } // Set up the session runtime for the turn. if let Some(ref mut rt) = state.session_runtime { rt.push_message(zesdex_infrastructure::ChatMessage { role: zesdex_infrastructure::Role::User, content: Some(text.clone()), tool_calls: None, tool_call_id: None, name: None, }); } // Clear the input buffer. state.input.buffer.clear(); state.input.cursor = 0; state.input.history_idx = None; state.dirty = true; // Resolve LLM provider credentials let provider_name = &state.settings.provider; let provider_cfg = state.app_config.providers.get(provider_name).cloned(); let mut api_key = String::new(); if let Some(key) = state.settings.api_keys.get(provider_name) { api_key = key.clone(); } else if let Some(ref cfg) = provider_cfg { if let Some(ref default_key) = cfg.default_api_key { api_key = default_key.clone(); } if api_key.is_empty() { if let Some(ref env_name) = cfg.api_key_env { if let Ok(val) = std::env::var(env_name) { api_key = val; } } } } let messages = state .session_runtime .as_ref() .map(|rt| rt.messages.clone()) .unwrap_or_default(); let params = zesdex_domain::agent::AgentTurnParams { messages, session_dir: state.session_dir.clone(), workspace_roots: state.workspace_roots.clone(), turn_events: state.turn_events.clone(), in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), abort: state.abort_flag.clone(), api_key: api_key.clone(), model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config), api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()), }; let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new( api_key, zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config), provider_cfg.map(|cfg| cfg.api_base.clone()), )); let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder() .session_dir(state.session_dir.clone()) .workspaces(state.workspace_roots.clone()) .turn_events(state.turn_events.clone()) .build(); let tool_executor = std::sync::Arc::new( zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx), ); let tools = zesdex_infrastructure::tools::all_tools(); let defs = zesdex_infrastructure::tools::tool_defs(&tools); let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new( client, tool_executor, defs, ); use zesdex_application::agent::AgentTurnService; tokio::spawn(async move { let _ = turn_service.run_turn(params).await; }); } fn handle_delete_char(state: &mut AppStateRest) { if state.input.cursor > 0 { state.input.buffer.remove(state.input.cursor - 1); state.input.cursor -= 1; state.dirty = true; } } fn handle_delete_char_right(state: &mut AppStateRest) { if state.input.cursor < state.input.buffer.len() { state.input.buffer.remove(state.input.cursor); state.dirty = true; } } fn handle_cursor_left(state: &mut AppStateRest) { if state.input.cursor > 0 { state.input.cursor = state.input.cursor.saturating_sub(1); state.dirty = true; } } fn handle_cursor_right(state: &mut AppStateRest) { if state.input.cursor < state.input.buffer.len() { state.input.cursor += 1; state.dirty = true; } } fn handle_history_up(state: &mut AppStateRest) { if state.input.history.is_empty() { return; } let idx = match state.input.history_idx { Some(i) if i > 0 => i - 1, None => state.input.history.len() - 1, _ => return, }; state.input.history_idx = Some(idx); state.input.buffer = state.input.history[idx].clone(); state.input.cursor = state.input.buffer.len(); state.dirty = true; } fn handle_history_down(state: &mut AppStateRest) { match state.input.history_idx { Some(i) if i + 1 < state.input.history.len() => { state.input.history_idx = Some(i + 1); state.input.buffer = state.input.history[i + 1].clone(); state.input.cursor = state.input.buffer.len(); state.dirty = true; } Some(_) => { state.input.history_idx = None; state.input.buffer.clear(); state.input.cursor = 0; state.dirty = true; } None => {} } } fn handle_scroll_up(state: &mut AppStateRest) { state.scroll.scroll_up(1); state.dirty = true; } fn handle_scroll_down(state: &mut AppStateRest) { state.scroll.scroll_down(1); state.dirty = true; } fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) { state.misc.overlay = overlay; state.dirty = true; } fn handle_close_overlay(state: &mut AppStateRest) { if state.misc.overlay.is_active() { state.misc.overlay = Overlay::None; state.dirty = true; } } fn handle_system_note(state: &mut AppStateRest, message: String) { state.push_transcript(ChatMessageDisplay::new(RoleWrapper::System, message)); } fn handle_model_list(state: &mut AppStateRest) { info!("opening model selector"); state.misc.overlay = Overlay::ModelSelector; state.dirty = true; } fn handle_abort_turn(state: &mut AppStateRest) { state .abort_flag .store(true, std::sync::atomic::Ordering::SeqCst); if let Ok(mut in_flight) = state.turn_in_flight.lock() { *in_flight = false; } state.dirty = true; } fn handle_compact(state: &mut AppStateRest) { tracing::info!("compacting conversation with AI summarization"); let provider_name = &state.settings.provider; let provider_cfg = state.app_config.providers.get(provider_name).cloned(); let api_key = state .settings .api_keys .get(provider_name) .cloned() .unwrap_or_default(); let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config); let api_base = provider_cfg.map(|cfg| cfg.api_base.clone()); let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base); if let Some(ref mut rt) = state.session_runtime { let tokio_rt = zesdex_infrastructure::runtime::runtime(); if let Ok(()) = tokio_rt.block_on( zesdex_application::agent::turn_service::compact_messages_with_ai( &mut rt.messages, &client, ), ) { let msg_count = rt.messages.len(); state.push_transcript(ChatMessageDisplay::new( RoleWrapper::System, format!("Conversation compacted via AI Summarizer to {msg_count} messages."), )); } } state.dirty = true; } fn handle_open_editor(state: &mut AppStateRest, path: String) { tracing::info!("opening editor for: {path}"); state.misc.editor = Some(crate::state::EditorState::new( std::path::PathBuf::from(&path), std::fs::read_to_string(&path).unwrap_or_default(), )); handle_open_overlay(state, Overlay::Editor); } fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) { tracing::info!("adding MCP server: {name}"); match state.mcp_manager.register(&name, &command) { Ok(()) => { state.toast_success(format!("MCP server '{name}' added with command: {command}")); state.dirty = true; } Err(e) => { state.toast_error(format!("Failed to add MCP server '{name}': {e}")); } } } fn handle_start_oauth(state: &mut AppStateRest, provider: String) { tracing::info!("starting OAuth for provider: {provider}"); state.toast_info(format!("OAuth flow started for {provider}...")); // Construct provider-specific OAuth authorization URL. let auth_url_owned; let url_to_open = match provider.as_str() { "github" => "https://github.com/login/oauth/authorize", "google" => "https://accounts.google.com/o/oauth2/v2/auth", "anthropic" => "https://anthropic.com/api/oauth/authorize", other => { auth_url_owned = format!("https://{other}.com/auth"); &auth_url_owned } }; if let Err(e) = webbrowser::open(url_to_open) { tracing::warn!("Failed to open browser for OAuth: {e}"); state.toast_error(format!("Failed to open browser: {e}")); } state.dirty = true; } fn handle_lesson_accept(state: &mut AppStateRest, name: String) { tracing::info!("lesson accepted: {name}"); state.toast_success(format!("Lesson accepted: {name}")); state.dirty = true; } fn handle_lesson_reject(state: &mut AppStateRest, name: String) { tracing::info!("lesson rejected: {name}"); state.toast_info(format!("Lesson rejected: {name}")); state.dirty = true; } fn handle_lesson_delete(state: &mut AppStateRest, name: String) { tracing::info!("lesson deleted: {name}"); state.toast_warning(format!("Lesson deleted: {name}")); state.dirty = true; } // --------------------------------------------------------------------------- // handle_key — translate crossterm KeyEvent into Vec // --------------------------------------------------------------------------- /// Handle a crossterm key event and produce a list of actions. /// /// Maps key codes + modifiers to Action variants. Mirrors the same /// dispatch logic used by the single-process TUI controller. pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { tracing::debug!( code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key" ); // ── Editor overlay ─────────────────────────────────────────────────── if state.misc.overlay == Overlay::Editor { return match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::QuitConfirm] } KeyCode::Esc => { vec![Action::CloseOverlay] } _ => vec![], }; } // ── Learning overlay ────────────────────────────────────────────────── if state.misc.overlay == Overlay::Learning { return match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::QuitConfirm] } KeyCode::Esc => vec![Action::CloseOverlay], KeyCode::Up => { state.misc.selected_index = state.misc.selected_index.saturating_sub(1); state.dirty = true; vec![] } KeyCode::Down => { state.misc.selected_index = state.misc.selected_index.saturating_add(1); state.dirty = true; vec![] } _ => vec![], }; } // ── Normal mode ─────────────────────────────────────────────────────── match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Always show quit-confirm, regardless of overlay state. vec![Action::QuitConfirm] } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::CloseOverlay] } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Yank: handled separately via clipboard — produce no action state.dirty = true; vec![] } KeyCode::Tab => { // Cycle autocomplete if !state.input.autocomplete_visible { state.input.open_autocomplete(); state.dirty = true; } else { state.input.autocomplete_idx = (state.input.autocomplete_idx + 1) % state.input.autocomplete_candidates.len().max(1); state.dirty = true; } vec![] } KeyCode::Enter => { if state.misc.overlay.is_active() { match state.misc.overlay { Overlay::QuitConfirm => vec![Action::ForceQuit], Overlay::ClearConfirm => vec![Action::SystemNote { kind: "clear".to_string(), message: "cleared".to_string(), }], _ => vec![Action::CloseOverlay], } } else if state.input.autocomplete_visible { // Select the current autocomplete candidate if !state.input.autocomplete_candidates.is_empty() { let idx = state .input .autocomplete_idx .min(state.input.autocomplete_candidates.len().saturating_sub(1)); if state.input.autocomplete_kind == AutocompleteKind::Command { state.input.buffer = state.input.autocomplete_candidates[idx].clone(); state.input.cursor = state.input.buffer.len(); } state.input.close_autocomplete(); state.dirty = true; } vec![] } else if !state.input.buffer.is_empty() { vec![Action::SubmitInput(state.input.buffer.clone())] } else { vec![] } } KeyCode::Esc => { if state.misc.overlay.is_active() { vec![Action::CloseOverlay] } else if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; vec![] } else { vec![Action::AbortTurn] } } KeyCode::Up => { if state.misc.overlay.is_active() { vec![Action::ScrollUp] } else { vec![Action::HistoryUp] } } KeyCode::Down => { if state.misc.overlay.is_active() { vec![Action::ScrollDown] } else { vec![Action::HistoryDown] } } KeyCode::PageUp => vec![Action::ScrollUp], KeyCode::PageDown => vec![Action::ScrollDown], KeyCode::Home => { state.scroll.offset = 0; state.dirty = true; vec![] } KeyCode::End => { state.scroll.offset = usize::MAX; state.dirty = true; vec![] } KeyCode::Backspace => vec![Action::DeleteChar], KeyCode::Delete => vec![Action::DeleteCharRight], KeyCode::Left => vec![Action::CursorLeft], KeyCode::Right => vec![Action::CursorRight], KeyCode::Char(c) => { // Regular character input if state.input.autocomplete_visible { state.input.close_autocomplete(); } state.input.buffer.insert(state.input.cursor, c); state.input.cursor += c.len_utf8(); state.dirty = true; vec![] } _ => { // Unhandled key vec![] } } } // --------------------------------------------------------------------------- // send_daemon_update — push full state snapshot to the client // --------------------------------------------------------------------------- /// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it /// to the attached client as a `DaemonFrame::StateUpdate`. /// /// Flow: map transcript messages/toasts to their wire DTOs → derive the /// active overlay name (or `None` if no overlay is active) → build and /// send one `DaemonFrame`. pub fn send_daemon_update(conn: &mut Connection, state: &AppStateRest) -> Result<()> { tracing::debug!("sending state update to attached client"); let messages: Vec = state .transcript_cache .messages .iter() .map(|m| MessageEntry { role: format!("{:?}", m.role), content: m.content.clone(), timestamp: m.timestamp, }) .collect(); let toasts: Vec = state .misc .toasts .iter() .map(|t| ToastEntry { kind: format!("{:?}", t.kind), message: t.message.clone(), created_at: t.created_at, lifetime_ms: t.lifetime_ms, }) .collect(); let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) } else { None }; let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { session_id: state.session_id.clone(), messages, edit_count: state.edit_log.len().cast_or(0u32), message_count: state.transcript_cache.messages.len(), overlay, toasts, dirty: state.dirty, input_buffer: state.input.buffer.clone(), input_cursor: state.input.cursor, })); conn.send(&frame)?; Ok(()) } // --------------------------------------------------------------------------- // handle_daemon_client — process an attached client's IPC messages // --------------------------------------------------------------------------- /// Handle an incoming client connection for the daemon. /// /// Flow: loop reading requests, modifying state, and sending updates back. pub fn handle_daemon_client(mut conn: Connection, state: &mut AppStateRest) -> Result<()> { tracing::debug!("handling daemon client connection"); let mut running = true; while running { match conn.receive::()? { Some(req) => { match req { ClientRequest::Tick => { apply_action(state, Action::Tick); } ClientRequest::KeyPress { key, ctrl, alt, shift, } => { let mut modifiers = KeyModifiers::NONE; if ctrl { modifiers |= KeyModifiers::CONTROL; } if alt { modifiers |= KeyModifiers::ALT; } if shift { modifiers |= KeyModifiers::SHIFT; } let key_event = KeyEvent::new(key_action_to_code(&key), modifiers); let actions = handle_key(key_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Submit(text) => { state.input.buffer = text; let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let actions = handle_key(enter_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Paste(text) => { state.input.buffer.insert_str(state.input.cursor, &text); state.input.cursor += text.len(); state.dirty = true; apply_action(state, Action::Tick); } ClientRequest::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); apply_action(state, Action::Tick); } ClientRequest::ScrollUp => { apply_action(state, Action::ScrollUp); apply_action(state, Action::Tick); } ClientRequest::ScrollDown => { apply_action(state, Action::ScrollDown); apply_action(state, Action::Tick); } ClientRequest::Close => { running = false; } } if let Some(text) = state.misc.pending_clipboard_copy.take() { conn.send(&DaemonFrame::ClipboardCopy(text))?; } // Only send state update for valid requests, not on EOF/disconnect. if running { send_daemon_update(&mut conn, state)?; } } None => { running = false; // Don't call send_daemon_update — connection is dead. } } } Ok(()) }