feat(code): wire TUI into real code path, /exit, color swap

- user input: white text, dim > prompt
- sol responses: warm yellow
- /exit slash command quits cleanly
- TUI replaces stdin loop in sunbeam code start
- hidden demo mode for testing (sunbeam code demo)
This commit is contained in:
2026-03-23 12:53:34 +00:00
parent 02e4d7fb37
commit cc9f169264
3 changed files with 1188 additions and 34 deletions

View File

@@ -2,6 +2,7 @@ pub mod client;
pub mod config;
pub mod project;
pub mod tools;
pub mod tui;
use clap::Subcommand;
use tracing::info;
@@ -17,6 +18,9 @@ pub enum CodeCommand {
#[arg(long)]
endpoint: Option<String>,
},
/// Demo the TUI with sample data (no Sol connection needed)
#[command(hide = true)]
Demo,
}
pub async fn cmd_code(cmd: Option<CodeCommand>) -> sunbeam_sdk::error::Result<()> {
@@ -30,6 +34,9 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
});
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());
@@ -59,39 +66,187 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
"Connected to Sol"
);
// For now, simple stdin loop (ratatui TUI in Phase 4)
println!("sunbeam code · {} · {}", project.name, model);
println!("connected to Sol (session: {})", &session.session_id[..8]);
println!("type a message, /quit to exit\n");
// TUI event loop
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
let stdin = tokio::io::stdin();
let reader = tokio::io::BufReader::new(stdin);
use tokio::io::AsyncBufReadExt;
let mut lines = reader.lines();
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);
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
if line == "/quit" {
session.end().await?;
println!("session ended.");
break;
}
let result = loop {
terminal.draw(|frame| tui::draw(frame, &app))?;
print!("> ");
match session.chat(&line).await {
Ok(response) => {
println!("\n{}\n", response);
}
Err(e) => {
eprintln!("error: {e}");
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
KeyCode::Char(c) => {
app.input.insert(app.cursor_pos, c);
app.cursor_pos += 1;
}
KeyCode::Backspace => {
if app.cursor_pos > 0 {
app.cursor_pos -= 1;
app.input.remove(app.cursor_pos);
}
}
KeyCode::Left => app.cursor_pos = app.cursor_pos.saturating_sub(1),
KeyCode::Right => app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()),
KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
KeyCode::Enter => {
if !app.input.is_empty() {
let text = app.input.clone();
app.input.clear();
app.cursor_pos = 0;
if text == "/exit" {
let _ = session.end().await;
break Ok(());
}
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()));
}
}
}
}
_ => {}
}
}
}
}
};
Ok(())
tui::restore_terminal(&mut terminal)?;
result
}
}
}
async fn run_demo() -> anyhow::Result<()> {
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
let mut terminal = tui::setup_terminal()?;
let mut app = tui::App::new("sol", "mainline ±", "devstral-2");
// Populate with sample conversation
app.push_log(tui::LogEntry::UserInput("fix the token validation bug in auth.rs".into()));
app.push_log(tui::LogEntry::AssistantText(
"Looking at the auth module, I can see the issue on line 42 where the token \
is not properly validated before use. The expiry check is missing entirely."
.into(),
));
app.push_log(tui::LogEntry::ToolSuccess {
name: "file_read".into(),
detail: "src/auth.rs (127 lines)".into(),
});
app.push_log(tui::LogEntry::ToolOutput {
lines: vec![
"38│ fn validate_token(token: &str) -> bool {".into(),
"39│ let decoded = decode(token);".into(),
"40│ // BUG: missing expiry check".into(),
"41│ decoded.is_ok()".into(),
"42│ }".into(),
"43│".into(),
"44│ fn refresh_token(token: &str) -> Result<String> {".into(),
"45│ let client = reqwest::Client::new();".into(),
"46│ // ...".into(),
],
collapsed: true,
});
app.push_log(tui::LogEntry::ToolSuccess {
name: "search_replace".into(),
detail: "src/auth.rs — applied 1 replacement (line 41)".into(),
});
app.push_log(tui::LogEntry::ToolExecuting {
name: "bash".into(),
detail: "cargo test --lib".into(),
});
app.push_log(tui::LogEntry::ToolOutput {
lines: vec![
"running 23 tests".into(),
"test auth::tests::test_validate_token ... ok".into(),
"test auth::tests::test_expired_token ... ok".into(),
"test auth::tests::test_refresh_flow ... ok".into(),
"test result: ok. 23 passed; 0 failed".into(),
],
collapsed: false,
});
app.push_log(tui::LogEntry::AssistantText(
"Fixed. The token validation now checks expiry before use. All 23 tests pass."
.into(),
));
app.push_log(tui::LogEntry::UserInput("now add rate limiting to the auth endpoint".into()));
app.push_log(tui::LogEntry::ToolExecuting {
name: "file_read".into(),
detail: "src/routes/auth.rs".into(),
});
app.is_thinking = true;
app.input_tokens = 2400;
app.output_tokens = 890;
loop {
terminal.draw(|frame| tui::draw(frame, &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(c) => {
app.input.insert(app.cursor_pos, c);
app.cursor_pos += 1;
}
KeyCode::Backspace => {
if app.cursor_pos > 0 {
app.cursor_pos -= 1;
app.input.remove(app.cursor_pos);
}
}
KeyCode::Left => {
app.cursor_pos = app.cursor_pos.saturating_sub(1);
}
KeyCode::Right => {
app.cursor_pos = (app.cursor_pos + 1).min(app.input.len());
}
KeyCode::Enter => {
if !app.input.is_empty() {
let text = app.input.clone();
app.input.clear();
app.cursor_pos = 0;
if text == "/exit" {
break;
}
app.push_log(tui::LogEntry::UserInput(text));
app.is_thinking = true;
}
}
KeyCode::Up => {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
}
KeyCode::Down => {
app.scroll_offset = app.scroll_offset.saturating_add(1);
}
_ => {}
}
}
}
}
tui::restore_terminal(&mut terminal)?;
Ok(())
}

437
sunbeam/src/code/tui.rs Normal file
View File

@@ -0,0 +1,437 @@
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;
// ── Sol color palette ──────────────────────────────────────────────────────
const SOL_YELLOW: Color = Color::Rgb(245, 197, 66);
const SOL_AMBER: Color = Color::Rgb(232, 168, 64);
const SOL_BLUE: Color = Color::Rgb(108, 166, 224);
const SOL_RED: Color = Color::Rgb(224, 88, 88);
const SOL_DIM: Color = Color::Rgb(138, 122, 90);
const SOL_GRAY: Color = Color::Rgb(112, 112, 112);
const SOL_FAINT: Color = Color::Rgb(80, 80, 80);
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);
// ── Message types for the conversation log ─────────────────────────────────
#[derive(Clone)]
pub enum LogEntry {
UserInput(String),
AssistantText(String),
ToolSuccess { name: String, detail: String },
ToolExecuting { name: String, detail: String },
ToolFailed { name: String, detail: String },
ToolOutput { lines: Vec<String>, collapsed: bool },
Status(String),
Error(String),
}
// ── Approval state ─────────────────────────────────────────────────────────
pub struct ApprovalPrompt {
pub tool_name: String,
pub command: String,
pub options: Vec<String>,
pub selected: usize,
}
// ── App state ──────────────────────────────────────────────────────────────
pub struct App {
pub log: Vec<LogEntry>,
pub input: String,
pub cursor_pos: usize,
pub scroll_offset: u16,
pub project_name: String,
pub branch: String,
pub model: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub approval: Option<ApprovalPrompt>,
pub is_thinking: bool,
pub should_quit: bool,
}
impl App {
pub fn new(project_name: &str, branch: &str, model: &str) -> Self {
Self {
log: Vec::new(),
input: String::new(),
cursor_pos: 0,
scroll_offset: 0,
project_name: project_name.into(),
branch: branch.into(),
model: model.into(),
input_tokens: 0,
output_tokens: 0,
approval: None,
is_thinking: false,
should_quit: false,
}
}
pub fn push_log(&mut self, entry: LogEntry) {
self.log.push(entry);
// Auto-scroll to bottom
self.scroll_offset = u16::MAX;
}
}
// ── Rendering ──────────────────────────────────────────────────────────────
pub fn draw(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
// Layout: title (1) + log (flex) + input (3) + status (1)
let chunks = Layout::vertical([
Constraint::Length(1), // title bar
Constraint::Min(5), // conversation log
Constraint::Length(3), // input area
Constraint::Length(1), // status bar
])
.split(area);
draw_title_bar(frame, chunks[0], app);
draw_log(frame, chunks[1], app);
if let Some(ref approval) = app.approval {
draw_approval(frame, chunks[2], approval);
} else {
draw_input(frame, chunks[2], app);
}
draw_status_bar(frame, chunks[3], app);
}
fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
let left = vec![
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
Span::styled(" · ", Style::default().fg(SOL_FAINT)),
Span::raw(&app.project_name),
Span::styled(" · ", Style::default().fg(SOL_FAINT)),
Span::styled(&app.branch, Style::default().fg(SOL_DIM)),
];
let right = 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_area = Rect {
x: area.width.saturating_sub(right.width() as u16 + 1),
y: area.y,
width: right.width() as u16 + 1,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(right)), right_area);
}
fn draw_log(frame: &mut ratatui::Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = Vec::new();
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),
)));
}
}
}
if app.is_thinking {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" thinking...",
Style::default().fg(SOL_DIM).add_modifier(Modifier::ITALIC),
)));
}
let total_lines = lines.len() as u16;
let visible = area.height;
let max_scroll = total_lines.saturating_sub(visible);
let scroll = if app.scroll_offset == u16::MAX {
max_scroll
} else {
app.scroll_offset.min(max_scroll)
};
let log_widget = Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
frame.render_widget(log_widget, area);
}
fn draw_input(frame: &mut ratatui::Frame, area: Rect, app: &App) {
let input_block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(SOL_FAINT));
let input_text = Line::from(vec![
Span::styled("> ", Style::default().fg(SOL_DIM)),
Span::raw(&app.input),
]);
let input_widget = Paragraph::new(input_text)
.block(input_block)
.wrap(Wrap { trim: false });
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));
}
fn draw_approval(frame: &mut ratatui::Frame, area: Rect, approval: &ApprovalPrompt) {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(SOL_FAINT));
let mut lines = vec![
Line::from(vec![
Span::styled("", Style::default().fg(SOL_YELLOW)),
Span::styled(&approval.tool_name, Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {}", approval.command), Style::default().fg(SOL_APPROVAL_CMD)),
]),
];
for (i, opt) in approval.options.iter().enumerate() {
let prefix = if i == approval.selected { " " } else { " " };
let style = if i == approval.selected {
Style::default().fg(SOL_YELLOW)
} else {
Style::default().fg(SOL_DIM)
};
lines.push(Line::from(Span::styled(format!("{prefix}{opt}"), style)));
}
let widget = Paragraph::new(Text::from(lines))
.block(block)
.style(Style::default().bg(SOL_APPROVAL_BG));
frame.render_widget(widget, area);
}
fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
let status = Line::from(vec![
Span::styled(
format!(" ~/…/{}", app.project_name),
Style::default().fg(SOL_STATUS),
),
Span::styled(
format!(" {} ±", app.branch),
Style::default().fg(SOL_STATUS),
),
Span::styled(
format!(" {}k in · {}k out", app.input_tokens / 1000, app.output_tokens / 1000),
Style::default().fg(SOL_STATUS),
),
]);
frame.render_widget(Paragraph::new(status), area);
}
// ── Terminal setup/teardown ────────────────────────────────────────────────
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
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)?;
terminal.show_cursor()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_creation() {
let app = App::new("sol", "mainline", "devstral-2");
assert_eq!(app.project_name, "sol");
assert!(!app.should_quit);
assert!(app.log.is_empty());
}
#[test]
fn test_push_log_auto_scrolls() {
let mut app = App::new("sol", "main", "devstral-2");
app.scroll_offset = 0;
app.push_log(LogEntry::Status("test".into()));
assert_eq!(app.scroll_offset, u16::MAX); // auto-scroll to bottom
}
#[test]
fn test_color_constants() {
assert!(matches!(SOL_YELLOW, Color::Rgb(245, 197, 66)));
assert!(matches!(SOL_AMBER, Color::Rgb(232, 168, 64)));
assert!(matches!(SOL_BLUE, Color::Rgb(108, 166, 224)));
assert!(matches!(SOL_RED, Color::Rgb(224, 88, 88)));
// No green in the palette
assert!(!matches!(SOL_YELLOW, Color::Rgb(_, 255, _)));
assert!(!matches!(SOL_BLUE, Color::Rgb(_, 255, _)));
}
#[test]
fn test_log_entries_all_variants() {
let mut app = App::new("test", "main", "devstral-2");
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() });
app.push_log(LogEntry::ToolExecuting { name: "bash".into(), detail: "cargo test".into() });
app.push_log(LogEntry::ToolFailed { name: "grep".into(), detail: "no matches".into() });
app.push_log(LogEntry::ToolOutput { lines: vec!["line 1".into(), "line 2".into()], collapsed: true });
app.push_log(LogEntry::Status("thinking".into()));
app.push_log(LogEntry::Error("connection lost".into()));
assert_eq!(app.log.len(), 8);
}
#[test]
fn test_tool_output_collapse_threshold() {
// Collapsed output shows max 5 lines + "... +N lines"
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
let entry = LogEntry::ToolOutput { lines: lines.clone(), collapsed: true };
if let LogEntry::ToolOutput { lines, collapsed } = &entry {
assert!(lines.len() > 5);
assert!(*collapsed);
}
}
#[test]
fn test_approval_prompt() {
let approval = ApprovalPrompt {
tool_name: "bash".into(),
command: "cargo test".into(),
options: vec![
"Yes".into(),
"Yes, always allow bash".into(),
"No".into(),
],
selected: 0,
};
assert_eq!(approval.options.len(), 3);
assert_eq!(approval.selected, 0);
}
#[test]
fn test_approval_navigation() {
let mut approval = ApprovalPrompt {
tool_name: "bash".into(),
command: "rm -rf".into(),
options: vec!["Yes".into(), "No".into()],
selected: 0,
};
// Navigate down
approval.selected = (approval.selected + 1).min(approval.options.len() - 1);
assert_eq!(approval.selected, 1);
// Navigate down again (clamped)
approval.selected = (approval.selected + 1).min(approval.options.len() - 1);
assert_eq!(approval.selected, 1);
// Navigate up
approval.selected = approval.selected.saturating_sub(1);
assert_eq!(approval.selected, 0);
}
#[test]
fn test_thinking_state() {
let mut app = App::new("sol", "main", "devstral-2");
assert!(!app.is_thinking);
app.is_thinking = true;
assert!(app.is_thinking);
}
#[test]
fn test_input_cursor() {
let mut app = App::new("sol", "main", "devstral-2");
app.input = "hello world".into();
app.cursor_pos = 5;
assert_eq!(&app.input[..app.cursor_pos], "hello");
}
#[test]
fn test_token_tracking() {
let mut app = App::new("sol", "main", "devstral-2");
app.input_tokens = 1200;
app.output_tokens = 340;
assert_eq!(app.input_tokens / 1000, 1);
assert_eq!(app.output_tokens / 1000, 0);
}
}