feat(code): async agent bus, virtual viewport, event drain
- Agent service (crossbeam channels): TUI never blocks on gRPC I/O. Chat runs on a background tokio task, events flow back via bounded crossbeam channel. Designed as a library-friendly internal RPC. - Virtual viewport: pre-wrap text with textwrap on content/width change, slice only visible rows for rendering. Paragraph gets no Wrap, no scroll() — pure O(viewport) per frame. - Event drain loop: coalesce all queued terminal events before drawing. Filters MouseEventKind::Moved (crossterm's EnableMouseCapture floods these via ?1003h any-event tracking). Single redraw per batch. - Conditional drawing: skip frames when nothing changed (needs_redraw). - Mouse wheel + PageUp/Down + Home/End scrolling, command history (Up/Down, persistent to .sunbeam/history), Alt+L debug log overlay. - Proto: SessionReady now includes history entries + resumed flag. Session resume loads conversation from Matrix room on reconnect. - Default model: devstral-small-latest (was devstral-small-2506).
This commit is contained in:
151
sunbeam/src/code/agent.rs
Normal file
151
sunbeam/src/code/agent.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! 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<AgentRequest>,
|
||||
pub rx: Receiver<AgentEvent>,
|
||||
}
|
||||
|
||||
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<AgentEvent> {
|
||||
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::<AgentRequest>(32);
|
||||
let (evt_tx, evt_rx) = crossbeam_channel::bounded::<AgentEvent>(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<AgentRequest>,
|
||||
evt_tx: Sender<AgentEvent>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user