1use crate::{Agent, Roots};
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::{HashMap, HashSet},
6 fs::{self, File},
7 io::{self, BufRead, BufReader},
8 path::Path,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum SessionTitleOrigin {
14 SourceTitle,
15 SourceSummary,
16}
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SessionTitle {
19 pub text: String,
20 pub origin: SessionTitleOrigin,
21}
22
23pub fn load_session_titles(
24 agent: Agent,
25 roots: &Roots,
26 ids: &[String],
27) -> io::Result<HashMap<String, SessionTitle>> {
28 if ids.is_empty() {
29 return Ok(HashMap::new());
30 }
31 let ids = ids.iter().map(String::as_str).collect::<HashSet<_>>();
32 match agent {
33 Agent::ClaudeCode => roots.claude.as_ref().map_or_else(
34 || Ok(HashMap::new()),
35 |root| claude(&root.join("projects"), &ids),
36 ),
37 Agent::Codex => roots.codex.as_ref().map_or_else(
38 || Ok(HashMap::new()),
39 |root| codex(&root.join("session_index.jsonl"), &ids),
40 ),
41 }
42}
43fn open(path: &Path) -> io::Result<Option<File>> {
44 match File::open(path) {
45 Ok(f) => Ok(Some(f)),
46 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
47 Err(e) => Err(e),
48 }
49}
50fn invalid(path: &Path, error: serde_json::Error) -> io::Error {
51 io::Error::new(
52 io::ErrorKind::InvalidData,
53 format!(
54 "Malformed title index {} at line {}, column {}",
55 path.display(),
56 error.line(),
57 error.column()
58 ),
59 )
60}
61#[derive(Deserialize)]
62struct CodexTitle {
63 id: String,
64 thread_name: String,
65}
66fn codex(path: &Path, ids: &HashSet<&str>) -> io::Result<HashMap<String, SessionTitle>> {
67 let mut out = HashMap::new();
68 let Some(file) = open(path)? else {
69 return Ok(out);
70 };
71 for (index, line) in BufReader::new(file).lines().enumerate() {
72 let line = line?;
73 if line.trim().is_empty() {
74 continue;
75 }
76 let row: CodexTitle = serde_json::from_str(&line).map_err(|_| {
77 io::Error::new(
78 io::ErrorKind::InvalidData,
79 format!(
80 "Malformed title index {} at line {}",
81 path.display(),
82 index + 1
83 ),
84 )
85 })?;
86 if ids.contains(row.id.as_str()) && !row.thread_name.trim().is_empty() {
87 out.insert(
88 row.id,
89 SessionTitle {
90 text: row.thread_name.trim().into(),
91 origin: SessionTitleOrigin::SourceTitle,
92 },
93 );
94 }
95 }
96 Ok(out)
97}
98#[derive(Deserialize)]
99struct ClaudeIndex {
100 entries: Vec<ClaudeTitle>,
101}
102#[derive(Deserialize)]
103struct ClaudeTitle {
104 #[serde(rename = "sessionId")]
105 session_id: String,
106 summary: Option<String>,
107}
108fn claude(projects: &Path, ids: &HashSet<&str>) -> io::Result<HashMap<String, SessionTitle>> {
109 let mut out = HashMap::new();
110 let dirs = match fs::read_dir(projects) {
111 Ok(d) => d,
112 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
113 Err(e) => return Err(e),
114 };
115 for dir in dirs {
116 let dir = dir?;
117 if !dir.file_type()?.is_dir() {
118 continue;
119 }
120 let path = dir.path().join("sessions-index.json");
121 let Some(file) = open(&path)? else {
122 continue;
123 };
124 let index: ClaudeIndex =
125 serde_json::from_reader(BufReader::new(file)).map_err(|e| invalid(&path, e))?;
126 for row in index.entries {
127 if ids.contains(row.session_id.as_str())
128 && let Some(s) = row.summary.filter(|s| !s.trim().is_empty())
129 {
130 out.insert(
131 row.session_id,
132 SessionTitle {
133 text: s.trim().into(),
134 origin: SessionTitleOrigin::SourceSummary,
135 },
136 );
137 }
138 }
139 }
140 Ok(out)
141}