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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user