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 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 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 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 .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}