Skip to main content

claudex_cli/commands/
sessions.rs

1use anyhow::Result;
2use chrono::DateTime;
3
4use crate::cli::ResolvedFilter;
5use crate::ui;
6use claudex::index::IndexStore;
7use claudex::parser::parse_session;
8use claudex::providers::enabled_default;
9use claudex::store::{SessionStore, decode_project_name, display_project_name, short_name};
10use claudex::types::SessionInfo;
11
12pub fn run(
13    project: Option<&str>,
14    file: Option<&str>,
15    limit: usize,
16    json: bool,
17    no_index: bool,
18    filter: &ResolvedFilter,
19) -> Result<()> {
20    if !no_index && let Ok(()) = run_indexed(project, file, limit, json, filter) {
21        return Ok(());
22    }
23    run_from_files(project, file, limit, json, filter)
24}
25
26fn run_indexed(
27    project: Option<&str>,
28    file: Option<&str>,
29    limit: usize,
30    json: bool,
31    filter: &ResolvedFilter,
32) -> Result<()> {
33    let providers = enabled_default()?;
34    let mut idx = IndexStore::open()?;
35    idx.ensure_fresh(&providers)?;
36    let rows = idx.query_sessions(project, file, filter, limit)?;
37
38    if json {
39        let output: Vec<_> = rows
40            .iter()
41            .map(|s| {
42                let date = s
43                    .first_timestamp_ms
44                    .and_then(DateTime::from_timestamp_millis)
45                    .map(|d| d.to_rfc3339());
46                serde_json::json!({
47                    "provider": s.provider,
48                    "project": s.project_name,
49                    "session_id": s.session_id,
50                    "file_path": s.file_path,
51                    "date": date,
52                    "message_count": s.message_count,
53                    "duration_ms": s.duration_ms,
54                    "model": s.model,
55                    "extras": s.extras.as_deref().and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok()),
56                    "present_on_disk": s.present_on_disk,
57                    "archived_at": s.archived_at.and_then(|secs| DateTime::from_timestamp(secs, 0)).map(|d| d.to_rfc3339()),
58                })
59            })
60            .collect();
61        println!("{}", serde_json::to_string_pretty(&output)?);
62        return Ok(());
63    }
64
65    let show_provider = ui::spans_providers(rows.iter().map(|r| r.provider.as_str()));
66    let mut table = ui::table();
67    let mut headers = vec![
68        "Project", "Session", "Date", "Messages", "Duration", "Model",
69    ];
70    if show_provider {
71        headers.insert(0, "Provider");
72    }
73    table.set_header(ui::header(headers));
74    ui::right_align(&mut table, if show_provider { &[4, 5] } else { &[3, 4] });
75
76    for s in &rows {
77        let sid: String = s
78            .session_id
79            .as_deref()
80            .unwrap_or("-")
81            .chars()
82            .take(8)
83            .collect();
84        let date = s
85            .first_timestamp_ms
86            .and_then(DateTime::from_timestamp_millis)
87            .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
88            .unwrap_or_else(|| "-".to_string());
89        let model = s
90            .model
91            .as_deref()
92            .map(|m| m.trim_start_matches("claude-"))
93            .unwrap_or("-")
94            .to_string();
95        let mut cells = vec![
96            ui::cell_project(&short_name(&s.project_name)),
97            ui::cell_dim(&sid),
98            ui::cell_dim(&date),
99            ui::cell_count(s.message_count as u64),
100            ui::cell_plain(format_duration(s.duration_ms as u64)),
101            ui::cell_model(&model),
102        ];
103        if show_provider {
104            cells.insert(0, ui::cell_provider(&s.provider));
105        }
106        table.add_row(cells);
107    }
108    println!("{table}");
109    Ok(())
110}
111
112fn run_from_files(
113    project: Option<&str>,
114    file: Option<&str>,
115    limit: usize,
116    json: bool,
117    filter: &ResolvedFilter,
118) -> Result<()> {
119    filter.ensure_no_index_supported()?;
120
121    let store = SessionStore::new()?;
122    let mut sessions: Vec<SessionInfo> = Vec::new();
123
124    for (project_raw, path) in store.all_session_files(project)? {
125        let stats = match parse_session(&path) {
126            Ok(s) => s,
127            Err(_) => continue,
128        };
129        // The `--no-index` fallback scans Claude transcripts; apply the
130        // cross-cutting provider/date/model filters in memory.
131        if !filter.matches("claude", &stats, false) {
132            continue;
133        }
134        if let Some(file_filter) = file
135            && !stats
136                .file_paths_modified
137                .iter()
138                .any(|p| p.contains(file_filter))
139        {
140            continue;
141        }
142        let session_id = stats
143            .session_id
144            .or_else(|| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
145            .unwrap_or_default();
146        sessions.push(SessionInfo {
147            project: display_project_name(&decode_project_name(&project_raw)),
148            session_id,
149            file_path: Some(path.to_string_lossy().into_owned()),
150            date: stats.first_timestamp,
151            message_count: stats.message_count,
152            duration_ms: stats.total_duration_ms,
153            model: stats.model,
154        });
155    }
156
157    sessions.sort_by_key(|s| std::cmp::Reverse(s.date));
158    sessions.truncate(limit);
159
160    if json {
161        let output: Vec<_> = sessions
162            .iter()
163            .map(|s| {
164                serde_json::json!({
165                    "provider": "claude",
166                    "project": s.project,
167                    "session_id": s.session_id,
168                    "file_path": s.file_path,
169                    "date": s.date.map(|d| d.to_rfc3339()),
170                    "message_count": s.message_count,
171                    "duration_ms": s.duration_ms,
172                    "model": s.model,
173                })
174            })
175            .collect();
176        println!("{}", serde_json::to_string_pretty(&output)?);
177        return Ok(());
178    }
179
180    let mut table = ui::table();
181    table.set_header(ui::header([
182        "Project", "Session", "Date", "Messages", "Duration", "Model",
183    ]));
184    ui::right_align(&mut table, &[3, 4]);
185
186    for s in &sessions {
187        let sid: String = s.session_id.chars().take(8).collect();
188        let date = s
189            .date
190            .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
191            .unwrap_or_else(|| "-".to_string());
192        let proj = short_name(&s.project);
193        let model = s
194            .model
195            .as_deref()
196            .map(|m| m.trim_start_matches("claude-"))
197            .unwrap_or("-")
198            .to_string();
199        table.add_row([
200            ui::cell_project(&proj),
201            ui::cell_dim(&sid),
202            ui::cell_dim(&date),
203            ui::cell_count(s.message_count as u64),
204            ui::cell_plain(format_duration(s.duration_ms)),
205            ui::cell_model(&model),
206        ]);
207    }
208    println!("{table}");
209    Ok(())
210}
211
212pub fn format_duration(ms: u64) -> String {
213    if ms == 0 {
214        return "-".to_string();
215    }
216    let secs = ms / 1000;
217    if secs < 60 {
218        format!("{secs}s")
219    } else if secs < 3600 {
220        format!("{}m{}s", secs / 60, secs % 60)
221    } else {
222        format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn duration_zero() {
232        assert_eq!(format_duration(0), "-");
233    }
234
235    #[test]
236    fn duration_seconds() {
237        assert_eq!(format_duration(45_000), "45s");
238    }
239
240    #[test]
241    fn duration_minutes() {
242        assert_eq!(format_duration(90_000), "1m30s");
243    }
244
245    #[test]
246    fn duration_hours() {
247        assert_eq!(format_duration(3_661_000), "1h1m");
248    }
249}