Skip to main content

claudex_cli/commands/
session.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Result, bail};
5use chrono::{DateTime, Utc};
6
7use crate::commands::sessions::format_duration;
8use crate::ui;
9use claudex::index::{
10    IndexStore, SessionDetail, SessionModelUsageRow, StopReasonRow, ToolRow, TurnStatsRow,
11};
12use claudex::parser::{ModelSessionStats, SessionStats, parse_session};
13use claudex::providers::enabled_default;
14use claudex::stats::percentile_sorted;
15use claudex::store::{
16    SessionStore, decode_project_name, display_project_name, find_matching_sessions, short_name,
17    subagent_transcripts_for,
18};
19use claudex::types::ModelPricing;
20
21pub fn run(selector: &str, project_filter: Option<&str>, json: bool, no_index: bool) -> Result<()> {
22    if !no_index {
23        let providers = enabled_default()?;
24        let mut idx = IndexStore::open()?;
25        idx.ensure_fresh(&providers)?;
26        if let Some(detail) = resolve_indexed_session(&idx, selector, project_filter)? {
27            return render_indexed(detail, json);
28        }
29    }
30
31    let store = SessionStore::new()?;
32    let (project_raw, path) = resolve_one_session(&store, selector, project_filter)?;
33    let project = display_project_name(&decode_project_name(&project_raw));
34
35    let mut stats = parse_session(&path)?;
36    // Roll up subagent transcripts the way the indexed path does, so the
37    // `--no-index` drill-down reports the same Subagents / Cost / Tokens. The
38    // top-line model stays the parent's own label (captured before merging),
39    // while the per-model breakdown spans children.
40    let parent_model_label = stats.model_label();
41    let mut subagents: Vec<(Option<DateTime<Utc>>, String)> = Vec::new();
42    for child_path in subagent_transcripts_for(&path)? {
43        let child = parse_session(&child_path)?;
44        subagents.push((
45            child.first_timestamp,
46            child_path.to_string_lossy().into_owned(),
47        ));
48        stats.merge(child);
49    }
50    // Match the indexed `ORDER BY first_timestamp, file_path`.
51    subagents.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
52    let subagent_files: Vec<String> = subagents.into_iter().map(|(_, p)| p).collect();
53    // The indexed drill-down lists modified files `ORDER BY file_path ASC`; sort
54    // here so the file-scan path renders them in the same order.
55    stats.file_paths_modified.sort();
56    render_from_file(
57        &project,
58        &path,
59        stats,
60        parent_model_label,
61        &subagent_files,
62        json,
63    )
64}
65
66fn render_indexed(detail: SessionDetail, json: bool) -> Result<()> {
67    if json {
68        println!("{}", serde_json::to_string_pretty(&indexed_json(&detail))?);
69        return Ok(());
70    }
71
72    section("Overview");
73    println!("  Project:      {}", ui::project(&detail.project));
74    println!("  File:         {}", detail.file_path);
75    println!(
76        "  Source:       {}",
77        if detail.present_on_disk {
78            "live"
79        } else {
80            "retained"
81        }
82    );
83    println!(
84        "  Session:      {}",
85        ui::session_id(short_session_id(detail.session_id.as_deref()))
86    );
87    if let Some(date) = detail
88        .first_timestamp_ms
89        .and_then(DateTime::from_timestamp_millis)
90    {
91        println!("  Started:      {}", date.format("%Y-%m-%d %H:%M UTC"));
92    }
93    if let Some(date) = detail
94        .last_timestamp_ms
95        .and_then(DateTime::from_timestamp_millis)
96    {
97        println!("  Last activity: {}", date.format("%Y-%m-%d %H:%M UTC"));
98    }
99    println!(
100        "  Duration:     {}",
101        format_duration(detail.duration_ms as u64)
102    );
103    println!(
104        "  Messages:     {}",
105        ui::fmt_count(detail.message_count as u64)
106    );
107    println!(
108        "  Model:        {}",
109        detail
110            .model
111            .as_deref()
112            .map(display_session_model)
113            .unwrap_or_else(|| "-".to_string())
114    );
115    println!("  Cost:         {}", ui::cost(detail.cost_usd));
116    if !detail.subagent_files.is_empty() {
117        println!(
118            "  Subagents:    {}",
119            ui::fmt_count(detail.subagent_files.len() as u64)
120        );
121    }
122    if let Some(extras) = &detail.extras {
123        println!("  Metadata:     {extras}");
124    }
125
126    print_tokens(
127        detail.input_tokens as u64,
128        detail.output_tokens as u64,
129        detail.cache_creation_tokens as u64,
130        detail.cache_read_tokens as u64,
131    );
132
133    if !detail.model_usage.is_empty() {
134        print_models_indexed(&detail.model_usage);
135    }
136    if let Some(turn_stats) = &detail.turn_stats {
137        print_turn_stats(turn_stats);
138    }
139    if detail.thinking_block_count > 0 {
140        section("Thinking");
141        println!(
142            "  Blocks: {}",
143            ui::fmt_count(detail.thinking_block_count as u64)
144        );
145    }
146    print_tools(&detail.tools);
147    print_files(&detail.files_modified);
148    print_prs(&detail.pr_links);
149    print_stop_reasons(&detail.stop_reasons);
150    print_attachments_indexed(&detail.attachments);
151    print_permission_changes_indexed(&detail.permission_changes);
152    print_subagents(&detail.subagent_files);
153
154    println!();
155    Ok(())
156}
157
158fn render_from_file(
159    project: &str,
160    path: &Path,
161    stats: SessionStats,
162    model_label: Option<String>,
163    subagent_files: &[String],
164    json: bool,
165) -> Result<()> {
166    if json {
167        println!(
168            "{}",
169            serde_json::to_string_pretty(&file_json(
170                project,
171                path,
172                &stats,
173                &model_label,
174                subagent_files
175            ))?
176        );
177        return Ok(());
178    }
179
180    section("Overview");
181    println!("  Project:      {}", ui::project(project));
182    println!("  File:         {}", path.display());
183    println!(
184        "  Session:      {}",
185        ui::session_id(short_session_id(
186            stats
187                .session_id
188                .as_deref()
189                .or_else(|| path.file_stem().and_then(|s| s.to_str()))
190        ))
191    );
192    if let Some(date) = stats.first_timestamp {
193        println!("  Started:      {}", date.format("%Y-%m-%d %H:%M UTC"));
194    }
195    if let Some(date) = stats.last_timestamp {
196        println!("  Last activity: {}", date.format("%Y-%m-%d %H:%M UTC"));
197    }
198    println!(
199        "  Duration:     {}",
200        format_duration(stats.total_duration_ms)
201    );
202    println!(
203        "  Messages:     {}",
204        ui::fmt_count(stats.message_count as u64)
205    );
206    println!(
207        "  Model:        {}",
208        model_label
209            .as_deref()
210            .map(display_session_model)
211            .unwrap_or_else(|| "-".to_string())
212    );
213    println!("  Cost:         {}", ui::cost(stats.cost_usd()));
214    if !subagent_files.is_empty() {
215        println!(
216            "  Subagents:    {}",
217            ui::fmt_count(subagent_files.len() as u64)
218        );
219    }
220
221    print_tokens(
222        stats.usage.input_tokens,
223        stats.usage.output_tokens,
224        stats.usage.cache_creation_tokens,
225        stats.usage.cache_read_tokens,
226    );
227
228    if !stats.model_usage.is_empty() {
229        print_models_file(&stats.model_usage);
230    }
231    if let Some(turn_stats) = build_turn_stats(project, &stats.turn_durations) {
232        print_turn_stats(&turn_stats);
233    }
234    if stats.thinking_block_count > 0 {
235        section("Thinking");
236        println!("  Blocks: {}", ui::fmt_count(stats.thinking_block_count));
237    }
238
239    let tools = tool_rows_from_names(&stats.tool_names);
240    print_tools(&tools);
241    print_files(&stats.file_paths_modified);
242    print_prs_file(project, stats.session_id.as_deref(), &stats.pr_links);
243    let stop_reasons = stop_reason_rows(&stats.stop_reason_counts);
244    print_stop_reasons(&stop_reasons);
245    print_attachments_file(&stats.attachments);
246    print_permission_changes_file(&stats.permission_modes);
247    print_subagents(subagent_files);
248
249    println!();
250    Ok(())
251}
252
253fn resolve_one_session(
254    store: &SessionStore,
255    selector: &str,
256    project_filter: Option<&str>,
257) -> Result<(String, PathBuf)> {
258    let all_files = store.all_session_files(project_filter)?;
259    let matches = find_matching_sessions(&all_files, selector);
260    match matches.as_slice() {
261        [] => bail!("no sessions found matching {:?}", selector),
262        [single] => Ok((single.0.clone(), single.1.clone())),
263        many => {
264            let mut preview = Vec::new();
265            for (project_raw, path) in many.iter().take(8) {
266                let sid = path
267                    .file_stem()
268                    .map(|s| s.to_string_lossy().into_owned())
269                    .unwrap_or_else(|| "?".to_string());
270                preview.push(format!(
271                    "{}  {}",
272                    short_session_id(Some(&sid)),
273                    short_name(&display_project_name(&decode_project_name(project_raw))),
274                ));
275            }
276            bail!(
277                "selector {:?} matched {} sessions; refine it:\n{}",
278                selector,
279                many.len(),
280                preview.join("\n")
281            )
282        }
283    }
284}
285
286fn resolve_indexed_session(
287    idx: &IndexStore,
288    selector: &str,
289    project_filter: Option<&str>,
290) -> Result<Option<SessionDetail>> {
291    let matches = idx.query_session_matches(selector, project_filter)?;
292    let selected = match matches.as_slice() {
293        [] => return Ok(None),
294        [single] => single,
295        many => {
296            let mut preview = Vec::new();
297            for row in many.iter().take(8) {
298                preview.push(format!(
299                    "{}  {}  {}",
300                    short_session_id(row.session_id.as_deref()),
301                    row.provider,
302                    short_name(&row.project_name),
303                ));
304            }
305            bail!(
306                "selector {:?} matched {} sessions; refine it:\n{}",
307                selector,
308                many.len(),
309                preview.join("\n")
310            )
311        }
312    };
313    idx.query_session_detail(&selected.file_path)
314}
315
316fn indexed_json(detail: &SessionDetail) -> serde_json::Value {
317    serde_json::json!({
318        "provider": detail.provider,
319        "project": detail.project,
320        "file_path": detail.file_path,
321        "session_id": detail.session_id,
322        "date": detail.first_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
323        "last_activity": detail.last_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
324        "duration_ms": detail.duration_ms,
325        "message_count": detail.message_count,
326        "model": detail.model,
327        "extras": detail.extras.as_deref().and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
328        "present_on_disk": detail.present_on_disk,
329        "archived_at": detail.archived_at.and_then(|s| DateTime::from_timestamp(s, 0)).map(|d| d.to_rfc3339()),
330        "input_tokens": detail.input_tokens,
331        "output_tokens": detail.output_tokens,
332        "cache_creation_tokens": detail.cache_creation_tokens,
333        "cache_read_tokens": detail.cache_read_tokens,
334        "total_tokens": detail.input_tokens + detail.output_tokens + detail.cache_creation_tokens + detail.cache_read_tokens,
335        "cost_usd": detail.cost_usd,
336        "thinking_block_count": detail.thinking_block_count,
337        "turn_stats": detail.turn_stats.as_ref().map(turn_stats_json),
338        "models": detail.model_usage.iter().map(indexed_model_json).collect::<Vec<_>>(),
339        "tools": detail.tools.iter().map(|t| serde_json::json!({"tool": t.tool_name, "count": t.count})).collect::<Vec<_>>(),
340        "files_modified": detail.files_modified,
341        "pr_links": detail.pr_links.iter().map(|p| serde_json::json!({
342            "pr_number": p.pr_number,
343            "pr_url": p.pr_url,
344            "pr_repository": p.pr_repository,
345            "timestamp": p.timestamp,
346        })).collect::<Vec<_>>(),
347        "stop_reasons": detail.stop_reasons.iter().map(|r| serde_json::json!({"stop_reason": r.stop_reason, "count": r.count})).collect::<Vec<_>>(),
348        "attachments": detail.attachments.iter().map(|a| serde_json::json!({"filename": a.filename, "mime_type": a.mime_type})).collect::<Vec<_>>(),
349        "permission_changes": detail.permission_changes.iter().map(|p| serde_json::json!({"mode": p.mode, "timestamp": p.timestamp})).collect::<Vec<_>>(),
350        "subagent_files": detail.subagent_files,
351    })
352}
353
354fn file_json(
355    project: &str,
356    path: &Path,
357    stats: &SessionStats,
358    model_label: &Option<String>,
359    subagent_files: &[String],
360) -> serde_json::Value {
361    let turn_stats = build_turn_stats(project, &stats.turn_durations);
362    let stop_reasons = stop_reason_rows(&stats.stop_reason_counts);
363    let tools = tool_rows_from_names(&stats.tool_names);
364    serde_json::json!({
365        "project": project,
366        "file_path": path.to_string_lossy().into_owned(),
367        "session_id": stats.session_id.clone().or_else(|| path.file_stem().map(|s| s.to_string_lossy().into_owned())),
368        "date": stats.first_timestamp.map(|d| d.to_rfc3339()),
369        "last_activity": stats.last_timestamp.map(|d| d.to_rfc3339()),
370        "duration_ms": stats.total_duration_ms,
371        "message_count": stats.message_count,
372        "model": model_label,
373        "input_tokens": stats.usage.input_tokens,
374        "output_tokens": stats.usage.output_tokens,
375        "cache_creation_tokens": stats.usage.cache_creation_tokens,
376        "cache_read_tokens": stats.usage.cache_read_tokens,
377        "total_tokens": stats.usage.total_tokens(),
378        "cost_usd": stats.cost_usd(),
379        "thinking_block_count": stats.thinking_block_count,
380        "turn_stats": turn_stats.as_ref().map(turn_stats_json),
381        "models": model_stats_rows(stats).iter().map(file_model_json).collect::<Vec<_>>(),
382        "tools": tools.iter().map(|t| serde_json::json!({"tool": t.tool_name, "count": t.count})).collect::<Vec<_>>(),
383        "files_modified": stats.file_paths_modified,
384        "pr_links": stats.pr_links.iter().map(|(pr_number, pr_url, pr_repository, timestamp)| serde_json::json!({
385            "pr_number": pr_number,
386            "pr_url": pr_url,
387            "pr_repository": pr_repository,
388            "timestamp": timestamp,
389        })).collect::<Vec<_>>(),
390        "stop_reasons": stop_reasons.iter().map(|r| serde_json::json!({"stop_reason": r.stop_reason, "count": r.count})).collect::<Vec<_>>(),
391        "attachments": stats.attachments.iter().map(|(filename, mime_type)| serde_json::json!({"filename": filename, "mime_type": mime_type})).collect::<Vec<_>>(),
392        "permission_changes": stats.permission_modes.iter().map(|(mode, timestamp)| serde_json::json!({"mode": mode, "timestamp": timestamp})).collect::<Vec<_>>(),
393        "subagent_files": subagent_files,
394    })
395}
396
397fn indexed_model_json(row: &SessionModelUsageRow) -> serde_json::Value {
398    serde_json::json!({
399        "model": row.model,
400        "model_family": ModelPricing::name(Some(&row.model)),
401        "assistant_message_count": row.assistant_message_count,
402        "input_tokens": row.input_tokens,
403        "output_tokens": row.output_tokens,
404        "cache_creation_tokens": row.cache_creation_tokens,
405        "cache_read_tokens": row.cache_read_tokens,
406        "cost_usd": row.cost_usd,
407        "inference_geos": row.inference_geos,
408        "service_tiers": row.service_tiers,
409        "avg_speed": row.avg_speed,
410        "iterations": row.iterations,
411    })
412}
413
414fn file_model_json((model, stats): &(String, ModelSessionStats)) -> serde_json::Value {
415    serde_json::json!({
416        "model": model,
417        "model_family": ModelPricing::name(Some(model)),
418        "assistant_message_count": stats.assistant_message_count,
419        "input_tokens": stats.usage.input_tokens,
420        "output_tokens": stats.usage.output_tokens,
421        "cache_creation_tokens": stats.usage.cache_creation_tokens,
422        "cache_read_tokens": stats.usage.cache_read_tokens,
423        "cost_usd": stats.usage.cost_for_model(Some(model)),
424        "inference_geos": stats.inference_geos.iter().cloned().collect::<Vec<_>>(),
425        "service_tiers": stats.service_tiers.iter().cloned().collect::<Vec<_>>(),
426        "avg_speed": stats.avg_speed(),
427        "iterations": stats.iterations,
428    })
429}
430
431fn turn_stats_json(turn_stats: &TurnStatsRow) -> serde_json::Value {
432    serde_json::json!({
433        "turn_count": turn_stats.turn_count,
434        "avg_duration_ms": turn_stats.avg_duration_ms,
435        "p50_duration_ms": turn_stats.p50_duration_ms,
436        "p95_duration_ms": turn_stats.p95_duration_ms,
437        "max_duration_ms": turn_stats.max_duration_ms,
438    })
439}
440
441fn model_stats_rows(stats: &SessionStats) -> Vec<(String, ModelSessionStats)> {
442    let mut rows = stats
443        .model_usage
444        .iter()
445        // Skip zero-token rows to match the index, which never persists them
446        // (e.g. a `<synthetic>` model that carried no usage). Without this the
447        // `--no-index` per-model breakdown would show phantom rows the indexed
448        // drill-down omits.
449        .filter(|(_, detail)| detail.usage.total_tokens() > 0)
450        .map(|(model, detail)| (model.clone(), detail.clone()))
451        .collect::<Vec<_>>();
452    rows.sort_by(|a, b| {
453        b.1.usage
454            .cost_for_model(Some(&b.0))
455            .partial_cmp(&a.1.usage.cost_for_model(Some(&a.0)))
456            .unwrap_or(std::cmp::Ordering::Equal)
457    });
458    rows
459}
460
461fn tool_rows_from_names(names: &[String]) -> Vec<ToolRow> {
462    let mut counts = HashMap::new();
463    for name in names {
464        *counts.entry(name.clone()).or_insert(0i64) += 1;
465    }
466    let mut rows = counts
467        .into_iter()
468        .map(|(tool_name, count)| ToolRow { tool_name, count })
469        .collect::<Vec<_>>();
470    rows.sort_by(|a, b| {
471        b.count
472            .cmp(&a.count)
473            .then_with(|| a.tool_name.cmp(&b.tool_name))
474    });
475    rows
476}
477
478fn stop_reason_rows(counts: &HashMap<String, u64>) -> Vec<StopReasonRow> {
479    let mut rows = counts
480        .iter()
481        .map(|(stop_reason, count)| StopReasonRow {
482            stop_reason: stop_reason.clone(),
483            count: *count as i64,
484        })
485        .collect::<Vec<_>>();
486    rows.sort_by(|a, b| {
487        b.count
488            .cmp(&a.count)
489            .then_with(|| a.stop_reason.cmp(&b.stop_reason))
490    });
491    rows
492}
493
494fn build_turn_stats(project: &str, turns: &[(u64, String)]) -> Option<TurnStatsRow> {
495    if turns.is_empty() {
496        return None;
497    }
498    let mut durations = turns.iter().map(|(dur, _)| *dur as i64).collect::<Vec<_>>();
499    durations.sort_unstable();
500    let turn_count = durations.len() as i64;
501    let avg_duration_ms = durations.iter().sum::<i64>() as f64 / turn_count as f64;
502    Some(TurnStatsRow {
503        project: project.to_string(),
504        turn_count,
505        avg_duration_ms,
506        p50_duration_ms: percentile_sorted(&durations, 50),
507        p95_duration_ms: percentile_sorted(&durations, 95),
508        max_duration_ms: *durations.last().unwrap_or(&0),
509    })
510}
511
512fn print_tokens(input: u64, output: u64, cache_write: u64, cache_read: u64) {
513    section("Tokens");
514    println!("  Input:       {}", ui::count(input));
515    println!("  Output:      {}", ui::count(output));
516    println!("  Cache write: {}", ui::count(cache_write));
517    println!("  Cache read:  {}", ui::count(cache_read));
518    println!(
519        "  Total:       {}",
520        ui::emphasis(&ui::count(input + output + cache_write + cache_read))
521    );
522}
523
524fn print_models_indexed(rows: &[SessionModelUsageRow]) {
525    section("Models");
526    let mut table = ui::table();
527    table.set_header(ui::header([
528        "Model",
529        "Msgs",
530        "Input",
531        "Output",
532        "Cache Read",
533        "Cost",
534    ]));
535    ui::right_align(&mut table, &[1, 2, 3, 4, 5]);
536    for row in rows {
537        table.add_row([
538            ui::cell_model(&display_session_model(&row.model)),
539            ui::cell_count(row.assistant_message_count as u64),
540            ui::cell_count(row.input_tokens as u64),
541            ui::cell_count(row.output_tokens as u64),
542            ui::cell_count(row.cache_read_tokens as u64),
543            ui::cell_cost(row.cost_usd),
544        ]);
545    }
546    println!("{table}");
547}
548
549fn print_models_file(rows: &std::collections::BTreeMap<String, ModelSessionStats>) {
550    let rows = model_stats_rows(&SessionStats {
551        model_usage: rows.clone(),
552        ..SessionStats::default()
553    });
554    section("Models");
555    let mut table = ui::table();
556    table.set_header(ui::header([
557        "Model",
558        "Msgs",
559        "Input",
560        "Output",
561        "Cache Read",
562        "Cost",
563    ]));
564    ui::right_align(&mut table, &[1, 2, 3, 4, 5]);
565    for (model, row) in rows {
566        table.add_row([
567            ui::cell_model(&display_session_model(&model)),
568            ui::cell_count(row.assistant_message_count),
569            ui::cell_count(row.usage.input_tokens),
570            ui::cell_count(row.usage.output_tokens),
571            ui::cell_count(row.usage.cache_read_tokens),
572            ui::cell_cost(row.usage.cost_for_model(Some(&model))),
573        ]);
574    }
575    println!("{table}");
576}
577
578fn print_turn_stats(turn_stats: &TurnStatsRow) {
579    section("Turns");
580    println!("  Turns: {}", ui::fmt_count(turn_stats.turn_count as u64));
581    println!(
582        "  Avg / P50 / P95 / Max: {} / {} / {} / {}",
583        format_duration(turn_stats.avg_duration_ms as u64),
584        format_duration(turn_stats.p50_duration_ms as u64),
585        format_duration(turn_stats.p95_duration_ms as u64),
586        format_duration(turn_stats.max_duration_ms as u64),
587    );
588}
589
590fn print_tools(tools: &[ToolRow]) {
591    section("Tools");
592    if tools.is_empty() {
593        println!("  (none)");
594        return;
595    }
596    for row in tools {
597        println!(
598            "  {}  {}",
599            ui::tool_name(&row.tool_name),
600            ui::fmt_count(row.count as u64)
601        );
602    }
603}
604
605fn print_subagents(files: &[String]) {
606    if files.is_empty() {
607        return;
608    }
609    section("Subagents");
610    for file in files {
611        println!("  {}", file);
612    }
613}
614
615fn print_files(files: &[String]) {
616    section("Files");
617    if files.is_empty() {
618        println!("  (none)");
619        return;
620    }
621    for file in files {
622        println!("  {}", file);
623    }
624}
625
626fn print_prs(prs: &[claudex::index::PrLinkRow]) {
627    section("PR Links");
628    if prs.is_empty() {
629        println!("  (none)");
630        return;
631    }
632    for pr in prs {
633        let repo = if pr.pr_repository.is_empty() {
634            "-".to_string()
635        } else {
636            pr.pr_repository.clone()
637        };
638        println!(
639            "  #{}  {}  {}",
640            pr.pr_number,
641            ui::timestamp(&repo),
642            pr.pr_url
643        );
644    }
645}
646
647fn print_prs_file(project: &str, session_id: Option<&str>, prs: &[(i64, String, String, String)]) {
648    let rows = prs
649        .iter()
650        .map(
651            |(pr_number, pr_url, pr_repository, timestamp)| claudex::index::PrLinkRow {
652                provider: String::new(),
653                project: project.to_string(),
654                session_id: session_id.map(|s| s.to_string()),
655                pr_number: *pr_number,
656                pr_url: pr_url.clone(),
657                pr_repository: pr_repository.clone(),
658                timestamp: timestamp.clone(),
659            },
660        )
661        .collect::<Vec<_>>();
662    print_prs(&rows);
663}
664
665fn print_stop_reasons(rows: &[StopReasonRow]) {
666    section("Stop Reasons");
667    if rows.is_empty() {
668        println!("  (none)");
669        return;
670    }
671    for row in rows {
672        println!(
673            "  {}  {}",
674            ui::role(&row.stop_reason),
675            ui::fmt_count(row.count as u64)
676        );
677    }
678}
679
680fn print_attachments_indexed(rows: &[claudex::index::AttachmentRow]) {
681    section("Attachments");
682    if rows.is_empty() {
683        println!("  (none)");
684        return;
685    }
686    for row in rows {
687        if row.mime_type.is_empty() {
688            println!("  {}", row.filename);
689        } else {
690            println!("  {}  {}", row.filename, ui::timestamp(&row.mime_type));
691        }
692    }
693}
694
695fn print_attachments_file(rows: &[(String, String)]) {
696    section("Attachments");
697    if rows.is_empty() {
698        println!("  (none)");
699        return;
700    }
701    for (filename, mime) in rows {
702        if mime.is_empty() {
703            println!("  {}", filename);
704        } else {
705            println!("  {}  {}", filename, ui::timestamp(mime));
706        }
707    }
708}
709
710fn print_permission_changes_indexed(rows: &[claudex::index::PermissionChangeRow]) {
711    section("Permission Changes");
712    if rows.is_empty() {
713        println!("  (none)");
714        return;
715    }
716    for row in rows {
717        if row.timestamp.is_empty() {
718            println!("  {}", row.mode);
719        } else {
720            println!("  {}  {}", row.mode, ui::timestamp(&row.timestamp));
721        }
722    }
723}
724
725fn print_permission_changes_file(rows: &[(String, String)]) {
726    section("Permission Changes");
727    if rows.is_empty() {
728        println!("  (none)");
729        return;
730    }
731    for (mode, timestamp) in rows {
732        if timestamp.is_empty() {
733            println!("  {}", mode);
734        } else {
735            println!("  {}  {}", mode, ui::timestamp(timestamp));
736        }
737    }
738}
739
740fn short_session_id(session_id: Option<&str>) -> &str {
741    session_id.unwrap_or("-")
742}
743
744fn display_session_model(model: &str) -> String {
745    if model == "mixed" {
746        "Mixed".to_string()
747    } else if model.is_empty() {
748        "-".to_string()
749    } else {
750        model.trim_start_matches("claude-").to_string()
751    }
752}
753
754fn section(title: &str) {
755    println!("\n{}", ui::section_title(title));
756    println!("{}", "─".repeat(title.len()));
757}