use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use throbber_widgets_tui::BRAILLE_SIX;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, InputMode};
use crate::theme::Theme;
pub fn render(
app: &App,
frame: &mut Frame,
area: Rect,
busy: bool,
activity_label: Option<&str>,
spinner_idx: u8,
) {
let theme = Theme::default();
let base_title = match app.input_mode() {
InputMode::Normal => " Press 'i' to type",
InputMode::Insert => " Input (Esc to cancel)",
};
let estimate = app.context_token_estimate();
let title_buf;
let title = if estimate > 0 {
let count_str = format_token_count(estimate);
title_buf = format!("{base_title} | {count_str} tokens ");
title_buf.as_str()
} else {
title_buf = format!("{base_title} ");
title_buf.as_str()
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(theme.panel_border)
.title(title);
if app.queued_count() > 0 {
let badge = format!(" [+{} queued] ", app.queued_count());
block = block.title_bottom(Span::styled(badge, theme.highlight));
}
if app.editing_queued() {
block = block.title_bottom(Span::styled(" [editing queued] ", theme.highlight));
}
if busy {
let sym_idx = usize::from(spinner_idx) % BRAILLE_SIX.symbols.len();
let symbol = BRAILLE_SIX.symbols[sym_idx];
let spinner_span = if let Some(label) = activity_label {
Span::styled(format!(" {symbol} {label} "), theme.highlight)
} else {
Span::styled(format!(" {symbol} "), theme.highlight)
};
let hint_span = Span::styled("Esc to interrupt ", theme.system_message);
block = block.title_bottom(Line::from(vec![spinner_span, hint_span]));
}
let visible_lines = area.height.saturating_sub(2);
let cursor_line = u16::try_from(
app.input()[..app
.input()
.char_indices()
.nth(app.cursor_position())
.map_or(app.input().len(), |(idx, _)| idx)]
.matches('\n')
.count(),
)
.unwrap_or(u16::MAX);
let scroll = cursor_line.saturating_sub(visible_lines.saturating_sub(1));
let paragraph = if let Some(ps) = app.paste_state() {
let size_label = if ps.byte_len >= 1024 {
#[allow(clippy::cast_precision_loss)]
let kb = ps.byte_len as f64 / 1024.0;
format!("{kb:.1} KB")
} else {
format!("{} B", ps.byte_len)
};
let indicator = format!("[Pasted: {} lines · {}]", ps.line_count, size_label);
Paragraph::new(indicator)
.block(block)
.style(theme.system_message)
.scroll((scroll, 0))
.wrap(Wrap { trim: false })
} else if app.input().is_empty() && matches!(app.input_mode(), InputMode::Insert) {
Paragraph::new("Type a message, / for commands, @ to mention")
.block(block)
.style(theme.system_message)
.scroll((scroll, 0))
.wrap(Wrap { trim: false })
} else {
Paragraph::new(app.input())
.block(block)
.style(theme.input_text)
.scroll((scroll, 0))
.wrap(Wrap { trim: false })
};
frame.render_widget(paragraph, area);
if app.paste_state().is_none() && matches!(app.input_mode(), InputMode::Insert) {
let prefix: String = app.input().chars().take(app.cursor_position()).collect();
let last_line = prefix.rsplit('\n').next().unwrap_or(&prefix);
#[allow(clippy::cast_possible_truncation)]
let cursor_x = area.x + last_line.width() as u16 + 1;
let line_count = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
#[allow(clippy::cast_possible_truncation)]
let cursor_y = area.y + 1 + line_count.saturating_sub(scroll);
frame.set_cursor_position((cursor_x, cursor_y));
}
}
fn format_token_count(tokens: usize) -> String {
if tokens < 1000 {
format!("~{tokens}")
} else {
let whole = tokens / 1000;
let frac = (tokens % 1000) / 100;
format!("~{whole}.{frac}k")
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use tokio::sync::mpsc;
use crate::app::App;
use crate::test_utils::render_to_string;
fn make_app() -> App {
let (user_tx, _) = mpsc::channel(1);
let (_, agent_rx) = mpsc::channel(1);
App::new(user_tx, agent_rx)
}
#[test]
fn input_insert_mode() {
let app = make_app();
let output = render_to_string(40, 5, |frame, area| {
super::render(&app, frame, area, false, None, 0);
});
assert_snapshot!(output);
}
#[test]
fn input_normal_mode() {
let mut app = make_app();
app.handle_event(crate::event::AppEvent::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
));
let output = render_to_string(40, 5, |frame, area| {
super::render(&app, frame, area, false, None, 0);
});
assert_snapshot!(output);
}
#[test]
fn input_busy_shows_spinner() {
let app = make_app();
let output = render_to_string(60, 5, |frame, area| {
super::render(&app, frame, area, true, Some("Thinking..."), 0);
});
assert!(
output.contains("Esc to interrupt"),
"spinner hint must appear when busy"
);
}
#[test]
fn input_idle_width_80() {
let app = make_app();
let output = render_to_string(80, 5, |frame, area| {
super::render(&app, frame, area, false, None, 0);
});
assert_snapshot!(output);
}
#[test]
fn input_busy_width_40() {
let app = make_app();
let output = render_to_string(40, 5, |frame, area| {
super::render(&app, frame, area, true, Some("Thinking..."), 0);
});
assert!(
output.contains("Esc to interrupt"),
"spinner hint must appear on narrow terminal"
);
}
#[test]
fn input_busy_width_80() {
let app = make_app();
let output = render_to_string(80, 5, |frame, area| {
super::render(&app, frame, area, true, Some("Thinking..."), 0);
});
assert!(
output.contains("Esc to interrupt"),
"spinner hint must appear on wide terminal"
);
}
#[test]
fn input_shows_token_estimate_when_nonzero() {
let mut app = make_app();
app.handle_event(crate::event::AppEvent::Agent(
crate::event::AgentEvent::ContextEstimate(14_200),
));
let output = render_to_string(80, 5, |frame, area| {
super::render(&app, frame, area, false, None, 0);
});
assert!(
output.contains("14.2k tokens"),
"token estimate must appear in input block title when estimate is nonzero"
);
}
#[test]
fn input_hides_token_estimate_when_zero() {
let app = make_app();
let output = render_to_string(80, 5, |frame, area| {
super::render(&app, frame, area, false, None, 0);
});
assert!(
!output.contains("tokens"),
"token estimate must not appear when estimate is 0"
);
}
#[test]
fn format_token_count_below_1000() {
assert_eq!(super::format_token_count(0), "~0");
assert_eq!(super::format_token_count(512), "~512");
assert_eq!(super::format_token_count(999), "~999");
}
#[test]
fn format_token_count_1000_and_above() {
assert_eq!(super::format_token_count(1000), "~1.0k");
assert_eq!(super::format_token_count(1500), "~1.5k");
assert_eq!(super::format_token_count(14_200), "~14.2k");
assert_eq!(super::format_token_count(100_000), "~100.0k");
}
}