use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use colored::Colorize;
use super::style::{Glyphs, SelfwareStyle};
use super::theme::current_theme;
const FOX_FRAMES: [&[&str]; 3] = [
&[
r" /\___/\ ",
r" ( o o ) ",
r" ( =^= ) ",
r" ) ( ",
r" ( ) ",
],
&[
r" /\___/\ ",
r" ( - - ) ",
r" ( =^= ) ",
r" ) ( ",
r" ( ) ",
],
&[
r" /\___/\ ",
r" ( o o ) ",
r" ( =^= )~",
r" ) ( ",
r" ( ) ",
],
];
const FOX_INLINE: &str = "/\\___/\\";
pub struct TaskDisplay {
pub task_description: String,
pub start_time: Instant,
pub tokens_in: AtomicU64,
pub tokens_out: AtomicU64,
pub tool_calls: AtomicU64,
pub current_tool: Mutex<String>,
pub animation_frame: AtomicU64,
tool_histogram: Mutex<HashMap<String, u64>>,
file_stats: Mutex<(u64, u64)>,
}
impl TaskDisplay {
pub fn new(description: &str) -> Self {
Self {
task_description: description.to_string(),
start_time: Instant::now(),
tokens_in: AtomicU64::new(0),
tokens_out: AtomicU64::new(0),
tool_calls: AtomicU64::new(0),
current_tool: Mutex::new(String::new()),
animation_frame: AtomicU64::new(0),
tool_histogram: Mutex::new(HashMap::new()),
file_stats: Mutex::new((0, 0)),
}
}
pub fn update_tokens(&self, input: u64, output: u64) {
self.tokens_in.fetch_add(input, Ordering::Relaxed);
self.tokens_out.fetch_add(output, Ordering::Relaxed);
}
pub fn record_tool_call(&self, tool_name: &str) {
self.tool_calls.fetch_add(1, Ordering::Relaxed);
if let Ok(mut hist) = self.tool_histogram.lock() {
*hist.entry(tool_name.to_string()).or_insert(0) += 1;
}
}
pub fn set_current_tool(&self, name: &str) {
if let Ok(mut current) = self.current_tool.lock() {
current.clear();
current.push_str(name);
}
}
pub fn record_file_change(&self, created: bool) {
if let Ok(mut stats) = self.file_stats.lock() {
if created {
stats.0 += 1;
} else {
stats.1 += 1;
}
}
}
pub fn advance_animation(&self) {
self.animation_frame.fetch_add(1, Ordering::Relaxed);
}
pub fn render_fox_frame(&self) -> String {
let idx = self.animation_frame.load(Ordering::Relaxed) as usize % FOX_FRAMES.len();
let theme = current_theme();
FOX_FRAMES[idx]
.iter()
.map(|line| format!(" {}", line.custom_color(theme.primary)))
.collect::<Vec<_>>()
.join("\n")
}
pub fn render_status_line(&self) -> String {
let elapsed = self.start_time.elapsed();
let time_str = format_duration(elapsed);
let total_tokens =
self.tokens_in.load(Ordering::Relaxed) + self.tokens_out.load(Ordering::Relaxed);
let token_str = format_tokens(total_tokens);
let calls = self.tool_calls.load(Ordering::Relaxed);
let current = self
.current_tool
.lock()
.map(|c| c.clone())
.unwrap_or_default();
let mut line = format!(
"{} {} {} {} {} {} tokens {} {} tools",
FOX_INLINE.custom_color(current_theme().primary),
self.task_description.as_str().emphasis(),
Glyphs::horiz().repeat(3).muted(),
time_str.as_str().timestamp(),
Glyphs::vert().muted(),
token_str.as_str().garden_healthy(),
Glyphs::vert().muted(),
calls.to_string().as_str().tool_name(),
);
if !current.is_empty() {
line.push_str(&format!(
" {} {}",
Glyphs::vert().muted(),
current.as_str().craftsman_voice(),
));
}
line
}
pub fn render_detailed_status(&self) -> String {
let elapsed = self.start_time.elapsed();
let time_str = format_duration(elapsed);
let tin = self.tokens_in.load(Ordering::Relaxed);
let tout = self.tokens_out.load(Ordering::Relaxed);
let calls = self.tool_calls.load(Ordering::Relaxed);
let current = self
.current_tool
.lock()
.map(|c| c.clone())
.unwrap_or_default();
let mut line = format!(
"{} Task: {} {} {} {} {} {} in / {} out {} {} {} calls",
Glyphs::sprout(),
self.task_description.as_str().emphasis(),
Glyphs::vert().muted(),
Glyphs::gear(),
time_str.as_str().timestamp(),
Glyphs::vert().muted(),
format_tokens(tin).as_str().garden_healthy(),
format_tokens(tout).as_str().garden_wilting(),
Glyphs::vert().muted(),
Glyphs::wrench(),
calls.to_string().as_str().tool_name(),
);
if !current.is_empty() {
line.push_str(&format!(
" {} Currently: {}",
Glyphs::vert().muted(),
current.as_str().craftsman_voice(),
));
}
line
}
pub fn render_completion_summary(&self) -> String {
let elapsed = self.start_time.elapsed();
let time_str = format_duration(elapsed);
let tin = self.tokens_in.load(Ordering::Relaxed);
let tout = self.tokens_out.load(Ordering::Relaxed);
let calls = self.tool_calls.load(Ordering::Relaxed);
let hist_str = if let Ok(hist) = self.tool_histogram.lock() {
if hist.is_empty() {
"none".to_string()
} else {
let mut pairs: Vec<_> = hist.iter().collect();
pairs.sort_by(|a, b| b.1.cmp(a.1));
let parts: Vec<String> = pairs
.iter()
.take(6)
.map(|(name, count)| format!("{} x{}", name, count))
.collect();
parts.join(", ")
}
} else {
"unknown".to_string()
};
let (files_created, files_modified) = self.file_stats.lock().map(|s| *s).unwrap_or((0, 0));
let desc_line = format!("{} {}", Glyphs::bloom(), self.task_description);
let dur_line = format!("Duration: {}", time_str);
let tok_line = format!(
"Tokens: {} in / {} out",
format_tokens_with_commas(tin),
format_tokens_with_commas(tout),
);
let tool_line = format!("Tools: {} calls ({})", calls, hist_str);
let file_line = format!(
"Files: {} created, {} modified",
files_created, files_modified
);
let content_lines = [&desc_line, &dur_line, &tok_line, &tool_line, &file_line];
let inner_width = content_lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(40)
.max(40)
+ 2;
let h = Glyphs::horiz();
let v = Glyphs::vert();
let top = format!(
"{} Task Complete {}{}",
Glyphs::corner_tl(),
h.repeat(inner_width.saturating_sub(15)),
Glyphs::corner_tr(),
);
let bottom = format!(
"{}{}{}",
Glyphs::corner_bl(),
h.repeat(inner_width + 2),
Glyphs::corner_br(),
);
let pad = |s: &str| -> String {
let chars = s.chars().count();
let remaining = inner_width.saturating_sub(chars);
format!("{} {}{} {}", v, s, " ".repeat(remaining), v)
};
let empty_line = pad("");
let mut result = String::new();
result.push_str(&format!("{}\n", top.as_str().muted()));
result.push_str(&format!("{}\n", pad(&desc_line).as_str().garden_healthy()));
result.push_str(&format!("{}\n", empty_line.as_str().muted()));
result.push_str(&format!("{}\n", pad(&dur_line).as_str().muted()));
result.push_str(&format!("{}\n", pad(&tok_line).as_str().muted()));
result.push_str(&format!("{}\n", pad(&tool_line).as_str().muted()));
result.push_str(&format!("{}\n", pad(&file_line).as_str().muted()));
result.push_str(&format!("{}", bottom.as_str().muted()));
result
}
}
pub fn render_welcome_banner() -> String {
let h = Glyphs::horiz();
let v = Glyphs::vert();
let width = 43;
let top = format!(
"{}{}{}",
Glyphs::corner_tl(),
h.repeat(width),
Glyphs::corner_tr(),
);
let bottom = format!(
"{}{}{}",
Glyphs::corner_bl(),
h.repeat(width),
Glyphs::corner_br(),
);
let line1 = format!(
"{} {} Selfware Workshop v0.1.5{}{}",
v,
Glyphs::sprout(),
" ".repeat(width - 30),
v,
);
let line2 = format!(
"{} Software that improves itself.{}{}",
v,
" ".repeat(width - 34),
v,
);
let line3 = format!(
"{} Local-first. Privacy-owned.{}{}",
v,
" ".repeat(width - 31),
v,
);
format!(
"{}\n{}\n{}\n{}\n{}",
top.as_str().muted(),
line1.as_str().emphasis(),
line2.as_str().craftsman_voice(),
line3.as_str().craftsman_voice(),
bottom.as_str().muted(),
)
}
pub fn format_duration(duration: Duration) -> String {
let total_secs = duration.as_secs();
if total_secs < 60 {
format!("{}s", total_secs)
} else if total_secs < 3600 {
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("{}m {}s", mins, secs)
} else {
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
format!("{}h {}m {}s", hours, mins, secs)
}
}
pub fn format_tokens(count: u64) -> String {
if count < 1_000 {
count.to_string()
} else if count < 1_000_000 {
let k = count as f64 / 1_000.0;
format!("{:.1}K", k)
} else {
let m = count as f64 / 1_000_000.0;
format!("{:.1}M", m)
}
}
pub fn format_tokens_with_commas(count: u64) -> String {
let s = count.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(Duration::from_secs(0)), "0s");
assert_eq!(format_duration(Duration::from_secs(42)), "42s");
assert_eq!(format_duration(Duration::from_secs(59)), "59s");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(Duration::from_secs(60)), "1m 0s");
assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
assert_eq!(format_duration(Duration::from_secs(192)), "3m 12s");
assert_eq!(format_duration(Duration::from_secs(3599)), "59m 59s");
}
#[test]
fn test_format_duration_hours() {
assert_eq!(format_duration(Duration::from_secs(3600)), "1h 0m 0s");
assert_eq!(format_duration(Duration::from_secs(3930)), "1h 5m 30s");
assert_eq!(format_duration(Duration::from_secs(7261)), "2h 1m 1s");
}
#[test]
fn test_format_tokens_small() {
assert_eq!(format_tokens(0), "0");
assert_eq!(format_tokens(42), "42");
assert_eq!(format_tokens(999), "999");
}
#[test]
fn test_format_tokens_thousands() {
assert_eq!(format_tokens(1_000), "1.0K");
assert_eq!(format_tokens(1_234), "1.2K");
assert_eq!(format_tokens(8_247), "8.2K");
assert_eq!(format_tokens(12_345), "12.3K");
assert_eq!(format_tokens(999_999), "1000.0K");
}
#[test]
fn test_format_tokens_millions() {
assert_eq!(format_tokens(1_000_000), "1.0M");
assert_eq!(format_tokens(1_500_000), "1.5M");
assert_eq!(format_tokens(12_345_678), "12.3M");
}
#[test]
fn test_format_tokens_with_commas() {
assert_eq!(format_tokens_with_commas(0), "0");
assert_eq!(format_tokens_with_commas(42), "42");
assert_eq!(format_tokens_with_commas(999), "999");
assert_eq!(format_tokens_with_commas(1_000), "1,000");
assert_eq!(format_tokens_with_commas(8_247), "8,247");
assert_eq!(format_tokens_with_commas(1_234_567), "1,234,567");
}
#[test]
fn test_task_display_new() {
let display = TaskDisplay::new("Test task");
assert_eq!(display.task_description, "Test task");
assert_eq!(display.tokens_in.load(Ordering::Relaxed), 0);
assert_eq!(display.tokens_out.load(Ordering::Relaxed), 0);
assert_eq!(display.tool_calls.load(Ordering::Relaxed), 0);
assert_eq!(display.animation_frame.load(Ordering::Relaxed), 0);
}
#[test]
fn test_update_tokens() {
let display = TaskDisplay::new("Tokens");
display.update_tokens(100, 50);
assert_eq!(display.tokens_in.load(Ordering::Relaxed), 100);
assert_eq!(display.tokens_out.load(Ordering::Relaxed), 50);
display.update_tokens(200, 75);
assert_eq!(display.tokens_in.load(Ordering::Relaxed), 300);
assert_eq!(display.tokens_out.load(Ordering::Relaxed), 125);
}
#[test]
fn test_record_tool_call() {
let display = TaskDisplay::new("Tools");
display.record_tool_call("file_read");
display.record_tool_call("file_read");
display.record_tool_call("shell_exec");
assert_eq!(display.tool_calls.load(Ordering::Relaxed), 3);
let hist = display.tool_histogram.lock().unwrap();
assert_eq!(hist.get("file_read"), Some(&2));
assert_eq!(hist.get("shell_exec"), Some(&1));
}
#[test]
fn test_set_current_tool() {
let display = TaskDisplay::new("Current");
display.set_current_tool("file_write");
assert_eq!(*display.current_tool.lock().unwrap(), "file_write");
display.set_current_tool("shell_exec");
assert_eq!(*display.current_tool.lock().unwrap(), "shell_exec");
display.set_current_tool("");
assert_eq!(*display.current_tool.lock().unwrap(), "");
}
#[test]
fn test_record_file_change() {
let display = TaskDisplay::new("Files");
display.record_file_change(true);
display.record_file_change(true);
display.record_file_change(false);
let stats = display.file_stats.lock().unwrap();
assert_eq!(*stats, (2, 1));
}
#[test]
fn test_advance_animation() {
let display = TaskDisplay::new("Anim");
assert_eq!(display.animation_frame.load(Ordering::Relaxed), 0);
display.advance_animation();
assert_eq!(display.animation_frame.load(Ordering::Relaxed), 1);
display.advance_animation();
display.advance_animation();
assert_eq!(display.animation_frame.load(Ordering::Relaxed), 3);
}
#[test]
fn test_render_fox_frame_cycles() {
let display = TaskDisplay::new("Fox");
let frame0 = display.render_fox_frame();
assert!(frame0.contains("/\\___/\\"));
display.advance_animation();
let frame1 = display.render_fox_frame();
assert!(frame1.contains("/\\___/\\"));
assert_ne!(frame0, frame1);
display.advance_animation();
display.advance_animation();
let frame3 = display.render_fox_frame();
assert_eq!(frame0, frame3);
}
#[test]
fn test_render_status_line_contains_description() {
let display = TaskDisplay::new("Building REST API");
display.update_tokens(5000, 2000);
display.record_tool_call("file_write");
let line = display.render_status_line();
assert!(line.contains("Building REST API"));
assert!(line.contains("tokens"));
assert!(line.contains("tools"));
}
#[test]
fn test_render_status_line_with_current_tool() {
let display = TaskDisplay::new("Task");
display.set_current_tool("shell_exec");
let line = display.render_status_line();
assert!(line.contains("shell_exec"));
}
#[test]
fn test_render_detailed_status() {
let display = TaskDisplay::new("Implement auth");
display.update_tokens(8200, 3100);
display.record_tool_call("file_write");
display.set_current_tool("file_write");
let line = display.render_detailed_status();
assert!(line.contains("Implement auth"));
assert!(line.contains("in"));
assert!(line.contains("out"));
assert!(line.contains("calls"));
assert!(line.contains("file_write"));
}
#[test]
fn test_render_completion_summary() {
let display = TaskDisplay::new("Implement user auth");
display.update_tokens(8247, 3102);
display.record_tool_call("file_write");
display.record_tool_call("file_write");
display.record_tool_call("file_read");
display.record_tool_call("shell_exec");
display.record_file_change(true);
display.record_file_change(false);
display.record_file_change(false);
let summary = display.render_completion_summary();
assert!(summary.contains("Task Complete"));
assert!(summary.contains("Implement user auth"));
assert!(summary.contains("Duration"));
assert!(summary.contains("Tokens"));
assert!(summary.contains("Tools"));
assert!(summary.contains("Files"));
assert!(summary.contains("file_write"));
}
#[test]
fn test_render_completion_summary_no_tools() {
let display = TaskDisplay::new("Empty task");
let summary = display.render_completion_summary();
assert!(summary.contains("Task Complete"));
assert!(summary.contains("none"));
}
#[test]
fn test_render_welcome_banner() {
let banner = render_welcome_banner();
assert!(banner.contains("Selfware Workshop"));
assert!(banner.contains("v0.1.5"));
assert!(banner.contains("Software that improves itself"));
assert!(banner.contains("Local-first"));
assert!(banner.contains("Privacy-owned"));
}
}