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
This commit is contained in:
2026-03-23 21:35:35 +00:00
parent 5f1fb09abb
commit 32f6ebacea
2 changed files with 250 additions and 61 deletions

View File

@@ -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, 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<CodeCommand>) -> 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<CodeCommand>) -> 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<CodeCommand>) -> 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<CodeCommand>) -> 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 {

View File

@@ -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<Span<'static>> = 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<String>,
@@ -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<ApprovalPrompt>,
pub is_thinking: bool,
pub sol_status: String,
pub sol_connected: bool,
pub thinking_since: Option<std::time::Instant>,
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<usize>,
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<Span> = 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.01.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<Terminal<CrosstermBackend<io::Stdout>>> {
@@ -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()],