//! Agent service — async message bus between TUI and Sol gRPC session. //! //! The TUI sends `AgentRequest`s and receives `AgentEvent`s through //! crossbeam channels. The gRPC session runs on a background tokio task, //! so the UI thread never blocks on network I/O. //! //! This module is designed to be usable as a library — nothing here //! depends on ratatui or terminal state. use crossbeam_channel::{Receiver, Sender, TrySendError}; use super::client::{self, CodeSession}; // ── Requests (TUI → Agent) ────────────────────────────────────────────── /// A request from the UI to the agent backend. pub enum AgentRequest { /// Send a chat message to Sol. Chat { text: String }, /// End the session gracefully. End, } // ── Events (Agent → TUI) ─────────────────────────────────────────────── /// An event from the agent backend to the UI. #[derive(Clone, Debug)] pub enum AgentEvent { /// Sol started generating a response. Generating, /// A tool started executing. ToolStart { name: String, detail: String }, /// A tool finished executing. ToolDone { name: String, success: bool }, /// Sol's full response text. Response { text: String }, /// A non-fatal error from Sol. Error { message: String }, /// Status update (shown in title bar). Status { message: String }, /// Session ended. SessionEnded, } // ── Agent handle (owned by TUI) ──────────────────────────────────────── /// Handle for the TUI to communicate with the background agent task. pub struct AgentHandle { tx: Sender, pub rx: Receiver, } impl AgentHandle { /// Send a chat message. Non-blocking. pub fn chat(&self, text: &str) { let _ = self.tx.try_send(AgentRequest::Chat { text: text.to_string(), }); } /// Request session end. Non-blocking. pub fn end(&self) { let _ = self.tx.try_send(AgentRequest::End); } /// Drain all pending events. Non-blocking. pub fn poll_events(&self) -> Vec { let mut events = Vec::new(); while let Ok(event) = self.rx.try_recv() { events.push(event); } events } } // ── Spawn ────────────────────────────────────────────────────────────── /// Spawn the agent background task. Returns a handle for the TUI. pub fn spawn(session: CodeSession) -> AgentHandle { let (req_tx, req_rx) = crossbeam_channel::bounded::(32); let (evt_tx, evt_rx) = crossbeam_channel::bounded::(256); tokio::spawn(agent_loop(session, req_rx, evt_tx)); AgentHandle { tx: req_tx, rx: evt_rx, } } /// The background agent loop. Reads requests, calls gRPC, emits events. async fn agent_loop( mut session: CodeSession, req_rx: Receiver, evt_tx: Sender, ) { loop { // Block on the crossbeam channel from a tokio context let req = match tokio::task::block_in_place(|| req_rx.recv()) { Ok(req) => req, Err(_) => break, // TUI dropped the handle }; match req { AgentRequest::Chat { text } => { let _ = evt_tx.try_send(AgentEvent::Generating); match session.chat(&text).await { Ok(resp) => { // Emit tool events for event in &resp.events { let agent_event = match event { client::ChatEvent::ToolStart { name, detail } => { AgentEvent::ToolStart { name: name.clone(), detail: detail.clone(), } } client::ChatEvent::ToolDone { name, success } => { AgentEvent::ToolDone { name: name.clone(), success: *success, } } client::ChatEvent::Status(msg) => AgentEvent::Status { message: msg.clone(), }, client::ChatEvent::Error(msg) => AgentEvent::Error { message: msg.clone(), }, }; let _ = evt_tx.try_send(agent_event); } let _ = evt_tx.try_send(AgentEvent::Response { text: resp.text }); } Err(e) => { let _ = evt_tx.try_send(AgentEvent::Error { message: e.to_string(), }); } } } AgentRequest::End => { let _ = session.end().await; let _ = evt_tx.try_send(AgentEvent::SessionEnded); break; } } } }