Symbol extraction (symbols.rs): - tree-sitter parsers for Rust, TypeScript, Python - Extracts: functions, structs, enums, traits, classes, interfaces - Signatures, docstrings, line ranges for each symbol - extract_project_symbols() walks project directory - Skips hidden/vendor/target/node_modules, files >100KB Proto: IndexSymbols + SymbolEntry messages for client→server symbol relay Client: after SessionReady, extracts symbols and sends IndexSymbols to Sol for indexing into the code search index. 14 unit tests for symbol extraction across Rust/TS/Python.
494 lines
22 KiB
Rust
494 lines
22 KiB
Rust
pub mod agent;
|
|
pub mod client;
|
|
pub mod config;
|
|
pub mod project;
|
|
pub mod symbols;
|
|
pub mod tools;
|
|
pub mod tui;
|
|
|
|
use clap::Subcommand;
|
|
use tracing::info;
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
pub enum CodeCommand {
|
|
/// Start a coding session (default — can omit subcommand)
|
|
Start {
|
|
/// 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)]
|
|
Demo,
|
|
}
|
|
|
|
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, 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(".")?;
|
|
info!(
|
|
project = project.name.as_str(),
|
|
path = project.path.as_str(),
|
|
branch = project.git_branch.as_deref().unwrap_or("?"),
|
|
"Discovered project"
|
|
);
|
|
|
|
// Load project config
|
|
let cfg = config::load_project_config(&project.path);
|
|
|
|
let model = model
|
|
.or(cfg.model_name.clone())
|
|
.unwrap_or_else(|| "mistral-medium-latest".into());
|
|
|
|
// Connect to Sol
|
|
let mut session = client::connect(&endpoint, &project, &cfg, &model).await?;
|
|
|
|
info!(
|
|
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"
|
|
);
|
|
|
|
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, endpoint.clone(), cfg, project.path.clone());
|
|
|
|
// 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, 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 {
|
|
// 1. Process any pending agent events (non-blocking)
|
|
for evt in agent.poll_events() {
|
|
match evt {
|
|
agent::AgentEvent::ApprovalNeeded { call_id, name, args_summary } => {
|
|
app.approval = Some(tui::ApprovalPrompt {
|
|
call_id: call_id.clone(),
|
|
tool_name: name.clone(),
|
|
command: args_summary.clone(),
|
|
options: vec![
|
|
"yes".into(),
|
|
format!("yes, always allow {name}"),
|
|
"no".into(),
|
|
],
|
|
selected: 0,
|
|
});
|
|
app.needs_redraw = true;
|
|
}
|
|
agent::AgentEvent::Generating => {
|
|
app.is_thinking = true;
|
|
app.sol_status.clear();
|
|
app.thinking_message = tui::random_sol_status().to_string();
|
|
app.thinking_since = Some(std::time::Instant::now());
|
|
app.needs_redraw = true;
|
|
}
|
|
agent::AgentEvent::ToolExecuting { 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, input_tokens, output_tokens } => {
|
|
app.is_thinking = false;
|
|
app.sol_status.clear();
|
|
app.thinking_since = None;
|
|
app.last_turn_tokens = input_tokens + output_tokens;
|
|
app.input_tokens += input_tokens;
|
|
app.output_tokens += output_tokens;
|
|
app.push_log(tui::LogEntry::AssistantText(text));
|
|
}
|
|
agent::AgentEvent::Error { message } => {
|
|
app.is_thinking = false;
|
|
app.sol_status.clear();
|
|
app.thinking_since = None;
|
|
app.push_log(tui::LogEntry::Error(message));
|
|
}
|
|
agent::AgentEvent::Health { connected } => {
|
|
if app.sol_connected != connected {
|
|
app.sol_connected = connected;
|
|
app.needs_redraw = true;
|
|
}
|
|
}
|
|
agent::AgentEvent::SessionEnded => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Draw only when something changed (or animating)
|
|
if app.needs_redraw || app.is_thinking {
|
|
terminal.draw(|frame| tui::draw(frame, &mut app))?;
|
|
app.needs_redraw = false;
|
|
}
|
|
|
|
// 3. Handle input — shorter poll when animating
|
|
let poll_ms = if app.is_thinking { 100 } else { 50 };
|
|
if event::poll(std::time::Duration::from_millis(poll_ms))? {
|
|
// 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) => {
|
|
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;
|
|
}
|
|
// Approval prompt navigation
|
|
KeyCode::Up if app.approval.is_some() => {
|
|
if let Some(ref mut a) = app.approval {
|
|
a.selected = a.selected.saturating_sub(1);
|
|
}
|
|
}
|
|
KeyCode::Down if app.approval.is_some() => {
|
|
if let Some(ref mut a) = app.approval {
|
|
a.selected = (a.selected + 1).min(a.options.len() - 1);
|
|
}
|
|
}
|
|
KeyCode::Enter if app.approval.is_some() => {
|
|
if let Some(a) = app.approval.take() {
|
|
let decision = match a.selected {
|
|
0 => agent::ApprovalDecision::Approved {
|
|
call_id: a.call_id.clone(),
|
|
},
|
|
1 => agent::ApprovalDecision::ApprovedAlways {
|
|
call_id: a.call_id.clone(),
|
|
tool_name: a.tool_name.clone(),
|
|
},
|
|
_ => agent::ApprovalDecision::Denied {
|
|
call_id: a.call_id.clone(),
|
|
},
|
|
};
|
|
agent.decide(decision);
|
|
}
|
|
}
|
|
KeyCode::Char(c) if !app.show_logs && app.approval.is_none() => {
|
|
app.history_index = None;
|
|
app.input.insert(app.cursor_pos, c);
|
|
app.cursor_pos += 1;
|
|
}
|
|
KeyCode::Backspace if !app.show_logs && app.approval.is_none() => {
|
|
if app.cursor_pos > 0 {
|
|
app.history_index = None;
|
|
app.cursor_pos -= 1;
|
|
app.input.remove(app.cursor_pos);
|
|
}
|
|
}
|
|
KeyCode::Left if !app.show_logs && app.approval.is_none() => app.cursor_pos = app.cursor_pos.saturating_sub(1),
|
|
KeyCode::Right if !app.show_logs && app.approval.is_none() => 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" {
|
|
agent.end();
|
|
app.should_quit = true;
|
|
break; // exit drain loop
|
|
}
|
|
|
|
app.push_log(tui::LogEntry::UserInput(text.clone()));
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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-small-latest", log_buffer);
|
|
|
|
// Populate with sample conversation
|
|
app.push_log(tui::LogEntry::UserInput("fix the token validation bug in auth.rs".into()));
|
|
app.push_log(tui::LogEntry::AssistantText(
|
|
"Looking at the auth module, I can see the issue on line 42 where the token \
|
|
is not properly validated before use. The expiry check is missing entirely."
|
|
.into(),
|
|
));
|
|
app.push_log(tui::LogEntry::ToolSuccess {
|
|
name: "file_read".into(),
|
|
detail: "src/auth.rs (127 lines)".into(),
|
|
});
|
|
app.push_log(tui::LogEntry::ToolOutput {
|
|
lines: vec![
|
|
"38│ fn validate_token(token: &str) -> bool {".into(),
|
|
"39│ let decoded = decode(token);".into(),
|
|
"40│ // BUG: missing expiry check".into(),
|
|
"41│ decoded.is_ok()".into(),
|
|
"42│ }".into(),
|
|
"43│".into(),
|
|
"44│ fn refresh_token(token: &str) -> Result<String> {".into(),
|
|
"45│ let client = reqwest::Client::new();".into(),
|
|
"46│ // ...".into(),
|
|
],
|
|
collapsed: true,
|
|
});
|
|
app.push_log(tui::LogEntry::ToolSuccess {
|
|
name: "search_replace".into(),
|
|
detail: "src/auth.rs — applied 1 replacement (line 41)".into(),
|
|
});
|
|
app.push_log(tui::LogEntry::ToolExecuting {
|
|
name: "bash".into(),
|
|
detail: "cargo test --lib".into(),
|
|
});
|
|
app.push_log(tui::LogEntry::ToolOutput {
|
|
lines: vec![
|
|
"running 23 tests".into(),
|
|
"test auth::tests::test_validate_token ... ok".into(),
|
|
"test auth::tests::test_expired_token ... ok".into(),
|
|
"test auth::tests::test_refresh_flow ... ok".into(),
|
|
"test result: ok. 23 passed; 0 failed".into(),
|
|
],
|
|
collapsed: false,
|
|
});
|
|
app.push_log(tui::LogEntry::AssistantText(
|
|
"Fixed. The token validation now checks expiry before use. All 23 tests pass."
|
|
.into(),
|
|
));
|
|
app.push_log(tui::LogEntry::UserInput("now add rate limiting to the auth endpoint".into()));
|
|
app.push_log(tui::LogEntry::ToolExecuting {
|
|
name: "file_read".into(),
|
|
detail: "src/routes/auth.rs".into(),
|
|
});
|
|
app.is_thinking = true;
|
|
app.input_tokens = 2400;
|
|
app.output_tokens = 890;
|
|
|
|
loop {
|
|
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;
|
|
}
|
|
KeyCode::Backspace => {
|
|
if app.cursor_pos > 0 {
|
|
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::Enter => {
|
|
if !app.input.is_empty() {
|
|
let text = app.input.clone();
|
|
app.input.clear();
|
|
app.cursor_pos = 0;
|
|
|
|
if text == "/exit" {
|
|
break;
|
|
}
|
|
|
|
app.push_log(tui::LogEntry::UserInput(text));
|
|
app.is_thinking = true;
|
|
}
|
|
}
|
|
KeyCode::Up => {
|
|
app.scroll_offset = app.scroll_offset.saturating_sub(1);
|
|
}
|
|
KeyCode::Down => {
|
|
app.scroll_offset = app.scroll_offset.saturating_add(1);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tui::restore_terminal(&mut terminal)?;
|
|
Ok(())
|
|
}
|