use anyhow::{Context, Result, bail};
use std::io::IsTerminal;
use crate::cli::{HistoryArgs, LastArgs};
use crate::output::{format_timestamp, truncate_arg};
const WORKTREE_SCAN_CAP: usize = 1000;
pub fn run_history(args: HistoryArgs) -> Result<()> {
use claude_wrapper::history::HistoryRoot;
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
if let Some(paths_n) = args.paths {
let (sessions, _) = list_for_history(&root, &args, paths_n)?;
for s in &sessions {
let path = root
.path()
.join(&s.project_slug)
.join(format!("{}.jsonl", s.session_id));
println!("{}", path.display());
}
return Ok(());
}
let limit = if args.all {
None
} else {
Some(args.limit.unwrap_or(10))
};
let (sessions, inferred_from_cwd) = list_for_history(&root, &args, limit)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&crate::VersionedResult::new(&sessions))?
);
return Ok(());
}
if sessions.is_empty() {
if inferred_from_cwd {
eprintln!("no sessions in this project (use --all-projects to widen)");
} else {
eprintln!("no sessions found");
}
return Ok(());
}
println!("{:<10} {:<17} {:>5} TITLE", "SESSION", "LAST", "MSGS");
for s in &sessions {
let short_id = s.session_id.get(..8).unwrap_or(&s.session_id);
let last = s
.last_timestamp
.as_deref()
.and_then(format_timestamp)
.unwrap_or_else(|| "?".to_string());
let title = s
.title
.as_deref()
.or(s.first_user_preview.as_deref())
.unwrap_or("(no title)");
let title = truncate_arg(title, 60);
println!(
"{:<10} {:<17} {:>5} {}",
short_id, last, s.message_count, title
);
}
Ok(())
}
fn list_for_history(
root: &claude_wrapper::history::HistoryRoot,
args: &HistoryArgs,
limit: Option<usize>,
) -> Result<(Vec<claude_wrapper::history::SessionSummary>, bool)> {
use claude_wrapper::history::{ListOptions, ListSort};
if let Some(name) = args.worktree.as_deref() {
let scan_opts = ListOptions {
limit: None,
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let candidates: Vec<_> = root
.list_sessions_with(None, &scan_opts)
.context("reading session history")?
.into_iter()
.filter(|s| slug_is_worktree(&s.project_slug))
.collect();
let capped = candidates.len() > WORKTREE_SCAN_CAP;
let mut matched = Vec::new();
for s in candidates.into_iter().take(WORKTREE_SCAN_CAP) {
if limit.is_some_and(|n| matched.len() >= n) {
break;
}
if session_in_worktree(root, &s, name) {
matched.push(s);
}
}
if capped && limit.is_none_or(|n| matched.len() < n) {
eprintln!(
"note: scanned only the {WORKTREE_SCAN_CAP} most recent worktree sessions for worktree `{name}`; older sessions were not checked"
);
}
return Ok((matched, false));
}
let (scope, inferred) = resolve_project_scope(args.project.clone(), args.all_projects);
let opts = ListOptions {
limit,
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let sessions = root
.list_sessions_with(scope.as_deref(), &opts)
.context("reading session history")?;
Ok((sessions, inferred))
}
fn session_in_worktree(
root: &claude_wrapper::history::HistoryRoot,
s: &claude_wrapper::history::SessionSummary,
name: &str,
) -> bool {
use std::io::BufRead;
let path = root
.path()
.join(&s.project_slug)
.join(format!("{}.jsonl", s.session_id));
let Ok(file) = std::fs::File::open(&path) else {
return false;
};
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("type").and_then(|t| t.as_str()) == Some("user")
&& let Some(cwd) = value.get("cwd").and_then(|c| c.as_str())
{
return session_worktree(cwd) == Some(name);
}
}
false
}
fn session_worktree(cwd: &str) -> Option<&str> {
let after = cwd.split(".claude/worktrees/").nth(1)?;
let name = after.split('/').next()?;
if name.is_empty() { None } else { Some(name) }
}
fn slug_is_worktree(slug: &str) -> bool {
slug.contains("claude-worktrees")
}
pub fn run_last(args: LastArgs) -> Result<()> {
use claude_wrapper::history::{HistoryEntry, HistoryRoot, ListOptions, ListSort};
let n = args.number.unwrap_or(1);
if n == 0 {
return Ok(());
}
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
let opts = ListOptions {
limit: Some(1),
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let (scope, inferred_from_cwd) = resolve_project_scope(args.project.clone(), args.all_projects);
let sessions = root
.list_sessions_with(scope.as_deref(), &opts)
.context("reading session history")?;
let summary = sessions.first().ok_or_else(|| {
if inferred_from_cwd {
anyhow::anyhow!("no sessions in this project (use --all-projects to widen)")
} else {
anyhow::anyhow!("no sessions found")
}
})?;
let log = root
.read_session(&summary.session_id)
.context("reading most recent session")?;
let mut items: Vec<Item> = Vec::new();
for entry in &log.entries {
if let HistoryEntry::Assistant { message, .. } = entry {
let Some(blocks) = message.get("content").and_then(|c| c.as_array()) else {
continue;
};
for block in blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(t) = block.get("text").and_then(|v| v.as_str())
&& !t.trim().is_empty()
{
items.push(Item::Text(t.to_string()));
}
}
Some("tool_use") => {
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let input = block
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
items.push(Item::Tool { name, input });
}
_ => {}
}
}
}
}
let filtered: Vec<&Item> = items
.iter()
.filter(|it| match args.kind {
crate::cli::LastKind::Text => matches!(it, Item::Text(_)),
crate::cli::LastKind::Tools => matches!(it, Item::Tool { .. }),
crate::cli::LastKind::All => true,
})
.collect();
if filtered.is_empty() {
bail!(
"session has no {} ({} total items)",
args.kind.label(),
items.len()
);
}
let start = filtered.len().saturating_sub(n);
let recent = &filtered[start..];
let style = crate::render::Style::detect_for_subcommand();
let mut prev_text = false;
for (i, item) in recent.iter().enumerate() {
match item {
Item::Text(text) => {
if i > 0 && prev_text {
crate::render::print_meta_blank();
crate::render::print_meta("---", &style);
crate::render::print_meta_blank();
}
crate::render::print_body(text, &style);
prev_text = true;
}
Item::Tool { name, input } => {
let summary = crate::output::summarize_tool(name, input);
crate::render::print_tool_call(&summary, &style);
prev_text = false;
}
}
}
if std::io::stderr().is_terminal() {
let short = summary.session_id.get(..8).unwrap_or(&summary.session_id);
let when = summary
.last_timestamp
.as_deref()
.and_then(format_timestamp)
.unwrap_or_else(|| "?".to_string());
crate::render::print_meta_blank();
crate::render::print_meta(
&format!(
"session {short} . {} messages . {when} . showing {} of {} {}",
summary.message_count,
recent.len(),
filtered.len(),
args.kind.label(),
),
&style,
);
}
Ok(())
}
enum Item {
Text(String),
Tool {
name: String,
input: serde_json::Value,
},
}
pub fn extract_message_text(message: &serde_json::Value) -> Option<String> {
let blocks = message.get("content")?.as_array()?;
let mut out = String::new();
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(text) = block.get("text").and_then(|v| v.as_str())
{
out.push_str(text);
}
}
if out.is_empty() { None } else { Some(out) }
}
pub fn resolve_project_scope(
explicit: Option<String>,
all_projects: bool,
) -> (Option<String>, bool) {
if let Some(p) = explicit {
return (Some(p), false);
}
if all_projects {
return (None, false);
}
match current_project_slug() {
Some(slug) => (Some(slug), true),
None => (None, false),
}
}
pub fn current_project_slug() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let canonical = cwd.canonicalize().unwrap_or(cwd);
let s = canonical.to_str()?;
Some(s.replace('/', "-"))
}
pub fn last_n_assistant_texts_in_cwd(n: usize) -> Result<Vec<String>> {
use claude_wrapper::history::{HistoryEntry, HistoryRoot, ListOptions, ListSort};
if n == 0 {
return Ok(Vec::new());
}
let Some(slug) = current_project_slug() else {
return Ok(Vec::new());
};
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
let opts = ListOptions {
limit: Some(1),
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let sessions = root
.list_sessions_with(Some(&slug), &opts)
.context("reading session history")?;
let Some(summary) = sessions.first() else {
return Ok(Vec::new());
};
let log = root
.read_session(&summary.session_id)
.context("reading most recent session")?;
let mut texts: Vec<String> = Vec::new();
for entry in &log.entries {
if let HistoryEntry::Assistant { message, .. } = entry
&& let Some(t) = extract_message_text(message)
&& !t.trim().is_empty()
{
texts.push(t);
}
}
let start = texts.len().saturating_sub(n);
Ok(texts[start..].to_vec())
}
pub fn current_project_session_ids() -> Result<Vec<String>> {
use claude_wrapper::history::{HistoryRoot, ListOptions, ListSort};
let Some(slug) = current_project_slug() else {
return Ok(Vec::new());
};
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
let opts = ListOptions {
limit: None,
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let sessions = root
.list_sessions_with(Some(&slug), &opts)
.context("reading session history")?;
Ok(sessions.into_iter().map(|s| s.session_id).collect())
}
pub fn pick_session_interactive() -> Result<String> {
use claude_wrapper::history::{HistoryRoot, ListOptions, ListSort};
use dialoguer::{FuzzySelect, theme::ColorfulTheme};
if !std::io::stdout().is_terminal() {
bail!("--pick requires a TTY");
}
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
let opts = ListOptions {
limit: Some(50),
offset: 0,
include_empty: false,
sort: ListSort::RecencyDesc,
};
let sessions = root
.list_sessions_with(None, &opts)
.context("reading session history")?;
if sessions.is_empty() {
bail!("no sessions to pick from");
}
let items: Vec<String> = sessions
.iter()
.map(|s| {
let id = s.session_id.get(..8).unwrap_or(&s.session_id);
let when = s
.last_timestamp
.as_deref()
.and_then(format_timestamp)
.unwrap_or_else(|| "?".to_string());
let title = s
.title
.as_deref()
.or(s.first_user_preview.as_deref())
.unwrap_or("(no title)");
format!("{id} {when} {}", truncate_arg(title, 60))
})
.collect();
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Pick a session to resume")
.items(&items)
.default(0)
.interact()
.context("session picker cancelled")?;
Ok(sessions[selection].session_id.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_message_text_concatenates_text_blocks() {
let msg = serde_json::json!({
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world."},
]
});
assert_eq!(extract_message_text(&msg), Some("Hello world.".to_string()));
}
#[test]
fn extract_message_text_ignores_tool_use_blocks() {
let msg = serde_json::json!({
"content": [
{"type": "tool_use", "name": "Read", "input": {"file_path": "x"}},
{"type": "text", "text": "After tool"},
]
});
assert_eq!(extract_message_text(&msg), Some("After tool".to_string()));
}
#[test]
fn extract_message_text_returns_none_when_only_tools() {
let msg = serde_json::json!({
"content": [
{"type": "tool_use", "name": "Read", "input": {"file_path": "x"}}
]
});
assert_eq!(extract_message_text(&msg), None);
}
#[test]
fn extract_message_text_returns_none_for_missing_content() {
let msg = serde_json::json!({});
assert_eq!(extract_message_text(&msg), None);
}
#[test]
fn extract_message_text_returns_none_for_content_not_array() {
let msg = serde_json::json!({"content": "should be an array"});
assert_eq!(extract_message_text(&msg), None);
}
#[test]
fn session_worktree_extracts_name() {
assert_eq!(
session_worktree(
"/Users/jo/Code/active/agent-tools/.claude/worktrees/agent-a1ce841658fc4a90d"
),
Some("agent-a1ce841658fc4a90d")
);
}
#[test]
fn session_worktree_ignores_trailing_subdirs() {
assert_eq!(
session_worktree("/repo/.claude/worktrees/foo/src/lib"),
Some("foo")
);
}
#[test]
fn session_worktree_none_for_non_worktree_cwd() {
assert_eq!(session_worktree("/Users/jo/Code/active/agent-tools"), None);
assert_eq!(session_worktree("/repo/.claude/projects/whatever"), None);
}
#[test]
fn session_worktree_none_for_empty_trailing_segment() {
assert_eq!(session_worktree("/repo/.claude/worktrees/"), None);
}
#[test]
fn slug_is_worktree_true_for_worktree_slug() {
assert!(slug_is_worktree("-repo--claude-worktrees-foo"));
assert!(slug_is_worktree(
"-Users-jo-Code-active-agent-tools--claude-worktrees-agent-a1ce841658fc4a90d"
));
}
#[test]
fn slug_is_worktree_false_for_base_slug() {
assert!(!slug_is_worktree("-repo"));
assert!(!slug_is_worktree("-Users-jo-Code-active-agent-tools"));
}
#[test]
fn paths_output_construction() {
let root_path = std::path::Path::new("projects");
let project_slug = "-Users-alice-myproject";
let session_id = "abc12345";
let path = root_path
.join(project_slug)
.join(format!("{}.jsonl", session_id));
assert_eq!(path.parent().unwrap().file_name().unwrap(), project_slug);
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"abc12345.jsonl"
);
}
}