use std::io::Write;
use std::sync::atomic::{AtomicBool, 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 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,
) {
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")).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, {} tok, {}",
style("↻").dim(),
last_session_turns,
last_session_tokens,
dur,
);
}
if !recent_memories.is_empty() {
let previews: Vec<String> = recent_memories.iter().take(2).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 tip_idx = rand::thread_rng().gen_range(0..TIPS.len());
let tip = TIPS[tip_idx];
println!(" {} {} {}", style("💡").dim(), style(tip.0).bold(), tip.1);
println!();
}
pub fn draw_full_box(provider: &str, model: &str, turn_count: u64) -> usize {
let width = terminal_width();
let mut header = format!(" {}/{} ", provider, model);
if turn_count > 0 {
header = format!(" {} {} turn {}", header, style("·").dim(), turn_count);
}
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,
turn_count: u64,
_tool_count: usize,
) {
let dur = format_duration(elapsed_secs);
let max_ctx = 128_000u64;
let pct_used = if max_ctx > 0 {
((tokens as f64 / max_ctx as f64) * 100.0).min(100.0) as u8
} else {
0
};
let pct_left = 100u8.saturating_sub(pct_used);
let token_str = if tokens > 0 {
format!(" {} tok ", tokens)
} else {
String::new()
};
eprintln!(
" {} {} {} {}% left{} {} turn {}",
DIVIDER,
style(provider).cyan(),
token_str,
style(pct_left).bold(),
style(" context").dim(),
style(&dur).dim(),
turn_count,
);
}
#[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!(" · {} tok session", session_total)
} else {
String::new()
};
println!(
" {} {} tok ({}→{}) · {}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 · {} tok · {}",
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(),
}
}
const COMMANDS: &[&str] = &[
"/exit", "/reset", "/clear", "/help", "/provider", "/switch", "/memory",
"/skills", "/tip", "/stats", "/cost", "/model",
];
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",
"google/gemini-pro-1.5",
"meta-llama/llama-3-70b-instruct",
],
_ => 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 {}
impl rustyline::validate::Validator for CortexHelper {}
impl rustyline::Helper for CortexHelper {}
pub fn create_editor() -> rustyline::Editor<CortexHelper, rustyline::history::FileHistory> {
let mut rl = rustyline::Editor::<CortexHelper, rustyline::history::FileHistory>::new().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));
}
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct Spinner {
running: Arc<AtomicBool>,
}
impl Spinner {
pub fn start(provider: &str, model: &str) -> Self {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
let prov = provider.to_string();
let mdl = model.to_string();
thread::spawn(move || {
let mut frame_idx = 0usize;
let start = Instant::now();
while r.load(Ordering::Relaxed) {
let frame = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()];
let elapsed = start.elapsed().as_secs();
eprint!(
"\r {} {} {} {}s …",
frame,
style(&prov).cyan(),
style(&mdl).yellow(),
elapsed,
);
frame_idx += 1;
thread::sleep(Duration::from_millis(80));
}
eprint!("\r");
let width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(40)
.min(40);
eprint!("{:<width$}\r", "", width = width);
let _ = std::io::stderr().flush();
});
Self { running }
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
thread::sleep(Duration::from_millis(100));
let width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(40)
.min(40);
eprint!("\r{:<width$}\r", "", width = width);
let _ = std::io::stderr().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"),
("/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"),
("/tip", "Utility", "Show a random tip"),
];
pub fn show_help() {
println!();
println!(" {} {}", style("Cortex Help").bold().white(), style("v0.2.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!();
}
pub fn render_error(msg: &str) {
eprintln!();
for line in msg.lines() {
eprintln!(" {} {}", style("✗").red().bold(), style(line).red());
}
eprintln!();
}
pub fn render_info(msg: &str) {
println!(" {} {}", style("ℹ").dim(), msg);
}
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!();
}
#[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,
}
}