pub mod agent; pub mod client; pub mod config; pub mod project; 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, /// Sol gRPC endpoint (default: from sunbeam config) #[arg(long)] endpoint: Option, /// 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) -> 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) -> 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 {".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(()) }