use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use tokio::sync::mpsc;
use zoetrope::state::session::{AgentKind, SessionModel, ToolState};
use zoetrope::state::{App, Mode};
use zoetrope::tailer::{Source, TailRequest, UiEvent, Update};
use zoetrope::{tailer, transcript, tui};
const CHANNEL_CAP: usize = 32;
#[derive(Debug, Clone)]
pub enum Cli {
View {
target: Option<PathBuf>,
follow: bool,
speed: f64,
},
Inspect { file: PathBuf },
}
const DEFAULT_REPLAY_SPEED: f64 = 8.0;
const USAGE: &str = "\
zoetrope — visualize Claude Code agent sessions as a flow graph
USAGE:
zoe follow the current project's live session
zoe <file.jsonl> replay a recording, played from the start
zoe <dir> follow another project's live session
zoe <file> --follow follow a file's live edge instead of replaying
zoe <file> --speed N playback speed (default 8.0)
zoe inspect <file> headless: print the session tree + info
zoe --version print the version and exit
Once open, scrub/follow/pause/go-live are available no matter how you launched.";
fn parse_cli(args: impl Iterator<Item = String>) -> Result<Cli> {
let mut args = args.skip(1).peekable();
if args.peek().map(String::as_str) == Some("inspect") {
args.next();
let file = args
.next()
.ok_or_else(|| anyhow!("inspect requires a <file.jsonl>\n\n{USAGE}"))?;
if args.next().is_some() {
bail!("inspect takes a single file argument\n\n{USAGE}");
}
return Ok(Cli::Inspect {
file: PathBuf::from(file),
});
}
let mut target: Option<PathBuf> = None;
let mut follow = false;
let mut speed = DEFAULT_REPLAY_SPEED;
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
println!("{USAGE}");
std::process::exit(0);
}
"-V" | "--version" => {
println!("zoe {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"--follow" => follow = true,
"--speed" => {
let v = args
.next()
.ok_or_else(|| anyhow!("--speed requires a number\n\n{USAGE}"))?;
speed = v
.parse::<f64>()
.with_context(|| format!("invalid --speed value: {v:?}"))?;
if !(speed.is_finite() && speed > 0.0) {
bail!("--speed must be a positive number, got {v:?}");
}
}
other if other.starts_with('-') => {
bail!("unknown flag {other:?}\n\n{USAGE}");
}
_ => {
if target.is_some() {
bail!("expected a single path argument\n\n{USAGE}");
}
target = Some(PathBuf::from(arg));
}
}
}
Ok(Cli::View {
target,
follow,
speed,
})
}
fn fold_file(model: &mut SessionModel, path: &Path, source: Source) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading transcript {}", path.display()))?;
for line in text.lines() {
if let Some(entry) = transcript::parse_line(line) {
model.apply_update(&Update::Entry {
source: source.clone(),
entry,
});
}
}
Ok(())
}
fn fold_meta(model: &mut SessionModel, path: &Path, agent_id: &str, workflow: Option<&str>) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
if let Some(meta) = transcript::parse_meta(&text) {
model.apply_meta(agent_id, workflow, &meta);
}
}
fn parse_session_fully(main_file: &Path) -> Result<SessionModel> {
let session_id = main_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("session")
.to_string();
let mut model = SessionModel::new(session_id.clone());
if let Some(subs) = transcript::subagents_dir(main_file) {
collect_subagents(&mut model, &subs, None);
for wf_id in transcript::scan_workflow_ids(&subs) {
let wf_path = transcript::workflow_dir(&subs, &wf_id);
collect_subagents(&mut model, &wf_path, Some(&wf_id));
let journal = transcript::workflow_journal(&subs, &wf_id);
if journal.is_file() {
let _ = fold_file(&mut model, &journal, Source::Journal(wf_id.clone()));
}
}
}
fold_file(&mut model, main_file, Source::Main)?;
model.recompute_workflow_status();
model.recompute_liveness(Some(chrono::Utc::now()));
Ok(model)
}
fn collect_subagents(model: &mut SessionModel, dir: &Path, workflow: Option<&str>) {
for file in transcript::scan_subagent_files(dir, workflow) {
if file.meta.is_file() {
fold_meta(model, &file.meta, &file.agent_id, workflow);
}
let _ = fold_file(model, &file.transcript, Source::Sub(file.agent_id));
}
}
async fn run_inspect(file: PathBuf) -> Result<()> {
if !file.is_file() {
bail!("not a readable file: {}", file.display());
}
let model = parse_session_fully(&file)?;
let info = read_session_info(&file);
let title = info.title.as_deref().unwrap_or("(untitled)");
println!("session {} — {title}", model.session_id);
println!(
" mode: {} · permission: {}",
info.mode.as_deref().unwrap_or("—"),
info.permission_mode.as_deref().unwrap_or("—"),
);
println!(
" {} agent(s), {} tool call(s) · {} queued · {} file edit(s)",
model.agent_count(),
model.tool_count(),
info.queued_ops,
info.file_snapshots,
);
if let Some(p) = &info.last_prompt {
println!(" last prompt: {p:?}");
}
println!();
print_agent_tree(&model, None, 0);
Ok(())
}
fn read_session_info(main_path: &Path) -> zoetrope::state::SessionInfo {
let mut info = zoetrope::state::SessionInfo::default();
if let Ok(text) = std::fs::read_to_string(main_path) {
for line in text.lines() {
if let Some(entry) = transcript::parse_line(line) {
info.apply(&entry);
}
}
}
info
}
fn print_agent_tree(model: &SessionModel, parent: Option<&str>, depth: usize) {
for id in &model.spawn_order {
let Some(agent) = model.agent(id) else {
continue;
};
if agent.parent.as_deref() != parent {
continue;
}
let indent = " ".repeat(depth + 1);
let kind = match agent.kind {
AgentKind::Main => "main",
AgentKind::Subagent => "subagent",
AgentKind::WorkflowGroup => "workflow",
};
let status = agent.status_word();
let glyph = agent.status.glyph();
let label = agent
.agent_type
.as_deref()
.or(agent.description.as_deref())
.unwrap_or(id);
let mut ok = 0u32;
let mut err = 0u32;
let mut pending = 0u32;
for t in &agent.tool_calls {
match t.state {
ToolState::Ok => ok += 1,
ToolState::Err => err += 1,
ToolState::Pending => pending += 1,
}
}
println!("{indent}{glyph} [{kind}] {label} ({status}) — id={id}");
if let Some(desc) = &agent.description
&& agent.agent_type.is_some()
{
println!("{indent} {desc}");
}
if let Some(model_name) = &agent.model {
println!("{indent} model: {model_name}");
}
println!(
"{indent} tools: {} ({ok}✓ {err}✗ {pending}⏳) tokens: {}",
agent.tool_calls.len(),
agent.output_tokens
);
if let Some(ctx) = model.provenance(agent) {
if let Some(prompt) = model.provenance_prompt(ctx) {
println!("{indent} ↳ prompt: {prompt}");
}
if let Some(reasoning) = &ctx.reasoning {
println!("{indent} ↳ thought: {reasoning}");
}
}
print_agent_tree(model, Some(id), depth + 1);
}
}
async fn run_tui(cli: Cli) -> Result<()> {
let Cli::View {
target,
follow,
speed,
} = cli
else {
unreachable!("inspect handled in main");
};
let (session_id, watch_target, mode, replay, speed) = match target {
Some(file) if file.is_file() => {
let session_id = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("session")
.to_string();
let mode = if follow { Mode::Live } else { Mode::Replay };
(session_id, file, mode, true, speed)
}
other => {
if let Some(p) = &other
&& !p.is_dir()
{
bail!("not found: {}", p.display());
}
let cwd = match other {
Some(d) => d,
None => std::env::current_dir().context("resolving current directory")?,
};
let proj = transcript::project_dir(&cwd)
.ok_or_else(|| anyhow!("no Claude projects directory for {}", cwd.display()))?;
let session_id = transcript::latest_session_file(&proj)
.as_deref()
.and_then(Path::file_stem)
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
(session_id, proj, Mode::Live, false, DEFAULT_REPLAY_SPEED)
}
};
let (tail_tx, tail_rx) = mpsc::channel::<TailRequest>(CHANNEL_CAP);
let (ui_tx, ui_rx) = mpsc::channel::<UiEvent>(CHANNEL_CAP);
tail_tx
.send(TailRequest::Watch(watch_target))
.await
.map_err(|_| anyhow!("tailer channel closed before start"))?;
tokio::spawn(async move {
if let Err(e) = tailer::run(tail_rx, ui_tx.clone(), replay, speed).await {
let _ = ui_tx.send(UiEvent::Error(e.to_string())).await;
}
});
let app = App::new(session_id, mode);
tui::run(app, tail_tx, ui_rx).await
}
#[tokio::main]
async fn main() -> Result<()> {
if std::env::var("ZOETROPE_DEMO").as_deref() == Ok("duration") {
println!("{:.2}", zoetrope::autopilot::tour_secs());
return Ok(());
}
let cli = parse_cli(std::env::args())?;
match cli {
Cli::Inspect { file } => run_inspect(file).await,
other => run_tui(other).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use zoetrope::state::session::AgentStatus;
fn cli(args: &[&str]) -> Result<Cli> {
let mut v = vec!["zoe".to_string()];
v.extend(args.iter().map(|s| s.to_string()));
parse_cli(v.into_iter())
}
#[test]
fn bare_invocation_is_live_for_cwd() {
match cli(&[]).unwrap() {
Cli::View {
target: None,
follow: false,
..
} => {}
other => panic!("expected View{{target:None}}, got {other:?}"),
}
}
#[test]
fn positional_path_is_the_target() {
match cli(&["/tmp/foo"]).unwrap() {
Cli::View {
target: Some(p), ..
} => assert_eq!(p, PathBuf::from("/tmp/foo")),
other => panic!("got {other:?}"),
}
match cli(&["s.jsonl"]).unwrap() {
Cli::View {
target: Some(p), ..
} => assert_eq!(p, PathBuf::from("s.jsonl")),
other => panic!("got {other:?}"),
}
}
#[test]
fn default_speed_and_no_follow() {
match cli(&["s.jsonl"]).unwrap() {
Cli::View { speed, follow, .. } => {
assert_eq!(speed, DEFAULT_REPLAY_SPEED);
assert!(!follow);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn speed_and_follow_flags_in_any_order() {
match cli(&["s.jsonl", "--speed", "4", "--follow"]).unwrap() {
Cli::View {
target: Some(p),
follow,
speed,
} => {
assert_eq!(p, PathBuf::from("s.jsonl"));
assert_eq!(speed, 4.0);
assert!(follow);
}
other => panic!("got {other:?}"),
}
match cli(&["--speed", "2.5", "s.jsonl"]).unwrap() {
Cli::View {
target: Some(p),
speed,
..
} => {
assert_eq!(p, PathBuf::from("s.jsonl"));
assert_eq!(speed, 2.5);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn rejects_bad_speed() {
assert!(cli(&["s.jsonl", "--speed", "nope"]).is_err());
assert!(cli(&["s.jsonl", "--speed", "0"]).is_err());
assert!(cli(&["s.jsonl", "--speed", "-3"]).is_err());
assert!(cli(&["s.jsonl", "--speed"]).is_err());
}
#[test]
fn rejects_extra_positional_and_unknown_flags() {
assert!(cli(&["a.jsonl", "b.jsonl"]).is_err());
assert!(cli(&["--bogus"]).is_err());
}
#[test]
fn inspect_takes_one_file() {
match cli(&["inspect", "s.jsonl"]).unwrap() {
Cli::Inspect { file } => assert_eq!(file, PathBuf::from("s.jsonl")),
other => panic!("got {other:?}"),
}
assert!(cli(&["inspect"]).is_err());
assert!(cli(&["inspect", "a", "b"]).is_err());
}
#[test]
fn parse_session_fully_marks_quiet_main_idle() {
let tmp = std::env::temp_dir().join(format!(
"zoetrope-fullparse-{}-{}.jsonl",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::write(
&tmp,
b"{\"type\":\"user\",\"uuid\":\"u\",\"parentUuid\":null,\"timestamp\":\"2026-06-05T13:51:00.000Z\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n",
)
.unwrap();
let model = parse_session_fully(&tmp).expect("parses");
assert_eq!(
model
.agent(zoetrope::state::session::MAIN_ID)
.unwrap()
.status,
AgentStatus::Idle
);
let _ = std::fs::remove_file(&tmp);
}
}