use crate::GlobalOpts;
use anyhow::Result;
use mecha_core::message::{Block, Role};
use mecha_core::session::Session;
#[derive(clap::Subcommand, Debug)]
pub enum Args {
List {
#[arg(long, short = 'n', default_value_t = 20)]
limit: usize,
},
Show {
id: String,
#[arg(long)]
json: bool,
},
Path {
id: String,
},
Stats {
#[arg(long)]
days: Option<i64>,
#[arg(long)]
json: bool,
},
}
pub async fn execute(_global: &GlobalOpts, args: Args) -> Result<()> {
let dir = Session::default_dir()?;
match args {
Args::List { limit } => {
let sessions = Session::list(&dir)?;
if sessions.is_empty() {
println!("no sessions in {}", dir.display());
return Ok(());
}
for (meta, _) in sessions.iter().take(limit) {
println!(
"{} {} {:<24} {}",
meta.id,
meta.created_at.format("%Y-%m-%d %H:%M"),
meta.model,
meta.title.as_deref().unwrap_or("")
);
}
if sessions.len() > limit {
println!("… {} more", sessions.len() - limit);
}
}
Args::Show { id, json } => {
let path = Session::find(&dir, &id)?;
if json {
print!("{}", std::fs::read_to_string(&path)?);
return Ok(());
}
let (meta, convo) = Session::load(&path)?;
println!(
"{} · {} ({}) · {}\n",
meta.id,
meta.model,
meta.provider,
meta.created_at.format("%Y-%m-%d %H:%M:%S")
);
for message in &convo.messages {
match message.role {
Role::User => {
let text = message.text();
if !text.is_empty() {
println!("› {text}\n");
}
for block in &message.content {
if let Block::ToolResult {
content, is_error, ..
} = block
{
let marker = if *is_error { "✗" } else { "✓" };
println!(" {marker} {}\n", first_line(content));
}
}
}
Role::Assistant => {
let text = message.text();
if !text.is_empty() {
println!("{text}\n");
}
for (_, name, input) in message.tool_uses() {
println!(" → {name} {}\n", first_line(&input.to_string()));
}
}
}
}
}
Args::Path { id } => println!("{}", Session::find(&dir, &id)?.display()),
Args::Stats { days, json } => stats(&dir, days, json)?,
}
Ok(())
}
#[derive(Default)]
struct StatRow {
sessions: u64,
turns: u64,
usage: mecha_core::message::Usage,
cost_usd: f64,
priced: bool,
}
fn stats(dir: &std::path::Path, days: Option<i64>, json: bool) -> Result<()> {
let config = mecha_core::config::Config::load(&std::env::current_dir()?)?;
let cutoff = days.map(|d| chrono::Utc::now() - chrono::Duration::days(d));
let mut rows = std::collections::BTreeMap::<(String, String), StatRow>::new();
for (meta, path) in Session::list(dir)? {
if let Some(cutoff) = cutoff {
if meta.created_at < cutoff {
continue;
}
}
let (usage, turns) = Session::usage_totals(&path).unwrap_or_default();
let pricing = config
.providers
.get(&meta.provider)
.and_then(|p| p.pricing());
let row = rows.entry((meta.provider, meta.model)).or_default();
row.sessions += 1;
row.turns += turns as u64;
if let Some(pricing) = &pricing {
row.cost_usd += usage.cost_usd(pricing);
row.priced = true;
}
row.usage.add(&usage);
}
if json {
let items: Vec<_> = rows
.iter()
.map(|((provider, model), r)| {
serde_json::json!({
"provider": provider,
"model": model,
"sessions": r.sessions,
"turns": r.turns,
"input_tokens": r.usage.input_tokens,
"output_tokens": r.usage.output_tokens,
"cache_creation_input_tokens": r.usage.cache_creation_input_tokens,
"cache_read_input_tokens": r.usage.cache_read_input_tokens,
"cost_usd": r.priced.then_some(r.cost_usd),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
return Ok(());
}
if rows.is_empty() {
println!("no sessions in {}", dir.display());
return Ok(());
}
println!(
"{:<34} {:>8} {:>7} {:>9} {:>9} {:>9} {:>9} {:>10}",
"provider/model", "sessions", "turns", "input", "output", "cache-w", "cache-r", "cost"
);
let mut total = StatRow::default();
for ((provider, model), r) in &rows {
println!(
"{:<34} {:>8} {:>7} {:>9} {:>9} {:>9} {:>9} {:>10}",
format!("{provider}/{model}"),
r.sessions,
r.turns,
fmt_tokens(r.usage.input_tokens),
fmt_tokens(r.usage.output_tokens),
fmt_tokens(r.usage.cache_creation_input_tokens),
fmt_tokens(r.usage.cache_read_input_tokens),
if r.priced {
format!("${:.2}", r.cost_usd)
} else {
"—".into()
},
);
total.sessions += r.sessions;
total.turns += r.turns;
total.usage.add(&r.usage);
total.cost_usd += r.cost_usd;
total.priced |= r.priced;
}
println!(
"{:<34} {:>8} {:>7} {:>9} {:>9} {:>9} {:>9} {:>10}",
"total",
total.sessions,
total.turns,
fmt_tokens(total.usage.input_tokens),
fmt_tokens(total.usage.output_tokens),
fmt_tokens(total.usage.cache_creation_input_tokens),
fmt_tokens(total.usage.cache_read_input_tokens),
if total.priced {
format!("${:.2}", total.cost_usd)
} else {
"—".into()
},
);
if total.priced {
println!("\ncost is at today's configured prices, not the prices at run time");
}
Ok(())
}
fn fmt_tokens(n: u64) -> String {
match n {
0..=9_999 => n.to_string(),
10_000..=9_999_999 => format!("{:.1}k", n as f64 / 1_000.0),
_ => format!("{:.1}M", n as f64 / 1_000_000.0),
}
}
fn first_line(s: &str) -> String {
let line = s.lines().next().unwrap_or("");
if line.chars().count() > 120 {
format!("{}…", line.chars().take(120).collect::<String>())
} else {
line.to_string()
}
}