use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use console::style;
use rand::Rng;
use chrono::Timelike;
use crate::memory::store::{MemoryEntry, SkillEntry};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Theme {
Mocha,
Latte,
}
impl Default for Theme {
fn default() -> Self { Theme::Mocha }
}
impl Theme {
pub fn accent(&self) -> console::Style { console::Style::new().cyan() }
pub fn secondary(&self) -> console::Style { console::Style::new().blue() }
pub fn success(&self) -> console::Style { console::Style::new().green() }
pub fn error(&self) -> console::Style { console::Style::new().red() }
pub fn dim(&self) -> console::Style { console::Style::new().dim() }
}
use std::cell::RefCell;
thread_local! {
static CURRENT_THEME: RefCell<Theme> = const { RefCell::new(Theme::Mocha) };
}
pub fn set_theme(theme: Theme) {
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
}
pub fn get_theme() -> Theme {
CURRENT_THEME.with(|t| *t.borrow())
}
pub fn auto_detect_theme() -> Theme {
if let Ok(cfgbg) = std::env::var("COLORFGBG") {
if let Some(bg) = cfgbg.split(';').last() {
if let Ok(bg_val) = bg.parse::<u8>() {
if bg_val > 7 {
return Theme::Latte;
}
}
}
}
if let Ok(colorterm) = std::env::var("COLORTERM") {
let lower = colorterm.to_lowercase();
if lower.contains("light") || lower.contains("24bit") && lower.contains("light") {
return Theme::Latte;
}
}
if let Ok(term) = std::env::var("TERM") {
if term.to_lowercase().contains("light") {
return Theme::Latte;
}
}
Theme::Mocha
}
pub const DIVIDER: &str = "╶";
#[allow(dead_code)]
const BAR_WIDTH: usize = 10;
const LOGO: &[&str] = &[
r" ██████╗ ██████╗ ██████╗ ███████╗",
r" ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
r" ██║ ██║ ██║██████╔╝█████╗ ",
r" ██║ ██║ ██║██╔══██╗██╔══╝ ",
r" ╚██████╗╚██████╔╝██║ ██║███████╗",
r" ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝",
];
const TIPS: &[(&str, &str)] = &[
("/provider", "interactive provider picker"),
("/switch", "quick provider switch"),
("/memory", "review saved facts"),
("/skills", "browse reusable workflows"),
("/tip", "random tip"),
("/help", "command reference"),
("/reset", "clear conversation"),
("/stats", "token usage & cost"),
("/model", "provider info"),
("/exit", "quit"),
("Tab ↹", "complete commands · ↑ history"),
];
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}
#[allow(dead_code)]
fn context_bar(used: u64, max: u64) -> String {
if max == 0 {
return String::new();
}
let pct = (used as f64 / max as f64).min(1.0);
let filled = (pct * BAR_WIDTH as f64).round() as usize;
let empty = BAR_WIDTH.saturating_sub(filled);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
let color = if pct < 0.50 {
style(&bar).green()
} else if pct < 0.80 {
style(&bar).yellow()
} else if pct < 0.95 {
style(&bar).red()
} else {
style(&bar).red().bold()
};
format!(" [{}] {:>3}%", color, (pct * 100.0) as u8)
}
pub fn terminal_width() -> usize {
let raw = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
(raw as f64 * 0.90).round() as usize
}
pub fn welcome(
tool_count: usize,
memory_on: bool,
provider: &str,
model: &str,
recent_memories: &[crate::memory::store::MemoryEntry],
last_session_turns: u64,
last_session_tokens: u64,
last_session_duration_secs: u64,
custom_tips: &[String],
project_label: &str,
) {
for line in LOGO {
println!("{}", style(line).cyan());
}
let mem_indicator = if memory_on { "●" } else { "○" };
let wdir = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
println!(
" {} {} {} {} {}",
style("╶").dim(),
style(format!("{}/{}", provider, model)).cyan().bold(),
style(format!("· {} {} · {}", tool_count, "tools", project_label)).dim(),
style(mem_indicator).green(),
style(&wdir).dim(),
);
println!();
let hour = chrono::Local::now().hour();
let greeting = match hour {
0..=11 => "Good morning",
12..=16 => "Good afternoon",
_ => "Good evening",
};
println!(" {} {} · ready with {} tools", style("☀").dim(), style(greeting).green().italic(), tool_count);
if last_session_turns > 0 {
let dur = if last_session_duration_secs > 60 {
format!("{}m {}s", last_session_duration_secs / 60, last_session_duration_secs % 60)
} else {
format!("{}s", last_session_duration_secs)
};
println!(
" {} Last: {} turns, {} tokens, {}",
style("↻").dim(),
last_session_turns,
last_session_tokens,
dur,
);
}
if !recent_memories.is_empty() {
let user_memories: Vec<&MemoryEntry> = recent_memories.iter()
.filter(|m| m.category != "session")
.take(1).collect();
if !user_memories.is_empty() {
let previews: Vec<String> = user_memories.iter().map(|m| {
let preview = if m.content.len() > 50 {
format!("{}…", &m.content[..50])
} else {
m.content.clone()
};
format!("{} [{}]", style(preview).dim(), style(&m.category).cyan().dim())
}).collect();
println!(" {} {}", style("◈").dim().cyan(), previews.join(" · "));
}
}
let all_tips: Vec<(&str, &str)> = {
let mut t: Vec<(&str, &str)> = Vec::with_capacity(TIPS.len() + custom_tips.len());
t.extend_from_slice(&TIPS);
for ct in custom_tips {
t.push(("", ct));
}
t
};
let tip_idx = rand::thread_rng().gen_range(0..all_tips.len());
let (tip_cmd, tip_desc) = all_tips[tip_idx];
if !tip_cmd.is_empty() {
println!(" {} {} {}", style("💡").dim(), style(tip_cmd).bold(), tip_desc);
} else {
println!(" {} {}", style("💡").dim(), tip_desc);
}
println!();
}
pub fn draw_full_box(provider: &str, model: &str, turn_count: u64) -> usize {
let width = terminal_width();
let header = format!(" {}/{} ", provider, model);
let pad = width.saturating_sub(header.len() + 4);
let mut out = std::io::stdout();
let _ = writeln!(out);
let _ = write!(out, " {}", style("╭─").dim());
let _ = write!(out, "{}", style(&header).cyan().bold());
let _ = write!(out, "{}", style("─".repeat(pad)).dim());
let _ = writeln!(out, "{}", style("╮").dim());
let _ = writeln!(out);
let _ = write!(out, " {}", style("╰").dim());
let _ = write!(out, "{}", style("─".repeat(width.saturating_sub(3))).dim());
let _ = writeln!(out, "{}", style("╯").dim());
let _ = write!(out, "\x1b[2A\r");
let _ = out.flush();
width
}
pub fn box_enter(width: usize) {
let safe = width.min(terminal_width());
let _ = write!(std::io::stdout(), "\x1b[1A"); let _ = write!(std::io::stdout(), "\x1b[{}G", safe); let _ = write!(std::io::stdout(), "{}", style("│").dim()); let _ = write!(std::io::stdout(), "\x1b[1B"); let _ = write!(std::io::stdout(), "\r\n"); }
pub fn session_status_bar(
provider: &str,
_model: &str,
tokens: u64,
_cost: f64,
elapsed_secs: u64,
_tool_count: usize,
scope_label: &str,
) {
let dur = format_duration(elapsed_secs);
let max_ctx = 128_000u64;
let pct_left = if max_ctx > 0 {
let pct_used = ((tokens as f64 / max_ctx as f64) * 100.0).min(100.0);
100u8.saturating_sub(pct_used as u8)
} else {
100
};
let token_str = if tokens > 0 {
format!(" {} tokens ", tokens)
} else {
String::new()
};
let scope_str = if scope_label.is_empty() {
String::new()
} else {
format!(" {} ", style(scope_label).yellow().bold())
};
println!(
"{} {} {}% left context{}{}",
style(provider).cyan(),
token_str,
style(format!("{}", pct_left)).bold(),
scope_str,
style(&dur).dim(),
);
println!();
}
#[allow(dead_code)]
pub fn turn_open(user_message: &str) {
let display = if user_message.len() > 72 {
format!("{}…", &user_message[..72])
} else {
user_message.to_string()
};
for line in display.lines() {
println!("{}", style(line).dim());
}
println!();
}
#[allow(dead_code)]
pub fn render_turn_summary(
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
cost: f64,
elapsed: Duration,
turn_count: u64,
session_total: u64,
_session_cost: f64,
) {
let ms = elapsed.as_millis();
let tps = if ms > 0 {
((total_tokens as f64) / (ms as f64 / 1000.0)) as u64
} else {
0
};
let cost_str = if cost > 0.0 {
format!(" · ${:.6}", cost)
} else {
String::new()
};
let session_str = if session_total > 0 {
format!(" · {} tokens session", session_total)
} else {
String::new()
};
println!(
" {} {} tokens ({}→{}) · {}tok/s{} · turn {}{}",
DIVIDER,
style(total_tokens).bold(),
prompt_tokens,
completion_tokens,
tps,
cost_str,
style(turn_count).dim(),
session_str,
);
println!();
}
pub fn render_stats(
session_prompt: u64,
session_completion: u64,
session_total: u64,
cost: f64,
provider: &str,
model: &str,
tool_count: usize,
memory_on: bool,
turn_count: u64,
elapsed: Option<Duration>,
) {
let ratio = if session_total > 0 {
format!("{:.1}% completion", (session_completion as f64 / session_total as f64) * 100.0)
} else {
"—".into()
};
let timing = match elapsed {
Some(d) => {
let s = d.as_secs_f64();
let tps = if s > 0.0 { session_total as f64 / s } else { 0.0 };
let m = (s / 60.0) as u64;
let sec = (s % 60.0) as u64;
if m > 0 {
format!("{}m {}s {:.0} tok/s", m, sec, tps)
} else {
format!("{}s {:.0} tok/s", sec, tps)
}
}
None => "—".into(),
};
let avg_cost = if turn_count > 0 && cost > 0.0 {
format!("${:.6}/turn", cost / turn_count as f64)
} else {
"—".into()
};
let mem = if memory_on { "●" } else { "○" };
println!();
println!(" {}", style("stats").bold().white());
println!(" {}", style(DIVIDER.repeat(40)).dim());
println!();
println!(
" {:>10}: {} ({} prompt → {} completion)",
style("tokens").bold(),
session_total,
session_prompt,
session_completion
);
println!(" {:>10}: {}", style("ratio").bold(), ratio);
if cost > 0.0 {
println!(" {:>10}: ${:.6} ({})", style("cost").bold(), cost, avg_cost);
}
println!(" {:>10}: {}", style("time").bold(), timing);
println!(
" {:>10}: {} {}",
style("model").bold(),
style(provider).cyan(),
style(model).yellow()
);
println!(
" {:>10}: {} memory {}",
style("tools").bold(),
tool_count,
style(mem).green()
);
println!(" {:>10}: {}", style("turns").bold(), turn_count);
println!();
}
pub fn render_model_info(provider: &str, model: &str, base_url: &str, api_key_set: bool) {
let url_short = base_url.replace("https://", "");
let key_status = if api_key_set {
style("configured").green()
} else {
style("missing").red()
};
println!();
println!(" {}", style("provider").bold().white());
println!(" {}", style(DIVIDER.repeat(40)).dim());
println!();
println!(
" {:>10}: {}",
style("name").bold(),
style(provider).cyan().bold()
);
println!(
" {:>10}: {}",
style("model").bold(),
style(model).yellow().bold()
);
println!(
" {:>10}: {}",
style("endpoint").bold(),
style(url_short).dim()
);
println!(" {:>10}: {}", style("api key").bold(), key_status);
println!();
}
pub fn render_session_summary(turn_count: u64, session_total: u64, session_cost: f64, elapsed: Duration) {
let s = elapsed.as_secs_f64();
let m = (s / 60.0) as u64;
let sec = (s % 60.0) as u64;
let dur = if m > 0 {
format!("{}m {}s", m, sec)
} else {
format!("{}s", sec)
};
println!();
println!(
" {} {} turns · {} tokens · {}",
style("done").bold().dim(),
turn_count,
session_total,
dur
);
if session_cost > 0.0 {
println!(" ${:.6}", session_cost);
}
println!(" {}", style(DIVIDER.repeat(40)).dim());
println!();
}
#[allow(dead_code)]
pub fn prompt_input(prompt_text: &str) -> String {
let prompt = style(prompt_text).yellow().bold().to_string();
print!("{}", prompt);
std::io::stdout().flush().ok();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(_) => line.trim().to_string(),
Err(_) => String::new(),
}
}
pub fn prompt_inline(prompt: &str) -> String {
print!(" {} {} ", style("❯").cyan().bold(), if prompt.is_empty() { "".to_string() } else { format!("{}:", prompt) });
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
line.trim().to_string()
}
pub fn prompt_yes_no(prompt: &str) -> Result<bool, std::io::Error> {
print!(" {} {} (y/N): ", style("❯").yellow().bold(), prompt);
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim().to_lowercase();
Ok(trimmed == "y" || trimmed == "yes")
}
pub fn select_inline(items: &[String], prompt: &str) -> Option<usize> {
if items.is_empty() { return None; }
println!(" {} {}", style("╭─").dim(), style(prompt).bold().white());
for (i, item) in items.iter().enumerate() {
let num = style(format!("{:>2}.", i + 1)).cyan().bold();
println!(" {} {} {}", style("│").dim(), num, item);
}
println!(" {} {} {}", style("│").dim(), style(" 0.").dim(), style("Cancel").dim());
println!(" {} {}", style("╰─").dim(), style("─".repeat(30)).dim());
print!(" {} {} ", style("❯").cyan().bold(), "Choice (0 to cancel):");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
let n: usize = line.trim().parse().unwrap_or(0);
if n == 0 { None } else { Some(n - 1) }
}
const COMMANDS: &[&str] = &[
"/exit", "/reset", "/clear", "/help", "/provider", "/switch", "/memory",
"/skills", "/tip", "/stats", "/cost", "/model", "/session", "/permissions",
"/lessons", "/plugin", "/theme", "/tools",
];
pub fn provider_models(provider: &str) -> Vec<&'static str> {
match provider {
"openai" => vec!["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
"deepseek" => vec!["deepseek-chat", "deepseek-reasoner", "deepseek-v4-pro", "deepseek-v4-flash"],
"groq" => vec!["mixtral-8x7b-32768", "llama3-70b-8192", "llama3-8b-8192", "gemma2-9b-it"],
"together" => vec![
"mistralai/Mixtral-8x7B-Instruct-v0.1",
"meta-llama/Llama-3-70b-chat-hf",
"togethercomputer/StripedHyena-Nous-7B",
],
"mistral" => vec!["mistral-large-latest", "mistral-medium-latest", "open-mistral-nemo"],
"nvidia" => vec![
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"meta/llama-3.1-8b-instruct",
"meta/llama-3.3-70b-instruct",
"nvidia/nemotron-4-340b-instruct",
],
"opencode" => vec!["deepseek-v4-flash-free", "deepseek-v4-pro", "mimo-v2-pro"],
"openrouter" => vec![
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
],
"ollama" => vec!["llama3.2", "llama3.1", "mistral", "codellama", "phi3", "qwen2.5"],
_ => vec![],
}
}
pub struct CortexHelper;
impl rustyline::completion::Completer for CortexHelper {
type Candidate = String;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let prefix = &line[..pos];
let lower_prefix = prefix.to_lowercase();
let candidates: Vec<String> = COMMANDS
.iter()
.filter(|c| {
let lower = c.to_lowercase();
lower.starts_with(&lower_prefix) || lower.contains(&lower_prefix)
})
.map(|s| s.to_string())
.collect();
Ok((0, candidates))
}
}
impl rustyline::hint::Hinter for CortexHelper {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
None
}
}
impl rustyline::highlight::Highlighter for CortexHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> std::borrow::Cow<'l, str> {
let trimmed = line.trim_start();
if trimmed.starts_with('/') {
let cmd_end = trimmed.find(|c: char| c.is_whitespace()).unwrap_or(trimmed.len());
let cmd = &trimmed[..cmd_end];
if COMMANDS.contains(&cmd) {
let indent = &line[..line.len() - trimmed.len()];
let styled = format!(
"{}{}{}",
indent,
console::Style::new().cyan().bold().apply_to(cmd),
&trimmed[cmd_end..]
);
return std::borrow::Cow::Owned(styled);
}
}
std::borrow::Cow::Borrowed(line)
}
}
impl rustyline::validate::Validator for CortexHelper {}
impl rustyline::Helper for CortexHelper {}
pub fn create_editor() -> rustyline::Editor<CortexHelper, rustyline::history::FileHistory> {
let config = rustyline::config::Builder::new()
.auto_add_history(false)
.build();
let mut rl = rustyline::Editor::<CortexHelper, rustyline::history::FileHistory>::with_config(config).unwrap();
rl.set_helper(Some(CortexHelper));
if let Ok(home) = std::env::var("HOME") {
let _ = rl.load_history(&format!("{}/.cortex_history", home));
}
rl
}
pub fn readline(
rl: &mut rustyline::Editor<CortexHelper, rustyline::history::FileHistory>,
width: usize,
) -> Option<String> {
let safe = width.min(terminal_width());
let prompt = format!(
" {} {}\x1b[{}G{}\x1b[6G",
style("│").dim(),
style("→").cyan().bold(),
safe,
style("│").dim(),
);
match rl.readline(&prompt) {
Ok(line) => {
let trimmed = line.trim().to_string();
if !trimmed.is_empty() {
let _ = rl.add_history_entry(&line);
}
Some(trimmed)
}
Err(_) => None,
}
}
pub fn save_history(rl: &mut rustyline::Editor<CortexHelper, rustyline::history::FileHistory>) {
if let Ok(home) = std::env::var("HOME") {
let _ = rl.save_history(&format!("{}/.cortex_history", home));
}
}
pub struct Spinner {
running: Arc<AtomicBool>,
}
impl Spinner {
pub fn start(provider: &str, model: &str, _token_count: Option<Arc<AtomicU64>>) -> Self {
print!(
" {} {} {} {}",
style("⟳").cyan().bold(),
style(provider).cyan(),
style(model).yellow(),
style("thinking…").dim(),
);
let _ = std::io::stdout().flush();
Self { running: Arc::new(AtomicBool::new(true)) }
}
pub fn stop(&self) {
print!("\r");
let width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(40)
.min(50);
print!("{:<width$}\r", "", width = width);
let _ = std::io::stdout().flush();
}
}
pub fn render_tool_line(name: &str, args: &str, status: &str) {
let (icon, show_args) = match status {
"running" => (style(" → ").dim().to_string(), true),
"done" => (style(" ✓ ").green().to_string(), false),
"error" => (style(" ✗ ").red().to_string(), true),
_ => (style(" • ").dim().to_string(), false),
};
if show_args && !args.is_empty() {
let args_display = if args.len() < 60 {
args.to_string()
} else {
format!("{}…", &args[..57])
};
println!("{}{} ({})", icon, name, args_display);
} else {
println!("{}{}", icon, name);
}
}
const HELP_TOPICS: &[(&str, &str, &str)] = &[
("/exit", "Session", "Quit Cortex and save session state"),
("/persona", "Session", "Switch conversation persona: default, code, concise, teacher, creative"),
("/reset", "Session", "Clear the current conversation (memory stays intact)"),
("/stats", "Session", "Show token usage, cost, and timing for this session"),
("/model", "Session", "Show active provider, model, endpoint, and API key status"),
("/help", "Session", "Show this help. Use `/help <command>` for details"),
("/provider", "Provider", "Interactive provider picker with model selection"),
("/switch", "Provider", "Switch provider: `/switch <name>` then enter model"),
("/memory", "Memory", "Browse saved memories. `/memory tree`, `/memory timeline`, `/memory stats`"),
("/skills", "Memory", "List reusable skills"),
("/session", "Session", "Save, load, list, or export conversations"),
("/lessons", "Memory", "Self-healing lessons: list, show <id>, forget <id>, stats"),
("/danger", "Security", "Temporarily approve dangerous tools: /danger <tool>"),
("/tip", "Utility", "Show a random tip"),
];
pub fn show_help() {
println!();
println!(" {} {}", style("Cortex Help").bold().white(), style("v0.3.0").dim());
println!(" {}", style(DIVIDER.repeat(40)).dim());
println!();
let mut categories: std::collections::BTreeMap<&str, Vec<(&str, &str)>> =
std::collections::BTreeMap::new();
for (cmd, cat, desc) in HELP_TOPICS {
categories.entry(cat).or_default().push((cmd, desc));
}
for (cat, items) in &categories {
println!(" {} {}", style("▸").cyan(), style(*cat).bold());
for (cmd, desc) in items {
println!(" {:>14} {}", style(cmd).bold(), desc);
}
println!();
}
println!(
" {} Tab↹ complete · ↑ history · /help <command> for details",
style("💡").dim()
);
println!(
" {} /memory tree | timeline | stats · /exit to save & quit",
style("⚡").dim()
);
println!();
}
pub fn show_command_help(cmd: &str) {
let topic = HELP_TOPICS.iter().find(|(name, _, _)| *name == cmd);
match topic {
Some((name, category, description)) => {
println!();
println!(" {} {}", style("Help:").bold().white(), style(name).bold().cyan());
println!(" {}", style(DIVIDER.repeat(40)).dim());
println!(" Category: {}", style(category).dim());
println!(" {}", description);
match *name {
"/help" => println!(" Usage: /help or /help <command>"),
"/switch" => println!(" Usage: /switch <provider_name> (then enter model when prompted)"),
"/provider" => println!(" Usage: /provider (interactive arrow-key selection)"),
"/memory" => println!(" Usage: /memory, /memory tree, /memory timeline, /memory stats"),
"/skills" => println!(" Usage: /skills (lists all saved skills)"),
"/exit" => println!(" Usage: /exit or /quit (saves session state)"),
"/reset" => println!(" Usage: /reset or /clear (keeps memory, clears conversation)"),
"/stats" => println!(" Usage: /stats or /cost (session token/cost summary)"),
"/model" => println!(" Usage: /model (provider details)"),
"/tip" => println!(" Usage: /tip (random tip)"),
_ => {}
}
println!();
}
None => {
println!(" Unknown command '{}'. Try /help.", cmd);
}
}
}
pub fn show_tip() {
let idx = rand::thread_rng().gen_range(0..TIPS.len());
let tip = TIPS[idx];
println!(" {}: {}", style(tip.0).bold(), tip.1);
}
pub fn render_memory_table(entries: &[MemoryEntry]) {
if entries.is_empty() {
println!(" No memories yet. Cortex will auto-save session summaries.");
println!(" Memories are saved when you share facts during conversation.");
return;
}
use std::collections::HashMap;
let mut cat_counts: HashMap<&str, usize> = HashMap::new();
for e in entries {
*cat_counts.entry(&e.category).or_insert(0) += 1;
}
let cat_summary: Vec<String> = cat_counts.iter()
.map(|(cat, count)| format!("{} {}", count, cat))
.collect();
println!(" Memory · {} entries ({})", style(entries.len()).bold(), cat_summary.join(", "));
println!(" {}", style("╶".repeat(40)).dim());
for e in entries {
let preview = if e.content.len() > 72 {
format!("{}…", &e.content[..72])
} else {
e.content.clone()
};
let stars = "★".repeat(e.importance as usize) + &"☆".repeat((5 - e.importance as usize).max(0));
println!(
" {} {} {} {}",
style(format!("#{}", e.id)).dim(),
style(stars).yellow(),
style(&e.category).dim(),
preview,
);
}
println!();
}
pub fn render_skill_table(entries: &[SkillEntry]) {
if entries.is_empty() {
println!(" no skills");
return;
}
for s in entries {
let desc = if s.description.len() > 56 {
format!("{}…", &s.description[..56])
} else {
s.description.clone()
};
println!(
" {:<18} v{:<3} {:<10} {}",
style(&s.name).bold(),
s.version,
style(&s.category).dim(),
desc
);
}
println!();
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ErrorLevel {
Critical,
Warning,
Info,
}
pub fn render_error(level: ErrorLevel, title: &str, message: &str, suggestions: &[&str]) {
let (icon, color_style) = match level {
ErrorLevel::Critical => (style("✗").red().bold(), console::Style::new().red()),
ErrorLevel::Warning => (style("⚠").yellow().bold(), console::Style::new().yellow()),
ErrorLevel::Info => (style("ℹ").cyan().bold(), console::Style::new().cyan()),
};
let width = terminal_width().min(72);
let sep = "─".repeat(width.saturating_sub(4));
println!();
println!(" {} {} {}", style("╭─").dim(), icon, color_style.apply_to(title));
for line in message.lines() {
println!(" {} {}", style("│").dim(), color_style.apply_to(line));
}
if !suggestions.is_empty() {
println!(" {} {}", style("│").dim(), style("──").dim());
for s in suggestions {
println!(" {} {} {}", style("│").dim(), style("→").dim(), s);
}
}
println!(" {} {}", style("╰─").dim(), sep);
println!();
}
pub fn render_info(msg: &str) {
println!(" {} {}", style("ℹ").dim(), msg);
}
pub fn render_suggestion(what: &str, suggestion: &str) {
let width = terminal_width().min(72);
println!();
println!(" {} {}", style("╭─").dim(), style("💡 Suggestion").bold().cyan());
println!(" {} {} {}", style("│").dim(), style("You typed:").dim(), style(what).red().dim());
println!(" {} {} {}", style("│").dim(), style("Did you mean:").dim(), style(suggestion).green().bold());
println!(" {} {}", style("╰─").dim(), style("─".repeat(width.saturating_sub(4))).dim());
println!();
}
pub fn render_action_card(icon: &str, title: &str, steps: &[&str], footer: Option<&str>) {
let width = terminal_width().min(72);
println!();
println!(" {} {} {}", style("╭─").dim(), style(icon).bold(), style(title).bold().white());
for (i, step) in steps.iter().enumerate() {
println!(" {} {}. {}", style("│").dim(), i + 1, style(step).dim());
}
if let Some(f) = footer {
println!(" {} {}", style("│").dim(), style("──").dim());
println!(" {} {}", style("│").dim(), style(f).italic().cyan());
}
println!(" {} {}", style("╰─").dim(), style("─".repeat(width.saturating_sub(4))).dim());
println!();
}
pub fn print_user_turn(message: &str, width: usize) {
let safe = width.min(terminal_width());
for line in message.lines() {
println!(" {} {}", style("╶").dim(), style(line).dim());
}
println!(" {}", style("╶".repeat(safe.saturating_sub(2))).cyan().dim());
}
pub fn print_left_wall() {
print!("{}", style("│ ").dim());
let _ = std::io::stdout().flush();
}
pub fn print_turn_separator(width: usize) {
let safe = width.min(terminal_width());
println!();
println!(" {}", style("╶".repeat(safe.saturating_sub(2))).cyan().dim());
}
pub fn render_memory_timeline(entries: &[crate::memory::store::MemoryEntry]) {
if entries.is_empty() {
println!(" No memories yet.");
return;
}
println!(" {} Timeline (most recent first)", style("◈").cyan());
println!(" {}", style(DIVIDER.repeat(40)).dim());
for e in entries {
let preview = if e.content.len() > 60 {
format!("{}…", &e.content[..60])
} else {
e.content.clone()
};
println!(
" {} {} {} {}",
style(&e.created_at[..10]).dim(),
style(format!("#{}", e.id)).dim(),
style(&e.category).cyan().dim(),
preview,
);
}
println!();
}
pub fn render_memory_tree(entries: &[crate::memory::store::MemoryEntry]) {
if entries.is_empty() {
println!(" No memories yet.");
return;
}
use std::collections::BTreeMap;
let mut tree: BTreeMap<&str, Vec<&crate::memory::store::MemoryEntry>> = BTreeMap::new();
for e in entries {
tree.entry(&e.category).or_default().push(e);
}
println!(" {} Memory Tree (by category)", style("◈").cyan());
println!(" {}", style(DIVIDER.repeat(40)).dim());
for (cat, items) in &tree {
println!(" {} {} ({} items)", style("📂").dim(), style(*cat).bold(), items.len());
for e in items.iter().take(5) {
let preview = if e.content.len() > 50 {
format!("{}…", &e.content[..50])
} else {
e.content.clone()
};
let stars = "★".repeat(e.importance as usize);
println!(" {} {} {} {}", style("▸").dim(), stars, style(preview).dim(), style(format!("#{}", e.id)).dim());
}
if items.len() > 5 {
println!(" {} ... and {} more", style("▸").dim(), items.len() - 5);
}
}
println!();
}
pub fn render_lessons_table(entries: &[crate::memory::store::LessonEntry]) {
use console::style;
if entries.is_empty() {
println!(" {} No lessons saved yet.", style("📘").dim());
println!(" {} Lessons are auto-created when tools fail, and grow more\n useful as they accumulate fixes and get resolved.", style("💡").dim());
return;
}
println!(" {} Lessons ({} total)", style("📘").bold().white(), entries.len());
println!(" {}", style(crate::ui::DIVIDER.repeat(40)).dim());
for e in entries {
let icon = if e.resolved { "✓".to_string() } else { "○".to_string() };
let icon_colored = if e.resolved { style(&icon).green() } else { style(&icon).yellow() };
let trigger_preview = if e.trigger.len() > 55 {
format!("{}…", &e.trigger[..55])
} else {
e.trigger.clone()
};
let conf = if e.confidence >= 3 {
style(format!("★{}", e.confidence)).green()
} else {
style(format!("☆{}", e.confidence)).dim()
};
println!(" {} {} #{} {} {}", icon_colored, conf, style(e.id).dim(), trigger_preview, style(&e.created_at).dim());
}
println!();
}
pub fn render_lesson_detail(lesson: &crate::memory::store::LessonEntry) {
use console::style;
println!();
println!(" {} {}", style("╭─ Lesson #").dim(), style(format!("{}", lesson.id)).bold().white());
println!(" {} Trigger: {}", style("│").dim(), style(&lesson.trigger).bold());
println!(" {} Status: {}", style("│").dim(),
if lesson.resolved { style("✓ Resolved").green() } else { style("○ Unresolved").yellow() });
println!(" {} Confidence: {}/5", style("│").dim(), lesson.confidence);
println!(" {} Created: {}", style("│").dim(), &lesson.created_at);
if !lesson.last_used_at.is_empty() {
println!(" {} Last used: {}", style("│").dim(), &lesson.last_used_at);
}
if !lesson.tags.is_empty() {
println!(" {} Tags: {}", style("│").dim(), &lesson.tags);
}
println!(" {} {}", style("│").dim(), style("─".repeat(40)).dim());
if !lesson.fix.is_empty() {
println!(" {} Fix: {}", style("│").dim(), style(&lesson.fix).green());
println!(" {} {}", style("│").dim(), style("─".repeat(40)).dim());
}
println!(" {} Context:", style("│").dim());
for line in lesson.context.lines() {
let preview = if line.len() > 80 {
format!("{}…", &line[..80])
} else {
line.to_string()
};
println!(" {} {}", style("│").dim(), style(preview).dim());
}
println!(" {}", style(format!("╰─{}─╯", "─".repeat(40))).dim());
println!();
}
#[allow(dead_code)]
pub fn stream_token(token: &str) {
print!("{}", token);
std::io::stdout().flush().ok();
}
#[allow(dead_code)]
pub fn stream_end() {
println!();
println!();
}
#[allow(dead_code)]
pub fn readline_raw() -> Option<String> {
let prompt = style("❯ ").cyan().bold().to_string();
print!("{}", prompt);
std::io::stdout().flush().ok()?;
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) => None,
Ok(_) => Some(line.trim().to_string()),
Err(_) => None,
}
}