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

View File

@@ -1,3 +1,4 @@
pub mod agent;
pub mod client;
pub mod config;
pub mod project;
@@ -11,12 +12,15 @@ use tracing::info;
pub enum CodeCommand {
/// Start a coding session (default — can omit subcommand)
Start {
/// Model override (e.g., devstral-2)
/// Model override (e.g., devstral-small-latest)
#[arg(long)]
model: Option<String>,
/// Sol gRPC endpoint (default: from sunbeam config)
#[arg(long)]
endpoint: Option<String>,
/// Connect to localhost:50051 (dev mode)
#[arg(long, hide = true)]
localhost: bool,
},
/// Demo the TUI with sample data (no Sol connection needed)
#[command(hide = true)]
@@ -27,18 +31,42 @@ pub async fn cmd_code(cmd: Option<CodeCommand>) -> sunbeam_sdk::error::Result<()
cmd_code_inner(cmd).await.map_err(|e| sunbeam_sdk::error::SunbeamError::Other(e.to_string()))
}
/// Install a tracing subscriber that writes to a LogBuffer instead of stderr.
/// Returns the guard — when dropped, the subscriber is unset.
fn install_tui_tracing(log_buffer: &tui::LogBuffer) -> tracing::subscriber::DefaultGuard {
use tracing_subscriber::fmt;
use tracing_subscriber::EnvFilter;
let subscriber = fmt::Subscriber::builder()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("sunbeam=info,sunbeam_sdk=info,warn")),
)
.with_target(false)
.with_ansi(false)
.with_writer(log_buffer.clone())
.finish();
tracing::subscriber::set_default(subscriber)
}
async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
let cmd = cmd.unwrap_or(CodeCommand::Start {
model: None,
endpoint: None,
localhost: false,
});
match cmd {
CodeCommand::Demo => {
return run_demo().await;
}
CodeCommand::Start { model, endpoint } => {
let endpoint = endpoint.unwrap_or_else(|| "http://127.0.0.1:50051".into());
CodeCommand::Start { model, endpoint, localhost } => {
let endpoint = if localhost {
"http://127.0.0.1:50051".into()
} else {
endpoint.unwrap_or_else(|| "http://127.0.0.1:50051".into())
};
// Discover project context
let project = project::discover_project(".")?;
@@ -54,7 +82,7 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
let model = model
.or(cfg.model_name.clone())
.unwrap_or_else(|| "devstral-small-2506".into());
.unwrap_or_else(|| "devstral-small-latest".into());
// Connect to Sol
let mut session = client::connect(&endpoint, &project, &cfg, &model).await?;
@@ -63,72 +91,221 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
session_id = session.session_id.as_str(),
room_id = session.room_id.as_str(),
model = session.model.as_str(),
resumed = session.resumed,
"Connected to Sol"
);
// TUI event loop
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
let resumed = session.resumed;
let history: Vec<_> = std::mem::take(&mut session.history);
// Switch tracing to in-memory buffer before entering TUI
let log_buffer = tui::LogBuffer::new();
let _guard = install_tui_tracing(&log_buffer);
// Spawn agent on background task
let project_path = project.path.clone();
let agent = agent::spawn(session);
// TUI event loop — never blocks on network I/O
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind};
let mut terminal = tui::setup_terminal()?;
let branch = project.git_branch.as_deref().unwrap_or("?");
let mut app = tui::App::new(&project.name, branch, &model);
let mut app = tui::App::new(&project.name, branch, &model, log_buffer);
// Load persistent command history
app.load_history(&project_path);
// Load conversation history from resumed session (batch, single rebuild)
if resumed {
let entries: Vec<_> = history
.iter()
.filter_map(|msg| match msg.role.as_str() {
"user" => Some(tui::LogEntry::UserInput(msg.content.clone())),
"assistant" => Some(tui::LogEntry::AssistantText(msg.content.clone())),
_ => None,
})
.collect();
app.push_logs(entries);
}
let result = loop {
terminal.draw(|frame| tui::draw(frame, &app))?;
// 1. Process any pending agent events (non-blocking)
for evt in agent.poll_events() {
match evt {
agent::AgentEvent::Generating => {
app.is_thinking = true;
app.sol_status = "generating…".into();
app.needs_redraw = true;
}
agent::AgentEvent::ToolStart { name, detail } => {
app.push_log(tui::LogEntry::ToolExecuting { name, detail });
}
agent::AgentEvent::ToolDone { name, success } => {
if success {
app.push_log(tui::LogEntry::ToolSuccess { name, detail: String::new() });
}
}
agent::AgentEvent::Status { message } => {
app.sol_status = message;
app.needs_redraw = true;
}
agent::AgentEvent::Response { text } => {
app.is_thinking = false;
app.sol_status.clear();
app.push_log(tui::LogEntry::AssistantText(text));
}
agent::AgentEvent::Error { message } => {
app.is_thinking = false;
app.sol_status.clear();
app.push_log(tui::LogEntry::Error(message));
}
agent::AgentEvent::SessionEnded => {
break;
}
}
}
// 2. Draw only when something changed
if app.needs_redraw {
terminal.draw(|frame| tui::draw(frame, &mut app))?;
app.needs_redraw = false;
}
// 3. Handle input — drain ALL pending events before next draw
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
// Drain all queued events in one batch (coalesces rapid scroll)
while event::poll(std::time::Duration::ZERO)? {
match event::read()? {
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
app.needs_redraw = true;
let size = terminal.size().unwrap_or_default();
let viewport_h = size.height.saturating_sub(5);
let delta: i16 = if matches!(mouse.kind, MouseEventKind::ScrollUp) { -3 } else { 3 };
if app.show_logs {
if delta < 0 {
app.log_scroll = if app.log_scroll == u16::MAX { u16::MAX.saturating_sub(3) } else { app.log_scroll.saturating_sub(3) };
} else {
app.log_scroll = app.log_scroll.saturating_add(3);
}
} else {
app.resolve_scroll(size.width, viewport_h);
if delta < 0 {
app.scroll_offset = app.scroll_offset.saturating_sub(3);
} else {
app.scroll_offset = app.scroll_offset.saturating_add(3);
}
}
}
_ => {} // Ignore MouseEventKind::Moved and other mouse events
}
}
Event::Key(key) => {
app.needs_redraw = true;
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
KeyCode::Char(c) => {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
agent.end();
app.should_quit = true;
break; // exit drain loop
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::ALT) => {
app.show_logs = !app.show_logs;
app.log_scroll = u16::MAX;
}
KeyCode::Char(c) if !app.show_logs => {
app.history_index = None;
app.input.insert(app.cursor_pos, c);
app.cursor_pos += 1;
}
KeyCode::Backspace => {
KeyCode::Backspace if !app.show_logs => {
if app.cursor_pos > 0 {
app.history_index = None;
app.cursor_pos -= 1;
app.input.remove(app.cursor_pos);
}
}
KeyCode::Left => app.cursor_pos = app.cursor_pos.saturating_sub(1),
KeyCode::Right => app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()),
KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
KeyCode::Enter => {
KeyCode::Left if !app.show_logs => app.cursor_pos = app.cursor_pos.saturating_sub(1),
KeyCode::Right if !app.show_logs => app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()),
KeyCode::Up if !app.show_logs => {
if !app.command_history.is_empty() {
let idx = match app.history_index {
None => {
app.input_saved = app.input.clone();
app.command_history.len() - 1
}
Some(i) => i.saturating_sub(1),
};
app.history_index = Some(idx);
app.input = app.command_history[idx].clone();
app.cursor_pos = app.input.len();
}
}
KeyCode::Down if !app.show_logs => {
if let Some(idx) = app.history_index {
if idx + 1 < app.command_history.len() {
let new_idx = idx + 1;
app.history_index = Some(new_idx);
app.input = app.command_history[new_idx].clone();
app.cursor_pos = app.input.len();
} else {
app.history_index = None;
app.input = app.input_saved.clone();
app.cursor_pos = app.input.len();
}
}
}
KeyCode::Up if app.show_logs => {
app.log_scroll = if app.log_scroll == u16::MAX { u16::MAX.saturating_sub(1) } else { app.log_scroll.saturating_sub(1) };
}
KeyCode::Down if app.show_logs => {
app.log_scroll = app.log_scroll.saturating_add(1);
}
KeyCode::PageUp => {
let size = terminal.size().unwrap_or_default();
app.resolve_scroll(size.width, size.height.saturating_sub(5));
app.scroll_offset = app.scroll_offset.saturating_sub(20);
}
KeyCode::PageDown => {
let size = terminal.size().unwrap_or_default();
app.resolve_scroll(size.width, size.height.saturating_sub(5));
app.scroll_offset = app.scroll_offset.saturating_add(20);
}
KeyCode::Home => app.scroll_offset = 0,
KeyCode::End => app.scroll_offset = u16::MAX,
KeyCode::Enter if !app.show_logs && !app.is_thinking => {
if !app.input.is_empty() {
let text = app.input.clone();
app.command_history.push(text.clone());
app.history_index = None;
app.input.clear();
app.cursor_pos = 0;
if text == "/exit" {
let _ = session.end().await;
break Ok(());
agent.end();
app.should_quit = true;
break; // exit drain loop
}
app.push_log(tui::LogEntry::UserInput(text.clone()));
app.is_thinking = true;
// Force a redraw to show "thinking..."
terminal.draw(|frame| tui::draw(frame, &app))?;
match session.chat(&text).await {
Ok(response) => {
app.is_thinking = false;
app.push_log(tui::LogEntry::AssistantText(response));
}
Err(e) => {
app.is_thinking = false;
app.push_log(tui::LogEntry::Error(e.to_string()));
}
}
agent.chat(&text);
}
}
_ => {}
}
}
_ => {}
} // match event::read
} // while poll(ZERO)
} // if poll(50ms)
if app.should_quit {
break Ok(());
}
};
app.save_history(&project_path);
tui::restore_terminal(&mut terminal)?;
result
}
@@ -138,8 +315,11 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
async fn run_demo() -> anyhow::Result<()> {
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
let log_buffer = tui::LogBuffer::new();
let _guard = install_tui_tracing(&log_buffer);
let mut terminal = tui::setup_terminal()?;
let mut app = tui::App::new("sol", "mainline ±", "devstral-2");
let mut app = tui::App::new("sol", "mainline ±", "devstral-small-latest", log_buffer);
// Populate with sample conversation
app.push_log(tui::LogEntry::UserInput("fix the token validation bug in auth.rs".into()));
@@ -198,13 +378,17 @@ async fn run_demo() -> anyhow::Result<()> {
app.output_tokens = 890;
loop {
terminal.draw(|frame| tui::draw(frame, &app))?;
terminal.draw(|frame| tui::draw(frame, &mut app))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Char('q') => break,
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::ALT) => {
app.show_logs = !app.show_logs;
app.log_scroll = u16::MAX;
}
KeyCode::Char(c) => {
app.input.insert(app.cursor_pos, c);
app.cursor_pos += 1;