use ralph_core::{EventRecord, TerminationReason, floor_char_boundary, truncate_with_ellipsis};
use ralph_proto::HatId;
use std::collections::HashMap;
use std::time::Duration;
pub mod colors {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const RED: &str = "\x1b[31m";
pub const CYAN: &str = "\x1b[36m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
}
pub fn hat_emoji(hat_id: &str) -> &'static str {
match hat_id {
"planner" => "?",
"builder" => "?",
"reviewer" => "?",
_ => "?",
}
}
pub fn print_iteration_separator(
iteration: u32,
hat_id: &str,
elapsed: Duration,
max_iterations: u32,
use_colors: bool,
) {
use colors::*;
let emoji = hat_emoji(hat_id);
let elapsed_str = format_elapsed(elapsed);
let content = format!(
" ITERATION {} | {} {} | {} elapsed | {}/{}",
iteration, emoji, hat_id, elapsed_str, iteration, max_iterations
);
let box_width = 79;
let separator = "=".repeat(box_width);
if use_colors {
println!("\n{BOLD}{CYAN}{separator}{RESET}");
println!("{BOLD}{CYAN}{content}{RESET}");
println!("{BOLD}{CYAN}{separator}{RESET}");
} else {
println!("\n{separator}");
println!("{content}");
println!("{separator}");
}
}
pub fn format_elapsed(d: Duration) -> String {
let total_secs = d.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
pub fn truncate(s: &str, max_len: usize) -> String {
truncate_with_ellipsis(s, max_len)
}
pub fn print_termination(
reason: &TerminationReason,
state: &ralph_core::LoopState,
use_colors: bool,
) {
use colors::*;
let (color, icon, label) = match reason {
TerminationReason::CompletionPromise => (GREEN, "?", "Completion promise detected"),
TerminationReason::MaxIterations => (YELLOW, "?", "Maximum iterations reached"),
TerminationReason::MaxRuntime => (YELLOW, "?", "Maximum runtime exceeded"),
TerminationReason::MaxCost => (YELLOW, "?", "Maximum cost exceeded"),
TerminationReason::ConsecutiveFailures => (RED, "?", "Too many consecutive failures"),
TerminationReason::LoopThrashing => (RED, "?", "Loop thrashing detected"),
TerminationReason::LoopStale => (RED, "?", "Stale loop detected"),
TerminationReason::ValidationFailure => (RED, "?", "Too many malformed JSONL events"),
TerminationReason::Stopped => (CYAN, "?", "Manually stopped"),
TerminationReason::Interrupted => (YELLOW, "?", "Interrupted by signal"),
TerminationReason::RestartRequested => (CYAN, "↻", "Restarting by human request"),
TerminationReason::WorkspaceGone => (RED, "?", "Workspace directory removed"),
TerminationReason::Cancelled => (CYAN, "⏹", "Cancelled gracefully"),
};
let separator = "-".repeat(58);
if use_colors {
println!("\n{BOLD}+{separator}+{RESET}");
println!(
"{BOLD}|{RESET} {color}{BOLD}{icon}{RESET} Loop terminated: {color}{label}{RESET}"
);
println!("{BOLD}+{separator}+{RESET}");
println!(
"{BOLD}|{RESET} Iterations: {CYAN}{}{RESET}",
state.iteration
);
println!(
"{BOLD}|{RESET} Elapsed: {CYAN}{:.1}s{RESET}",
state.elapsed().as_secs_f64()
);
if state.cumulative_cost > 0.0 {
println!(
"{BOLD}|{RESET} Est. cost: {CYAN}${:.2}{RESET}",
state.cumulative_cost
);
}
println!("{BOLD}+{separator}+{RESET}");
} else {
println!("\n+{}+", "-".repeat(58));
println!("| {icon} Loop terminated: {label}");
println!("+{}+", "-".repeat(58));
println!("| Iterations: {}", state.iteration);
println!("| Elapsed: {:.1}s", state.elapsed().as_secs_f64());
if state.cumulative_cost > 0.0 {
println!("| Est. cost: ${:.2}", state.cumulative_cost);
}
println!("+{}+", "-".repeat(58));
}
}
pub fn get_topic_color(topic: &str) -> &'static str {
use colors::*;
if topic.starts_with("task.") {
CYAN
} else if topic.starts_with("build.done") {
GREEN
} else if topic.starts_with("build.blocked") {
RED
} else if topic.starts_with("build.") {
YELLOW
} else if topic.starts_with("review.") {
MAGENTA
} else {
BLUE
}
}
pub fn print_events_table(records: &[EventRecord], use_colors: bool) {
use colors::*;
if use_colors {
println!(
"{BOLD}{DIM} # | Time | Iteration | Hat | Topic | Triggered | Payload{RESET}"
);
println!(
"{DIM}----+----------+-----------+---------------+--------------------+----------------+-----------------{RESET}"
);
} else {
println!(
" # | Time | Iteration | Hat | Topic | Triggered | Payload"
);
println!(
"----|----------|-----------|---------------|--------------------|-----------------|-----------------"
);
}
for (i, record) in records.iter().enumerate() {
let topic_color = get_topic_color(&record.topic);
let triggered = record.triggered.as_deref().unwrap_or("-");
let payload_one_line = record.payload.replace('\n', " ");
let payload_preview = truncate_with_ellipsis(&payload_one_line, 40);
let time = record
.ts
.find('T')
.and_then(|t_pos| {
let after_t = &record.ts[t_pos + 1..];
let end = after_t
.find(|c| c == 'Z' || c == '+' || c == '-')
.unwrap_or(after_t.len());
let time_str = &after_t[..end];
let boundary = floor_char_boundary(time_str, 8);
Some(&time_str[..boundary])
})
.unwrap_or("-");
if use_colors {
println!(
"{DIM}{:>3}{RESET} | {:<8} | {:>9} | {:<13} | {topic_color}{:<18}{RESET} | {:<14} | {DIM}{}{RESET}",
i + 1,
time,
record.iteration,
truncate(&record.hat, 13),
truncate(&record.topic, 18),
truncate(triggered, 14),
payload_preview
);
} else {
println!(
"{:>3} | {:<8} | {:>9} | {:<13} | {:<18} | {:<14} | {}",
i + 1,
time,
record.iteration,
truncate(&record.hat, 13),
truncate(&record.topic, 18),
truncate(triggered, 14),
payload_preview
);
}
}
if use_colors {
println!("\n{DIM}Total: {} events{RESET}", records.len());
} else {
println!("\nTotal: {} events", records.len());
}
}
pub fn print_wave_header(hat_name: &str, worker_count: usize, timeout_secs: u64, use_colors: bool) {
use colors::*;
let emoji = hat_emoji(hat_name);
let content = format!(
" WAVE: {} {} | {} workers | timeout {}s ",
emoji, hat_name, worker_count, timeout_secs
);
let box_width = 79;
let content_len = content.len();
let pad = if content_len + 2 < box_width {
box_width - content_len - 2
} else {
0
};
if use_colors {
eprintln!("\n{BOLD}{MAGENTA}──{content}{}{RESET}", "─".repeat(pad));
} else {
eprintln!("\n──{content}{}", "─".repeat(pad));
}
}
pub fn print_wave_worker_done(
index: u32,
total: u32,
duration: Duration,
success: bool,
payload_preview: &str,
use_colors: bool,
) {
use colors::*;
let elapsed = format_elapsed(duration);
let status_word = if success { "done" } else { "failed" };
let preview = truncate(payload_preview, 60);
if use_colors {
let (icon, color) = if success {
("✓", GREEN)
} else {
("✗", RED)
};
eprintln!(
" {color}{BOLD}{icon}{RESET} Worker {}/{} {} ({}) — {}",
index + 1,
total,
status_word,
elapsed,
preview
);
} else {
let icon = if success { "✓" } else { "✗" };
eprintln!(
" {} Worker {}/{} {} ({}) — {}",
icon,
index + 1,
total,
status_word,
elapsed,
preview
);
}
}
pub fn print_wave_summary(
succeeded: usize,
failed: usize,
total_duration: Duration,
use_colors: bool,
) {
use colors::*;
let elapsed = format_elapsed(total_duration);
let content = format!(
" Wave complete: {} succeeded, {} failed ({}) ",
succeeded, failed, elapsed
);
let box_width = 79;
let content_len = content.len();
let pad = if content_len + 2 < box_width {
box_width - content_len - 2
} else {
0
};
if use_colors {
let summary_color = if failed > 0 { YELLOW } else { GREEN };
eprintln!("{BOLD}{summary_color}──{content}{}{RESET}", "─".repeat(pad));
} else {
eprintln!("──{content}{}", "─".repeat(pad));
}
}
pub fn build_tui_hat_map(registry: &ralph_core::HatRegistry) -> HashMap<String, (HatId, String)> {
let mut map = HashMap::new();
for hat in registry.all() {
for subscription in &hat.subscriptions {
let topic_str = subscription.to_string();
if !topic_str.contains('*') {
map.insert(topic_str, (hat.id.clone(), hat.name.clone()));
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
use ralph_core::RalphConfig;
#[test]
fn test_format_elapsed_seconds_only() {
let d = Duration::from_secs(45);
assert_eq!(format_elapsed(d), "45s");
}
#[test]
fn test_format_elapsed_minutes_and_seconds() {
let d = Duration::from_secs(125); assert_eq!(format_elapsed(d), "2m 5s");
}
#[test]
fn test_format_elapsed_hours_minutes_seconds() {
let d = Duration::from_secs(3725); assert_eq!(format_elapsed(d), "1h 2m 5s");
}
#[test]
fn test_truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_long_string() {
assert_eq!(truncate("hello world", 8), "hello...");
}
#[test]
fn test_truncate_does_not_panic_on_multibyte_chars() {
let s = format!("{}✅{}", "x".repeat(39), "y".repeat(10));
let out = truncate(&s, 40);
for _ in out.chars() {}
assert!(out.ends_with("..."));
}
#[test]
fn test_print_events_table_does_not_panic_on_multibyte_payload() {
let payload = format!("{}✅{}", "x".repeat(39), "y".repeat(10));
let record = EventRecord {
ts: "2026-01-23T00:00:00Z".to_string(),
iteration: 1,
hat: "hat".to_string(),
topic: "task.start".to_string(),
triggered: None,
payload,
blocked_count: None,
wave_id: None,
wave_index: None,
wave_total: None,
};
print_events_table(&[record], false);
}
#[test]
fn test_print_events_table_does_not_panic_on_multibyte_ts() {
let record = EventRecord {
ts: "2026-01-23Txxxxxxx✅Z".to_string(),
iteration: 1,
hat: "hat".to_string(),
topic: "task.start".to_string(),
triggered: None,
payload: "ok".to_string(),
blocked_count: None,
wave_id: None,
wave_index: None,
wave_total: None,
};
print_events_table(&[record], false);
}
#[test]
fn test_hat_emoji_known_hats() {
assert_eq!(hat_emoji("planner"), "?");
assert_eq!(hat_emoji("builder"), "?");
assert_eq!(hat_emoji("reviewer"), "?");
}
#[test]
fn test_hat_emoji_unknown_hat() {
assert_eq!(hat_emoji("custom_hat"), "?");
}
#[test]
fn test_build_tui_hat_map_extracts_custom_hats() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
publishes: ["security.done"]
correctness_reviewer:
name: "Correctness Reviewer"
triggers: ["review.correctness"]
publishes: ["correctness.done"]
architecture_reviewer:
name: "Architecture Reviewer"
triggers: ["review.architecture", "arch.*"]
publishes: ["architecture.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = ralph_core::HatRegistry::from_config(&config);
let hat_map = build_tui_hat_map(®istry);
assert_eq!(hat_map.len(), 3, "Should have 3 exact topic mappings");
assert!(
hat_map.contains_key("review.security"),
"Should map review.security topic"
);
let (hat_id, hat_display) = &hat_map["review.security"];
assert_eq!(hat_id.as_str(), "security_reviewer");
assert_eq!(hat_display, "Security Reviewer");
assert!(
hat_map.contains_key("review.correctness"),
"Should map review.correctness topic"
);
let (hat_id, hat_display) = &hat_map["review.correctness"];
assert_eq!(hat_id.as_str(), "correctness_reviewer");
assert_eq!(hat_display, "Correctness Reviewer");
assert!(
hat_map.contains_key("review.architecture"),
"Should map review.architecture topic"
);
let (hat_id, hat_display) = &hat_map["review.architecture"];
assert_eq!(hat_id.as_str(), "architecture_reviewer");
assert_eq!(hat_display, "Architecture Reviewer");
assert!(
!hat_map.contains_key("arch.*"),
"Wildcard patterns should not be in the map"
);
}
#[test]
fn test_build_tui_hat_map_empty_registry() {
let config = RalphConfig::default();
let registry = ralph_core::HatRegistry::from_config(&config);
let hat_map = build_tui_hat_map(®istry);
assert_eq!(
hat_map.len(),
0,
"Empty registry should produce empty hat map"
);
}
#[test]
fn test_build_tui_hat_map_skips_wildcard_patterns() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.*", "build.*"]
publishes: ["plan.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = ralph_core::HatRegistry::from_config(&config);
let hat_map = build_tui_hat_map(®istry);
assert_eq!(
hat_map.len(),
0,
"Wildcard-only patterns should produce empty hat map"
);
}
}