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
|
// Spawn agent on background task
|
||||||
let project_path = project.path.clone();
|
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
|
// TUI event loop — never blocks on network I/O
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind};
|
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)
|
// 1. Process any pending agent events (non-blocking)
|
||||||
for evt in agent.poll_events() {
|
for evt in agent.poll_events() {
|
||||||
match evt {
|
match evt {
|
||||||
agent::AgentEvent::Generating => {
|
agent::AgentEvent::ApprovalNeeded { call_id, name, args_summary } => {
|
||||||
app.is_thinking = true;
|
app.approval = Some(tui::ApprovalPrompt {
|
||||||
app.sol_status = "generating…".into();
|
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;
|
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 });
|
app.push_log(tui::LogEntry::ToolExecuting { name, detail });
|
||||||
}
|
}
|
||||||
agent::AgentEvent::ToolDone { name, success } => {
|
agent::AgentEvent::ToolDone { name, success } => {
|
||||||
@@ -150,14 +166,19 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
app.sol_status = message;
|
app.sol_status = message;
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
agent::AgentEvent::Response { text } => {
|
agent::AgentEvent::Response { text, input_tokens, output_tokens } => {
|
||||||
app.is_thinking = false;
|
app.is_thinking = false;
|
||||||
app.sol_status.clear();
|
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));
|
app.push_log(tui::LogEntry::AssistantText(text));
|
||||||
}
|
}
|
||||||
agent::AgentEvent::Error { message } => {
|
agent::AgentEvent::Error { message } => {
|
||||||
app.is_thinking = false;
|
app.is_thinking = false;
|
||||||
app.sol_status.clear();
|
app.sol_status.clear();
|
||||||
|
app.thinking_since = None;
|
||||||
app.push_log(tui::LogEntry::Error(message));
|
app.push_log(tui::LogEntry::Error(message));
|
||||||
}
|
}
|
||||||
agent::AgentEvent::Health { connected } => {
|
agent::AgentEvent::Health { connected } => {
|
||||||
@@ -172,14 +193,15 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw only when something changed
|
// 2. Draw only when something changed (or animating)
|
||||||
if app.needs_redraw {
|
if app.needs_redraw || app.is_thinking {
|
||||||
terminal.draw(|frame| tui::draw(frame, &mut app))?;
|
terminal.draw(|frame| tui::draw(frame, &mut app))?;
|
||||||
app.needs_redraw = false;
|
app.needs_redraw = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle input — drain ALL pending events before next draw
|
// 3. Handle input — shorter poll when animating
|
||||||
if event::poll(std::time::Duration::from_millis(50))? {
|
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)
|
// Drain all queued events in one batch (coalesces rapid scroll)
|
||||||
while event::poll(std::time::Duration::ZERO)? {
|
while event::poll(std::time::Duration::ZERO)? {
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
@@ -220,20 +242,48 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
app.show_logs = !app.show_logs;
|
app.show_logs = !app.show_logs;
|
||||||
app.log_scroll = u16::MAX;
|
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.history_index = None;
|
||||||
app.input.insert(app.cursor_pos, c);
|
app.input.insert(app.cursor_pos, c);
|
||||||
app.cursor_pos += 1;
|
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 {
|
if app.cursor_pos > 0 {
|
||||||
app.history_index = None;
|
app.history_index = None;
|
||||||
app.cursor_pos -= 1;
|
app.cursor_pos -= 1;
|
||||||
app.input.remove(app.cursor_pos);
|
app.input.remove(app.cursor_pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Left if !app.show_logs => app.cursor_pos = app.cursor_pos.saturating_sub(1),
|
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.cursor_pos = (app.cursor_pos + 1).min(app.input.len()),
|
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 => {
|
KeyCode::Up if !app.show_logs => {
|
||||||
if !app.command_history.is_empty() {
|
if !app.command_history.is_empty() {
|
||||||
let idx = match app.history_index {
|
let idx = match app.history_index {
|
||||||
|
|||||||
@@ -12,6 +12,95 @@ use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
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 ──────────────────────────────────────────────────────
|
// ── Sol color palette ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SOL_YELLOW: Color = Color::Rgb(245, 197, 66);
|
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_DIM: Color = Color::Rgb(138, 122, 90);
|
||||||
const SOL_GRAY: Color = Color::Rgb(112, 112, 112);
|
const SOL_GRAY: Color = Color::Rgb(112, 112, 112);
|
||||||
const SOL_FAINT: Color = Color::Rgb(80, 80, 80);
|
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_BG: Color = Color::Rgb(50, 42, 20);
|
||||||
const SOL_APPROVAL_CMD: Color = Color::Rgb(200, 180, 120);
|
const SOL_APPROVAL_CMD: Color = Color::Rgb(200, 180, 120);
|
||||||
|
|
||||||
@@ -122,13 +210,32 @@ impl Viewport {
|
|||||||
self.visual_lines.push(Line::from(""));
|
self.visual_lines.push(Line::from(""));
|
||||||
}
|
}
|
||||||
LogEntry::AssistantText(text) => {
|
LogEntry::AssistantText(text) => {
|
||||||
let style = Style::default().fg(SOL_YELLOW);
|
// Render markdown to styled ratatui Lines
|
||||||
for logical_line in text.lines() {
|
let md_text: Text<'_> = tui_markdown::from_str(text);
|
||||||
if logical_line.is_empty() {
|
let base_style = Style::default().fg(SOL_YELLOW);
|
||||||
self.visual_lines.push(Line::from(""));
|
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 {
|
} else {
|
||||||
for wrapped in textwrap::wrap(logical_line, w) {
|
// For wrapped markdown lines, fall back to textwrap on the raw text
|
||||||
self.visual_lines.push(Line::styled(wrapped.into_owned(), style));
|
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 ─────────────────────────────────────────────────────────
|
// ── Approval state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct ApprovalPrompt {
|
pub struct ApprovalPrompt {
|
||||||
|
pub call_id: String,
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub command: String,
|
pub command: String,
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
@@ -270,10 +378,13 @@ pub struct App {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
|
pub last_turn_tokens: u32,
|
||||||
pub approval: Option<ApprovalPrompt>,
|
pub approval: Option<ApprovalPrompt>,
|
||||||
pub is_thinking: bool,
|
pub is_thinking: bool,
|
||||||
pub sol_status: String,
|
pub sol_status: String,
|
||||||
pub sol_connected: bool,
|
pub sol_connected: bool,
|
||||||
|
pub thinking_since: Option<std::time::Instant>,
|
||||||
|
pub thinking_message: String,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub show_logs: bool,
|
pub show_logs: bool,
|
||||||
pub log_buffer: LogBuffer,
|
pub log_buffer: LogBuffer,
|
||||||
@@ -282,6 +393,7 @@ pub struct App {
|
|||||||
pub history_index: Option<usize>,
|
pub history_index: Option<usize>,
|
||||||
pub input_saved: String,
|
pub input_saved: String,
|
||||||
pub needs_redraw: bool,
|
pub needs_redraw: bool,
|
||||||
|
pub frame_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -297,10 +409,13 @@ impl App {
|
|||||||
model: model.into(),
|
model: model.into(),
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
|
last_turn_tokens: 0,
|
||||||
approval: None,
|
approval: None,
|
||||||
is_thinking: false,
|
is_thinking: false,
|
||||||
sol_status: String::new(),
|
sol_status: String::new(),
|
||||||
sol_connected: true,
|
sol_connected: true,
|
||||||
|
thinking_since: None,
|
||||||
|
thinking_message: String::new(),
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
show_logs: false,
|
show_logs: false,
|
||||||
log_buffer,
|
log_buffer,
|
||||||
@@ -309,6 +424,7 @@ impl App {
|
|||||||
history_index: None,
|
history_index: None,
|
||||||
input_saved: String::new(),
|
input_saved: String::new(),
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
|
frame_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,14 +480,14 @@ impl App {
|
|||||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn draw(frame: &mut ratatui::Frame, app: &mut App) {
|
pub fn draw(frame: &mut ratatui::Frame, app: &mut App) {
|
||||||
|
app.frame_count = app.frame_count.wrapping_add(1);
|
||||||
let area = frame.area();
|
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([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Length(1), // title bar
|
Constraint::Length(1), // title bar (all system info)
|
||||||
Constraint::Min(5), // conversation log
|
Constraint::Min(5), // conversation log
|
||||||
Constraint::Length(3), // input area
|
Constraint::Length(3), // input area
|
||||||
Constraint::Length(1), // status bar
|
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
@@ -388,13 +504,12 @@ pub fn draw(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
} else {
|
} else {
|
||||||
draw_input(frame, chunks[2], app);
|
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) {
|
fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
let health = if app.sol_connected { "☀️" } else { "⛈️" };
|
let health = if app.sol_connected { "☀️" } else { "⛈️" };
|
||||||
|
|
||||||
|
// Left: branding + project + branch
|
||||||
let left = vec![
|
let left = vec![
|
||||||
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
|
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled(" · ", Style::default().fg(SOL_FAINT)),
|
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)),
|
Span::styled(&app.branch, Style::default().fg(SOL_DIM)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Right side: health + status + model
|
// Right: timer · status_wave · tokens · model · health
|
||||||
let mut right_parts = vec![Span::raw(health.to_string())];
|
let mut right_parts: Vec<Span> = Vec::new();
|
||||||
|
|
||||||
if app.is_thinking {
|
if app.is_thinking {
|
||||||
let status = if app.sol_status.is_empty() {
|
// Elapsed timer first
|
||||||
"generating…"
|
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 {
|
} else {
|
||||||
&app.sol_status
|
&app.thinking_message
|
||||||
};
|
};
|
||||||
right_parts.push(Span::styled(" ", Style::default().fg(SOL_FAINT)));
|
let status_text = format!("{status}…");
|
||||||
right_parts.push(Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC)));
|
|
||||||
|
// 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(" · ", Style::default().fg(SOL_FAINT)));
|
||||||
right_parts.push(Span::styled(&app.model, Style::default().fg(SOL_DIM)));
|
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);
|
let title_line = Line::from(left);
|
||||||
frame.render_widget(Paragraph::new(title_line), area);
|
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);
|
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) {
|
fn draw_log(frame: &mut ratatui::Frame, area: Rect, app: &mut App) {
|
||||||
// Ensure pre-wrapped lines are built for current width
|
// Ensure pre-wrapped lines are built for current width
|
||||||
app.viewport.ensure(&app.log, area.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);
|
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 ────────────────────────────────────────────────
|
// ── Terminal setup/teardown ────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||||
@@ -640,6 +777,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_approval_prompt() {
|
fn test_approval_prompt() {
|
||||||
let approval = ApprovalPrompt {
|
let approval = ApprovalPrompt {
|
||||||
|
call_id: "test-1".into(),
|
||||||
tool_name: "bash".into(),
|
tool_name: "bash".into(),
|
||||||
command: "cargo test".into(),
|
command: "cargo test".into(),
|
||||||
options: vec![
|
options: vec![
|
||||||
@@ -656,6 +794,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_approval_navigation() {
|
fn test_approval_navigation() {
|
||||||
let mut approval = ApprovalPrompt {
|
let mut approval = ApprovalPrompt {
|
||||||
|
call_id: "test-2".into(),
|
||||||
tool_name: "bash".into(),
|
tool_name: "bash".into(),
|
||||||
command: "rm -rf".into(),
|
command: "rm -rf".into(),
|
||||||
options: vec!["Yes".into(), "No".into()],
|
options: vec!["Yes".into(), "No".into()],
|
||||||
|
|||||||
Reference in New Issue
Block a user