use std::time::{Duration, Instant};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::config::{IconConfig, IconPreset};
use crate::session::{Direction, EventKind, Protocol, SessionEvent, SessionMessage};
pub struct Theme {
pub border_focused: Style,
pub border_unfocused: Style,
pub title: Style,
pub hint_key: Style,
pub hint_label: Style,
pub selection: Style,
pub session_active: Style,
pub session_dead: Style,
pub session_meta: Style,
pub timestamp: Style,
pub text: Style,
pub accent: Style,
pub success: Style,
pub error: Style,
pub warning: Style,
pub info: Style,
pub muted: Style,
}
impl Default for Theme {
fn default() -> Self {
Self::new()
}
}
impl Theme {
#[must_use]
pub const fn new() -> Self {
Self {
border_focused: Style::new(),
border_unfocused: Style::new().add_modifier(Modifier::DIM),
title: Style::new().add_modifier(Modifier::BOLD),
hint_key: Style::new().add_modifier(Modifier::BOLD),
hint_label: Style::new().add_modifier(Modifier::DIM),
selection: Style::new().add_modifier(Modifier::REVERSED),
session_active: Style::new().fg(Color::Green),
session_dead: Style::new().add_modifier(Modifier::DIM),
session_meta: Style::new().add_modifier(Modifier::DIM),
timestamp: Style::new().add_modifier(Modifier::DIM),
text: Style::new(),
accent: Style::new().fg(Color::Cyan),
success: Style::new().fg(Color::Green),
error: Style::new().fg(Color::Red),
warning: Style::new().fg(Color::Yellow),
info: Style::new().fg(Color::Blue),
muted: Style::new().add_modifier(Modifier::DIM),
}
}
#[must_use]
pub fn detect() -> Self {
let mut theme = Self::new();
if let Some((r, g, b)) = detect_terminal_bg() {
theme.selection = Style::new().bg(selection_bg_from_terminal(r, g, b));
}
theme
}
}
const SELECTION_LIGHTNESS_SHIFT: f64 = 0.2;
const DETECTION_POLL: Duration = Duration::from_millis(200);
const DETECTION_TICK_THRESHOLD: i64 = 10;
const DETECTION_WALL_CAP: Duration = Duration::from_secs(2);
#[cfg(unix)]
fn detect_terminal_bg() -> Option<(u8, u8, u8)> {
use std::io::{Read, Write};
use std::sync::mpsc::RecvTimeoutError;
use catenary_proc::ProcessMonitor;
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if !was_raw {
crossterm::terminal::enable_raw_mode().ok()?;
}
tty.write_all(b"\x1b]11;?\x07").ok()?;
tty.flush().ok()?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::with_capacity(64);
let mut byte = [0u8; 1];
loop {
match tty.read(&mut byte) {
Ok(1) => {
buf.push(byte[0]);
if byte[0] == 0x07 {
let _ = tx.send(buf);
return;
}
if buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && buf[buf.len() - 1] == b'\\' {
let _ = tx.send(buf);
return;
}
}
_ => return,
}
}
});
let mut monitor = ProcessMonitor::new(std::process::id())?;
let deadline = Instant::now() + DETECTION_WALL_CAP;
let mut remaining_threshold = DETECTION_TICK_THRESHOLD;
let result = loop {
match rx.recv_timeout(DETECTION_POLL) {
Ok(response) => break Some(response),
Err(RecvTimeoutError::Disconnected) => break None,
Err(RecvTimeoutError::Timeout) => {}
}
let (delta, state) = monitor.sample()?;
if state == catenary_proc::ProcessState::Dead {
break None;
}
if state == catenary_proc::ProcessState::Running && delta > 0 {
remaining_threshold -= i64::try_from(delta).unwrap_or(remaining_threshold);
}
if remaining_threshold <= 0 || Instant::now() >= deadline {
break None;
}
};
if !was_raw {
let _ = crossterm::terminal::disable_raw_mode();
}
result.and_then(|r| parse_osc11_response(&r))
}
#[cfg(not(unix))]
const fn detect_terminal_bg() -> Option<(u8, u8, u8)> {
None
}
fn parse_osc11_response(response: &[u8]) -> Option<(u8, u8, u8)> {
let text = std::str::from_utf8(response).ok()?;
let rgb_start = text.find("rgb:")?;
let rgb_part = &text[rgb_start + 4..];
let rgb_clean = rgb_part.trim_end_matches(['\x07', '\\', '\x1b']);
let mut channels = rgb_clean.splitn(3, '/');
let r_hex = channels.next()?;
let g_hex = channels.next()?;
let b_hex = channels.next()?;
Some((
parse_osc_channel(r_hex)?,
parse_osc_channel(g_hex)?,
parse_osc_channel(b_hex)?,
))
}
fn parse_osc_channel(hex: &str) -> Option<u8> {
let val = u16::from_str_radix(hex, 16).ok()?;
#[allow(
clippy::cast_possible_truncation,
reason = "intentional 16-to-8-bit conversion"
)]
let byte = match hex.len() {
4 => (val >> 8) as u8,
3 => (val >> 4) as u8,
2 => val as u8,
1 => (val * 17) as u8, _ => return None,
};
Some(byte)
}
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
let rf = f64::from(r) / 255.0;
let gf = f64::from(g) / 255.0;
let bf = f64::from(b) / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let delta = max - min;
let light = f64::midpoint(max, min);
if delta < f64::EPSILON {
return (0.0, 0.0, light);
}
let sat = if light <= 0.5 {
delta / (max + min)
} else {
delta / (2.0 - max - min)
};
let hue_sector = if (max - rf).abs() < f64::EPSILON {
((gf - bf) / delta) % 6.0
} else if (max - gf).abs() < f64::EPSILON {
(bf - rf) / delta + 2.0
} else {
(rf - gf) / delta + 4.0
};
let hue = hue_sector * 60.0;
let hue = if hue < 0.0 { hue + 360.0 } else { hue };
(hue, sat, light)
}
#[allow(
clippy::many_single_char_names,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "standard HSL math with clamped f64 to u8 conversion"
)]
fn hsl_to_rgb(hue: f64, sat: f64, light: f64) -> (u8, u8, u8) {
if sat < f64::EPSILON {
let val = (light * 255.0).round() as u8;
return (val, val, val);
}
let q_val = if light < 0.5 {
light * (1.0 + sat)
} else {
light.mul_add(-sat, light + sat)
};
let p_val = 2.0f64.mul_add(light, -q_val);
let h_norm = hue / 360.0;
let channel = |tc: f64| -> u8 {
let tc = if tc < 0.0 {
tc + 1.0
} else if tc > 1.0 {
tc - 1.0
} else {
tc
};
let out = if tc < 1.0 / 6.0 {
((q_val - p_val) * 6.0).mul_add(tc, p_val)
} else if tc < 0.5 {
q_val
} else if tc < 2.0 / 3.0 {
((q_val - p_val) * (2.0 / 3.0 - tc)).mul_add(6.0, p_val)
} else {
p_val
};
(out * 255.0).round() as u8
};
(
channel(h_norm + 1.0 / 3.0),
channel(h_norm),
channel(h_norm - 1.0 / 3.0),
)
}
#[allow(
clippy::many_single_char_names,
reason = "r/g/b are standard color notation"
)]
fn selection_bg_from_terminal(r: u8, g: u8, b: u8) -> Color {
let (hue, sat, light) = rgb_to_hsl(r, g, b);
let new_light = if light < 0.5 {
(light + SELECTION_LIGHTNESS_SHIFT).min(1.0)
} else {
(light - SELECTION_LIGHTNESS_SHIFT).max(0.0)
};
let (nr, ng, nb) = hsl_to_rgb(hue, sat, new_light);
Color::Rgb(nr, ng, nb)
}
pub struct IconSet {
pub diag_error: String,
pub diag_warn: String,
pub diag_info: String,
pub diag_ok: String,
pub tool_search: String,
pub tool_glob: String,
pub tool_default: String,
pub workspace_open: String,
pub workspace_closed: String,
pub pinned: String,
pub progress: String,
pub session_started: String,
pub session_shutdown: String,
pub server_state: String,
pub tool_result: String,
pub tool_result_sep: String,
pub tool_sed: String,
pub ls_active: String,
pub ls_inactive: String,
pub spinner_grow: Vec<String>,
pub spinner_cycle: Vec<String>,
pub spinner_done: String,
}
struct PresetDefaults {
diag_error: &'static str,
diag_warn: &'static str,
diag_info: &'static str,
diag_ok: &'static str,
tool_search: &'static str,
tool_glob: &'static str,
tool_default: &'static str,
workspace_open: &'static str,
workspace_closed: &'static str,
pinned: &'static str,
progress: &'static str,
session_started: &'static str,
session_shutdown: &'static str,
server_state: &'static str,
tool_result: &'static str,
tool_result_sep: &'static str,
tool_sed: &'static str,
ls_active: &'static str,
ls_inactive: &'static str,
spinner_grow: &'static [&'static str],
spinner_cycle: &'static [&'static str],
spinner_done: &'static str,
}
const PRESET_UNICODE: PresetDefaults = PresetDefaults {
diag_error: "\u{2717} ", diag_warn: "\u{26A0} ", diag_info: "\u{2139} ", diag_ok: "\u{2713} ", tool_search: "\u{2B9E} ", tool_glob: "\u{2B9E} ", tool_default: "\u{2B9E} ", workspace_open: "\u{25BE} ", workspace_closed: "\u{25B8} ", pinned: "\u{2020}", progress: "\u{2726} ", session_started: "\u{25CF} ", session_shutdown: "\u{25CB} ", server_state: "\u{25C6} ", tool_result: "\u{2B9C} ", tool_result_sep: "\u{276F} ", tool_sed: "\u{2B9E} ", ls_active: "\u{25CF} ", ls_inactive: "\u{25CB} ", spinner_grow: &["\u{2596}", "\u{258C}", "\u{259B}"], spinner_cycle: &["\u{259C}", "\u{259F}", "\u{2599}", "\u{259B}"], spinner_done: "\u{2588}", };
const PRESET_NERD: PresetDefaults = PresetDefaults {
diag_error: " ", diag_warn: " ", diag_info: " ", diag_ok: " ", tool_search: " ", tool_glob: " ", tool_default: "\u{2192} ", workspace_open: " ", workspace_closed: " ", pinned: " ", progress: " ", session_started: "\u{EB2C} ", session_shutdown: "\u{EB67} ", server_state: "\u{EB99} ", tool_result: "\u{EA9B} ", tool_result_sep: "\u{F07F6} ",
tool_sed: "\u{EA73} ", ls_active: "\u{EAB0} ", ls_inactive: "\u{EAB1} ", spinner_grow: &[],
spinner_cycle: &[
"\u{F144B}",
"\u{F144C}",
"\u{F144D}",
"\u{F144E}",
"\u{F144F}",
"\u{F1450}",
"\u{F1451}",
"\u{F1452}",
"\u{F1453}",
"\u{F1454}",
"\u{F1455}",
"\u{F1456}",
],
spinner_done: "\u{F1456}", };
const PRESET_EMOJI: PresetDefaults = PresetDefaults {
diag_error: "\u{274C}\u{FE0F}", diag_warn: "\u{26A0}\u{FE0F} ", diag_info: "\u{2139}\u{FE0F} ", diag_ok: "\u{2705}\u{FE0F}", tool_search: "\u{1F50D}", tool_glob: "\u{1F5C2}\u{FE0F}", tool_default: "\u{27A1}\u{FE0F}", workspace_open: "\u{1F4C2}", workspace_closed: "\u{1F4C1}", pinned: "\u{1F4CC}", progress: "\u{2726} ", session_started: "\u{1F7E2}", session_shutdown: "\u{1F534}", server_state: "\u{1F537}", tool_result: "\u{21A9}\u{FE0F} ", tool_result_sep: "\u{1F5E8}\u{FE0F}", tool_sed: "\u{270F}\u{FE0F}", ls_active: "\u{1F7E2}", ls_inactive: "\u{26AA}", spinner_grow: &[],
spinner_cycle: &[
"\u{1F550}",
"\u{1F551}",
"\u{1F552}",
"\u{1F553}",
"\u{1F554}",
"\u{1F555}",
"\u{1F556}",
"\u{1F557}",
"\u{1F558}",
"\u{1F559}",
"\u{1F55A}",
"\u{1F55B}",
"\u{1F55C}",
"\u{1F55D}",
"\u{1F55E}",
"\u{1F55F}",
"\u{1F560}",
"\u{1F561}",
"\u{1F562}",
"\u{1F563}",
"\u{1F564}",
"\u{1F565}",
"\u{1F566}",
"\u{1F567}",
],
spinner_done: "\u{1F55B}", };
impl IconSet {
#[must_use]
pub fn from_config(config: IconConfig) -> Self {
let base = match config.preset {
IconPreset::Unicode => &PRESET_UNICODE,
IconPreset::Nerd => &PRESET_NERD,
IconPreset::Emoji => &PRESET_EMOJI,
};
Self {
diag_error: config
.diag_error
.unwrap_or_else(|| base.diag_error.to_string()),
diag_warn: config
.diag_warn
.unwrap_or_else(|| base.diag_warn.to_string()),
diag_info: config
.diag_info
.unwrap_or_else(|| base.diag_info.to_string()),
diag_ok: config.diag_ok.unwrap_or_else(|| base.diag_ok.to_string()),
tool_search: config
.tool_search
.unwrap_or_else(|| base.tool_search.to_string()),
tool_glob: config
.tool_glob
.unwrap_or_else(|| base.tool_glob.to_string()),
tool_default: config
.tool_default
.unwrap_or_else(|| base.tool_default.to_string()),
workspace_open: config
.workspace_open
.unwrap_or_else(|| base.workspace_open.to_string()),
workspace_closed: config
.workspace_closed
.unwrap_or_else(|| base.workspace_closed.to_string()),
pinned: config.pinned.unwrap_or_else(|| base.pinned.to_string()),
progress: config.progress.unwrap_or_else(|| base.progress.to_string()),
session_started: config
.session_started
.unwrap_or_else(|| base.session_started.to_string()),
session_shutdown: config
.session_shutdown
.unwrap_or_else(|| base.session_shutdown.to_string()),
server_state: config
.server_state
.unwrap_or_else(|| base.server_state.to_string()),
tool_result: config
.tool_result
.unwrap_or_else(|| base.tool_result.to_string()),
tool_result_sep: config
.tool_result_sep
.unwrap_or_else(|| base.tool_result_sep.to_string()),
tool_sed: config.tool_sed.unwrap_or_else(|| base.tool_sed.to_string()),
ls_active: config
.ls_active
.unwrap_or_else(|| base.ls_active.to_string()),
ls_inactive: config
.ls_inactive
.unwrap_or_else(|| base.ls_inactive.to_string()),
spinner_grow: config
.spinner_grow
.unwrap_or_else(|| base.spinner_grow.iter().map(|s| (*s).to_string()).collect()),
spinner_cycle: config.spinner_cycle.unwrap_or_else(|| {
base.spinner_cycle
.iter()
.map(|s| (*s).to_string())
.collect()
}),
spinner_done: config
.spinner_done
.unwrap_or_else(|| base.spinner_done.to_string()),
}
}
}
#[must_use]
pub fn format_ago(started: chrono::DateTime<chrono::Utc>) -> String {
let elapsed = chrono::Utc::now()
.signed_duration_since(started)
.num_seconds()
.max(0);
if elapsed < 60 {
format!("{elapsed}s ago")
} else if elapsed < 3600 {
format!("{}m ago", elapsed / 60)
} else if elapsed < 86400 {
format!("{}h ago", elapsed / 3600)
} else {
format!("{}d ago", elapsed / 86400)
}
}
#[must_use]
pub fn format_event_plain(ev: &SessionEvent) -> String {
let ts = ev.timestamp.format("%H:%M:%S");
match &ev.kind {
EventKind::Started => format!("{ts} session started"),
EventKind::Shutdown => format!("{ts} session shutdown"),
EventKind::ServerState { language, state } => format!("{ts} {language} {state}"),
EventKind::Progress {
language, title, ..
} => format!("{ts} {language} {title}"),
EventKind::ProgressEnd { language } => format!("{ts} {language} complete"),
EventKind::ToolCall { tool, file, .. } => {
format!("{ts} {tool} {}", file.as_deref().unwrap_or(""))
}
EventKind::ToolResult {
tool,
success,
output: _,
..
} => {
format!("{ts} {tool} {}", if *success { "ok" } else { "error" })
}
EventKind::Diagnostics {
file,
count,
preview,
} => format!("{ts} {file} {count} {preview}"),
EventKind::ProtocolMessage {
protocol,
language,
direction,
message,
} => {
let tag = match protocol {
Protocol::Mcp => "[mcp]".to_string(),
Protocol::Lsp => format!("[{}]", language.as_deref().unwrap_or("lsp")),
};
let arrow = match direction {
Direction::Send => "\u{2192}",
Direction::Recv => "\u{2190}",
};
let method = message
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("response");
format!("{ts} {tag} {arrow} {method}")
}
}
}
#[must_use]
pub fn tool_icon<'a>(name: &str, icons: &'a IconSet) -> &'a str {
match name {
"grep" => &icons.tool_search,
"glob" => &icons.tool_glob,
"sed" => &icons.tool_sed,
_ => &icons.tool_default,
}
}
#[must_use]
pub fn basename(path: &str) -> &str {
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
}
#[must_use]
pub fn diag_style<'a>(
count: usize,
preview: &str,
icons: &'a IconSet,
theme: &Theme,
) -> (&'a str, Style) {
if count == 0 {
return (&icons.diag_ok, theme.success);
}
let lower = preview.to_lowercase();
if lower.contains("[error]") {
(&icons.diag_error, theme.error)
} else if lower.contains("[warning]") {
(&icons.diag_warn, theme.warning)
} else if lower.contains("[info]") || lower.contains("[hint]") {
(&icons.diag_info, theme.info)
} else {
(&icons.diag_warn, theme.warning)
}
}
#[must_use]
#[allow(clippy::too_many_lines, reason = "match arms for each event kind")]
pub fn format_event_styled(ev: &SessionEvent, icons: &IconSet, theme: &Theme) -> Line<'static> {
let ts = ev.timestamp.format("%H:%M:%S").to_string();
let ts_span = Span::styled(format!("{ts} "), theme.timestamp);
match &ev.kind {
EventKind::Started => Line::from(vec![
ts_span,
Span::styled(icons.session_started.clone(), theme.success),
Span::styled("session started", theme.text),
]),
EventKind::Shutdown => Line::from(vec![
ts_span,
Span::styled(icons.session_shutdown.clone(), theme.muted),
Span::styled("session shutdown", theme.text),
]),
EventKind::ServerState { language, state } => Line::from(vec![
ts_span,
Span::styled(icons.server_state.clone(), theme.accent),
Span::styled(format!("[{language}] "), theme.accent),
Span::styled(format!("state → {state}"), theme.text),
]),
EventKind::Progress {
language,
title,
message,
percentage,
} => {
let pct = percentage.map_or(String::new(), |p| format!(" ({p}%)"));
let msg = message.as_ref().map_or(String::new(), |m| format!(": {m}"));
Line::from(vec![
ts_span,
Span::styled(icons.progress.clone(), theme.text),
Span::styled(format!("[{language}] "), theme.accent),
Span::styled(format!("{title}{msg}{pct}"), theme.text),
])
}
EventKind::ProgressEnd { language } => Line::from(vec![
ts_span,
Span::styled(icons.progress.clone(), theme.text),
Span::styled(format!("[{language}] "), theme.accent),
Span::styled("complete", theme.text),
]),
EventKind::ToolCall { tool, file, .. } => {
let icon = tool_icon(tool, icons);
let file_str = file
.as_ref()
.map(|f| format!(" {}", basename(f)))
.unwrap_or_default();
Line::from(vec![
ts_span,
Span::styled(icon.to_string(), theme.success),
Span::styled(format!("{tool}{file_str}"), theme.text),
])
}
EventKind::ToolResult {
tool,
success,
duration_ms,
..
} => {
let (status_text, status_style) = if *success {
("ok", theme.success)
} else {
("error", theme.error)
};
Line::from(vec![
ts_span,
Span::styled(icons.tool_result.clone(), theme.info),
Span::styled(format!("{tool} {}", icons.tool_result_sep), theme.text),
Span::styled(status_text.to_string(), status_style),
Span::styled(format!(" ({duration_ms}ms)"), theme.text),
])
}
EventKind::Diagnostics {
file,
count,
preview,
} => {
let (icon, style) = diag_style(*count, preview, icons, theme);
let base = basename(file);
if *count == 0 {
Line::from(vec![
ts_span,
Span::styled(icon.to_string(), style),
Span::styled(base.to_string(), theme.text),
])
} else {
let label = format!("{count} diagnostic{}", if *count == 1 { "" } else { "s" });
Line::from(vec![
ts_span,
Span::styled(icon.to_string(), style),
Span::styled(format!("{base}: "), theme.text),
Span::styled(label, style),
])
}
}
EventKind::ProtocolMessage {
protocol,
language,
direction,
message,
} => {
let tag = match protocol {
Protocol::Mcp => "[mcp]".to_string(),
Protocol::Lsp => format!("[{}]", language.as_deref().unwrap_or("lsp")),
};
let arrow = match direction {
Direction::Send => "\u{2192}",
Direction::Recv => "\u{2190}",
};
let method = message
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("response");
Line::from(vec![
ts_span,
Span::styled(format!("{tag} "), theme.text),
Span::styled(format!("{arrow} "), theme.text),
Span::styled(method.to_string(), theme.text),
])
}
}
}
fn message_direction_arrow(payload: &serde_json::Value) -> &'static str {
if payload.get("result").is_some() || payload.get("error").is_some() {
"\u{2190}" } else {
"\u{2192}" }
}
#[must_use]
pub fn format_message_styled(
msg: &SessionMessage,
icons: &IconSet,
theme: &Theme,
) -> Line<'static> {
let ts = msg.timestamp.format("%H:%M:%S").to_string();
let ts_span = Span::styled(format!("{ts} "), theme.timestamp);
match msg.r#type.as_str() {
"lsp" => {
let arrow = message_direction_arrow(&msg.payload);
Line::from(vec![
ts_span,
Span::styled(format!("[{}] ", msg.server), theme.accent),
Span::styled(format!("{arrow} "), theme.text),
Span::styled(msg.method.clone(), theme.text),
])
}
"mcp" => {
if msg.method == "tools/call" {
let tool_name = msg
.payload
.get("params")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or(&msg.method);
let icon = tool_icon(tool_name, icons);
Line::from(vec![
ts_span,
Span::styled(icon.to_string(), theme.success),
Span::styled(tool_name.to_string(), theme.text),
])
} else {
let arrow = message_direction_arrow(&msg.payload);
Line::from(vec![
ts_span,
Span::styled("[mcp] ".to_string(), theme.text),
Span::styled(format!("{arrow} "), theme.text),
Span::styled(msg.method.clone(), theme.text),
])
}
}
"hook" => {
if let Some(count_val) = msg.payload.get("count") {
let count = count_val.as_u64().unwrap_or(0);
let file = msg
.payload
.get("file")
.and_then(|f| f.as_str())
.unwrap_or(&msg.method);
let base = basename(file);
if count == 0 {
Line::from(vec![
ts_span,
Span::styled(icons.diag_ok.clone(), theme.success),
Span::styled(base.to_string(), theme.text),
])
} else {
let preview = msg
.payload
.get("preview")
.and_then(|p| p.as_str())
.unwrap_or("");
#[allow(
clippy::cast_possible_truncation,
reason = "diagnostic count is always small"
)]
let (icon, style) = diag_style(count as usize, preview, icons, theme);
let label = format!("{count} diagnostic{}", if count == 1 { "" } else { "s" });
Line::from(vec![
ts_span,
Span::styled(icon.to_string(), style),
Span::styled(format!("{base}: "), theme.text),
Span::styled(label, style),
])
}
} else {
Line::from(vec![
ts_span,
Span::styled("[hook] ".to_string(), theme.text),
Span::styled(msg.method.clone(), theme.text),
])
}
}
other => Line::from(vec![
ts_span,
Span::styled(format!("[{other}] "), theme.text),
Span::styled(msg.method.clone(), theme.text),
]),
}
}
#[must_use]
pub fn format_message_plain(msg: &SessionMessage) -> String {
let ts = msg.timestamp.format("%H:%M:%S");
match msg.r#type.as_str() {
"lsp" => {
let arrow = message_direction_arrow(&msg.payload);
format!("{ts} [{}] {arrow} {}", msg.server, msg.method)
}
"mcp" => {
if msg.method == "tools/call" {
let tool_name = msg
.payload
.get("params")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or(&msg.method);
format!("{ts} {tool_name}")
} else {
let arrow = message_direction_arrow(&msg.payload);
format!("{ts} [mcp] {arrow} {}", msg.method)
}
}
"hook" => msg.payload.get("count").map_or_else(
|| format!("{ts} [hook] {}", msg.method),
|count_val| {
let count = count_val.as_u64().unwrap_or(0);
let file = msg
.payload
.get("file")
.and_then(|f| f.as_str())
.unwrap_or(&msg.method);
let base = basename(file);
if count == 0 {
format!("{ts} {base}")
} else {
format!("{ts} {base}: {count} diagnostics")
}
},
),
other => format!("{ts} [{other}] {}", msg.method),
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use chrono::{TimeDelta, Utc};
use crate::config::IconConfig;
use crate::session::{EventKind, SessionEvent, SessionMessage};
fn make_event(kind: EventKind) -> SessionEvent {
SessionEvent {
timestamp: Utc::now(),
kind,
}
}
fn make_message(r#type: &str, method: &str, server: &str) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: Utc::now(),
payload: serde_json::json!({}),
}
}
fn make_message_with_payload(
r#type: &str,
method: &str,
server: &str,
payload: serde_json::Value,
) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: Utc::now(),
payload,
}
}
#[test]
fn test_theme_construction() {
let theme = Theme::new();
assert!(!theme.border_focused.add_modifier.contains(Modifier::DIM));
assert!(theme.border_unfocused.add_modifier.contains(Modifier::DIM));
}
#[test]
fn test_icon_set_unicode_preset() {
let icons = IconSet::from_config(IconConfig::default());
assert_eq!(icons.diag_error, "\u{2717} ");
}
#[test]
fn test_icon_set_nerd_preset() {
let config = IconConfig {
preset: IconPreset::Nerd,
..IconConfig::default()
};
let icons = IconSet::from_config(config);
assert_eq!(icons.diag_error, " ");
}
#[test]
fn test_icon_set_new_unicode_fields() {
let icons = IconSet::from_config(IconConfig::default());
assert_eq!(icons.session_started, "\u{25CF} ");
assert_eq!(icons.session_shutdown, "\u{25CB} ");
assert_eq!(icons.server_state, "\u{25C6} ");
assert_eq!(icons.tool_result, "\u{2B9C} ");
assert_eq!(icons.tool_result_sep, "\u{276F} ");
assert_eq!(icons.tool_sed, "\u{2B9E} ");
assert_eq!(icons.ls_active, "\u{25CF} ");
assert_eq!(icons.ls_inactive, "\u{25CB} ");
assert_eq!(icons.spinner_grow.len(), 3);
assert_eq!(icons.spinner_cycle.len(), 4);
assert_eq!(icons.spinner_done, "\u{2588}");
}
#[test]
fn test_tool_icon_sed() {
let icons = IconSet::from_config(IconConfig::default());
assert_eq!(tool_icon("sed", &icons), "\u{2B9E} ");
}
#[test]
fn test_emoji_preset_tool_glob() {
let config = IconConfig {
preset: IconPreset::Emoji,
..IconConfig::default()
};
let icons = IconSet::from_config(config);
assert_eq!(icons.tool_glob, "\u{1F5C2}\u{FE0F}");
}
#[test]
fn test_icon_set_overrides() {
let config = IconConfig {
diag_error: Some("ERR ".into()),
..IconConfig::default()
};
let icons = IconSet::from_config(config);
assert_eq!(icons.diag_error, "ERR ");
}
#[test]
fn test_format_event_plain_tool_call() {
let ev = make_event(EventKind::ToolCall {
tool: "grep".to_string(),
file: Some("/src/main.rs".to_string()),
params: None,
});
let plain = format_event_plain(&ev);
assert!(plain.contains("grep"));
}
#[test]
fn test_format_event_plain_diagnostics() {
let ev = make_event(EventKind::Diagnostics {
file: "/src/lib.rs".to_string(),
count: 3,
preview: "[error] something".to_string(),
});
let plain = format_event_plain(&ev);
assert!(plain.contains("lib.rs"));
assert!(plain.contains('3'));
}
#[test]
fn test_format_ago_seconds() {
let ts = Utc::now() - TimeDelta::seconds(30);
assert_eq!(format_ago(ts), "30s ago");
}
#[test]
fn test_format_ago_minutes() {
let ts = Utc::now() - TimeDelta::minutes(5);
assert_eq!(format_ago(ts), "5m ago");
}
#[test]
fn test_format_ago_hours() {
let ts = Utc::now() - TimeDelta::hours(2);
assert_eq!(format_ago(ts), "2h ago");
}
#[test]
fn test_rgb_to_hsl_black() {
let (h, s, l) = rgb_to_hsl(0, 0, 0);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 0.0).abs() < 0.01);
assert!((l - 0.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsl_white() {
let (h, s, l) = rgb_to_hsl(255, 255, 255);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 0.0).abs() < 0.01);
assert!((l - 1.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsl_pure_red() {
let (h, s, l) = rgb_to_hsl(255, 0, 0);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 1.0).abs() < 0.01);
assert!((l - 0.5).abs() < 0.01);
}
#[test]
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn test_hsl_roundtrip_dark_gray() {
let (r, g, b) = (30u8, 30, 30);
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!((i16::from(r) - i16::from(r2)).abs() <= 1);
assert!((i16::from(g) - i16::from(g2)).abs() <= 1);
assert!((i16::from(b) - i16::from(b2)).abs() <= 1);
}
#[test]
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn test_hsl_roundtrip_color() {
let (r, g, b) = (50u8, 130, 180);
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!((i16::from(r) - i16::from(r2)).abs() <= 1);
assert!((i16::from(g) - i16::from(g2)).abs() <= 1);
assert!((i16::from(b) - i16::from(b2)).abs() <= 1);
}
#[test]
fn test_selection_bg_dark_background_lightens() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(26, 26, 26) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(r > 26, "red channel should increase: {r}");
assert!(g > 26, "green channel should increase: {g}");
assert!(b > 26, "blue channel should increase: {b}");
}
#[test]
fn test_selection_bg_light_background_darkens() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(240, 240, 240) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(r < 240, "red channel should decrease: {r}");
assert!(g < 240, "green channel should decrease: {g}");
assert!(b < 240, "blue channel should decrease: {b}");
}
#[test]
fn test_selection_bg_preserves_hue() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(20, 20, 40) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(b >= r, "blue should still dominate: r={r} b={b}");
assert!(b >= g, "blue should still dominate: g={g} b={b}");
}
#[test]
fn test_parse_osc11_response_4digit() {
let response = b"\x1b]11;rgb:1a1a/1a1a/1a1a\x07";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0x1a, 0x1a, 0x1a)));
}
#[test]
fn test_parse_osc11_response_2digit() {
let response = b"\x1b]11;rgb:1a/1a/1a\x07";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0x1a, 0x1a, 0x1a)));
}
#[test]
fn test_parse_osc11_response_st_terminator() {
let response = b"\x1b]11;rgb:ffff/0000/8080\x1b\\";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0xff, 0x00, 0x80)));
}
#[test]
fn test_parse_osc11_garbage() {
let response = b"not a valid response";
assert!(parse_osc11_response(response).is_none());
}
#[test]
fn test_parse_osc_channel_variants() {
assert_eq!(parse_osc_channel("ff"), Some(0xff));
assert_eq!(parse_osc_channel("ffff"), Some(0xff));
assert_eq!(parse_osc_channel("0000"), Some(0x00));
assert_eq!(parse_osc_channel("8080"), Some(0x80));
assert_eq!(parse_osc_channel("f"), Some(0xff));
assert_eq!(parse_osc_channel("0"), Some(0x00));
}
#[test]
fn test_theme_detect_fallback() {
let theme = Theme::detect();
let _ = theme.selection;
}
#[test]
fn test_format_message_styled_lsp() {
let theme = Theme::new();
let icons = IconSet::from_config(IconConfig::default());
let msg = make_message("lsp", "textDocument/hover", "rust-analyzer");
let line = format_message_styled(&msg, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("[rust-analyzer]"),
"should contain server name"
);
assert!(text.contains("textDocument/hover"), "should contain method");
assert!(text.contains("\u{2192}"), "outbound request should show →");
}
#[test]
fn test_format_message_styled_lsp_response() {
let theme = Theme::new();
let icons = IconSet::from_config(IconConfig::default());
let msg = make_message_with_payload(
"lsp",
"textDocument/hover",
"rust-analyzer",
serde_json::json!({"id": 1, "result": {"contents": "fn main()"}}),
);
let line = format_message_styled(&msg, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("\u{2190}"), "response should show ←");
}
#[test]
fn test_format_message_styled_mcp() {
let theme = Theme::new();
let icons = IconSet::from_config(IconConfig::default());
let msg = make_message_with_payload(
"mcp",
"tools/call",
"catenary",
serde_json::json!({"params": {"name": "grep"}}),
);
let line = format_message_styled(&msg, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("grep"), "should contain tool name");
}
#[test]
fn test_format_message_styled_hook() {
let theme = Theme::new();
let icons = IconSet::from_config(IconConfig::default());
let msg = make_message_with_payload(
"hook",
"post-tool",
"catenary",
serde_json::json!({
"file": "/src/lib.rs",
"count": 2,
"preview": "\t:12:1 [error] rustc: bad"
}),
);
let line = format_message_styled(&msg, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("lib.rs"), "should contain file basename");
assert!(text.contains("2 diagnostics"), "should show count");
}
#[test]
fn test_format_message_styled_hook_clean() {
let theme = Theme::new();
let icons = IconSet::from_config(IconConfig::default());
let msg = make_message_with_payload(
"hook",
"post-tool",
"catenary",
serde_json::json!({"file": "/src/lib.rs", "count": 0}),
);
let line = format_message_styled(&msg, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("lib.rs"), "should contain file basename");
assert!(
line.spans.iter().any(|s| s.style == theme.success),
"clean diagnostics should use success style"
);
}
#[test]
fn test_format_message_plain() {
let msg = make_message("lsp", "textDocument/hover", "rust-analyzer");
let plain = format_message_plain(&msg);
assert!(plain.contains("[rust-analyzer]"));
assert!(plain.contains("textDocument/hover"));
assert!(plain.contains("\u{2192}"));
let mcp_msg = make_message_with_payload(
"mcp",
"tools/call",
"catenary",
serde_json::json!({"params": {"name": "grep"}}),
);
let plain = format_message_plain(&mcp_msg);
assert!(plain.contains("grep"));
let hook_msg = make_message_with_payload(
"hook",
"post-tool",
"catenary",
serde_json::json!({"file": "/src/main.rs", "count": 3}),
);
let plain = format_message_plain(&hook_msg);
assert!(plain.contains("main.rs"));
assert!(plain.contains("3 diagnostics"));
}
}