From 0102d362aa977dd2cfb16e52a48d8ec5f7fc5071 Mon Sep 17 00:00:00 2001 From: dudegladiator Date: Thu, 11 Jun 2026 14:43:44 +0530 Subject: [PATCH] feat: prefer Claude Code aiTitle for session listings scan() reads type:"ai-title" line and uses its aiTitle field as the session title. Falls back to first user message text when ai-title is absent or empty. release: v0.4.0 --- Cargo.toml | 2 +- src/scan.rs | 85 +++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 13cc631..66face6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-session" -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.75" description = "Interactive TUI editor for Claude Code session JSONL files. Browse, search, and surgically delete messages while preserving tool_use/tool_result pairing." diff --git a/src/scan.rs b/src/scan.rs index 7098141..493588d 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -86,13 +86,20 @@ pub fn scan(projects_dir: &Path) -> anyhow::Result> { } fn derive_title(path: &Path) -> String { + // Prefer Claude Code's auto-generated `aiTitle` (a single + // `{"type":"ai-title","aiTitle":"..."}` line that can appear anywhere + // in the file). Fall back to first user message text. Final fallback: + // a placeholder. Single pass, capped at 1000 lines to keep scan fast on + // very large sessions. let file = match fs::File::open(path) { Ok(f) => f, Err(_) => return "".into(), }; let reader = BufReader::new(file); + let mut first_user_text: Option = None; + for (peeked, line) in reader.lines().enumerate() { - if peeked >= 50 { + if peeked >= 1000 { break; } let raw = match line { @@ -106,25 +113,39 @@ fn derive_title(path: &Path) -> String { Ok(v) => v, Err(_) => continue, }; - if v.get("type").and_then(Value::as_str) != Some("user") { - continue; + let entry_type = v.get("type").and_then(Value::as_str); + + if entry_type == Some("ai-title") { + if let Some(t) = v.get("aiTitle").and_then(Value::as_str) { + let t = t.trim(); + if !t.is_empty() { + return clamp_title(t); + } + } } - let content = v.get("message").and_then(|m| m.get("content")); - let text = match content { - Some(Value::String(s)) => s.clone(), - Some(Value::Array(arr)) => arr - .iter() - .filter_map(|b| { - if b.get("type").and_then(Value::as_str) == Some("text") { - b.get("text").and_then(Value::as_str).map(str::to_string) - } else { - None - } - }) - .collect::>() - .join(" "), - _ => continue, - }; + + if first_user_text.is_none() && entry_type == Some("user") { + let content = v.get("message").and_then(|m| m.get("content")); + let text = match content { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|b| { + if b.get("type").and_then(Value::as_str) == Some("text") { + b.get("text").and_then(Value::as_str).map(str::to_string) + } else { + None + } + }) + .collect::>() + .join(" "), + _ => continue, + }; + first_user_text = Some(text); + } + } + + if let Some(text) = first_user_text { return clamp_title(&text); } "".into() @@ -240,6 +261,32 @@ mod tests { assert!(entries[0].title.contains("line1 line2")); } + #[test] + fn ai_title_overrides_first_user_message() { + let dir = tempfile::tempdir().unwrap(); + make_session( + dir.path(), + "p", + "s", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"raw question\"}}\n{\"type\":\"ai-title\",\"aiTitle\":\"Pretty Generated Title\"}\n", + ); + let entries = scan(dir.path()).unwrap(); + assert_eq!(entries[0].title, "Pretty Generated Title"); + } + + #[test] + fn empty_ai_title_falls_back_to_user_msg() { + let dir = tempfile::tempdir().unwrap(); + make_session( + dir.path(), + "p", + "s", + "{\"type\":\"ai-title\",\"aiTitle\":\"\"}\n{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fallback text\"}}\n", + ); + let entries = scan(dir.path()).unwrap(); + assert_eq!(entries[0].title, "fallback text"); + } + #[test] fn malformed_first_line_recovers() { let dir = tempfile::tempdir().unwrap();