ai titles for sessions

This commit is contained in:
Jonas H
2026-09-07 08:07:43 +02:00
parent b9d7d2c969
commit 6bb7e7d424
4 changed files with 184 additions and 36 deletions

View File

@@ -383,6 +383,19 @@ impl App {
}
}
/// Claude Code's own name for a session (its `/resume` picker title), from
/// the scanner's read of the transcript — see `sessions::read_meta`. It
/// covers a *live* session too: Claude Code writes its JSONL continuously,
/// so a running row and its later disk stub read the same. `None` until the
/// file names it, which is the gap `ui::live_title` fills.
pub fn cc_title(&self, key: &str) -> Option<&str> {
self.disk_sessions
.iter()
.find(|d| d.uuid == key)?
.title
.as_deref()
}
/// Indices into `disk_sessions` that should appear as stubs: everything
/// not already present as a live session (a live session *is* on disk —
/// Claude Code writes the JSONL continuously — so dedupe by uuid).
@@ -644,7 +657,7 @@ impl App {
0,
crate::sessions::DiskSession {
uuid: new_uuid.clone(),
label: title,
title: Some(title),
model,
// A materialized branch carries no subagent transcripts (the
// `Agent` tool_results in it hold the reports the model saw).
@@ -2925,7 +2938,7 @@ mod tests {
fn stub(uuid: &str, model: &str) -> crate::sessions::DiskSession {
crate::sessions::DiskSession {
uuid: uuid.into(),
label: uuid.into(),
title: Some(uuid.into()),
model: model.into(),
agents: 0,
modified: std::time::SystemTime::now(),
@@ -4900,6 +4913,24 @@ eligible.\",\"type\":\"AccessDenied.Unpurchased\"}</status>\n\
stub(uuid, "")
}
/// A live session takes Claude Code's own name for it: its transcript is
/// on disk while it runs, so the running row and its later stub read the
/// same. An unnamed or unknown session reports nothing, and the row falls
/// back to `ui::live_title`.
#[test]
fn live_sessions_read_claude_codes_own_title() {
let mut a = App::new();
a.sessions.push(Session::new("live".into(), "m".into()));
let mut named = stub("live", "claude-sonnet-4-5-20250929");
named.title = Some("fix the parser".into());
let mut unnamed = stub("fresh", "");
unnamed.title = None;
a.set_disk_sessions(vec![named, unnamed]);
assert_eq!(a.cc_title("live"), Some("fix the parser"));
assert_eq!(a.cc_title("fresh"), None);
assert_eq!(a.cc_title("nosuch"), None);
}
#[test]
fn disk_stubs_dedupe_against_live_sessions() {
let mut a = App::new();

View File

@@ -27,8 +27,11 @@ pub struct DiskSession {
/// always writes the parent's `Agent` records too, so the parent's mtime
/// moves whenever this can change).
pub agents: usize,
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
pub label: String,
/// Claude Code's own name for the session — its `/resume` picker title,
/// read from the transcript (see `read_meta`). `None` when the file holds
/// none of the records it comes from. A *live* session's file is on disk
/// too, so `App::cc_title` gives a running session the same name.
pub title: Option<String>,
/// API model id of the session's last main-chain assistant message
/// (empty when the file has none yet). `App::resume_model` turns it into
/// the `--model` argument a resume spawns with.
@@ -36,6 +39,15 @@ pub struct DiskSession {
pub modified: SystemTime,
}
impl DiskSession {
/// Row label: Claude Code's own title, else the uuid prefix.
pub fn label(&self) -> String {
self.title
.clone()
.unwrap_or_else(|| self.uuid.chars().take(8).collect())
}
}
/// Background scanner: keeps `App::disk_sessions` in sync with the project
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
/// labels and models are re-read only for files whose mtime changed, so the
@@ -43,9 +55,9 @@ pub struct DiskSession {
/// only taken when the list actually changed.
pub fn spawn_scanner(app: SharedApp) {
std::thread::spawn(move || {
// uuid → (mtime when read, label, model, subagent count): skip
// uuid → (mtime when read, title, model, subagent count): skip
// re-parsing unchanged files (one pass yields all — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String, usize)> = HashMap::new();
let mut meta: HashMap<String, (SystemTime, Option<String>, String, usize)> = HashMap::new();
let mut last: Vec<DiskSession> = Vec::new();
loop {
let list = scan(&mut meta).unwrap_or_default();
@@ -60,7 +72,7 @@ pub fn spawn_scanner(app: SharedApp) {
/// One scan of the project directory, newest first.
fn scan(
meta: &mut HashMap<String, (SystemTime, String, String, usize)>,
meta: &mut HashMap<String, (SystemTime, Option<String>, String, usize)>,
) -> Result<Vec<DiskSession>, String> {
let dir = project_dir()?;
let rd = std::fs::read_dir(&dir)
@@ -74,21 +86,21 @@ fn scan(
}
let uuid = path.file_stem()?.to_str()?.to_string();
let modified = e.metadata().ok()?.modified().ok()?;
let (label, model, agents) = match meta.get(&uuid) {
Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n),
let (title, model, agents) = match meta.get(&uuid) {
Some((m, t, md, n)) if *m == modified => (t.clone(), md.clone(), *n),
_ => {
let (label, model) = read_meta(&path, &uuid);
let (title, model) = read_meta(&path);
let agents = scan_agents(&uuid).len();
meta.insert(
uuid.clone(),
(modified, label.clone(), model.clone(), agents),
(modified, title.clone(), model.clone(), agents),
);
(label, model, agents)
(title, model, agents)
}
};
Some(DiskSession {
uuid,
label,
title,
model,
agents,
modified,
@@ -1008,28 +1020,51 @@ fn encode_cwd(path: &str) -> String {
}
/// Read a session's list metadata from its JSONL file in one pass:
/// `(label, model)`.
/// `(title, model)`.
///
/// The label prefers `ai-title`; it falls back to the last non-empty
/// `last-prompt` text, then the uuid prefix. The model is the API model id of
/// The title is Claude Code's *own* name for the session: the same records, in
/// the same order, that its `/resume` picker reads, so a row here reads as it
/// does there — `custom-title` (a manual rename) > `ai-title` (Claude's
/// generated title) > `last-prompt` (the newest prompt, which is what an
/// untitled session shows) > a legacy compaction `summary` > the first real
/// user prompt in the file. `None` when the file names the session in none of
/// those ways (nothing recorded yet, or unreadable); the caller decides what
/// to show instead (`DiskSession::label`, `ui::live_title`).
///
/// The newest rename wins, but the *first* `ai-title` does: a branch file we
/// materialized carries our own `⑂ …` title at the top and Claude Code may
/// append its own later. The opening prompt goes through `prompt_text`, so a
/// slash command or an injected block yields nothing rather than machinery.
///
/// The model is the API model id of
/// the last *main-chain* assistant message — what Claude Code itself recorded
/// for the newest turn, so a resume can continue on it. Subagent records
/// (`isSidechain`) carry their own model and are skipped, as are the
/// `<synthetic>` ids Claude Code writes for API-error records.
fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
let fallback = || uuid.chars().take(8).collect::<String>();
fn read_meta(path: &std::path::Path) -> (Option<String>, String) {
let Ok(f) = std::fs::File::open(path) else {
return (fallback(), String::new());
return (None, String::new());
};
let reader = std::io::BufReader::new(f);
let mut custom_title: Option<String> = None;
let mut ai_title: Option<String> = None;
let mut last_prompt: Option<String> = None;
let mut summary: Option<String> = None;
let mut first_prompt: Option<String> = None;
let mut model = String::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match v.get("type").and_then(|t| t.as_str()) {
// A `/title` rename: the newest one is the current name.
Some("custom-title") => {
if let Some(t) = v.get("customTitle").and_then(|t| t.as_str())
&& !t.is_empty()
{
custom_title = Some(one_line(t));
}
}
// First title wins (the scan no longer stops there — it still
// needs the model): a branch file we materialized carries our own
// `⑂ …` title first, and Claude Code may append its own later.
@@ -1047,6 +1082,22 @@ fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
last_prompt = Some(one_line(p));
}
}
// A pre-2.x compaction summary (`summaryHint` in the picker) —
// still the only name a session compacted back then carries.
Some("summary") => {
if let Some(t) = v.get("summary").and_then(|t| t.as_str())
&& !t.is_empty()
{
summary = Some(one_line(t));
}
}
// Last resort: the prompt the session opened with. Young sessions
// land here — Claude Code writes `last-prompt` a while later.
Some("user") if first_prompt.is_none() => {
if let Some(p) = prompt_text(&v) {
first_prompt = Some(one_line(&p));
}
}
Some("assistant") => {
if v.get("isSidechain").and_then(Value::as_bool) != Some(true)
&& let Some(m) = v.pointer("/message/model").and_then(Value::as_str)
@@ -1058,8 +1109,13 @@ fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
_ => {}
}
}
let label = ai_title.or(last_prompt).unwrap_or_else(fallback);
(label, model)
let title = custom_title
.or(ai_title)
.or(last_prompt)
.or(summary)
.or(first_prompt)
.filter(|t| !t.is_empty());
(title, model)
}
/// First line only, control characters dropped — labels go into a one-row
@@ -1582,28 +1638,62 @@ mod tests {
assert_eq!(encode_cwd("/a/b1_c2.d3"), "-a-b1-c2-d3");
}
/// The title follows Claude Code's own precedence, so a row here reads as
/// it does in `/resume`.
#[test]
fn label_prefers_ai_title() {
fn title_prefers_a_rename_then_the_ai_title() {
// A rename beats a generated title, and the newest rename wins.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"custom-title","customTitle":"my name"}"#,
r#"{"type":"custom-title","customTitle":"renamed again"}"#,
]);
assert_eq!(read_meta(&p).0.as_deref(), Some("renamed again"));
std::fs::remove_file(p).ok();
// The *first* ai-title wins: our own `⑂ …` title sits at the top of a
// branch file, and Claude Code may append its own later.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"ai-title","aiTitle":"retitled later"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "bug fixing session");
assert_eq!(read_meta(&p).0.as_deref(), Some("bug fixing session"));
std::fs::remove_file(p).ok();
}
#[test]
fn label_falls_back_to_last_prompt_then_uuid() {
fn title_falls_back_to_last_prompt_then_the_opening_prompt() {
// Untitled: the newest prompt, first line only — what the picker shows.
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "latest");
assert_eq!(read_meta(&p).0.as_deref(), Some("latest"));
std::fs::remove_file(p).ok();
// No `last-prompt` record yet (a young session): the opening prompt.
let p = write_jsonl(&[
r#"{"type":"user","uuid":"u1","message":{"role":"user","content":"opening prompt"}}"#,
r#"{"type":"user","uuid":"u2","message":{"role":"user","content":"later prompt"}}"#,
]);
assert_eq!(read_meta(&p).0.as_deref(), Some("opening prompt"));
std::fs::remove_file(p).ok();
}
#[test]
fn an_unnamed_session_reads_as_its_uuid_prefix() {
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "deadbeef");
assert_eq!(read_meta(&p).0, None);
let d = DiskSession {
uuid: "deadbeef-0000".into(),
title: None,
model: String::new(),
agents: 0,
modified: SystemTime::now(),
};
assert_eq!(d.label(), "deadbeef");
std::fs::remove_file(p).ok();
}
@@ -1617,12 +1707,12 @@ mod tests {
// An API-error record carries a placeholder id, not a model.
r#"{"type":"assistant","message":{"model":"<synthetic>","content":[]}}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "claude-sonnet-4-5-20250929");
assert_eq!(read_meta(&p).1, "claude-sonnet-4-5-20250929");
std::fs::remove_file(p).ok();
// A session with no assistant record yet reports no model.
let p = write_jsonl(&[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "");
assert_eq!(read_meta(&p).1, "");
std::fs::remove_file(p).ok();
}

View File

@@ -1411,7 +1411,14 @@ fn draw(
} else {
white
};
(s.key.clone(), lead, live_title(s), meta, title_style)
// Claude Code's own name for the session, exactly as its
// `/resume` picker reads it; the feed's first prompt only
// covers the gap before the transcript names it.
let title: String = a
.cc_title(&s.key)
.map(str::to_string)
.unwrap_or_else(|| live_title(s));
(s.key.clone(), lead, title, meta, title_style)
} else {
let d = &a.disk_sessions[stubs[m - live_n]];
let id: String = d.uuid.chars().take(8).collect();
@@ -1424,7 +1431,7 @@ fn draw(
(
d.uuid.clone(),
"· ".dark_gray(),
d.label.clone(),
d.label(),
meta,
white,
)
@@ -2351,9 +2358,11 @@ fn short_model(m: &str) -> String {
m.strip_prefix("claude-").unwrap_or(m).to_string()
}
/// A human title for a live session: the first line of its first user prompt
/// (what the session is *about*), falling back to the model / a placeholder
/// before any prompt has streamed in.
/// Fallback title for a live session, used only while `App::cc_title` has
/// nothing: Claude Code names every session in its transcript, but it writes
/// that file a moment after the first request reaches us. Until then the feed
/// answers — the first line of the first user prompt (what the session is
/// *about*), then the model / a placeholder before any prompt streamed in.
fn live_title(s: &Session) -> String {
if let Some(e) = s.entries.iter().find(|e| matches!(e.kind, Kind::User)) {
let first = e.content.lines().map(str::trim).find(|l| !l.is_empty());