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:
2026-03-23 15:57:15 +00:00
parent cc9f169264
commit 8b4f187d1b
7 changed files with 853 additions and 172 deletions

151
sunbeam/src/code/agent.rs Normal file
View 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;
}
}
}
}