use crate::scroll_buffer::ScrollBuffer;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
pub fn emit_line(buffer: &mut ScrollBuffer, line: Line<'static>) {
buffer.push(line);
}
pub const DIM: Style = Style::new().fg(Color::DarkGray);
pub const BOLD: Style = Style::new().add_modifier(Modifier::BOLD);
pub const CYAN: Style = Style::new().fg(Color::Cyan);
pub const YELLOW: Style = Style::new().fg(Color::Yellow);
pub const RED: Style = Style::new().fg(Color::Red);
pub const GREEN: Style = Style::new().fg(Color::Green);
pub const MAGENTA: Style = Style::new().fg(Color::Magenta);
pub const ORANGE: Style = Style::new().fg(Color::Rgb(255, 165, 0));
pub const AMBER: Style = Style::new().fg(Color::Rgb(255, 191, 0));
pub const TOOL_PREFIX: Style = Style::new().fg(Color::DarkGray);
pub const READ_CONTENT: Style = Style::new().fg(Color::Rgb(198, 200, 209));
pub const WRITE_CONTENT: Style = Style::new().fg(Color::DarkGray);
pub const WARM_TITLE: Style = Style::new()
.fg(Color::Rgb(229, 192, 123)) .add_modifier(Modifier::BOLD);
pub const WARM_ACCENT: Style = Style::new().fg(Color::Rgb(209, 154, 102)); pub const WARM_MUTED: Style = Style::new().fg(Color::Rgb(124, 111, 100)); pub const WARM_INFO: Style = Style::new().fg(Color::Rgb(198, 165, 106));
pub fn ok_msg(buffer: &mut ScrollBuffer, msg: String) {
buffer.push(Line::from(vec![
Span::styled(" \u{2713} ", GREEN),
Span::raw(msg),
]));
}
pub fn err_msg(buffer: &mut ScrollBuffer, msg: String) {
buffer.push(Line::from(vec![
Span::styled(" \u{2717} ", RED),
Span::styled(msg, RED),
]));
}
pub fn dim_msg(buffer: &mut ScrollBuffer, msg: String) {
buffer.push(Line::styled(format!(" {msg}"), DIM));
}
pub fn warn_msg(buffer: &mut ScrollBuffer, msg: String) {
buffer.push(Line::from(vec![
Span::styled(" \u{26a0} ", YELLOW),
Span::styled(msg, YELLOW),
]));
}
pub fn blank(buffer: &mut ScrollBuffer) {
buffer.push(Line::default());
}
pub fn interrupted_turn_banner(
kind: &koda_core::persistence::InterruptionKind,
) -> Vec<Line<'static>> {
use koda_core::persistence::InterruptionKind;
let mut lines = vec![Line::default()];
match kind {
InterruptionKind::Prompt(preview) => {
lines.push(Line::from(vec![
Span::styled(" ↻ ", AMBER),
Span::styled(
"Last turn was interrupted — your prompt was never answered:",
AMBER,
),
]));
let display = if preview.len() >= 77 {
format!(" \u{201c}{}\u{2026}\u{201d}", &preview[..77])
} else {
format!(" \u{201c}{}\u{201d}", preview)
};
lines.push(Line::styled(display, DIM));
}
InterruptionKind::Tool => {
lines.push(Line::from(vec![
Span::styled(" ↻ ", AMBER),
Span::styled(
"Last turn was interrupted — tool result was never processed.",
AMBER,
),
]));
}
}
lines.push(Line::styled(
" Type \"continue\" to resume, or start a new message.",
DIM,
));
lines.push(Line::default());
lines
}
fn format_idle(secs: i64) -> Option<String> {
match secs {
..=299 => None, 300..=3599 => Some(format!("{} min", secs / 60)),
3600..=86399 => {
let h = secs / 3600;
Some(format!("{} h", h))
}
86400..=604799 => {
let d = secs / 86400;
Some(format!("{} day{}", d, if d == 1 { "" } else { "s" }))
}
_ => {
let w = secs / 604800;
Some(format!("{} week{}", w, if w == 1 { "" } else { "s" }))
}
}
}
pub fn away_summary_banner(
idle_secs: Option<i64>,
title: Option<&str>,
user_msg_count: usize,
tool_call_count: usize,
total_tokens: i64,
) -> Vec<Line<'static>> {
let mut parts: Vec<String> = Vec::new();
if user_msg_count > 0 {
let msgs_label = match user_msg_count {
1 => "1 msg".to_string(),
n => format!("{n} msgs"),
};
parts.push(msgs_label);
}
if tool_call_count > 0 {
parts.push(format!(
"{tool_call_count} tool call{}",
if tool_call_count == 1 { "" } else { "s" }
));
}
if total_tokens > 0 {
parts.push(format!("{}k tok", total_tokens / 1000));
}
if let Some(secs) = idle_secs
&& let Some(away) = format_idle(secs)
{
parts.push(format!("away {away}"));
}
if parts.is_empty() {
return Vec::new();
}
let stats: String = parts.join(" \u{00b7} "); let title_span = title
.map(|t| {
let t: String = t.chars().take(50).collect();
Span::styled(format!("“{t}” "), WARM_ACCENT)
})
.unwrap_or_else(|| Span::raw(""));
vec![
Line::default(),
Line::from(vec![
Span::styled(" \u{1f4cb} ", CYAN),
title_span,
Span::styled(stats, WARM_MUTED),
]),
Line::default(),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_thresholds() {
assert!(format_idle(0).is_none());
assert!(format_idle(299).is_none()); assert_eq!(format_idle(300).as_deref(), Some("5 min"));
assert_eq!(format_idle(3600).as_deref(), Some("1 h"));
assert_eq!(format_idle(86400).as_deref(), Some("1 day"));
assert_eq!(format_idle(172800).as_deref(), Some("2 days"));
assert_eq!(format_idle(604800).as_deref(), Some("1 week"));
assert_eq!(format_idle(1209600).as_deref(), Some("2 weeks"));
}
#[test]
fn banner_empty_for_zero_messages() {
let lines = away_summary_banner(None, None, 0, 0, 0);
assert!(lines.is_empty());
}
#[test]
fn banner_shows_with_messages() {
let lines = away_summary_banner(Some(7200), None, 5, 3, 15000);
assert_eq!(lines.len(), 3);
}
#[test]
fn banner_silent_for_short_idle() {
let lines = away_summary_banner(Some(30), None, 2, 0, 0);
assert_eq!(lines.len(), 3); }
}