Skip to content

Commit 5c667f3

Browse files
feat: prefer Claude Code aiTitle for session listings (#3)
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
1 parent 9b88245 commit 5c667f3

2 files changed

Lines changed: 67 additions & 20 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cc-session"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
rust-version = "1.75"
66
description = "Interactive TUI editor for Claude Code session JSONL files. Browse, search, and surgically delete messages while preserving tool_use/tool_result pairing."

src/scan.rs

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,20 @@ pub fn scan(projects_dir: &Path) -> anyhow::Result<Vec<SessionEntry>> {
8686
}
8787

8888
fn derive_title(path: &Path) -> String {
89+
// Prefer Claude Code's auto-generated `aiTitle` (a single
90+
// `{"type":"ai-title","aiTitle":"..."}` line that can appear anywhere
91+
// in the file). Fall back to first user message text. Final fallback:
92+
// a placeholder. Single pass, capped at 1000 lines to keep scan fast on
93+
// very large sessions.
8994
let file = match fs::File::open(path) {
9095
Ok(f) => f,
9196
Err(_) => return "<unreadable>".into(),
9297
};
9398
let reader = BufReader::new(file);
99+
let mut first_user_text: Option<String> = None;
100+
94101
for (peeked, line) in reader.lines().enumerate() {
95-
if peeked >= 50 {
102+
if peeked >= 1000 {
96103
break;
97104
}
98105
let raw = match line {
@@ -106,25 +113,39 @@ fn derive_title(path: &Path) -> String {
106113
Ok(v) => v,
107114
Err(_) => continue,
108115
};
109-
if v.get("type").and_then(Value::as_str) != Some("user") {
110-
continue;
116+
let entry_type = v.get("type").and_then(Value::as_str);
117+
118+
if entry_type == Some("ai-title") {
119+
if let Some(t) = v.get("aiTitle").and_then(Value::as_str) {
120+
let t = t.trim();
121+
if !t.is_empty() {
122+
return clamp_title(t);
123+
}
124+
}
111125
}
112-
let content = v.get("message").and_then(|m| m.get("content"));
113-
let text = match content {
114-
Some(Value::String(s)) => s.clone(),
115-
Some(Value::Array(arr)) => arr
116-
.iter()
117-
.filter_map(|b| {
118-
if b.get("type").and_then(Value::as_str) == Some("text") {
119-
b.get("text").and_then(Value::as_str).map(str::to_string)
120-
} else {
121-
None
122-
}
123-
})
124-
.collect::<Vec<_>>()
125-
.join(" "),
126-
_ => continue,
127-
};
126+
127+
if first_user_text.is_none() && entry_type == Some("user") {
128+
let content = v.get("message").and_then(|m| m.get("content"));
129+
let text = match content {
130+
Some(Value::String(s)) => s.clone(),
131+
Some(Value::Array(arr)) => arr
132+
.iter()
133+
.filter_map(|b| {
134+
if b.get("type").and_then(Value::as_str) == Some("text") {
135+
b.get("text").and_then(Value::as_str).map(str::to_string)
136+
} else {
137+
None
138+
}
139+
})
140+
.collect::<Vec<_>>()
141+
.join(" "),
142+
_ => continue,
143+
};
144+
first_user_text = Some(text);
145+
}
146+
}
147+
148+
if let Some(text) = first_user_text {
128149
return clamp_title(&text);
129150
}
130151
"<no user message>".into()
@@ -240,6 +261,32 @@ mod tests {
240261
assert!(entries[0].title.contains("line1 line2"));
241262
}
242263

264+
#[test]
265+
fn ai_title_overrides_first_user_message() {
266+
let dir = tempfile::tempdir().unwrap();
267+
make_session(
268+
dir.path(),
269+
"p",
270+
"s",
271+
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"raw question\"}}\n{\"type\":\"ai-title\",\"aiTitle\":\"Pretty Generated Title\"}\n",
272+
);
273+
let entries = scan(dir.path()).unwrap();
274+
assert_eq!(entries[0].title, "Pretty Generated Title");
275+
}
276+
277+
#[test]
278+
fn empty_ai_title_falls_back_to_user_msg() {
279+
let dir = tempfile::tempdir().unwrap();
280+
make_session(
281+
dir.path(),
282+
"p",
283+
"s",
284+
"{\"type\":\"ai-title\",\"aiTitle\":\"\"}\n{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fallback text\"}}\n",
285+
);
286+
let entries = scan(dir.path()).unwrap();
287+
assert_eq!(entries[0].title, "fallback text");
288+
}
289+
243290
#[test]
244291
fn malformed_first_line_recovers() {
245292
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)