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:
@@ -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 {
|
||||
|
||||
@@ -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.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<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()],
|
||||
|
||||
Reference in New Issue
Block a user