ai titles for sessions
This commit is contained in:
24
CLAUDE.md
24
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: <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
|
||||
|
||||
35
src/app.rs
35
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\"}</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();
|
||||
|
||||
142
src/sessions.rs
142
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<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();
|
||||
}
|
||||
|
||||
|
||||
19
src/ui.rs
19
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());
|
||||
|
||||
Reference in New Issue
Block a user