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

@@ -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: <hex>`), 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

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());