use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub enum Turn {
User(String),
Assistant(String),
Thinking(String),
ToolUse {
name: String,
summary: String,
},
ToolResult(String),
}
#[derive(Debug, Clone)]
pub struct PastSession {
pub session_id: String,
pub path: PathBuf,
pub mtime: i64,
pub preview: String,
}
pub fn list_sessions(workspace: &Path) -> Vec<PastSession> {
let Some(home) = std::env::var_os("HOME") else {
return Vec::new();
};
let dashed = workspace.to_string_lossy().replace(['/', '.'], "-");
let dir = Path::new(&home)
.join(".claude")
.join("projects")
.join(dashed);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out: Vec<PastSession> = Vec::new();
for e in entries.flatten() {
let path = e.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let mtime = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let preview = first_user_message(&path).unwrap_or_default();
out.push(PastSession {
session_id: stem.to_string(),
path: path.clone(),
mtime,
preview,
});
}
out.sort_by_key(|s| std::cmp::Reverse(s.mtime));
out
}
fn first_user_message(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
if v.get("type").and_then(Value::as_str) != Some("user") {
continue;
}
let content = v.get("message").and_then(|m| m.get("content"))?;
let text = match content {
Value::String(s) => s.clone(),
Value::Array(blocks) => blocks
.iter()
.filter_map(|b| {
if b.get("type").and_then(Value::as_str)? != "text" {
return None;
}
b.get("text").and_then(Value::as_str).map(String::from)
})
.next()?,
_ => continue,
};
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
return Some(trimmed.chars().take(80).collect());
}
None
}
pub fn session_path(workspace: &Path, session_id: &str) -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let dashed = workspace.to_string_lossy().replace(['/', '.'], "-");
Some(
Path::new(&home)
.join(".claude")
.join("projects")
.join(dashed)
.join(format!("{session_id}.jsonl")),
)
}
pub fn read(path: &Path) -> Vec<Turn> {
match std::fs::read_to_string(path) {
Ok(text) => parse(&text),
Err(_) => Vec::new(),
}
}
pub fn parse(text: &str) -> Vec<Turn> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
continue; }
let role = match v.get("type").and_then(Value::as_str) {
Some("user") => "user",
Some("assistant") => "assistant",
_ => continue,
};
let Some(content) = v.get("message").and_then(|m| m.get("content")) else {
continue;
};
match content {
Value::String(s) => {
let s = s.trim();
if !s.is_empty() {
out.push(if role == "user" {
Turn::User(s.to_string())
} else {
Turn::Assistant(s.to_string())
});
}
}
Value::Array(blocks) => {
for b in blocks {
push_block(role, b, &mut out);
}
}
_ => {}
}
}
out
}
fn push_block(role: &str, b: &Value, out: &mut Vec<Turn>) {
match b.get("type").and_then(Value::as_str) {
Some("text") => {
let t = b.get("text").and_then(Value::as_str).unwrap_or("").trim();
if !t.is_empty() {
out.push(if role == "user" {
Turn::User(t.to_string())
} else {
Turn::Assistant(t.to_string())
});
}
}
Some("thinking") => {
let t = b
.get("thinking")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
if !t.is_empty() {
out.push(Turn::Thinking(first_line(t, 100)));
}
}
Some("tool_use") => {
let name = b
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
let summary = tool_input_summary(b.get("input"));
out.push(Turn::ToolUse { name, summary });
}
Some("tool_result") => {
let txt = match b.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
let txt = txt.trim();
if !txt.is_empty() {
out.push(Turn::ToolResult(first_lines(txt, 2, 200)));
}
}
_ => {}
}
}
fn tool_input_summary(input: Option<&Value>) -> String {
let Some(obj) = input.and_then(Value::as_object) else {
return String::new();
};
for key in [
"command",
"file_path",
"path",
"pattern",
"query",
"url",
"description",
"prompt",
] {
if let Some(v) = obj.get(key).and_then(Value::as_str) {
return first_line(v, 80);
}
}
obj.iter()
.next()
.map(|(k, v)| format!("{k}={}", first_line(&v.to_string(), 60)))
.unwrap_or_default()
}
fn first_line(s: &str, max: usize) -> String {
let line = s.lines().next().unwrap_or("").trim();
truncate(line, max)
}
fn first_lines(s: &str, n: usize, max: usize) -> String {
let joined: String = s.lines().take(n).collect::<Vec<_>>().join(" ⏎ ");
truncate(joined.trim(), max)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let keep: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{keep}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_path_dashes_the_cwd() {
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", "/home/x");
let p = session_path(Path::new("/Users/me/Projects/mnml"), "abc-123").unwrap();
assert_eq!(
p,
Path::new("/home/x/.claude/projects/-Users-me-Projects-mnml/abc-123.jsonl")
);
}
#[test]
fn parses_messages_tools_and_skips_meta_and_sidechains() {
let jsonl = r#"
{"type":"last-prompt","sessionId":"s"}
{"type":"permission-mode","permissionMode":"plan"}
{"type":"user","message":{"role":"user","content":"hello there"}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm\nlet me think"},{"type":"text","text":"Hi! Let me look."},{"type":"tool_use","name":"Bash","input":{"command":"ls -la /tmp\necho done"}}]}}
{"type":"user","isSidechain":true,"message":{"role":"user","content":"subagent noise"}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":[{"type":"text","text":"total 0\ndrwxr-xr-x ...\nmore"}]}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]}}
"#;
let turns = parse(jsonl);
assert_eq!(turns.len(), 6);
assert_eq!(turns[0], Turn::User("hello there".into()));
assert!(matches!(&turns[1], Turn::Thinking(s) if s.starts_with("hmm")));
assert_eq!(turns[2], Turn::Assistant("Hi! Let me look.".into()));
assert!(
matches!(&turns[3], Turn::ToolUse { name, summary } if name == "Bash" && summary == "ls -la /tmp")
);
assert!(matches!(&turns[4], Turn::ToolResult(s) if s.contains("total 0")));
assert_eq!(turns[5], Turn::Assistant("Done.".into()));
assert!(
!turns
.iter()
.any(|t| matches!(t, Turn::User(s) if s.contains("noise")))
);
}
#[test]
fn parsing_is_line_local_so_incremental_appends_work() {
let head = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"one\"}}\n";
let tail = "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"two\"}]}}\n";
let whole = format!("{head}{tail}");
let split: Vec<_> = parse(head).into_iter().chain(parse(tail)).collect();
assert_eq!(split, parse(&whole));
assert_eq!(
split,
vec![Turn::User("one".into()), Turn::Assistant("two".into())]
);
}
}