diff --git a/Cargo.lock b/Cargo.lock index ce102df..73b702d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -653,6 +653,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -3718,6 +3727,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -3861,13 +3876,16 @@ dependencies = [ "anyhow", "chrono", "clap", + "crossbeam-channel", "crossterm", + "futures", "ratatui", "rustls", "serde", "serde_json", "sunbeam-proto", "sunbeam-sdk", + "textwrap", "tokio", "tokio-stream", "toml", @@ -3978,6 +3996,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4438,6 +4467,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/sunbeam-proto/proto/code.proto b/sunbeam-proto/proto/code.proto index a15cfd2..580bdc5 100644 --- a/sunbeam-proto/proto/code.proto +++ b/sunbeam-proto/proto/code.proto @@ -66,6 +66,13 @@ message SessionReady { string session_id = 1; string room_id = 2; string model = 3; + bool resumed = 4; + repeated HistoryEntry history = 5; +} + +message HistoryEntry { + string role = 1; // "user" or "assistant" + string content = 2; } message TextDelta { diff --git a/sunbeam/Cargo.toml b/sunbeam/Cargo.toml index dd75082..15ae443 100644 --- a/sunbeam/Cargo.toml +++ b/sunbeam/Cargo.toml @@ -25,3 +25,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" anyhow = "1" +futures = "0.3" +crossbeam-channel = "0.5" +textwrap = "0.16" + +[dev-dependencies] +tokio-stream = { version = "0.1", features = ["net"] } diff --git a/sunbeam/src/code/agent.rs b/sunbeam/src/code/agent.rs new file mode 100644 index 0000000..20c99e6 --- /dev/null +++ b/sunbeam/src/code/agent.rs @@ -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, + pub rx: Receiver, +} + +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 { + 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::(32); + let (evt_tx, evt_rx) = crossbeam_channel::bounded::(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, + evt_tx: Sender, +) { + 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; + } + } + } +} diff --git a/sunbeam/src/code/client.rs b/sunbeam/src/code/client.rs index 804a41e..408d0a4 100644 --- a/sunbeam/src/code/client.rs +++ b/sunbeam/src/code/client.rs @@ -9,12 +9,43 @@ use tracing::{debug, error, info, warn}; use super::config::LoadedConfig; use super::project::ProjectContext; +/// Events produced during a chat turn, for the TUI to render. +pub enum ChatEvent { + ToolStart { name: String, detail: String }, + ToolDone { name: String, success: bool }, + Status(String), + Error(String), +} + +/// Result of a chat turn. +pub struct ChatResponse { + pub text: String, + pub events: Vec, +} + +fn truncate_args(args_json: &str) -> String { + // Extract a short summary from the JSON args + if args_json.len() <= 80 { + args_json.to_string() + } else { + format!("{}…", &args_json[..77]) + } +} + +/// A history entry from a resumed session. +pub struct HistoryMessage { + pub role: String, + pub content: String, +} + /// An active coding session connected to Sol via gRPC. pub struct CodeSession { pub session_id: String, pub room_id: String, pub model: String, pub project_path: String, + pub resumed: bool, + pub history: Vec, tx: mpsc::Sender, rx: tonic::Streaming, } @@ -69,11 +100,22 @@ pub async fn connect( } }; + let history = ready + .history + .into_iter() + .map(|h| HistoryMessage { + role: h.role, + content: h.content, + }) + .collect(); + Ok(CodeSession { session_id: ready.session_id, room_id: ready.room_id, model: ready.model, project_path: project.path.clone(), + resumed: ready.resumed, + history, tx, rx, }) @@ -82,7 +124,8 @@ pub async fn connect( impl CodeSession { /// Send a chat message and collect the response. /// Handles tool calls by executing them locally and sending results back. - pub async fn chat(&mut self, text: &str) -> anyhow::Result { + /// Returns (full_text, events) — events are for the TUI to display. + pub async fn chat(&mut self, text: &str) -> anyhow::Result { self.tx .send(ClientMessage { payload: Some(client_message::Payload::Input(UserInput { @@ -91,50 +134,42 @@ impl CodeSession { }) .await?; + let mut events = Vec::new(); + // Read server messages until we get TextDone loop { match self.rx.message().await? { Some(ServerMessage { - payload: Some(server_message::Payload::Delta(d)), + payload: Some(server_message::Payload::Delta(_)), }) => { - // Streaming text — print incrementally - print!("{}", d.text); + // Streaming text — we'll use full_text from Done } Some(ServerMessage { payload: Some(server_message::Payload::Done(d)), }) => { - return Ok(d.full_text); + return Ok(ChatResponse { + text: d.full_text, + events, + }); } Some(ServerMessage { payload: Some(server_message::Payload::ToolCall(tc)), }) => { if tc.is_local { - // Execute locally - if tc.needs_approval { - eprint!(" [{}] approve? (y/n) ", tc.name); - // Simple stdin approval for now - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().starts_with('y') { - self.tx - .send(ClientMessage { - payload: Some(client_message::Payload::ToolResult( - ToolResult { - call_id: tc.call_id.clone(), - result: "Denied by user.".into(), - is_error: true, - }, - )), - }) - .await?; - continue; - } - } + // TODO: approval flow through TUI + events.push(ChatEvent::ToolStart { + name: tc.name.clone(), + detail: truncate_args(&tc.args_json), + }); - eprintln!(" 🔧 {}", tc.name); let result = super::tools::execute(&tc.name, &tc.args_json, &self.project_path); + events.push(ChatEvent::ToolDone { + name: tc.name.clone(), + success: true, + }); + self.tx .send(ClientMessage { payload: Some(client_message::Payload::ToolResult(ToolResult { @@ -145,14 +180,16 @@ impl CodeSession { }) .await?; } else { - // Server-side tool — Sol handles it, we just see the status - eprintln!(" 🔧 {} (server)", tc.name); + events.push(ChatEvent::ToolStart { + name: format!("{} (server)", tc.name), + detail: String::new(), + }); } } Some(ServerMessage { payload: Some(server_message::Payload::Status(s)), }) => { - eprintln!(" [{}]", s.message); + events.push(ChatEvent::Status(s.message)); } Some(ServerMessage { payload: Some(server_message::Payload::Error(e)), @@ -160,12 +197,15 @@ impl CodeSession { if e.fatal { anyhow::bail!("Fatal error: {}", e.message); } - eprintln!(" error: {}", e.message); + events.push(ChatEvent::Error(e.message)); } Some(ServerMessage { payload: Some(server_message::Payload::End(_)), }) => { - return Ok("Session ended by server.".into()); + return Ok(ChatResponse { + text: "Session ended by server.".into(), + events, + }); } Some(_) => continue, None => anyhow::bail!("Stream closed unexpectedly"), diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs index 2aa8339..0f0a064 100644 --- a/sunbeam/src/code/mod.rs +++ b/sunbeam/src/code/mod.rs @@ -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, /// 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)] @@ -27,18 +31,42 @@ 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 } => { - 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) -> 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) -> 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) -> 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; diff --git a/sunbeam/src/code/tui.rs b/sunbeam/src/code/tui.rs index 8a030c2..ec8f303 100644 --- a/sunbeam/src/code/tui.rs +++ b/sunbeam/src/code/tui.rs @@ -1,4 +1,5 @@ use std::io; +use std::sync::{Arc, Mutex}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; @@ -9,6 +10,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Terminal; +use tracing_subscriber::fmt::MakeWriter; // ── Sol color palette ────────────────────────────────────────────────────── @@ -23,6 +25,215 @@ const SOL_STATUS: Color = Color::Rgb(106, 96, 80); const SOL_APPROVAL_BG: Color = Color::Rgb(50, 42, 20); const SOL_APPROVAL_CMD: Color = Color::Rgb(200, 180, 120); +// ── In-memory log buffer for tracing ───────────────────────────────────── + +const LOG_BUFFER_CAPACITY: usize = 500; + +#[derive(Clone)] +pub struct LogBuffer(Arc>>); + +impl LogBuffer { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(Vec::new()))) + } + + pub fn lines(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +/// Writer that appends each line to the ring buffer. +pub struct LogBufferWriter(Arc>>); + +impl io::Write for LogBufferWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let s = String::from_utf8_lossy(buf); + let mut lines = self.0.lock().unwrap(); + for line in s.lines() { + if !line.is_empty() { + lines.push(line.to_string()); + if lines.len() > LOG_BUFFER_CAPACITY { + lines.remove(0); + } + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for LogBuffer { + type Writer = LogBufferWriter; + + fn make_writer(&'a self) -> Self::Writer { + LogBufferWriter(self.0.clone()) + } +} + +// ── Virtual viewport ───────────────────────────────────────────────────── + +/// Cached pre-wrapped visual lines for the conversation log. +/// Text is wrapped using `textwrap` when content or width changes. +/// Drawing just slices the visible window — O(viewport), zero wrapping by ratatui. +pub struct Viewport { + /// Pre-wrapped visual lines (one Line per screen row). Already wrapped to width. + visual_lines: Vec>, + /// Width used for the last wrap pass. + last_width: u16, + /// True when log content changed. + dirty: bool, +} + +impl Viewport { + pub fn new() -> Self { + Self { + visual_lines: Vec::new(), + last_width: 0, + dirty: true, + } + } + + pub fn invalidate(&mut self) { + self.dirty = true; + } + + /// Total visual (screen) lines. + pub fn len(&self) -> u16 { + self.visual_lines.len() as u16 + } + + /// Rebuild pre-wrapped lines from log entries for a given width. + pub fn rebuild(&mut self, log: &[LogEntry], width: u16) { + let w = width.max(1) as usize; + self.visual_lines.clear(); + + for entry in log { + match entry { + LogEntry::UserInput(text) => { + self.visual_lines.push(Line::from("")); + // Wrap user input with "> " prefix + let prefixed = format!("> {text}"); + for wrapped in wrap_styled(&prefixed, w, SOL_DIM, Color::White, 2) { + self.visual_lines.push(wrapped); + } + self.visual_lines.push(Line::from("")); + } + LogEntry::AssistantText(text) => { + let style = Style::default().fg(SOL_YELLOW); + for logical_line in text.lines() { + if logical_line.is_empty() { + self.visual_lines.push(Line::from("")); + } else { + for wrapped in textwrap::wrap(logical_line, w) { + self.visual_lines.push(Line::styled(wrapped.into_owned(), style)); + } + } + } + } + LogEntry::ToolSuccess { name, detail } => { + self.visual_lines.push(Line::from(vec![ + Span::styled(" ✓ ", Style::default().fg(SOL_BLUE)), + Span::styled(name.clone(), Style::default().fg(SOL_AMBER)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolExecuting { name, detail } => { + self.visual_lines.push(Line::from(vec![ + Span::styled(" ● ", Style::default().fg(SOL_AMBER)), + Span::styled(name.clone(), Style::default().fg(SOL_AMBER)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolFailed { name, detail } => { + self.visual_lines.push(Line::from(vec![ + Span::styled(" ✗ ", Style::default().fg(SOL_RED)), + Span::styled(name.clone(), Style::default().fg(SOL_RED)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolOutput { lines: output_lines, collapsed } => { + let show = if *collapsed { 5 } else { output_lines.len() }; + let style = Style::default().fg(SOL_GRAY); + for line in output_lines.iter().take(show) { + self.visual_lines.push(Line::styled(format!(" {line}"), style)); + } + if *collapsed && output_lines.len() > 5 { + self.visual_lines.push(Line::styled( + format!(" … +{} lines", output_lines.len() - 5), + Style::default().fg(SOL_FAINT), + )); + } + } + LogEntry::Status(msg) => { + self.visual_lines.push(Line::styled( + format!(" [{msg}]"), + Style::default().fg(SOL_DIM), + )); + } + LogEntry::Error(msg) => { + let style = Style::default().fg(SOL_RED); + for wrapped in textwrap::wrap(&format!(" error: {msg}"), w) { + self.visual_lines.push(Line::styled(wrapped.into_owned(), style)); + } + } + } + } + + self.dirty = false; + self.last_width = width; + } + + /// Ensure lines are built for the given width. Rebuilds if width changed. + pub fn ensure(&mut self, log: &[LogEntry], width: u16) { + if self.dirty || self.last_width != width { + self.rebuild(log, width); + } + } + + /// Get the visible slice of pre-wrapped lines for the scroll position. + /// Returns owned lines ready to render — NO wrapping by ratatui. + pub fn window(&self, height: u16, scroll_offset: u16) -> Vec> { + let total = self.visual_lines.len() as u16; + let max_scroll = total.saturating_sub(height); + let scroll = if scroll_offset == u16::MAX { + max_scroll + } else { + scroll_offset.min(max_scroll) + }; + + let start = scroll as usize; + let end = (start + height as usize).min(self.visual_lines.len()); + self.visual_lines[start..end].to_vec() + } + + pub fn max_scroll(&self, height: u16) -> u16 { + (self.visual_lines.len() as u16).saturating_sub(height) + } +} + +/// Wrap a "> text" line preserving the dim prefix style on the first line +/// and white text style for content. Returns pre-wrapped visual lines. +fn wrap_styled(text: &str, width: usize, prefix_color: Color, text_color: Color, prefix_len: usize) -> Vec> { + let wrapped = textwrap::wrap(text, width); + let mut lines = Vec::with_capacity(wrapped.len()); + for (i, w) in wrapped.iter().enumerate() { + let s = w.to_string(); + if i == 0 && s.len() >= prefix_len { + // First line: split into styled prefix + text + lines.push(Line::from(vec![ + Span::styled(s[..prefix_len].to_string(), Style::default().fg(prefix_color)), + Span::styled(s[prefix_len..].to_string(), Style::default().fg(text_color)), + ])); + } else { + lines.push(Line::styled(s, Style::default().fg(text_color))); + } + } + lines +} + // ── Message types for the conversation log ───────────────────────────────── #[derive(Clone)] @@ -50,6 +261,7 @@ pub struct ApprovalPrompt { pub struct App { pub log: Vec, + pub viewport: Viewport, pub input: String, pub cursor_pos: usize, pub scroll_offset: u16, @@ -60,13 +272,22 @@ pub struct App { pub output_tokens: u32, pub approval: Option, pub is_thinking: bool, + pub sol_status: String, pub should_quit: bool, + pub show_logs: bool, + pub log_buffer: LogBuffer, + pub log_scroll: u16, + pub command_history: Vec, + pub history_index: Option, + pub input_saved: String, + pub needs_redraw: bool, } impl App { - pub fn new(project_name: &str, branch: &str, model: &str) -> Self { + pub fn new(project_name: &str, branch: &str, model: &str, log_buffer: LogBuffer) -> Self { Self { log: Vec::new(), + viewport: Viewport::new(), input: String::new(), cursor_pos: 0, scroll_offset: 0, @@ -77,20 +298,70 @@ impl App { output_tokens: 0, approval: None, is_thinking: false, + sol_status: String::new(), should_quit: false, + show_logs: false, + log_buffer, + log_scroll: u16::MAX, + command_history: Vec::new(), + history_index: None, + input_saved: String::new(), + needs_redraw: true, } } pub fn push_log(&mut self, entry: LogEntry) { self.log.push(entry); - // Auto-scroll to bottom + self.viewport.invalidate(); self.scroll_offset = u16::MAX; + self.needs_redraw = true; + } + + /// Batch-add log entries without per-entry viewport rebuilds. + pub fn push_logs(&mut self, entries: Vec) { + self.log.extend(entries); + self.viewport.invalidate(); + self.scroll_offset = u16::MAX; + self.needs_redraw = true; + } + + /// Resolve the u16::MAX auto-scroll sentinel to the actual max scroll + /// position. Call before applying relative scroll deltas. + /// Resolve scroll sentinel AND clamp to valid range. Call before + /// applying any relative scroll delta. + pub fn resolve_scroll(&mut self, width: u16, height: u16) { + self.viewport.ensure(&self.log, width); + let max = self.viewport.max_scroll(height); + if self.scroll_offset == u16::MAX { + self.scroll_offset = max; + } else { + self.scroll_offset = self.scroll_offset.min(max); + } + } + + /// Load command history from a project's .sunbeam/history file. + pub fn load_history(&mut self, project_path: &str) { + let path = std::path::Path::new(project_path).join(".sunbeam").join("history"); + if let Ok(contents) = std::fs::read_to_string(&path) { + self.command_history = contents.lines().map(String::from).collect(); + } + } + + /// Save command history to a project's .sunbeam/history file. + pub fn save_history(&self, project_path: &str) { + let dir = std::path::Path::new(project_path).join(".sunbeam"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("history"); + // Keep last 500 entries + let start = self.command_history.len().saturating_sub(500); + let contents = self.command_history[start..].join("\n"); + let _ = std::fs::write(&path, contents); } } // ── Rendering ────────────────────────────────────────────────────────────── -pub fn draw(frame: &mut ratatui::Frame, app: &App) { +pub fn draw(frame: &mut ratatui::Frame, app: &mut App) { let area = frame.area(); // Layout: title (1) + log (flex) + input (3) + status (1) @@ -103,7 +374,12 @@ pub fn draw(frame: &mut ratatui::Frame, app: &App) { .split(area); draw_title_bar(frame, chunks[0], app); - draw_log(frame, chunks[1], app); + + if app.show_logs { + draw_debug_log(frame, chunks[1], app); + } else { + draw_log(frame, chunks[1], app); + } if let Some(ref approval) = app.approval { draw_approval(frame, chunks[2], approval); @@ -123,112 +399,79 @@ fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { Span::styled(&app.branch, Style::default().fg(SOL_DIM)), ]; - let right = Span::styled(&app.model, Style::default().fg(SOL_DIM)); + // Right side: model name + sol status + let right_parts = if app.is_thinking { + let status = if app.sol_status.is_empty() { + "generating…" + } else { + &app.sol_status + }; + vec![ + Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC)), + Span::styled(" · ", Style::default().fg(SOL_FAINT)), + Span::styled(&app.model, Style::default().fg(SOL_DIM)), + ] + } else { + vec![Span::styled(&app.model, Style::default().fg(SOL_DIM))] + }; - // Render left-aligned title and right-aligned model let title_line = Line::from(left); frame.render_widget(Paragraph::new(title_line), area); + let right_line = Line::from(right_parts); + let right_width = right_line.width() as u16 + 1; let right_area = Rect { - x: area.width.saturating_sub(right.width() as u16 + 1), + x: area.width.saturating_sub(right_width), y: area.y, - width: right.width() as u16 + 1, + width: right_width, height: 1, }; - frame.render_widget(Paragraph::new(Line::from(right)), right_area); + frame.render_widget(Paragraph::new(right_line), right_area); } -fn draw_log(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let mut lines: Vec = Vec::new(); +fn draw_log(frame: &mut ratatui::Frame, area: Rect, app: &mut App) { + // Ensure pre-wrapped lines are built for current width + app.viewport.ensure(&app.log, area.width); - for entry in &app.log { - match entry { - LogEntry::UserInput(text) => { - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("> ", Style::default().fg(SOL_DIM)), - Span::raw(text.as_str()), - ])); - lines.push(Line::from("")); - } - LogEntry::AssistantText(text) => { - for line in text.lines() { - lines.push(Line::from(Span::styled(line, Style::default().fg(SOL_YELLOW)))); - } - } - LogEntry::ToolSuccess { name, detail } => { - lines.push(Line::from(vec![ - Span::styled(" ✓ ", Style::default().fg(SOL_BLUE)), - Span::styled(name.as_str(), Style::default().fg(SOL_AMBER)), - Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), - ])); - } - LogEntry::ToolExecuting { name, detail } => { - lines.push(Line::from(vec![ - Span::styled(" ● ", Style::default().fg(SOL_AMBER)), - Span::styled(name.as_str(), Style::default().fg(SOL_AMBER)), - Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), - ])); - } - LogEntry::ToolFailed { name, detail } => { - lines.push(Line::from(vec![ - Span::styled(" ✗ ", Style::default().fg(SOL_RED)), - Span::styled(name.as_str(), Style::default().fg(SOL_RED)), - Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), - ])); - } - LogEntry::ToolOutput { lines: output_lines, collapsed } => { - let show = if *collapsed { 5 } else { output_lines.len() }; - for line in output_lines.iter().take(show) { - lines.push(Line::from(Span::styled( - format!(" {line}"), - Style::default().fg(SOL_GRAY), - ))); - } - if *collapsed && output_lines.len() > 5 { - lines.push(Line::from(Span::styled( - format!(" … +{} lines (ctrl+o to expand)", output_lines.len() - 5), - Style::default().fg(SOL_FAINT), - ))); - } - } - LogEntry::Status(msg) => { - lines.push(Line::from(Span::styled( - format!(" [{msg}]"), - Style::default().fg(SOL_DIM), - ))); - } - LogEntry::Error(msg) => { - lines.push(Line::from(Span::styled( - format!(" error: {msg}"), - Style::default().fg(SOL_RED), - ))); - } - } - } + // Slice only the visible rows — O(viewport), no wrapping by ratatui + let window = app.viewport.window(area.height, app.scroll_offset); + frame.render_widget(Paragraph::new(window), area); +} - if app.is_thinking { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " thinking...", - Style::default().fg(SOL_DIM).add_modifier(Modifier::ITALIC), - ))); - } +fn draw_debug_log(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let log_lines = app.log_buffer.lines(); + let lines: Vec = std::iter::once( + Line::from(Span::styled( + " debug log (Alt+L to close) ", + Style::default().fg(SOL_AMBER).add_modifier(Modifier::BOLD), + )), + ) + .chain(log_lines.iter().map(|l| { + let color = if l.contains("ERROR") { + SOL_RED + } else if l.contains("WARN") { + SOL_YELLOW + } else { + SOL_GRAY + }; + Line::from(Span::styled(l.as_str(), Style::default().fg(color))) + })) + .collect(); - let total_lines = lines.len() as u16; + let total = lines.len() as u16; let visible = area.height; - let max_scroll = total_lines.saturating_sub(visible); - let scroll = if app.scroll_offset == u16::MAX { + let max_scroll = total.saturating_sub(visible); + let scroll = if app.log_scroll == u16::MAX { max_scroll } else { - app.scroll_offset.min(max_scroll) + app.log_scroll.min(max_scroll) }; - let log_widget = Paragraph::new(Text::from(lines)) + let widget = Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) .scroll((scroll, 0)); - frame.render_widget(log_widget, area); + frame.render_widget(widget, area); } fn draw_input(frame: &mut ratatui::Frame, area: Rect, app: &App) { @@ -247,10 +490,12 @@ fn draw_input(frame: &mut ratatui::Frame, area: Rect, app: &App) { frame.render_widget(input_widget, area); - // Position cursor - let cursor_x = area.x + 2 + app.cursor_pos as u16; - let cursor_y = area.y + 1; - frame.set_cursor_position((cursor_x, cursor_y)); + if !app.is_thinking { + // Only show cursor when not waiting for Sol + let cursor_x = area.x + 2 + app.cursor_pos as u16; + let cursor_y = area.y + 1; + frame.set_cursor_position((cursor_x, cursor_y)); + } } fn draw_approval(frame: &mut ratatui::Frame, area: Rect, approval: &ApprovalPrompt) { @@ -284,7 +529,7 @@ fn draw_approval(frame: &mut ratatui::Frame, area: Rect, approval: &ApprovalProm } fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let status = Line::from(vec![ + let left = vec![ Span::styled( format!(" ~/…/{}", app.project_name), Style::default().fg(SOL_STATUS), @@ -297,9 +542,18 @@ fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { format!(" {}k in · {}k out", app.input_tokens / 1000, app.output_tokens / 1000), Style::default().fg(SOL_STATUS), ), - ]); + ]; + frame.render_widget(Paragraph::new(Line::from(left)), area); - frame.render_widget(Paragraph::new(status), area); + let hint = if app.show_logs { "Alt+L close log" } else { "Alt+L debug log" }; + let right = Span::styled(format!("{hint} "), Style::default().fg(SOL_FAINT)); + let right_area = Rect { + x: area.width.saturating_sub(right.width() as u16), + y: area.y, + width: right.width() as u16, + height: 1, + }; + frame.render_widget(Paragraph::new(Line::from(right)), right_area); } // ── Terminal setup/teardown ──────────────────────────────────────────────── @@ -307,14 +561,18 @@ fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { pub fn setup_terminal() -> io::Result>> { terminal::enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); Terminal::new(backend) } pub fn restore_terminal(terminal: &mut Terminal>) -> io::Result<()> { terminal::disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; terminal.show_cursor()?; Ok(()) } @@ -325,7 +583,7 @@ mod tests { #[test] fn test_app_creation() { - let app = App::new("sol", "mainline", "devstral-2"); + let app = App::new("sol", "mainline", "devstral-2", LogBuffer::new()); assert_eq!(app.project_name, "sol"); assert!(!app.should_quit); assert!(app.log.is_empty()); @@ -333,7 +591,7 @@ mod tests { #[test] fn test_push_log_auto_scrolls() { - let mut app = App::new("sol", "main", "devstral-2"); + let mut app = App::new("sol", "main", "devstral-2", LogBuffer::new()); app.scroll_offset = 0; app.push_log(LogEntry::Status("test".into())); assert_eq!(app.scroll_offset, u16::MAX); // auto-scroll to bottom @@ -352,7 +610,7 @@ mod tests { #[test] fn test_log_entries_all_variants() { - let mut app = App::new("test", "main", "devstral-2"); + let mut app = App::new("test", "main", "devstral-2", LogBuffer::new()); app.push_log(LogEntry::UserInput("hello".into())); app.push_log(LogEntry::AssistantText("response".into())); app.push_log(LogEntry::ToolSuccess { name: "file_read".into(), detail: "src/main.rs".into() }); @@ -412,7 +670,7 @@ mod tests { #[test] fn test_thinking_state() { - let mut app = App::new("sol", "main", "devstral-2"); + let mut app = App::new("sol", "main", "devstral-2", LogBuffer::new()); assert!(!app.is_thinking); app.is_thinking = true; assert!(app.is_thinking); @@ -420,7 +678,7 @@ mod tests { #[test] fn test_input_cursor() { - let mut app = App::new("sol", "main", "devstral-2"); + let mut app = App::new("sol", "main", "devstral-2", LogBuffer::new()); app.input = "hello world".into(); app.cursor_pos = 5; assert_eq!(&app.input[..app.cursor_pos], "hello"); @@ -428,7 +686,7 @@ mod tests { #[test] fn test_token_tracking() { - let mut app = App::new("sol", "main", "devstral-2"); + let mut app = App::new("sol", "main", "devstral-2", LogBuffer::new()); app.input_tokens = 1200; app.output_tokens = 340; assert_eq!(app.input_tokens / 1000, 1);