use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Alignment},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, BorderType, Clear, Paragraph, Padding, Wrap},
Terminal,
};
use std::io;
use tachyonfx::{fx, Effect, Interpolation, Shader};
fn sidecar_pill_segment(
sidecar: &super::sidecar::SidecarUiState,
spinner_frame: usize,
) -> Span<'static> {
let label = sidecar.display_name.as_deref().unwrap_or("sidecar");
let text = sidecar_pill_text(label, &sidecar.status, sidecar.armed, spinner_frame);
let color = match &sidecar.status {
super::sidecar::SidecarUiStatus::Idle => {
if sidecar.armed {
let pulse = ((spinner_frame as f64 / 18.0).sin() * 0.3 + 0.7).max(0.4);
let base = match THEME.load().status_streaming {
Color::Rgb(r, g, b) => (r, g, b),
_ => (220, 80, 80),
};
Color::Rgb(
(base.0 as f64 * pulse) as u8,
(base.1 as f64 * pulse) as u8,
(base.2 as f64 * pulse) as u8,
)
} else {
THEME.load().muted
}
}
super::sidecar::SidecarUiStatus::Active { .. } => {
let pulse = ((spinner_frame as f64 / 18.0).sin() * 0.3 + 0.7).max(0.4);
let base = match THEME.load().status_streaming {
Color::Rgb(r, g, b) => (r, g, b),
_ => (220, 80, 80),
};
Color::Rgb(
(base.0 as f64 * pulse) as u8,
(base.1 as f64 * pulse) as u8,
(base.2 as f64 * pulse) as u8,
)
}
super::sidecar::SidecarUiStatus::Error(_) => Color::Red,
};
let modifier = Modifier::BOLD;
Span::styled(text, Style::default().fg(color).add_modifier(modifier))
}
pub(crate) fn order_sidecar_pills(
sidecars: &[(String, Option<String>)],
claims: &[synaps_cli::skills::registry::LifecycleClaim],
) -> Vec<String> {
let importance_for = |pid: &str| -> i32 {
claims.iter().find(|c| c.plugin == pid).map(|c| c.importance).unwrap_or(0)
};
let mut keys: Vec<&(String, Option<String>)> = sidecars.iter().collect();
keys.sort_by(|a, b| {
let imp_a = importance_for(&a.0);
let imp_b = importance_for(&b.0);
imp_b.cmp(&imp_a)
.then_with(|| {
let an = a.1.as_deref().unwrap_or(a.0.as_str());
let bn = b.1.as_deref().unwrap_or(b.0.as_str());
an.cmp(bn)
})
.then_with(|| a.0.cmp(&b.0))
});
keys.into_iter().map(|(p, _)| p.clone()).collect()
}
pub(crate) fn sidecar_pill_spans(
app: &super::app::App,
registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
) -> Vec<Span<'static>> {
if app.sidecars.is_empty() {
return Vec::new();
}
let claims = registry.lifecycle_claims();
let inputs: Vec<(String, Option<String>)> = app.sidecars.iter()
.map(|(pid, st)| (pid.clone(), st.display_name.clone()))
.collect();
let order = order_sidecar_pills(&inputs, &claims);
let mut spans = Vec::with_capacity(order.len() * 2);
for pid in &order {
let Some(state) = app.sidecars.get(pid) else { continue; };
spans.push(Span::styled("\u{2502}", Style::default().fg(THEME.load().border)));
spans.push(sidecar_pill_segment(state, app.spinner_frame));
}
spans
}
pub(crate) fn sidecar_pill_text(
label: &str,
status: &super::sidecar::SidecarUiStatus,
armed: bool,
spinner_frame: usize,
) -> String {
match status {
super::sidecar::SidecarUiStatus::Idle => {
if armed {
format!(" \u{25cf} {label} active ")
} else {
format!(" \u{25cb} {label} ")
}
}
super::sidecar::SidecarUiStatus::Active { label: state_label } => {
let spinner_idx = (spinner_frame / 3) % SPINNER_FRAMES.len();
let frame = SPINNER_FRAMES[spinner_idx];
format!(" {frame} {label}: {state_label} ")
}
super::sidecar::SidecarUiStatus::Error(_) => format!(" \u{26a0} {label} error "),
}
}
#[cfg(test)]
mod sidecar_pill_tests {
use super::*;
use synaps_cli::Session;
fn fresh_app() -> super::super::app::App {
super::super::app::App::new(Session::new("test", "medium", None))
}
fn empty_registry() -> std::sync::Arc<synaps_cli::skills::registry::CommandRegistry> {
std::sync::Arc::new(synaps_cli::skills::registry::CommandRegistry::new_with_plugins(
&[], vec![], vec![],
))
}
#[test]
fn pill_returns_empty_when_no_sidecars() {
let app = fresh_app();
assert!(sidecar_pill_spans(&app, &empty_registry()).is_empty());
}
#[test]
fn pill_uses_display_name_when_set() {
let text = sidecar_pill_text(
"Voice",
&super::super::sidecar::SidecarUiStatus::Idle,
false,
0,
);
assert!(text.contains("Voice"), "expected pill to contain 'Voice', got: {text:?}");
assert!(!text.contains("sidecar"), "expected no 'sidecar' fallback, got: {text:?}");
}
#[test]
fn pill_falls_back_to_sidecar_when_no_display_name() {
let text = sidecar_pill_text(
"sidecar",
&super::super::sidecar::SidecarUiStatus::Idle,
false,
0,
);
assert!(text.contains("sidecar"), "got: {text:?}");
}
#[test]
fn pill_error_state_uses_display_name() {
let text = sidecar_pill_text(
"Voice",
&super::super::sidecar::SidecarUiStatus::Error("oops".into()),
false,
0,
);
assert!(text.contains("Voice error"), "got: {text:?}");
}
#[test]
fn pill_active_state_shows_plugin_supplied_label() {
let text = sidecar_pill_text(
"Plugin",
&super::super::sidecar::SidecarUiStatus::Active { label: "Working".into() },
true,
0,
);
assert!(text.contains("Plugin"), "got: {text:?}");
assert!(text.contains("Working"), "got: {text:?}");
}
fn claim(plugin: &str, command: &str, importance: i32) -> synaps_cli::skills::registry::LifecycleClaim {
synaps_cli::skills::registry::LifecycleClaim {
plugin: plugin.into(),
command: command.into(),
settings_category: None,
display_name: command.into(),
importance,
}
}
#[test]
fn multi_segment_pill_orders_by_importance_desc() {
let claims = vec![claim("alpha", "alpha", 10), claim("beta", "beta", 90)];
let inputs = vec![
("alpha".to_string(), Some("Alpha".to_string())),
("beta".to_string(), Some("Beta".to_string())),
];
let order = super::order_sidecar_pills(&inputs, &claims);
assert_eq!(order, vec!["beta".to_string(), "alpha".to_string()]);
}
#[test]
fn multi_segment_pill_tiebreaks_alphabetical_by_display_name() {
let claims = vec![claim("p2", "p2", 50), claim("p1", "p1", 50)];
let inputs = vec![
("p2".to_string(), Some("Alpha".to_string())),
("p1".to_string(), Some("Beta".to_string())),
];
let order = super::order_sidecar_pills(&inputs, &claims);
assert_eq!(order, vec!["p2".to_string(), "p1".to_string()]);
}
#[test]
fn active_tasks_progress_line_renders_fraction() {
let mut tasks = synaps_cli::extensions::active_tasks::ActiveTasks::new();
tasks.apply(synaps_cli::extensions::tasks::TaskEvent::Start {
id: "dl".into(),
label: "Download base".into(),
kind: synaps_cli::extensions::tasks::TaskKind::Download,
});
tasks.apply(synaps_cli::extensions::tasks::TaskEvent::Update {
id: "dl".into(),
current: Some(50),
total: Some(100),
message: Some("half".into()),
});
let _ = render_active_tasks_line(&tasks, 80);
}
}
use super::theme::THEME;
use super::markdown::format_tokens;
use super::app::{App, SPINNER_FRAMES};
fn render_toasts(frame: &mut ratatui::Frame<'_>, provider: &super::toast::ToastProvider) {
let area = frame.area();
for toast in provider.visible() {
let lines = super::toast::toast_lines(toast);
let content_width = lines.iter()
.flat_map(|line| line.spans.iter())
.map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref()))
.max()
.unwrap_or(1) as u16;
let width = content_width.saturating_add(4).clamp(18, area.width.min(64));
let height = (lines.len() as u16).saturating_add(2).clamp(3, area.height.max(1));
let rect = super::toast::toast_rect(area, width, height, toast.position);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(THEME.load().border_active))
.style(Style::default().bg(THEME.load().bg));
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: true })
.style(Style::default().fg(THEME.load().help_fg)),
rect,
);
}
}
pub(crate) fn bash_trace(spinner_frame: usize) -> (String, Color) {
const CHARS: [char; 8] = [' ', '░', '▒', '▓', '█', '▓', '▒', '░'];
const WIDTH: usize = 14;
let offset = (spinner_frame / 2) % (WIDTH + CHARS.len());
let trace: String = (0..WIDTH).map(|i| {
let dist = if offset >= i { offset - i } else { WIDTH + CHARS.len() };
if dist < CHARS.len() { CHARS[dist] } else { ' ' }
}).collect();
let pulse = (spinner_frame as f64 / 15.0).sin() * 0.3 + 0.7;
let Color::Rgb(br, bg, bb) = THEME.load().border_active else { return (trace, Color::Reset) };
let color = Color::Rgb(
(br as f64 * pulse) as u8,
(bg as f64 * pulse) as u8,
(bb as f64 * pulse) as u8,
);
(trace, color)
}
pub(crate) fn format_tool_name(tool_name: &str) -> (&'static str, String, Option<String>) {
if tool_name.starts_with("ext__") {
let parts: Vec<&str> = tool_name.splitn(3, "__").collect();
let server = parts.get(1).unwrap_or(&"mcp").to_string();
let tool = parts.get(2).unwrap_or(&tool_name).to_string();
("\u{00bb}", tool, Some(server)) } else {
let icon = match tool_name {
"bash" => "$",
"read" => ">",
"write" => "<",
"edit" => "~",
"grep" => "/",
"find" => "?",
"ls" => "=",
"subagent" => "*",
_ => "-",
};
(icon, tool_name.to_string(), None)
}
}
pub(crate) fn boot_effect() -> Effect {
use tachyonfx::fx::Direction as FxDir;
let Color::Rgb(r, g, b) = THEME.load().bg else { return fx::sleep(0) };
fx::parallel(&[
fx::sweep_in(FxDir::UpToDown, 10, 0, Color::Rgb(r.saturating_add(10), g.saturating_add(15), b.saturating_add(20)), (750, Interpolation::QuintOut)),
fx::fade_from_fg(Color::Rgb(r.saturating_add(2), g.saturating_add(3), b.saturating_add(5)), (750, Interpolation::QuintOut)),
])
}
pub(crate) fn quit_effect() -> Effect {
use tachyonfx::fx::Direction as FxDir;
let Color::Rgb(r, g, b) = THEME.load().muted else { return fx::sleep(0) };
fx::sequence(&[
fx::hsl_shift_fg([180.0, -40.0, 0.0], (180, Interpolation::QuadOut)),
fx::parallel(&[
fx::sweep_out(FxDir::DownToUp, 18, 12, Color::Rgb(r, g, b), (650, Interpolation::QuadIn)),
fx::dissolve((650, Interpolation::QuadIn)),
fx::fade_to_fg(Color::Black, (650, Interpolation::QuadIn)),
]),
])
}
pub(crate) fn render_active_tasks_line<'a>(
tasks: &'a synaps_cli::extensions::active_tasks::ActiveTasks,
width: u16,
) -> Paragraph<'a> {
let theme = THEME.load();
let Some(task) = tasks.iter().next() else {
return Paragraph::new(Line::from(""));
};
let pct = task.fraction().map(|f| (f * 100.0).round() as u32);
let bar_width = ((width as usize).saturating_sub(42)).clamp(8, 28);
let fill = task.fraction().map(|f| (f * bar_width as f32).round() as usize).unwrap_or(0).min(bar_width);
let bar = format!("{}{}", "█".repeat(fill), "░".repeat(bar_width - fill));
let status = if let Some(err) = &task.error {
format!("✘ {}: {}", task.label, err)
} else if task.done {
format!("✓ {}", task.label)
} else {
let pct_text = pct.map(|p| format!("{p}%")).unwrap_or_else(|| "?%".to_string());
match &task.message {
Some(msg) if !msg.is_empty() => format!("⟳ {} [{}] {} {}", task.label, bar, pct_text, msg),
_ => format!("⟳ {} [{}] {}", task.label, bar, pct_text),
}
};
Paragraph::new(Line::from(Span::styled(status, Style::default().fg(theme.help_fg))))
}
pub(crate) fn draw(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
runtime: &synaps_cli::Runtime,
effect: &mut Option<Effect>,
exit_effect: &mut Option<Effect>,
elapsed: std::time::Duration,
registry: &std::sync::Arc<synaps_cli::skills::registry::CommandRegistry>,
secret_prompts: &synaps_cli::tools::SecretPromptQueue,
) -> io::Result<()> {
let model = runtime.model();
let thinking = runtime.thinking_level();
if app.gamba_child.is_some() {
return Ok(());
}
let term_size = terminal.size()?;
let has_subagents = !app.subagents.is_empty();
let subagent_height = if has_subagents {
(app.subagents.len() as u16 + 2).min(8) } else {
0
};
let input_inner_width = term_size.width.saturating_sub(2); let (input_lines, _, _) = app.input_wrap_info(input_inner_width);
let max_input_lines: u16 = 10;
let input_height = (input_lines.min(max_input_lines)) + 2; let download_height: u16 = if !app.active_tasks.is_empty() { 1 } else { 0 };
let protected_bottom_rows = subagent_height
.saturating_add(download_height)
.saturating_add(input_height)
.saturating_add(1); super::viewport::scrub_crossterm_terminal_edges(
terminal,
protected_bottom_rows,
Style::default().bg(THEME.load().bg),
)?;
terminal.draw(|frame| {
let has_subagents = !app.subagents.is_empty();
let subagent_height = if has_subagents {
(app.subagents.len() as u16 + 2).min(8) } else {
0
};
let frame_width = frame.area().width;
let input_inner_width = frame_width.saturating_sub(2); let (input_lines, _, _) = app.input_wrap_info(input_inner_width);
let max_input_lines: u16 = 10;
let input_height = (input_lines.min(max_input_lines)) + 2;
let download_height: u16 = if !app.active_tasks.is_empty() { 1 } else { 0 };
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(subagent_height), Constraint::Length(download_height), Constraint::Length(input_height), Constraint::Length(1), ])
.split(frame.area());
let status_span = if let Some(ref status) = app.status_text {
let spinner_idx = (app.spinner_frame / 3) % SPINNER_FRAMES.len();
Span::styled(
format!(" {} {} ", SPINNER_FRAMES[spinner_idx], status),
Style::default().fg(THEME.load().status_streaming),
)
} else if !app.subagents.is_empty() {
let active = app.subagents.iter().filter(|s| !s.done).count();
let done = app.subagents.iter().filter(|s| s.done).count();
let spinner_idx = (app.spinner_frame / 3) % SPINNER_FRAMES.len();
let spinner = if active > 0 { SPINNER_FRAMES[spinner_idx] } else { "\u{2714}" };
Span::styled(
format!(" {} {} agent{} ({} done) ", spinner, active, if active != 1 { "s" } else { "" }, done),
Style::default().fg(THEME.load().subagent_name),
)
} else if app.streaming {
let pulse = ((app.spinner_frame as f64 / 20.0).sin() * 0.3 + 0.7).max(0.4);
let (sr, sg, sb) = match THEME.load().status_streaming { Color::Rgb(r, g, b) => (r, g, b), _ => (128, 128, 128) };
let r = (sr as f64 * pulse) as u8;
let g = (sg as f64 * pulse) as u8;
let b = (sb as f64 * pulse) as u8;
Span::styled(" \u{25cf} streaming ", Style::default().fg(Color::Rgb(r, g, b)))
} else {
Span::styled(" \u{25cb} ready ", Style::default().fg(THEME.load().status_ready))
};
let header = Paragraph::new(Line::from({
let mut spans = vec![
Span::styled(" Synaps", Style::default().fg(THEME.load().header_fg).add_modifier(Modifier::BOLD)),
Span::styled("CLI ", Style::default().fg(THEME.load().muted)),
Span::styled("\u{2502}", Style::default().fg(THEME.load().border)),
status_span,
];
for span in sidecar_pill_spans(app, registry) {
spans.push(span);
}
spans
}))
.style(Style::default().bg(THEME.load().bg));
frame.render_widget(header, outer[0]);
let msg_area = outer[1];
let content_height = msg_area.height.saturating_sub(2) as usize;
let content_width = msg_area.width.saturating_sub(2) as usize;
let needs_rebuild = app
.line_cache
.as_ref()
.map_or(true, |(w, _)| *w != content_width);
if needs_rebuild {
let lines = app.render_lines(content_width);
app.line_cache = Some((content_width, lines));
}
let all_lines: &[Line<'static>] = &app.line_cache.as_ref().unwrap().1;
let total = all_lines.len();
if app.scroll_pinned {
app.scroll_back = 0;
} else {
let prev = app.last_line_count;
if total > prev && prev > 0 {
let growth = (total - prev) as u16;
app.scroll_back = app.scroll_back.saturating_add(growth);
}
let max_back = total.saturating_sub(content_height).min(u16::MAX as usize) as u16;
if app.scroll_back > max_back {
app.scroll_back = max_back;
}
}
app.last_line_count = total;
let end = total.saturating_sub(app.scroll_back as usize);
let start = end.saturating_sub(content_height);
let visible: Vec<Line> = all_lines[start..end].to_vec();
let msg_block = Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(THEME.load().border))
.padding(Padding::horizontal(1));
let msg_inner = msg_block.inner(msg_area);
let messages_widget = Paragraph::new(visible.clone()).block(msg_block.clone());
frame.render_widget(Clear, msg_area);
if secret_prompts.is_active() {
let blank_messages = Paragraph::new(Vec::<Line>::new()).block(msg_block);
frame.render_widget(blank_messages, msg_area);
} else {
frame.render_widget(messages_widget, msg_area);
}
app.msg_area_rect = Some(msg_inner);
app.visible_line_range = Some((start, end));
if let Some((sc, sr, ec, er)) = app.selection_range() {
let content_x = msg_inner.x;
let content_y = msg_inner.y;
let content_h = msg_inner.height;
let content_w = msg_inner.width;
for y in sr..=er {
if y < content_y || y >= content_y + content_h {
continue;
}
let row_start = if y == sr { sc.max(content_x) } else { content_x };
let row_end = if y == er { ec.min(content_x + content_w) } else { content_x + content_w };
for x in row_start..row_end {
if x >= content_x && x < content_x + content_w {
if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
let fg = cell.fg;
let bg = cell.bg;
cell.set_fg(bg);
cell.set_bg(match fg {
Color::Reset => THEME.load().border_active,
other => other,
});
}
}
}
}
}
let show_logo = app.messages.is_empty() || app.logo_dismiss_t.is_some();
if show_logo && visible.is_empty() {
let ascii_art: Vec<&str> = vec![
r" ███████ ██ ██ ███ ██ █████ ██████ ███████",
r" ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ",
r" ███████ ████ ██ ██ ██ ███████ ██████ ███████",
r" ██ ██ ██ ██ ██ ██ ██ ██ ██",
r" ███████ ██ ██ ████ ██ ██ ██ ███████",
];
use unicode_width::UnicodeWidthStr;
let art_display_widths: Vec<usize> = ascii_art.iter()
.map(|l| UnicodeWidthStr::width(*l))
.collect();
let max_art_width = art_display_widths.iter().copied().max().unwrap_or(0);
let avail_w = msg_area.width as usize;
let avail_h = msg_area.height as usize;
let art_height = ascii_art.len();
let sub_text = "neural interface ready";
let sub_width = sub_text.chars().count();
let total_block = art_height + 3;
if avail_h >= total_block && avail_w >= max_art_width + 2 {
let center_y = msg_area.y + msg_area.height / 2;
let dismiss_t = app.logo_dismiss_t.unwrap_or(0.0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if dismiss_t < 0.001 {
let phase1 = ((t % 4000) as f64 / 4000.0 * std::f64::consts::PI * 2.0).sin();
let phase2 = ((t % 6500) as f64 / 6500.0 * std::f64::consts::PI * 2.0).sin();
let breathe = phase1 * 0.7 + phase2 * 0.3;
let (ar, ag, ab) = match THEME.load().border_active { Color::Rgb(r, g, b) => (r, g, b), _ => (128, 128, 128) };
let breathe_scale = 0.7 + 0.3 * breathe; let r = (ar as f64 * breathe_scale) as u8;
let g = (ag as f64 * breathe_scale) as u8;
let b = (ab as f64 * breathe_scale) as u8;
let art_style = Style::default().fg(Color::Rgb(r, g, b)).add_modifier(Modifier::BOLD);
let (mr, mg, mb) = match THEME.load().muted { Color::Rgb(r, g, b) => (r, g, b), _ => (64, 64, 64) };
let sub_style = Style::default().fg(Color::Rgb(
(mr as f64 * breathe_scale) as u8,
(mg as f64 * breathe_scale) as u8,
(mb as f64 * breathe_scale) as u8,
));
let build_t = app.logo_build_t.unwrap_or(1.0);
let start_y = center_y.saturating_sub((total_block as u16) / 2);
let art_x = msg_area.x + (avail_w as u16).saturating_sub(max_art_width as u16) / 2;
for (j, line) in ascii_art.iter().enumerate() {
let x = art_x;
let y = start_y + j as u16;
if y >= msg_area.y && y < msg_area.y + msg_area.height {
let clamped_w = max_art_width.min(avail_w);
if build_t >= 1.0 {
let mut spans: Vec<Span> = Vec::with_capacity(clamped_w);
for ch in line.chars().take(clamped_w) {
spans.push(Span::styled(ch.to_string(), art_style));
}
let area = ratatui::layout::Rect { x, y, width: clamped_w as u16, height: 1 };
frame.render_widget(Paragraph::new(Line::from(spans)), area);
} else {
let mut built = String::with_capacity(clamped_w);
let build_chars: &[char] = &['░', '▒', '▓'];
for (ci, ch) in line.chars().take(clamped_w).enumerate() {
let inv_row = (art_height - 1 - j) as f64;
let inv_col = (max_art_width.saturating_sub(ci + 1)) as f64;
let diag = (inv_row + inv_col) / (art_height as f64 + max_art_width as f64);
if build_t >= diag {
let lp = ((build_t - diag) / 0.15).min(1.0);
if lp < 1.0 && ch != ' ' {
built.push(build_chars[(lp * build_chars.len() as f64) as usize]);
} else { built.push(ch); }
} else { built.push(' '); }
}
let area = ratatui::layout::Rect { x, y, width: clamped_w as u16, height: 1 };
frame.render_widget(Paragraph::new(Span::styled(built, art_style)), area);
}
}
}
if build_t >= 1.0 {
let sub_y = start_y + art_height as u16 + 1;
if sub_y >= msg_area.y && sub_y < msg_area.y + msg_area.height && avail_w >= sub_width {
let sub_x = msg_area.x + (avail_w as u16).saturating_sub(sub_width as u16) / 2;
let area = ratatui::layout::Rect { x: sub_x, y: sub_y, width: sub_width as u16, height: 1 };
frame.render_widget(Paragraph::new(Span::styled(sub_text, sub_style)), area);
}
}
} else {
let art_style = Style::default().fg(THEME.load().muted).add_modifier(Modifier::BOLD);
let start_y = center_y.saturating_sub((total_block as u16) / 2);
for (j, line) in ascii_art.iter().enumerate() {
let char_w = art_display_widths[j];
let x = msg_area.x + (avail_w as u16).saturating_sub(char_w as u16) / 2;
let y = start_y + j as u16;
if y >= msg_area.y && y < msg_area.y + msg_area.height {
let clamped_w = char_w.min(avail_w);
let mut dis = String::with_capacity(clamped_w);
let dis_chars: &[char] = &['▓', '▒', '░'];
for (ci, ch) in line.chars().take(clamped_w).enumerate() {
let row = j as f64;
let col = ci as f64;
let diag = (row + col) / (art_height as f64 + max_art_width as f64);
let threshold = diag;
if dismiss_t < (1.0 - threshold) {
let rem = (1.0 - threshold) - dismiss_t;
if rem < 0.15 && ch != ' ' {
let idx = ((1.0 - rem/0.15) * dis_chars.len() as f64) as usize;
dis.push(dis_chars[idx.min(dis_chars.len()-1)]);
} else { dis.push(ch); }
} else { dis.push(' '); }
}
let area = ratatui::layout::Rect { x, y, width: clamped_w as u16, height: 1 };
frame.render_widget(Paragraph::new(Span::styled(dis, art_style)), area);
}
}
}
}
}
if app.scroll_back > 0 {
let indicator = format!(" \u{2191}{} ", app.scroll_back);
let indicator_widget = Paragraph::new(Span::styled(
indicator,
Style::default().fg(THEME.load().muted),
))
.alignment(Alignment::Right);
let indicator_area = ratatui::layout::Rect {
x: msg_area.x,
y: msg_area.y,
width: msg_area.width,
height: 1,
};
frame.render_widget(indicator_widget, indicator_area);
}
if has_subagents {
let spinner_idx = (app.spinner_frame / 3) % SPINNER_FRAMES.len();
let mut agent_lines: Vec<Line> = Vec::new();
for sa in &app.subagents {
let elapsed = sa.duration_secs.unwrap_or_else(|| sa.start_time.elapsed().as_secs_f64());
let time_str = if elapsed < 60.0 {
format!("{:.1}s", elapsed)
} else {
format!("{}m{:.0}s", (elapsed / 60.0) as u32, elapsed % 60.0)
};
if sa.done {
let is_timeout = sa.status.contains("timed out");
let is_error = sa.status.starts_with("\u{2718}");
let done_color = if is_timeout {
THEME.load().warning_color
} else if is_error {
THEME.load().error_color
} else {
THEME.load().subagent_done
};
let icon = if is_timeout { " \u{26a0} " } else if is_error { " \u{2718} " } else { " \u{2714} " };
agent_lines.push(Line::from(vec![
Span::styled(icon, Style::default().fg(done_color)),
Span::styled(
format!("{} ", sa.name),
Style::default().fg(THEME.load().subagent_name).add_modifier(Modifier::BOLD),
),
Span::styled(
&sa.status,
Style::default().fg(done_color).add_modifier(Modifier::DIM),
),
Span::styled(
format!(" {}", time_str),
Style::default().fg(THEME.load().subagent_time),
),
]));
} else {
let spinner = SPINNER_FRAMES[spinner_idx];
agent_lines.push(Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default().fg(THEME.load().subagent_name),
),
Span::styled(
format!("{} ", sa.name),
Style::default().fg(THEME.load().subagent_name).add_modifier(Modifier::BOLD),
),
Span::styled(
&sa.status,
Style::default().fg(THEME.load().subagent_status),
),
Span::styled(
format!(" {}", time_str),
Style::default().fg(THEME.load().subagent_time),
),
]));
}
}
let active = app.subagents.iter().filter(|s| !s.done).count();
let done = app.subagents.iter().filter(|s| s.done).count();
let title = if done > 0 && active > 0 {
format!(" \u{25c8} {} running, {} done ", active, done)
} else if active > 0 {
format!(" \u{25c8} {} agent{} ", active, if active != 1 { "s" } else { "" })
} else {
format!(" \u{2714} {} done ", done)
};
let agent_block = Block::default()
.title(Span::styled(
title,
Style::default().fg(THEME.load().subagent_name).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(THEME.load().subagent_border))
.style(Style::default().bg(THEME.load().bg));
let agent_widget = Paragraph::new(agent_lines).block(agent_block);
frame.render_widget(agent_widget, outer[2]);
}
let input_border_color = if app.streaming { THEME.load().border } else { THEME.load().border_active };
let input_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(input_border_color))
.style(Style::default().bg(THEME.load().bg));
let input_lines_vec: Vec<Line> = {
use unicode_width::UnicodeWidthChar;
let w = input_inner_width.max(1) as usize;
let prefix_width: usize = 2;
let prompt_style = Style::default().fg(THEME.load().prompt_fg);
let input_style = Style::default().fg(THEME.load().input_fg);
let mut rows: Vec<Vec<Span>> = Vec::new();
let mut current_row: Vec<Span> = vec![Span::styled("\u{276f} ", prompt_style)];
let mut col: usize = prefix_width;
for ch in app.input.chars() {
if ch == '\n' {
rows.push(std::mem::take(&mut current_row));
current_row = vec![Span::styled(" ", prompt_style)];
col = prefix_width;
continue;
}
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + cw > w {
rows.push(std::mem::take(&mut current_row));
current_row = Vec::new();
col = 0;
}
let mut s = String::new();
s.push(ch);
current_row.push(Span::styled(s, input_style));
col += cw;
}
rows.push(current_row);
if app.input.starts_with('/') && app.input.len() > 1 && !app.input[1..].contains(' ') {
let partial = &app.input[1..];
let commands = super::commands::all_commands_with_skills(registry);
let hint: Option<&String> = {
let prefix_matches: Vec<&String> = commands.iter()
.filter(|c| c.starts_with(partial))
.collect();
if prefix_matches.len() == 1 {
Some(prefix_matches[0])
} else if prefix_matches.len() > 1 {
let ghost_style = Style::default()
.fg(THEME.load().border)
.add_modifier(Modifier::DIM);
if let Some(last_row) = rows.last_mut() {
last_row.push(Span::styled(format!(" {} matches · Tab search", prefix_matches.len()), ghost_style));
}
None
} else {
None
}
};
if let Some(cmd) = hint {
if cmd.as_str() != partial {
let ghost_style = Style::default()
.fg(THEME.load().border)
.add_modifier(Modifier::DIM);
let ghost_text = if cmd.starts_with(partial) {
cmd[partial.len()..].to_string()
} else {
format!(" → /{}", cmd)
};
if let Some(last_row) = rows.last_mut() {
last_row.push(Span::styled(ghost_text, ghost_style));
}
}
}
}
rows.into_iter().map(Line::from).collect()
};
let (_, cursor_row, cursor_col) = app.input_wrap_info(input_inner_width);
let visible_lines = max_input_lines;
let input_scroll: u16 = if cursor_row >= visible_lines {
cursor_row - visible_lines + 1
} else {
0
};
let input_widget = Paragraph::new(input_lines_vec)
.scroll((input_scroll, 0))
.block(input_block);
frame.render_widget(input_widget, outer[4]);
let cursor_x = outer[4].x + 1 + cursor_col;
let cursor_y = outer[4].y + 1 + cursor_row - input_scroll;
if cursor_x < outer[4].x.saturating_add(outer[4].width)
&& cursor_y < outer[4].y.saturating_add(outer[4].height)
{
if let Some(cell) = frame.buffer_mut().cell_mut((cursor_x, cursor_y)) {
let symbol = cell.symbol().to_string();
let cursor_symbol = if symbol.trim().is_empty() { " " } else { symbol.as_str() };
cell.set_symbol(cursor_symbol)
.set_fg(THEME.load().bg)
.set_bg(THEME.load().input_fg);
}
}
if !app.active_tasks.is_empty() {
let bar = render_active_tasks_line(&app.active_tasks, outer[3].width);
frame.render_widget(bar, outer[3]);
}
let footer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(1),
Constraint::Length(model.len() as u16 + 75),
])
.split(outer[5]);
let key_style = Style::default().fg(THEME.load().muted);
let label_style = Style::default().fg(THEME.load().help_fg);
let dot_style = Style::default().fg(THEME.load().help_fg);
let keybinds = Paragraph::new(Line::from(vec![
Span::styled(" ctrl+c ", key_style),
Span::styled("quit", label_style),
Span::styled(" \u{00b7} ", dot_style),
Span::styled("esc ", key_style),
Span::styled("abort", label_style),
Span::styled(" \u{00b7} ", dot_style),
Span::styled("shift+\u{2191}\u{2193} ", key_style),
Span::styled("scroll", label_style),
Span::styled(" \u{00b7} ", dot_style),
Span::styled("ctrl+o ", key_style),
Span::styled(
if app.show_full_output { "full" } else { "compact" },
label_style,
),
Span::styled(" \u{00b7} ", dot_style),
Span::styled("enter ", key_style),
Span::styled("send", label_style),
]))
.style(Style::default().bg(THEME.load().bg));
frame.render_widget(keybinds, footer_chunks[0]);
let cost_str = if app.session_cost > 0.0 {
format!("${:.4} ", app.session_cost)
} else {
String::new()
};
let cache_rate = {
let total_input = app.total_input_tokens + app.total_cache_read_tokens + app.total_cache_creation_tokens;
if total_input > 0 && app.total_cache_read_tokens > 0 {
let rate = (app.total_cache_read_tokens as f64 / total_input as f64 * 100.0) as u32;
format!(" {}%↺", rate)
} else {
String::new()
}
};
let token_str = if app.total_input_tokens > 0 || app.total_output_tokens > 0 {
format!(
"{}\u{2191} {}\u{2193}{} ",
format_tokens(app.total_input_tokens),
format_tokens(app.total_output_tokens),
cache_rate,
)
} else {
String::new()
};
let info = Paragraph::new(Line::from(vec![
Span::styled(&cost_str, Style::default().fg(THEME.load().cost_color)),
Span::styled(&token_str, Style::default().fg(THEME.load().muted)),
{
let turn_context = app.last_turn_context;
let context_window = app.last_turn_context_window.max(1);
if turn_context > 0 {
let usage_ratio = (turn_context as f64 / context_window as f64).min(1.0);
let bar_width: usize = 14;
let filled = (usage_ratio * bar_width as f64).round() as usize;
let empty = bar_width.saturating_sub(filled);
let bar_color = if usage_ratio < 0.5 {
THEME.load().border_active
} else if usage_ratio < 0.75 {
THEME.load().status_streaming
} else {
THEME.load().error_color
};
let pct = (usage_ratio * 100.0) as u32;
Span::styled(
format!("{}{} {}% ", "\u{2593}".repeat(filled), "\u{2591}".repeat(empty), pct),
Style::default().fg(bar_color),
)
} else {
Span::raw("")
}
},
Span::styled("\u{03b8}:", Style::default().fg(THEME.load().muted)),
Span::styled(thinking.to_string(), Style::default().fg(THEME.load().help_fg)),
Span::styled(" \u{2502} ", Style::default().fg(THEME.load().border)),
Span::styled(model, Style::default().fg(THEME.load().header_fg)),
Span::styled(" ", Style::default()),
]))
.alignment(Alignment::Right)
.style(Style::default().bg(THEME.load().bg));
frame.render_widget(info, footer_chunks[1]);
if let Some(ref mut fx) = effect {
let area = frame.area();
fx.process(elapsed.into(), frame.buffer_mut(), area);
if fx.done() {
*effect = None;
}
}
if let Some(ref mut fx) = exit_effect {
let area = frame.area();
fx.process(elapsed.into(), frame.buffer_mut(), area);
}
if let Some(prompt) = secret_prompts.active() {
let area = frame.area();
let width = area.width.min(62).max(30);
let height = 7u16;
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let modal_area = ratatui::layout::Rect { x, y, width, height };
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(Span::styled(
format!(" {} ", prompt.title),
Style::default().fg(THEME.load().warning_color).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(THEME.load().warning_color))
.style(Style::default().bg(THEME.load().bg));
let masked = "•".repeat(prompt.buffer.chars().count());
let text = vec![
Line::from(Span::styled(prompt.prompt.clone(), Style::default().fg(THEME.load().help_fg))),
Line::from(""),
Line::from(vec![
Span::styled("password: ", Style::default().fg(THEME.load().muted)),
Span::styled(masked, Style::default().fg(THEME.load().input_fg)),
]),
Line::from(Span::styled("Enter submit · Esc cancel", Style::default().fg(THEME.load().muted))),
];
frame.render_widget(Paragraph::new(text).block(block).alignment(Alignment::Left), modal_area);
}
render_toasts(frame, &app.toasts);
if let Some(ref state) = app.settings {
let snap = super::settings::RuntimeSnapshot::from_runtime_with_health(runtime, registry, app.model_health.clone());
super::settings::render(frame, frame.area(), state, &snap);
}
if let Some(ref state) = app.models {
super::models::render(frame, frame.area(), state, runtime.model());
}
if let Some(ref state) = app.plugins {
super::plugins::render(frame, frame.area(), state);
}
if let Some(ref mut state) = app.help_find {
super::help_find::render(frame, frame.area(), state);
}
})?;
Ok(())
}