use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
mod backends;
mod bookmarks;
mod nicknames;
mod session;
mod tui;
mod util;
use backends::{all, by_name, scan_all};
use session::{Role, Session, Turn};
use tui::{AppAction, run};
use util::truncate;
#[derive(Copy, Clone, Debug, clap::ValueEnum)]
enum ExportFormat {
Md,
Json,
}
#[derive(Parser)]
#[command(name = "ccr", version, about = "CLI Code Resume — TUI session picker")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
List,
Path {
id: String,
},
Show {
id: String,
},
Export {
id: String,
#[arg(long, value_enum, default_value_t = ExportFormat::Md)]
format: ExportFormat,
},
Stats,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
None => launch_picker(),
Some(Command::List) => run_list(),
Some(Command::Path { id }) => run_path(&id),
Some(Command::Show { id }) => run_show(&id),
Some(Command::Export { id, format }) => run_export(&id, format),
Some(Command::Stats) => run_stats(),
}
}
fn find_session_by_id(id: &str) -> Result<Session> {
let backends = all();
scan_all(&backends)
.into_iter()
.find(|s| s.id == id)
.with_context(|| format!("no session with id `{id}`"))
}
fn run_path(id: &str) -> Result<()> {
let s = find_session_by_id(id)?;
println!("{}", s.origin.display());
Ok(())
}
fn run_show(id: &str) -> Result<()> {
let s = find_session_by_id(id)?;
let content = std::fs::read_to_string(&s.origin)
.with_context(|| format!("read {}", s.origin.display()))?;
print!("{content}");
Ok(())
}
fn run_export(id: &str, format: ExportFormat) -> Result<()> {
let backends = all();
let session = scan_all(&backends)
.into_iter()
.find(|s| s.id == id)
.with_context(|| format!("no session with id `{id}`"))?;
let backend = by_name(&backends, session.backend)
.with_context(|| format!("unknown backend `{}`", session.backend))?;
let turns = backend.all_turns(&session)?;
match format {
ExportFormat::Md => print!("{}", format_md(&session, &turns)),
ExportFormat::Json => println!("{}", format_json(&session, &turns)?),
}
Ok(())
}
pub(crate) fn format_md(s: &Session, turns: &[Turn]) -> String {
let mut out = String::new();
out.push_str(&format!("# Session `{}`\n\n", s.id));
out.push_str(&format!("- **Tool:** {}\n", s.backend));
out.push_str(&format!(
"- **Last active:** {}\n",
s.last_activity.format("%Y-%m-%d %H:%M")
));
out.push_str(&format!("- **cwd:** `{}`\n", s.cwd.display()));
out.push_str(&format!("- **Turns:** {}\n\n", turns.len()));
out.push_str("---\n\n");
for t in turns {
let tag = match t.role {
Role::User => "## ❯ user",
Role::Assistant => "## ◆ assistant",
};
out.push_str(tag);
out.push_str("\n\n");
out.push_str(t.text.trim());
out.push_str("\n\n");
}
out
}
fn run_stats() -> Result<()> {
let backends = all();
let sessions = scan_all(&backends);
print!("{}", format_stats(&sessions));
Ok(())
}
pub(crate) fn format_stats(sessions: &[Session]) -> String {
use std::collections::BTreeMap;
use std::collections::HashMap;
let mut out = String::new();
let total = sessions.len();
let total_turns: usize = sessions.iter().map(|s| s.message_count).sum();
let tools: std::collections::BTreeSet<&str> = sessions.iter().map(|s| s.backend).collect();
out.push_str(&format!(
"Total: {total} session{} · {total_turns} turn{} · {} tool{}\n",
plural(total),
plural(total_turns),
tools.len(),
plural(tools.len()),
));
let mut by_tool: HashMap<&str, (usize, usize)> = HashMap::new();
for s in sessions {
let e = by_tool.entry(s.backend).or_default();
e.0 += 1;
e.1 += s.message_count;
}
out.push_str("\nBy tool:\n");
let mut tool_rows: Vec<_> = by_tool.iter().collect();
tool_rows.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (tool, (count, turns)) in tool_rows {
out.push_str(&format!(
" {tool:<10} {count:>5} session{} {turns:>7} turn{}\n",
plural(*count),
plural(*turns)
));
}
let mut by_project: HashMap<String, usize> = HashMap::new();
for s in sessions {
let name = s
.cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unknown)")
.to_string();
*by_project.entry(name).or_default() += 1;
}
let mut projects: Vec<_> = by_project.into_iter().collect();
projects.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
let top = projects.len().min(10);
out.push_str(&format!("\nBy project (top {top}):\n"));
for (name, count) in projects.iter().take(top) {
out.push_str(&format!(
" {name:<30} {count:>5} session{}\n",
plural(*count)
));
}
let now = chrono::Local::now().date_naive();
let mut by_date: BTreeMap<chrono::NaiveDate, usize> = BTreeMap::new();
for s in sessions {
let d = s.last_activity.date_naive();
if (now - d).num_days() < 30 {
*by_date.entry(d).or_default() += 1;
}
}
if !by_date.is_empty() {
out.push_str("\nActivity (last 30 days):\n");
let max = by_date.values().copied().max().unwrap_or(1).max(1);
for (date, count) in by_date.iter().rev() {
let width = (count * 30 + max / 2) / max;
let bar = "▇".repeat(width.max(1));
out.push_str(&format!(" {date} {bar} {count}\n"));
}
}
let live = sessions.iter().filter(|s| s.possibly_live).count();
if live > 0 {
out.push_str(&format!(
"\nPossibly live: {live} session{} (active in last 5 min)\n",
plural(live),
));
}
out
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
pub(crate) fn format_json(s: &Session, turns: &[Turn]) -> Result<String> {
let turns_json: Vec<serde_json::Value> = turns
.iter()
.map(|t| {
serde_json::json!({
"role": match t.role {
Role::User => "user",
Role::Assistant => "assistant",
},
"text": &t.text,
})
})
.collect();
let doc = serde_json::json!({
"id": s.id,
"backend": s.backend,
"cwd": s.cwd.to_string_lossy(),
"last_activity": s.last_activity.to_rfc3339(),
"message_count": s.message_count,
"turns": turns_json,
});
Ok(serde_json::to_string_pretty(&doc)?)
}
fn launch_picker() -> Result<()> {
let backends = all();
let sessions = scan_all(&backends);
if sessions.is_empty() {
eprintln!("ccr: no sessions found. Supported tools:");
for b in &backends {
eprintln!(" - {}", b.name());
}
std::process::exit(1);
}
match run(sessions, &backends)? {
AppAction::Quit => Ok(()),
AppAction::Resume(s) => {
let backend = by_name(&backends, s.backend)
.with_context(|| format!("unknown backend `{}`", s.backend))?;
let status = backend
.resume(&s)
.status()
.with_context(|| format!("failed to spawn `{}` — is it on PATH?", s.backend))?;
std::process::exit(status.code().unwrap_or(1));
}
AppAction::View(s) => {
let status = std::process::Command::new("agx")
.arg(&s.origin)
.current_dir(&s.cwd)
.status()
.context(
"failed to spawn `agx` — install from https://github.com/brevity1swos/agx",
)?;
std::process::exit(status.code().unwrap_or(1));
}
}
}
fn run_list() -> Result<()> {
let backends = all();
for s in scan_all(&backends) {
println!(
"[{}] {} {} {}",
s.backend,
s.id,
s.last_activity.format("%Y-%m-%d %H:%M"),
truncate(&s.title, 60)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
use std::path::PathBuf;
fn sample_session() -> Session {
Session {
backend: "claude",
id: "abc-123".into(),
cwd: PathBuf::from("/proj"),
title: "hi".into(),
last_activity: Local::now(),
message_count: 2,
preview: Vec::new(),
possibly_live: false,
origin: PathBuf::from("<t>"),
searchable: String::new(),
}
}
fn sample_turns() -> Vec<Turn> {
vec![
Turn {
role: Role::User,
text: "hello".into(),
},
Turn {
role: Role::Assistant,
text: "hi back".into(),
},
]
}
#[test]
fn format_md_has_header_and_turns() {
let md = format_md(&sample_session(), &sample_turns());
assert!(md.starts_with("# Session `abc-123`"));
assert!(md.contains("- **Tool:** claude"));
assert!(md.contains("## ❯ user"));
assert!(md.contains("hello"));
assert!(md.contains("## ◆ assistant"));
assert!(md.contains("hi back"));
}
#[test]
fn format_json_round_trips() {
let s = sample_session();
let turns = sample_turns();
let json = format_json(&s, &turns).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["id"], "abc-123");
assert_eq!(v["backend"], "claude");
assert_eq!(v["turns"][0]["role"], "user");
assert_eq!(v["turns"][1]["role"], "assistant");
}
#[test]
fn format_stats_on_empty_still_prints_zero_row() {
let out = format_stats(&[]);
assert!(out.starts_with("Total: 0 sessions"));
}
#[test]
fn format_stats_groups_by_tool_and_project() {
let mut a = sample_session();
a.cwd = PathBuf::from("/repos/alpha");
a.backend = "claude";
a.message_count = 10;
let mut b = a.clone();
b.id = "def".into();
b.backend = "codex";
b.cwd = PathBuf::from("/repos/beta");
b.message_count = 3;
let out = format_stats(&[a, b]);
assert!(out.contains("Total: 2 sessions"));
assert!(out.contains("13 turns"));
assert!(out.contains("2 tools"));
assert!(out.contains("claude"));
assert!(out.contains("codex"));
assert!(out.contains("alpha"));
assert!(out.contains("beta"));
}
}