use chrono::{DateTime, Utc};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
};
use crate::app::{App, DiffSource, InputMode, Message, MessageType};
use crate::theme::Theme;
use crate::ui::styles;
pub fn format_relative_time(earlier: DateTime<Utc>, now: DateTime<Utc>) -> String {
let delta = now.signed_duration_since(earlier);
let secs = delta.num_seconds();
if secs < 0 {
return "just now".to_string();
}
if secs < 5 {
return "just now".to_string();
}
if secs < 60 {
return format!("{secs}s ago");
}
let minutes = delta.num_minutes();
if minutes < 60 {
return format!("{minutes}m ago");
}
let hours = delta.num_hours();
if hours < 24 {
return format!("{hours}h ago");
}
let days = delta.num_days();
format!("{days}d ago")
}
pub fn build_message_span(message: Option<&Message>, theme: &Theme) -> (Span<'static>, usize) {
if let Some(msg) = message {
let (fg, bg) = match msg.message_type {
MessageType::Info => (theme.message_info_fg, theme.message_info_bg),
MessageType::Warning => (theme.message_warning_fg, theme.message_warning_bg),
MessageType::Error => (theme.message_error_fg, theme.message_error_bg),
};
let content = format!(" {} ", msg.content);
let width = content.len();
(
Span::styled(
content,
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
),
width,
)
} else {
(Span::raw(""), 0)
}
}
pub fn build_flash_span(text: &str, theme: &Theme) -> (Span<'static>, usize) {
let content = format!(" {text} ");
let width = content.len();
(
Span::styled(
content,
Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg)
.add_modifier(Modifier::BOLD | Modifier::REVERSED),
),
width,
)
}
pub fn build_right_aligned_spans<'a>(
mut left_spans: Vec<Span<'a>>,
message_span: Span<'a>,
message_width: usize,
total_width: usize,
) -> Vec<Span<'a>> {
let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum();
let padding_width = total_width.saturating_sub(left_width + message_width);
let padding = Span::raw(" ".repeat(padding_width));
left_spans.push(padding);
if message_width > 0 {
left_spans.push(message_span);
}
left_spans
}
pub fn render_header(frame: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let vcs_type = &app.vcs_info.vcs_type;
let branch = app.vcs_info.branch_name.as_deref().unwrap_or("detached");
let title = " trv - Code Review ".to_string();
let vcs_info = format!("[{vcs_type}:{branch}] ");
let source_info = match &app.diff_source {
DiffSource::WorkingTree => String::new(),
DiffSource::Staged => "[staged] ".to_string(),
DiffSource::Unstaged => "[unstaged] ".to_string(),
DiffSource::StagedAndUnstaged => "[staged + unstaged] ".to_string(),
DiffSource::CommitRange(commits) => {
if commits.len() == 1 {
format!("[commit {}] ", &commits[0][..7.min(commits[0].len())])
} else {
match app.commit_select.selection_range {
Some((start, end)) if end - start + 1 < app.inline_selector.commits.len() => {
format!(
"[{}/{} commits] ",
end - start + 1,
app.inline_selector.commits.len()
)
}
_ => format!("[{} commits] ", commits.len()),
}
}
}
DiffSource::StagedUnstagedAndCommits(commits) => {
if commits.len() == 1 {
format!(
"[staged + unstaged + commit {}] ",
&commits[0][..7.min(commits[0].len())]
)
} else {
format!("[staged + unstaged + {} commits] ", commits.len())
}
}
DiffSource::Remote { pr_number, .. } => {
format!("[PR #{pr_number}] ")
}
};
let progress = format!("{}/{} reviewed ", app.reviewed_count(), app.file_count());
let title_span = Span::styled(title, styles::header_style(theme));
let vcs_span = Span::styled(vcs_info, Style::default().fg(theme.fg_secondary));
let source_span = Span::styled(source_info, Style::default().fg(theme.diff_hunk_header));
let progress_span = Span::styled(
progress,
if app.reviewed_count() == app.file_count() {
styles::reviewed_style(theme)
} else {
styles::pending_style(theme)
},
);
let (update_span, update_width) = if let Some(ref info) = app.update_info {
if info.update_available {
let text = format!(" v{} available ", info.latest_version);
let width = text.len();
(
Span::styled(
text,
Style::default()
.fg(theme.update_badge_fg)
.bg(theme.update_badge_bg)
.add_modifier(Modifier::BOLD),
),
width,
)
} else if info.is_ahead {
let text = format!(" unreleased v{} ", info.current_version);
let width = text.len();
(
Span::styled(
text,
Style::default()
.fg(theme.update_badge_fg)
.bg(theme.update_badge_bg)
.add_modifier(Modifier::BOLD),
),
width,
)
} else {
(Span::raw(""), 0)
}
} else {
(Span::raw(""), 0)
};
let left_spans = vec![title_span, vcs_span, source_span, progress_span];
let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum();
let total_width = area.width as usize;
let padding_width = total_width.saturating_sub(left_width + update_width);
let mut spans = left_spans;
spans.push(Span::raw(" ".repeat(padding_width)));
if update_width > 0 {
spans.push(update_span);
}
let line = Line::from(spans);
let header = Paragraph::new(line)
.style(styles::status_bar_style(theme))
.block(Block::default());
frame.render_widget(header, area);
}
pub fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let left_spans = if matches!(
app.nav.input_mode,
InputMode::Command | InputMode::Search | InputMode::CommandPalette
) {
let prefix = match app.nav.input_mode {
InputMode::Search => "/",
_ => ":",
};
let buffer: &str = match app.nav.input_mode {
InputMode::Search => &app.search_buffer,
_ => app.palette.buffer(),
};
let command_text = format!("{prefix}{buffer}");
vec![Span::styled(
command_text,
Style::default().fg(theme.fg_primary),
)]
} else {
let mode_str = match app.nav.input_mode {
InputMode::Normal => {
if app.live.active {
match app.live.last_refresh_at {
Some(ts) => {
let stamp = ts.format("%H:%M:%S");
if let Some(count) = app.pending_count {
format!(" LIVE {count} · {stamp} ")
} else {
format!(" LIVE · {stamp} ")
}
}
None => {
if let Some(count) = app.pending_count {
format!(" LIVE {count} ")
} else {
" LIVE ".to_string()
}
}
}
} else if let Some(count) = app.pending_count {
format!(" NORMAL {count} ")
} else {
" NORMAL ".to_string()
}
}
InputMode::Command => " COMMAND ".to_string(),
InputMode::Search => " SEARCH ".to_string(),
InputMode::Comment => " COMMENT ".to_string(),
InputMode::Help => " HELP ".to_string(),
InputMode::Confirm => " CONFIRM ".to_string(),
InputMode::CommitSelect => " SELECT ".to_string(),
InputMode::VisualSelect => {
if let Some((range, _)) = app.get_visual_selection() {
if range.is_single() {
format!(" VISUAL L{} ", range.start)
} else {
format!(" VISUAL L{}-L{} ", range.start, range.end)
}
} else {
" VISUAL ".to_string()
}
}
InputMode::ReviewSubmit => " REVIEW ".to_string(),
InputMode::CommandPalette => " COMMAND ".to_string(),
InputMode::ReactionPicker => " REACTION ".to_string(),
InputMode::CommentTemplatePicker => " TEMPLATE ".to_string(),
InputMode::MentalModelEdit => " MENTAL MODEL ".to_string(),
};
let mode_span = Span::styled(mode_str, styles::mode_style(theme));
let hints = match app.nav.input_mode {
InputMode::Normal => {
" j,k (scroll) r (reviewed) c (comment) \\ (files) <,> (resize) ? (help) : (commands) "
}
InputMode::Command => " Enter (execute) Esc (cancel) ",
InputMode::Search => " Enter (search) Esc (cancel) ",
InputMode::Comment => " Ctrl-S (save) Esc (cancel) ",
InputMode::Help => " q,?,Esc (close) ",
InputMode::Confirm => " y (yes) n (no) ",
InputMode::CommitSelect => {
" j,k (navigate) Space (select) Enter (confirm) Esc (back) q (quit) "
}
InputMode::VisualSelect => " j,k (extend) c,Enter (comment) Esc,V (cancel) ",
InputMode::ReviewSubmit => {
if app.remote().is_some_and(|r| r.review_body_editing) {
" Type body text Esc (back to verdict) Ctrl+S (submit) "
} else {
" j,k (verdict) Enter (edit body) Ctrl+S (submit) Esc (cancel) "
}
}
InputMode::CommandPalette => " Enter (execute) Esc (cancel) j,k (navigate) ",
InputMode::ReactionPicker => {
" \u{2190}/\u{2192} (move) Enter (select) 1-8 (quick) Esc (cancel) "
}
InputMode::CommentTemplatePicker => " Enter (insert) Esc (cancel) j,k (navigate) ",
InputMode::MentalModelEdit => {
" Tab (next field) Shift+Tab (prev) Ctrl+S (save) Esc (cancel) "
}
};
let hints_span = Span::styled(hints, Style::default().fg(theme.fg_secondary));
let dirty_indicator = if app.dirty {
Span::styled(" [modified] ", Style::default().fg(theme.pending))
} else {
Span::raw("")
};
let tour_indicator = if let Some(tour) = app.tour.plan.as_ref()
&& let Some(stop) = tour.current()
{
let n = tour.stops.len();
let i = tour.index + 1;
let summary = stop.summary.trim();
let short = if summary.chars().count() > 40 {
let mut s: String = summary.chars().take(39).collect();
s.push('…');
s
} else {
summary.to_string()
};
let batched = if stop.is_batched() {
format!(" ({} commits)", stop.commit_ids.len())
} else {
String::new()
};
let threshold = tour.threshold.as_u8();
let risk = stop.risk.as_u8();
Span::styled(
format!(" Tour {i}/{n}{batched} threshold={threshold} risk={risk}: {short} "),
Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw("")
};
let triage_indicator = {
let (live, obsolete, moved) = app.tour_triage_counts();
if live + obsolete + moved > 0 {
Span::styled(
format!(" · {live}L {obsolete}O {moved}M "),
Style::default().fg(theme.fg_secondary),
)
} else {
Span::raw("")
}
};
let blind_indicator = if app.blind_mode {
Span::styled(" [blind] ", Style::default().fg(theme.fg_secondary))
} else {
Span::raw("")
};
let spar_indicator = if app.spar_mode {
let n = app.engine.session().spec_count();
let label = if n == 0 {
" [spar] ".to_string()
} else {
let linked = app
.spec_statuses
.values()
.filter(|s| matches!(s, travelagent_core::sparring::SparringStatus::Linked))
.count();
if linked >= n {
format!(" [spar: {n}✓] ")
} else {
format!(" [spar: {linked}/{n}] ")
}
};
Span::styled(label, Style::default().fg(theme.fg_secondary))
} else {
Span::raw("")
};
let mcp_indicator = if app.mcp_listener.is_draining() {
Span::styled(
" [mcp:draining] ",
Style::default()
.fg(theme.pending)
.add_modifier(Modifier::BOLD),
)
} else if app.mcp_listener.is_on() {
let pid = std::process::id();
let peers = app.mcp_peer_count();
if peers > 0 {
Span::styled(
format!(
" [mcp:{pid} · {peers} peer{}] ",
if peers == 1 { "" } else { "s" }
),
Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
format!(" [mcp:{pid}] "),
Style::default().fg(theme.fg_secondary),
)
}
} else {
Span::raw("")
};
let ai_summary_indicator = if app.ai.summary.is_some() {
if app.ai.unread {
Span::styled(
" [AI!] ",
Style::default()
.fg(theme.pending)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(" [AI] ", Style::default().fg(theme.fg_secondary))
}
} else {
Span::raw("")
};
let last_review_indicator = if let Some(ts) = app.engine.session().last_review_submitted_at
{
let current_sha = app
.remote()
.and_then(|r| r.pr_metadata.as_ref().map(|m| m.head_sha.as_str()));
let recorded_sha = app.engine.session().last_review_sha.as_deref();
let is_stale = matches!(
(recorded_sha, current_sha),
(Some(rec), Some(cur)) if rec != cur
);
if is_stale {
Span::styled(
" [reviewed@old] ".to_string(),
Style::default().fg(theme.fg_dim),
)
} else {
let label = ts.format("%H:%M");
Span::styled(
format!(" [reviewed: {label}] "),
Style::default().fg(theme.reviewed),
)
}
} else {
Span::raw("")
};
let pin_indicator = if app.viewport_pinned {
let body = if let Some(g) = app.agent_ghost.as_ref() {
let base = match g.path.rsplit_once('/') {
Some((_, tail)) if !tail.is_empty() => tail,
_ => g.path.as_str(),
};
format!(" \u{1f4cc} pinned · \u{1f440} agent: {base} ")
} else {
" \u{1f4cc} pinned ".to_string()
};
Span::styled(
body,
Style::default()
.fg(theme.message_info_fg)
.bg(theme.message_info_bg)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw("")
};
vec![
mode_span,
dirty_indicator,
mcp_indicator,
ai_summary_indicator,
spar_indicator,
blind_indicator,
pin_indicator,
last_review_indicator,
hints_span,
triage_indicator,
tour_indicator,
]
};
let mut left_spans = left_spans;
if let Some(r) = app.remote()
&& matches!(app.diff_source, DiffSource::Remote { .. })
{
if let Some(ts) = r.last_refreshed_at {
let rel = format_relative_time(ts, chrono::Utc::now());
left_spans.push(Span::styled(
format!(" last: {rel} "),
Style::default().fg(theme.fg_secondary),
));
}
if let Some(remaining) = r.rate_limit_remaining
&& remaining < 1000
{
left_spans.push(Span::styled(
format!(" rl: {remaining} "),
Style::default()
.fg(theme.message_warning_fg)
.bg(theme.message_warning_bg)
.add_modifier(Modifier::BOLD),
));
}
}
let show_error = matches!(
app.message.as_ref().map(|m| &m.message_type),
Some(MessageType::Error)
);
let (message_span, message_width) =
if !show_error && let Some(flash_text) = app.current_flash_text() {
build_flash_span(flash_text, theme)
} else {
build_message_span(app.message.as_ref(), theme)
};
let total_width = area.width as usize;
let spans = build_right_aligned_spans(left_spans, message_span, message_width, total_width);
let line = Line::from(spans);
let status = Paragraph::new(line)
.style(styles::status_bar_style(theme))
.block(Block::default());
frame.render_widget(status, area);
}
#[cfg(test)]
mod tests {
use super::*;
fn test_message(message_type: MessageType) -> Message {
Message {
content: "hello".to_string(),
message_type,
}
}
#[test]
fn should_style_info_message_using_theme_fields() {
let theme = Theme::dark();
let (span, width) = build_message_span(Some(&test_message(MessageType::Info)), &theme);
assert_eq!(span.style.fg, Some(theme.message_info_fg));
assert_eq!(span.style.bg, Some(theme.message_info_bg));
assert_eq!(width, " hello ".len());
}
#[test]
fn should_return_empty_span_when_message_is_none() {
let theme = Theme::dark();
let (span, width) = build_message_span(None, &theme);
assert_eq!(span.content.as_ref(), "");
assert_eq!(width, 0);
}
#[test]
fn should_style_warning_message_using_theme_fields() {
let theme = Theme::dark();
let (span, _) = build_message_span(Some(&test_message(MessageType::Warning)), &theme);
assert_eq!(span.style.fg, Some(theme.message_warning_fg));
assert_eq!(span.style.bg, Some(theme.message_warning_bg));
}
#[test]
fn should_style_error_message_using_theme_fields() {
let theme = Theme::dark();
let (span, _) = build_message_span(Some(&test_message(MessageType::Error)), &theme);
assert_eq!(span.style.fg, Some(theme.message_error_fg));
assert_eq!(span.style.bg, Some(theme.message_error_bg));
}
#[test]
fn build_flash_span_uses_info_palette_with_reversed_modifier() {
let theme = Theme::dark();
let (span, width) = build_flash_span("\u{1f916} agent: jumped to foo.rs", &theme);
assert!(span.content.contains("agent: jumped to foo.rs"));
assert_eq!(width, span.content.len());
assert_eq!(span.style.fg, Some(theme.message_info_fg));
assert_eq!(span.style.bg, Some(theme.message_info_bg));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
assert!(span.style.add_modifier.contains(Modifier::REVERSED));
}
#[test]
fn format_relative_time_under_five_seconds_reads_just_now() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::seconds(2);
assert_eq!(format_relative_time(earlier, now), "just now");
}
#[test]
fn format_relative_time_seconds() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::seconds(30);
assert_eq!(format_relative_time(earlier, now), "30s ago");
}
#[test]
fn format_relative_time_minutes() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::minutes(2);
assert_eq!(format_relative_time(earlier, now), "2m ago");
}
#[test]
fn format_relative_time_hours() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::hours(3);
assert_eq!(format_relative_time(earlier, now), "3h ago");
}
#[test]
fn format_relative_time_days() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::days(5);
assert_eq!(format_relative_time(earlier, now), "5d ago");
}
#[test]
fn format_relative_time_clock_skew_treated_as_just_now() {
let now = chrono::Utc::now();
let future = now + chrono::Duration::seconds(10);
assert_eq!(format_relative_time(future, now), "just now");
}
#[test]
fn format_relative_time_hour_and_day_boundary() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::minutes(59);
assert_eq!(format_relative_time(earlier, now), "59m ago");
let earlier = now - chrono::Duration::minutes(60);
assert_eq!(format_relative_time(earlier, now), "1h ago");
let earlier = now - chrono::Duration::hours(23);
assert_eq!(format_relative_time(earlier, now), "23h ago");
let earlier = now - chrono::Duration::hours(24);
assert_eq!(format_relative_time(earlier, now), "1d ago");
}
#[test]
fn format_relative_time_minute_boundary() {
let now = chrono::Utc::now();
let earlier = now - chrono::Duration::seconds(60);
assert_eq!(format_relative_time(earlier, now), "1m ago");
let earlier = now - chrono::Duration::seconds(59);
assert_eq!(format_relative_time(earlier, now), "59s ago");
}
}