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:
35
Cargo.lock
generated
35
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
151
sunbeam/src/code/agent.rs
Normal file
151
sunbeam/src/code/agent.rs
Normal file
@@ -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<AgentRequest>,
|
||||
pub rx: Receiver<AgentEvent>,
|
||||
}
|
||||
|
||||
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<AgentEvent> {
|
||||
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::<AgentRequest>(32);
|
||||
let (evt_tx, evt_rx) = crossbeam_channel::bounded::<AgentEvent>(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<AgentRequest>,
|
||||
evt_tx: Sender<AgentEvent>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ChatEvent>,
|
||||
}
|
||||
|
||||
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<HistoryMessage>,
|
||||
tx: mpsc::Sender<ClientMessage>,
|
||||
rx: tonic::Streaming<ServerMessage>,
|
||||
}
|
||||
@@ -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<String> {
|
||||
/// Returns (full_text, events) — events are for the TUI to display.
|
||||
pub async fn chat(&mut self, text: &str) -> anyhow::Result<ChatResponse> {
|
||||
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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Mutex<Vec<String>>>);
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
pub fn lines(&self) -> Vec<String> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer that appends each line to the ring buffer.
|
||||
pub struct LogBufferWriter(Arc<Mutex<Vec<String>>>);
|
||||
|
||||
impl io::Write for LogBufferWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
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<Line<'static>>,
|
||||
/// 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<Line<'static>> {
|
||||
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<Line<'static>> {
|
||||
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<LogEntry>,
|
||||
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<ApprovalPrompt>,
|
||||
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<String>,
|
||||
pub history_index: Option<usize>,
|
||||
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<LogEntry>) {
|
||||
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);
|
||||
|
||||
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<Line> = 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<Line> = 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,11 +490,13 @@ fn draw_input(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||
|
||||
frame.render_widget(input_widget, area);
|
||||
|
||||
// Position cursor
|
||||
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) {
|
||||
let block = Block::default()
|
||||
@@ -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<CrosstermBackend<io::Stdout>>> {
|
||||
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<CrosstermBackend<io::Stdout>>) -> 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);
|
||||
|
||||
Reference in New Issue
Block a user