diff --git a/CLAUDE.md b/CLAUDE.md index 0b68da3..a4d668b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,8 +109,10 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed (`lane_tokens` / `lane_dur`) over the wire-counted `out …`. See the subagent-popup invariant. Sessions panel is a uniform 50% of the main area: each session - is a multi-line item — full white title (live = first user - prompt via `live_title`, stub = disk label, word-wrapped by + is a multi-line item — full white title (Claude Code's own name + for the session via `App::cc_title`, live rows and stubs alike; + `live_title` only covers a live session the transcript has not + named yet — word-wrapped by `wrap_words`) over a dimmed id·model meta row; expanded turn rows are indented past the title and `truncate_str`'d to one line each. @@ -120,7 +122,8 @@ src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing, src/sessions.rs on-disk session history (main chain *and* subagents): background scanner thread keeps App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on - mtime change — one pass yields the label *and* the session's + mtime change — one pass yields the title (see the session-name + invariant) *and* the session's last main-chain model, which `App::resume_model` turns into the `--model` a resume spawns with — always at `[1m]`); load_view/load_history rebuild a feed Session @@ -431,6 +434,21 @@ agentId: `), and the real completion is injected into the parent's next the guard for sessions whose instance we killed ourselves). `--session-id` cannot be combined with `--resume` (CLI rejects it without `--fork-session`); `--model` can, and every resume passes it. +- **A session's name is Claude Code's, not ours.** `sessions::read_meta` reads + the same records its `/resume` picker does, in the same order — + `custom-title` (a manual rename, newest wins) > `ai-title` (Claude's + generated title, *first* wins so a materialized branch keeps its own `⑂ …` + label) > `last-prompt` (the newest prompt, which is what an untitled session + shows there) > a legacy compaction `summary` > the opening user prompt — so a + row reads the same in both places. It applies to **live** sessions too + (`App::cc_title`): Claude Code writes the JSONL continuously, so a running + row and its later disk stub cannot disagree, and the title tracks the newest + prompt exactly as the picker's does. Two fallbacks stay, for the gap before + the file names anything: `ui::live_title` (the feed's own first prompt) for a + live row, `DiskSession::label`'s uuid prefix for a stub. Claude Code 2.1.2x + writes no `ai-title` record and writes `last-prompt` a while into the + session, which is why the opening-prompt step exists — without it a young + session reads as a bare uuid. - **A resume continues on the session's own model**, not the CLI default: `App::resume_model` reads the model Claude Code recorded for the session's last main-chain assistant message (`DiskSession::model`, filled by the diff --git a/src/app.rs b/src/app.rs index 0a80fbe..14ce8de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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\"}\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(); diff --git a/src/sessions.rs b/src/sessions.rs index 905427b..a95c669 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -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, /// 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 = HashMap::new(); + let mut meta: HashMap, String, usize)> = HashMap::new(); let mut last: Vec = 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, + meta: &mut HashMap, String, usize)>, ) -> Result, 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 /// `` 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::(); +fn read_meta(path: &std::path::Path) -> (Option, 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 = None; let mut ai_title: Option = None; let mut last_prompt: Option = None; + let mut summary: Option = None; + let mut first_prompt: Option = None; let mut model = String::new(); for line in reader.lines().map_while(Result::ok) { let Ok(v) = serde_json::from_str::(&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":"","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(); } diff --git a/src/ui.rs b/src/ui.rs index 7ccf5d7..e370770 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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());