From 32f6ebaceabcf70faf02293f3f2b8af078746fde Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Mar 2026 21:35:35 +0000 Subject: [PATCH] feat(tui): wire approval prompt with key handlers - ApprovalPrompt gains call_id for routing decisions - Up/Down navigates options, Enter selects - "yes, always allow {tool}" sends ApprovedAlways - Input/cursor blocked while approval prompt is active - AgentEvent::ApprovalNeeded populates the prompt --- sunbeam/src/code/mod.rs | 78 +++++++++++--- sunbeam/src/code/tui.rs | 233 ++++++++++++++++++++++++++++++++-------- 2 files changed, 250 insertions(+), 61 deletions(-) diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs index 7b54f72..8632036 100644 --- a/sunbeam/src/code/mod.rs +++ b/sunbeam/src/code/mod.rs @@ -104,7 +104,7 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { // Spawn agent on background task let project_path = project.path.clone(); - let agent = agent::spawn(session, endpoint.clone()); + let agent = agent::spawn(session, endpoint.clone(), cfg, project.path.clone()); // TUI event loop — never blocks on network I/O use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; @@ -133,12 +133,28 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { // 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(); + agent::AgentEvent::ApprovalNeeded { call_id, name, args_summary } => { + app.approval = Some(tui::ApprovalPrompt { + call_id: call_id.clone(), + tool_name: name.clone(), + command: args_summary.clone(), + options: vec![ + "yes".into(), + format!("yes, always allow {name}"), + "no".into(), + ], + selected: 0, + }); app.needs_redraw = true; } - agent::AgentEvent::ToolStart { name, detail } => { + agent::AgentEvent::Generating => { + app.is_thinking = true; + app.sol_status.clear(); + app.thinking_message = tui::random_sol_status().to_string(); + app.thinking_since = Some(std::time::Instant::now()); + app.needs_redraw = true; + } + agent::AgentEvent::ToolExecuting { name, detail } => { app.push_log(tui::LogEntry::ToolExecuting { name, detail }); } agent::AgentEvent::ToolDone { name, success } => { @@ -150,14 +166,19 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { app.sol_status = message; app.needs_redraw = true; } - agent::AgentEvent::Response { text } => { + agent::AgentEvent::Response { text, input_tokens, output_tokens } => { app.is_thinking = false; app.sol_status.clear(); + app.thinking_since = None; + app.last_turn_tokens = input_tokens + output_tokens; + app.input_tokens += input_tokens; + app.output_tokens += output_tokens; app.push_log(tui::LogEntry::AssistantText(text)); } agent::AgentEvent::Error { message } => { app.is_thinking = false; app.sol_status.clear(); + app.thinking_since = None; app.push_log(tui::LogEntry::Error(message)); } agent::AgentEvent::Health { connected } => { @@ -172,14 +193,15 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { } } - // 2. Draw only when something changed - if app.needs_redraw { + // 2. Draw only when something changed (or animating) + if app.needs_redraw || app.is_thinking { terminal.draw(|frame| tui::draw(frame, &mut app))?; app.needs_redraw = false; } - // 3. Handle input — drain ALL pending events before next draw - if event::poll(std::time::Duration::from_millis(50))? { + // 3. Handle input — shorter poll when animating + let poll_ms = if app.is_thinking { 100 } else { 50 }; + if event::poll(std::time::Duration::from_millis(poll_ms))? { // Drain all queued events in one batch (coalesces rapid scroll) while event::poll(std::time::Duration::ZERO)? { match event::read()? { @@ -220,20 +242,48 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { app.show_logs = !app.show_logs; app.log_scroll = u16::MAX; } - KeyCode::Char(c) if !app.show_logs => { + // Approval prompt navigation + KeyCode::Up if app.approval.is_some() => { + if let Some(ref mut a) = app.approval { + a.selected = a.selected.saturating_sub(1); + } + } + KeyCode::Down if app.approval.is_some() => { + if let Some(ref mut a) = app.approval { + a.selected = (a.selected + 1).min(a.options.len() - 1); + } + } + KeyCode::Enter if app.approval.is_some() => { + if let Some(a) = app.approval.take() { + let decision = match a.selected { + 0 => agent::ApprovalDecision::Approved { + call_id: a.call_id.clone(), + }, + 1 => agent::ApprovalDecision::ApprovedAlways { + call_id: a.call_id.clone(), + tool_name: a.tool_name.clone(), + }, + _ => agent::ApprovalDecision::Denied { + call_id: a.call_id.clone(), + }, + }; + agent.decide(decision); + } + } + KeyCode::Char(c) if !app.show_logs && app.approval.is_none() => { app.history_index = None; app.input.insert(app.cursor_pos, c); app.cursor_pos += 1; } - KeyCode::Backspace if !app.show_logs => { + KeyCode::Backspace if !app.show_logs && app.approval.is_none() => { if app.cursor_pos > 0 { app.history_index = None; app.cursor_pos -= 1; app.input.remove(app.cursor_pos); } } - KeyCode::Left if !app.show_logs => app.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::Left if !app.show_logs && app.approval.is_none() => app.cursor_pos = app.cursor_pos.saturating_sub(1), + KeyCode::Right if !app.show_logs && app.approval.is_none() => app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()), KeyCode::Up if !app.show_logs => { if !app.command_history.is_empty() { let idx = match app.history_index { diff --git a/sunbeam/src/code/tui.rs b/sunbeam/src/code/tui.rs index c9af44e..660a75d 100644 --- a/sunbeam/src/code/tui.rs +++ b/sunbeam/src/code/tui.rs @@ -12,6 +12,95 @@ use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Terminal; use tracing_subscriber::fmt::MakeWriter; +// ── Sol status messages (sun/fusion theme) ─────────────────────────────── + +const SOL_STATUS_MESSAGES: &[&str] = &[ + "fusing hydrogen", + "solar flare", + "coronal mass", + "helium flash", + "photon escape", + "plasma arc", + "sunspot forming", + "chromosphere", + "radiating", + "nuclear fusion", + "proton chain", + "solar wind", + "burning bright", + "going nova", + "core ignition", + "stellar drift", + "dawn breaking", + "light bending", + "warmth spreading", + "horizon glow", + "golden hour", + "ray tracing", + "luminous flux", + "thermal bloom", + "heliosphere", + "magnetic storm", + "sun worship", + "solstice", + "perihelion", + "daybreak", + "photosphere", + "solar apex", + "corona pulse", + "neutrino bath", + "deuterium burn", + "kelvin climb", + "fusion yield", + "radiant heat", + "stellar core", + "light speed", +]; + +/// Pick a random status message for the generating indicator. +pub fn random_sol_status() -> &'static str { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use std::time::SystemTime; + + let mut hasher = DefaultHasher::new(); + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .hash(&mut hasher); + let idx = hasher.finish() as usize % SOL_STATUS_MESSAGES.len(); + SOL_STATUS_MESSAGES[idx] +} + +// ── Sol color wave palette (warm amber gradient) ───────────────────────── + +const WAVE_COLORS: &[(u8, u8, u8)] = &[ + (255, 216, 0), // bright gold + (255, 197, 66), // sol yellow + (245, 175, 0), // amber + (232, 140, 30), // deep amber + (210, 110, 20), // burnt orange +]; + +/// Get the wave color for a character position at the current frame. +fn wave_color_at(pos: usize, frame: u64, text_len: usize) -> Color { + let total = text_len + 2; // text + padding + let cycle_len = total * 2; // bounce back and forth + let wave_pos = (frame as usize / 2) % cycle_len; // advance every 2 frames + let wave_pos = if wave_pos >= total { + cycle_len - wave_pos - 1 // bounce back + } else { + wave_pos + }; + + // Distance from wave front determines color index + let dist = if pos >= wave_pos { pos - wave_pos } else { wave_pos - pos }; + let idx = dist.min(WAVE_COLORS.len() - 1); + let (r, g, b) = WAVE_COLORS[idx]; + Color::Rgb(r, g, b) +} + // ── Sol color palette ────────────────────────────────────────────────────── const SOL_YELLOW: Color = Color::Rgb(245, 197, 66); @@ -21,7 +110,6 @@ 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); @@ -122,13 +210,32 @@ impl Viewport { 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("")); + // Render markdown to styled ratatui Lines + let md_text: Text<'_> = tui_markdown::from_str(text); + let base_style = Style::default().fg(SOL_YELLOW); + for line in md_text.lines { + // Apply base yellow color to spans that don't have explicit styling + let styled_spans: Vec> = line + .spans + .into_iter() + .map(|span| { + let mut style = span.style; + if style.fg.is_none() { + style = style.fg(SOL_YELLOW); + } + Span::styled(span.content.into_owned(), style) + }) + .collect(); + let styled_line = Line::from(styled_spans); + // Wrap long lines + let line_width = styled_line.width(); + if line_width <= w { + self.visual_lines.push(styled_line); } else { - for wrapped in textwrap::wrap(logical_line, w) { - self.visual_lines.push(Line::styled(wrapped.into_owned(), style)); + // For wrapped markdown lines, fall back to textwrap on the raw text + let raw: String = styled_line.spans.iter().map(|s| s.content.as_ref()).collect(); + for wrapped in textwrap::wrap(&raw, w) { + self.visual_lines.push(Line::styled(wrapped.into_owned(), base_style)); } } } @@ -251,6 +358,7 @@ pub enum LogEntry { // ── Approval state ───────────────────────────────────────────────────────── pub struct ApprovalPrompt { + pub call_id: String, pub tool_name: String, pub command: String, pub options: Vec, @@ -270,10 +378,13 @@ pub struct App { pub model: String, pub input_tokens: u32, pub output_tokens: u32, + pub last_turn_tokens: u32, pub approval: Option, pub is_thinking: bool, pub sol_status: String, pub sol_connected: bool, + pub thinking_since: Option, + pub thinking_message: String, pub should_quit: bool, pub show_logs: bool, pub log_buffer: LogBuffer, @@ -282,6 +393,7 @@ pub struct App { pub history_index: Option, pub input_saved: String, pub needs_redraw: bool, + pub frame_count: u64, } impl App { @@ -297,10 +409,13 @@ impl App { model: model.into(), input_tokens: 0, output_tokens: 0, + last_turn_tokens: 0, approval: None, is_thinking: false, sol_status: String::new(), sol_connected: true, + thinking_since: None, + thinking_message: String::new(), should_quit: false, show_logs: false, log_buffer, @@ -309,6 +424,7 @@ impl App { history_index: None, input_saved: String::new(), needs_redraw: true, + frame_count: 0, } } @@ -364,14 +480,14 @@ impl App { // ── Rendering ────────────────────────────────────────────────────────────── pub fn draw(frame: &mut ratatui::Frame, app: &mut App) { + app.frame_count = app.frame_count.wrapping_add(1); let area = frame.area(); - // Layout: title (1) + log (flex) + input (3) + status (1) + // Layout: title (1) + log (flex) + input (3) — no status bar let chunks = Layout::vertical([ - Constraint::Length(1), // title bar + Constraint::Length(1), // title bar (all system info) Constraint::Min(5), // conversation log Constraint::Length(3), // input area - Constraint::Length(1), // status bar ]) .split(area); @@ -388,13 +504,12 @@ pub fn draw(frame: &mut ratatui::Frame, app: &mut App) { } 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 health = if app.sol_connected { "☀️" } else { "⛈️" }; + // Left: branding + project + branch let left = vec![ Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)), Span::styled(" · ", Style::default().fg(SOL_FAINT)), @@ -403,21 +518,60 @@ fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { Span::styled(&app.branch, Style::default().fg(SOL_DIM)), ]; - // Right side: health + status + model - let mut right_parts = vec![Span::raw(health.to_string())]; + // Right: timer · status_wave · tokens · model · health + let mut right_parts: Vec = Vec::new(); if app.is_thinking { - let status = if app.sol_status.is_empty() { - "generating…" + // Elapsed timer first + if let Some(since) = app.thinking_since { + let elapsed = since.elapsed().as_secs(); + right_parts.push(Span::styled( + format!("{elapsed}s "), + Style::default().fg(SOL_FAINT), + )); + } + + let status = if app.thinking_message.is_empty() { + "generating" } else { - &app.sol_status + &app.thinking_message }; - 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))); + let status_text = format!("{status}…"); + + // Per-character color wave + global dim/brighten pulse + let pulse = ((app.frame_count as f64 / 15.0).sin() + 1.0) / 2.0; // 0.0–1.0 + let text_len = status_text.chars().count(); + for (i, ch) in status_text.chars().enumerate() { + let wave = wave_color_at(i, app.frame_count, text_len); + // Blend wave color with pulse brightness + let (wr, wg, wb) = match wave { Color::Rgb(r, g, b) => (r, g, b), _ => (245, 197, 66) }; + let r = (wr as f64 * (0.4 + 0.6 * pulse)) as u8; + let g = (wg as f64 * (0.4 + 0.6 * pulse)) as u8; + let b = (wb as f64 * (0.4 + 0.6 * pulse)) as u8; + right_parts.push(Span::styled( + ch.to_string(), + Style::default().fg(Color::Rgb(r, g, b)).add_modifier(Modifier::BOLD), + )); + } + + right_parts.push(Span::styled(" · ", Style::default().fg(SOL_FAINT))); + } + + // Token counters — context (last turn prompt) + total session tokens + let total = app.input_tokens + app.output_tokens; + if total > 0 { + right_parts.push(Span::styled( + format!("ctx:{} tot:{}", format_tokens(app.last_turn_tokens), format_tokens(total)), + Style::default().fg(SOL_DIM), + )); + } else { + right_parts.push(Span::styled("—", Style::default().fg(SOL_FAINT))); } right_parts.push(Span::styled(" · ", Style::default().fg(SOL_FAINT))); right_parts.push(Span::styled(&app.model, Style::default().fg(SOL_DIM))); + right_parts.push(Span::styled(" ", Style::default().fg(SOL_FAINT))); + right_parts.push(Span::raw(health.to_string())); let title_line = Line::from(left); frame.render_widget(Paragraph::new(title_line), area); @@ -433,6 +587,17 @@ fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { frame.render_widget(Paragraph::new(right_line), right_area); } +/// Format token count: 1234 → "1.2k", 123 → "123" +fn format_tokens(n: u32) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}k", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + 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); @@ -532,34 +697,6 @@ fn draw_approval(frame: &mut ratatui::Frame, area: Rect, approval: &ApprovalProm frame.render_widget(widget, area); } -fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { - let left = 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(Line::from(left)), 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 ──────────────────────────────────────────────── pub fn setup_terminal() -> io::Result>> { @@ -640,6 +777,7 @@ mod tests { #[test] fn test_approval_prompt() { let approval = ApprovalPrompt { + call_id: "test-1".into(), tool_name: "bash".into(), command: "cargo test".into(), options: vec![ @@ -656,6 +794,7 @@ mod tests { #[test] fn test_approval_navigation() { let mut approval = ApprovalPrompt { + call_id: "test-2".into(), tool_name: "bash".into(), command: "rm -rf".into(), options: vec!["Yes".into(), "No".into()],