feat(code): friendly errors, batch history, persistent command history

- Agent errors sanitized: raw hyper/h2/gRPC dumps replaced with
  human-readable messages ("sol disconnected", "connection lost", etc.)
- Batch history loading: single viewport rebuild instead of per-entry
- Persistent command history: saved to .sunbeam/history, loaded on start
- Default model: mistral-medium-latest (personality adherence)
This commit is contained in:
2026-03-23 17:08:24 +00:00
parent 8b4f187d1b
commit d7c5a677da
5 changed files with 329 additions and 17 deletions

View File

@@ -7,10 +7,43 @@
//! 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 crossbeam_channel::{Receiver, Sender};
use super::client::{self, CodeSession};
/// Turn raw internal errors into something a human can read.
fn friendly_error(e: &str) -> String {
let lower = e.to_lowercase();
if lower.contains("broken pipe") || lower.contains("stream closed") || lower.contains("h2 protocol") {
"sol disconnected — try again or restart with /exit".into()
} else if lower.contains("channel closed") || lower.contains("send on closed") {
"connection to sol lost".into()
} else if lower.contains("timed out") || lower.contains("timeout") {
"request timed out — sol may be overloaded".into()
} else if lower.contains("connection refused") {
"can't reach sol — is it running?".into()
} else if lower.contains("not found") && lower.contains("agent") {
"sol's agent was reset — reconnect with /exit".into()
} else if lower.contains("invalid_request_error") {
// Extract the actual message from Mistral API errors
if let Some(start) = e.find("\"msg\":\"") {
let rest = &e[start + 7..];
if let Some(end) = rest.find('"') {
return rest[..end].to_string();
}
}
"request error from sol".into()
} else {
// Truncate long errors and strip Rust debug formatting
let clean = e.replace("\\n", " ").replace("\\\"", "'");
if clean.len() > 120 {
format!("{}", &clean[..117])
} else {
clean
}
}
}
// ── Requests (TUI → Agent) ──────────────────────────────────────────────
/// A request from the UI to the agent backend.
@@ -38,6 +71,8 @@ pub enum AgentEvent {
Error { message: String },
/// Status update (shown in title bar).
Status { message: String },
/// Connection health: true = reachable, false = unreachable.
Health { connected: bool },
/// Session ended.
SessionEnded,
}
@@ -76,11 +111,12 @@ impl AgentHandle {
// ── Spawn ──────────────────────────────────────────────────────────────
/// Spawn the agent background task. Returns a handle for the TUI.
pub fn spawn(session: CodeSession) -> AgentHandle {
pub fn spawn(session: CodeSession, endpoint: String) -> 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));
tokio::spawn(agent_loop(session, req_rx, evt_tx.clone()));
tokio::spawn(heartbeat_loop(endpoint, evt_tx));
AgentHandle {
tx: req_tx,
@@ -88,6 +124,25 @@ pub fn spawn(session: CodeSession) -> AgentHandle {
}
}
/// Ping the gRPC endpoint every second to check if Sol is reachable.
async fn heartbeat_loop(endpoint: String, evt_tx: Sender<AgentEvent>) {
use sunbeam_proto::sunbeam_code_v1::code_agent_client::CodeAgentClient;
let mut last_state = true; // assume connected initially (we just connected)
let _ = evt_tx.try_send(AgentEvent::Health { connected: true });
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let connected = CodeAgentClient::connect(endpoint.clone()).await.is_ok();
if connected != last_state {
let _ = evt_tx.try_send(AgentEvent::Health { connected });
last_state = connected;
}
}
}
/// The background agent loop. Reads requests, calls gRPC, emits events.
async fn agent_loop(
mut session: CodeSession,
@@ -126,7 +181,7 @@ async fn agent_loop(
message: msg.clone(),
},
client::ChatEvent::Error(msg) => AgentEvent::Error {
message: msg.clone(),
message: friendly_error(msg),
},
};
let _ = evt_tx.try_send(agent_event);
@@ -136,7 +191,7 @@ async fn agent_loop(
}
Err(e) => {
let _ = evt_tx.try_send(AgentEvent::Error {
message: e.to_string(),
message: friendly_error(&e.to_string()),
});
}
}

View File

@@ -82,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-latest".into());
.unwrap_or_else(|| "mistral-medium-latest".into());
// Connect to Sol
let mut session = client::connect(&endpoint, &project, &cfg, &model).await?;
@@ -104,7 +104,7 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
// Spawn agent on background task
let project_path = project.path.clone();
let agent = agent::spawn(session);
let agent = agent::spawn(session, endpoint.clone());
// TUI event loop — never blocks on network I/O
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind};
@@ -160,6 +160,12 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
app.sol_status.clear();
app.push_log(tui::LogEntry::Error(message));
}
agent::AgentEvent::Health { connected } => {
if app.sol_connected != connected {
app.sol_connected = connected;
app.needs_redraw = true;
}
}
agent::AgentEvent::SessionEnded => {
break;
}

View File

@@ -273,6 +273,7 @@ pub struct App {
pub approval: Option<ApprovalPrompt>,
pub is_thinking: bool,
pub sol_status: String,
pub sol_connected: bool,
pub should_quit: bool,
pub show_logs: bool,
pub log_buffer: LogBuffer,
@@ -299,6 +300,7 @@ impl App {
approval: None,
is_thinking: false,
sol_status: String::new(),
sol_connected: true,
should_quit: false,
show_logs: false,
log_buffer,
@@ -391,6 +393,8 @@ pub fn draw(frame: &mut ratatui::Frame, app: &mut App) {
}
fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
let health = if app.sol_connected { "☀️" } else { "⛈️" };
let left = vec![
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
Span::styled(" · ", Style::default().fg(SOL_FAINT)),
@@ -399,21 +403,21 @@ fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
Span::styled(&app.branch, Style::default().fg(SOL_DIM)),
];
// Right side: model name + sol status
let right_parts = if app.is_thinking {
// Right side: health + status + model
let mut right_parts = vec![Span::raw(health.to_string())];
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))]
};
right_parts.push(Span::styled(" ", Style::default().fg(SOL_FAINT)));
right_parts.push(Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC)));
}
right_parts.push(Span::styled(" · ", Style::default().fg(SOL_FAINT)));
right_parts.push(Span::styled(&app.model, Style::default().fg(SOL_DIM)));
let title_line = Line::from(left);
frame.render_widget(Paragraph::new(title_line), area);