@@ -86,13 +86,20 @@ pub fn scan(projects_dir: &Path) -> anyhow::Result<Vec<SessionEntry>> {
8686}
8787
8888fn derive_title ( path : & Path ) -> String {
89+ // Prefer Claude Code's auto-generated `aiTitle` (a single
90+ // `{"type":"ai-title","aiTitle":"..."}` line that can appear anywhere
91+ // in the file). Fall back to first user message text. Final fallback:
92+ // a placeholder. Single pass, capped at 1000 lines to keep scan fast on
93+ // very large sessions.
8994 let file = match fs:: File :: open ( path) {
9095 Ok ( f) => f,
9196 Err ( _) => return "<unreadable>" . into ( ) ,
9297 } ;
9398 let reader = BufReader :: new ( file) ;
99+ let mut first_user_text: Option < String > = None ;
100+
94101 for ( peeked, line) in reader. lines ( ) . enumerate ( ) {
95- if peeked >= 50 {
102+ if peeked >= 1000 {
96103 break ;
97104 }
98105 let raw = match line {
@@ -106,25 +113,39 @@ fn derive_title(path: &Path) -> String {
106113 Ok ( v) => v,
107114 Err ( _) => continue ,
108115 } ;
109- if v. get ( "type" ) . and_then ( Value :: as_str) != Some ( "user" ) {
110- continue ;
116+ let entry_type = v. get ( "type" ) . and_then ( Value :: as_str) ;
117+
118+ if entry_type == Some ( "ai-title" ) {
119+ if let Some ( t) = v. get ( "aiTitle" ) . and_then ( Value :: as_str) {
120+ let t = t. trim ( ) ;
121+ if !t. is_empty ( ) {
122+ return clamp_title ( t) ;
123+ }
124+ }
111125 }
112- let content = v. get ( "message" ) . and_then ( |m| m. get ( "content" ) ) ;
113- let text = match content {
114- Some ( Value :: String ( s) ) => s. clone ( ) ,
115- Some ( Value :: Array ( arr) ) => arr
116- . iter ( )
117- . filter_map ( |b| {
118- if b. get ( "type" ) . and_then ( Value :: as_str) == Some ( "text" ) {
119- b. get ( "text" ) . and_then ( Value :: as_str) . map ( str:: to_string)
120- } else {
121- None
122- }
123- } )
124- . collect :: < Vec < _ > > ( )
125- . join ( " " ) ,
126- _ => continue ,
127- } ;
126+
127+ if first_user_text. is_none ( ) && entry_type == Some ( "user" ) {
128+ let content = v. get ( "message" ) . and_then ( |m| m. get ( "content" ) ) ;
129+ let text = match content {
130+ Some ( Value :: String ( s) ) => s. clone ( ) ,
131+ Some ( Value :: Array ( arr) ) => arr
132+ . iter ( )
133+ . filter_map ( |b| {
134+ if b. get ( "type" ) . and_then ( Value :: as_str) == Some ( "text" ) {
135+ b. get ( "text" ) . and_then ( Value :: as_str) . map ( str:: to_string)
136+ } else {
137+ None
138+ }
139+ } )
140+ . collect :: < Vec < _ > > ( )
141+ . join ( " " ) ,
142+ _ => continue ,
143+ } ;
144+ first_user_text = Some ( text) ;
145+ }
146+ }
147+
148+ if let Some ( text) = first_user_text {
128149 return clamp_title ( & text) ;
129150 }
130151 "<no user message>" . into ( )
@@ -240,6 +261,32 @@ mod tests {
240261 assert ! ( entries[ 0 ] . title. contains( "line1 line2" ) ) ;
241262 }
242263
264+ #[ test]
265+ fn ai_title_overrides_first_user_message ( ) {
266+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
267+ make_session (
268+ dir. path ( ) ,
269+ "p" ,
270+ "s" ,
271+ "{\" type\" :\" user\" ,\" message\" :{\" role\" :\" user\" ,\" content\" :\" raw question\" }}\n {\" type\" :\" ai-title\" ,\" aiTitle\" :\" Pretty Generated Title\" }\n " ,
272+ ) ;
273+ let entries = scan ( dir. path ( ) ) . unwrap ( ) ;
274+ assert_eq ! ( entries[ 0 ] . title, "Pretty Generated Title" ) ;
275+ }
276+
277+ #[ test]
278+ fn empty_ai_title_falls_back_to_user_msg ( ) {
279+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
280+ make_session (
281+ dir. path ( ) ,
282+ "p" ,
283+ "s" ,
284+ "{\" type\" :\" ai-title\" ,\" aiTitle\" :\" \" }\n {\" type\" :\" user\" ,\" message\" :{\" role\" :\" user\" ,\" content\" :\" fallback text\" }}\n " ,
285+ ) ;
286+ let entries = scan ( dir. path ( ) ) . unwrap ( ) ;
287+ assert_eq ! ( entries[ 0 ] . title, "fallback text" ) ;
288+ }
289+
243290 #[ test]
244291 fn malformed_first_line_recovers ( ) {
245292 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments