Skip to main content

smc_cli_cc/
session.rs

1use crate::config::SessionFile;
2use crate::display;
3use crate::models::{ContentBlock, MessageContent, Record};
4use anyhow::Result;
5use std::io::BufRead;
6
7pub fn parse_records(file: &SessionFile) -> Result<Vec<Record>> {
8    let f = std::fs::File::open(&file.path)?;
9    let reader = std::io::BufReader::new(f);
10    let mut records = Vec::new();
11
12    for line in reader.lines() {
13        let line = line?;
14        if line.trim().is_empty() {
15            continue;
16        }
17        match serde_json::from_str::<Record>(&line) {
18            Ok(record) => records.push(record),
19            Err(_) => continue,
20        }
21    }
22
23    Ok(records)
24}
25
26pub fn list_sessions(
27    files: &[SessionFile],
28    limit: usize,
29    after: Option<&str>,
30    before: Option<&str>,
31) -> Result<()> {
32    let mut entries: Vec<SessionListEntry> = Vec::new();
33
34    for file in files {
35        let f = std::fs::File::open(&file.path)?;
36        let reader = std::io::BufReader::new(f);
37
38        let mut first_timestamp = None;
39        let mut first_user_msg = None;
40        let mut msg_count = 0u32;
41
42        for line in reader.lines() {
43            let line = line?;
44            if line.trim().is_empty() {
45                continue;
46            }
47            let Ok(record) = serde_json::from_str::<Record>(&line) else {
48                continue;
49            };
50
51            if let Some(msg) = record.as_message_record() {
52                msg_count += 1;
53                if first_timestamp.is_none() {
54                    first_timestamp = msg.timestamp.clone();
55                }
56                if first_user_msg.is_none() && matches!(record, Record::User(_)) {
57                    let text = msg.text_content();
58                    first_user_msg = Some(text.chars().take(100).collect::<String>());
59                }
60            }
61
62            if first_timestamp.is_some() && first_user_msg.is_some() && msg_count > 5 {
63                break;
64            }
65        }
66
67        // Date filters
68        if let Some(after_date) = after {
69            if let Some(ts) = &first_timestamp {
70                if ts.as_str() < after_date {
71                    continue;
72                }
73            }
74        }
75        if let Some(before_date) = before {
76            if let Some(ts) = &first_timestamp {
77                if ts.as_str() > before_date {
78                    continue;
79                }
80            }
81        }
82
83        entries.push(SessionListEntry {
84            file: file.clone(),
85            timestamp: first_timestamp,
86            preview: first_user_msg,
87            msg_count,
88        });
89    }
90
91    entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
92
93    let show = if limit > 0 {
94        std::cmp::min(limit, entries.len())
95    } else {
96        entries.len()
97    };
98
99    println!(
100        "{} sessions found (showing {})\n",
101        entries.len(),
102        show
103    );
104
105    for entry in entries.iter().take(show) {
106        let ts = entry
107            .timestamp
108            .as_deref()
109            .unwrap_or("unknown")
110            .get(..10)
111            .unwrap_or("unknown");
112        let preview = entry
113            .preview
114            .as_deref()
115            .unwrap_or("[no user message]");
116
117        display::print_session_header(
118            &entry.file.project_name,
119            &entry.file.session_id,
120            &entry.file.size_human(),
121        );
122        println!("  {} {}", ts.to_string(), preview);
123        println!();
124    }
125
126    Ok(())
127}
128
129pub fn show_session(
130    file: &SessionFile,
131    show_thinking: bool,
132    from: Option<usize>,
133    to: Option<usize>,
134) -> Result<()> {
135    let records = parse_records(file)?;
136
137    println!(
138        "Session: {} | Project: {} | Size: {}",
139        file.session_id,
140        file.project_name,
141        file.size_human()
142    );
143    if from.is_some() || to.is_some() {
144        println!(
145            "Showing messages {}..{}",
146            from.unwrap_or(0),
147            to.map_or("end".to_string(), |t| t.to_string())
148        );
149    }
150    println!();
151
152    let mut index = 0;
153    for record in &records {
154        if !record.is_message() {
155            continue;
156        }
157
158        let in_range = match (from, to) {
159            (Some(f), Some(t)) => index >= f && index <= t,
160            (Some(f), None) => index >= f,
161            (None, Some(t)) => index <= t,
162            (None, None) => true,
163        };
164
165        if in_range {
166            if !show_thinking {
167                // Still show it but we let display handle truncation
168            }
169            display::print_record(record, index);
170        }
171
172        index += 1;
173
174        // Early exit if past range
175        if let Some(t) = to {
176            if index > t {
177                break;
178            }
179        }
180    }
181
182    println!("{}", "─".repeat(80));
183    println!("{} messages total, displayed range", index);
184
185    Ok(())
186}
187
188pub fn show_tools(file: &SessionFile) -> Result<()> {
189    let records = parse_records(file)?;
190
191    println!(
192        "Tool calls in session: {} ({})",
193        file.session_id, file.project_name
194    );
195    println!();
196
197    let mut count = 0;
198    for record in &records {
199        let Some(msg) = record.as_message_record() else {
200            continue;
201        };
202
203        if let Some(summary) = display::format_tool_summary(msg, record.role_str()) {
204            println!("{}", summary);
205            count += 1;
206        }
207    }
208
209    println!("\n{} tool-calling messages", count);
210    Ok(())
211}
212
213pub fn export_session(file: &SessionFile, to_stdout: bool, md_path: Option<&str>) -> Result<()> {
214    use std::io::Write;
215
216    let records = parse_records(file)?;
217
218    let mut content = String::new();
219    content.push_str(&format!(
220        "# Session: {}\n\n**Project:** {}  \n**Size:** {}\n\n---\n\n",
221        file.session_id,
222        file.project_name,
223        file.size_human()
224    ));
225
226    for record in &records {
227        let Some(msg) = record.as_message_record() else {
228            continue;
229        };
230
231        let role = record.role_str();
232        let timestamp = msg.timestamp.as_deref().unwrap_or("unknown");
233        let ts_short = timestamp.get(..19).unwrap_or(timestamp);
234
235        content.push_str(&format!("## {} ({})\n\n", role.to_uppercase(), ts_short));
236
237        match &msg.message.content {
238            MessageContent::Text(s) => {
239                content.push_str(s);
240                content.push_str("\n\n");
241            }
242            MessageContent::Blocks(blocks) => {
243                for block in blocks {
244                    match block {
245                        ContentBlock::Text { text } => {
246                            content.push_str(text);
247                            content.push_str("\n\n");
248                        }
249                        ContentBlock::Thinking { thinking } => {
250                            content.push_str(&format!(
251                                "<details>\n<summary>Thinking</summary>\n\n{}\n\n</details>\n\n",
252                                thinking
253                            ));
254                        }
255                        ContentBlock::ToolUse { name, input, .. } => {
256                            content.push_str(&format!(
257                                "**Tool: {}**\n```json\n{}\n```\n\n",
258                                name,
259                                serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string())
260                            ));
261                        }
262                        ContentBlock::ToolResult { content: c, .. } => {
263                            if let Some(val) = c {
264                                let s = val.to_string();
265                                let preview: String = s.chars().take(2000).collect();
266                                content.push_str(&format!("**Result:**\n```\n{}\n```\n\n", preview));
267                            }
268                        }
269                        ContentBlock::Other => {}
270                    }
271                }
272            }
273        }
274
275        content.push_str("---\n\n");
276    }
277
278    if to_stdout {
279        print!("{}", content);
280    }
281
282    let output_path = if let Some(p) = md_path {
283        p.to_string()
284    } else if !to_stdout {
285        format!("{}.md", &file.session_id[..8.min(file.session_id.len())])
286    } else {
287        return Ok(());
288    };
289
290    if !to_stdout || md_path.is_some() {
291        let mut f = std::fs::File::create(&output_path)?;
292        f.write_all(content.as_bytes())?;
293        eprintln!("Exported to {}", output_path);
294    }
295
296    Ok(())
297}
298
299pub fn show_context(file: &SessionFile, target_line: usize, context: usize) -> Result<()> {
300    let f = std::fs::File::open(&file.path)?;
301    let reader = std::io::BufReader::new(f);
302
303    let mut messages: Vec<(usize, Record)> = Vec::new();
304
305    for (line_num, line) in reader.lines().enumerate() {
306        let Ok(line) = line else { continue };
307        if line.trim().is_empty() {
308            continue;
309        }
310        let Ok(record) = serde_json::from_str::<Record>(&line) else {
311            continue;
312        };
313        if record.is_message() {
314            messages.push((line_num + 1, record));
315        }
316    }
317
318    // Find the message at or nearest to target_line
319    let target_idx = messages
320        .iter()
321        .position(|(ln, _)| *ln >= target_line)
322        .unwrap_or(messages.len().saturating_sub(1));
323
324    let start = target_idx.saturating_sub(context);
325    let end = std::cmp::min(messages.len(), target_idx + context + 1);
326
327    println!(
328        "Context around line {} in {} ({})\n",
329        target_line, file.session_id, file.project_name
330    );
331
332    for (i, (line_num, record)) in messages[start..end].iter().enumerate() {
333        let is_target = start + i == target_idx;
334        if is_target {
335            println!("{}", ">>> TARGET <<<".to_string());
336        }
337        display::print_record(record, *line_num);
338    }
339
340    println!("{}", "─".repeat(80));
341    println!(
342        "Showing messages {} through {} (of {} total)",
343        start + 1,
344        end,
345        messages.len()
346    );
347
348    Ok(())
349}
350
351pub fn show_recent(
352    files: &[SessionFile],
353    limit: usize,
354    role_filter: Option<&str>,
355) -> Result<()> {
356    use colored::*;
357
358    #[allow(dead_code)]
359    struct RecentMsg {
360        project: String,
361        session_id: String,
362        timestamp: String,
363        role: String,
364        preview: String,
365    }
366
367    let mut all_messages: Vec<RecentMsg> = Vec::new();
368
369    for file in files {
370        let f = std::fs::File::open(&file.path)?;
371        let reader = std::io::BufReader::new(f);
372
373        // Read last N lines efficiently — read all lines, keep last ones
374        let mut last_records: Vec<String> = Vec::new();
375        for line in reader.lines() {
376            let Ok(line) = line else { continue };
377            if line.trim().is_empty() {
378                continue;
379            }
380            last_records.push(line);
381            // Keep a buffer — we only need the last few per file
382            if last_records.len() > limit * 2 + 50 {
383                last_records.drain(..last_records.len() - limit - 25);
384            }
385        }
386
387        for line in last_records.iter().rev().take(limit + 10) {
388            let Ok(record) = serde_json::from_str::<Record>(line) else {
389                continue;
390            };
391            let Some(msg) = record.as_message_record() else {
392                continue;
393            };
394
395            let role = record.role_str().to_string();
396            if let Some(rf) = role_filter {
397                if role != rf {
398                    continue;
399                }
400            }
401
402            let ts = msg.timestamp.clone().unwrap_or_default();
403            let text = msg.text_content();
404            let preview: String = text.chars().take(120).collect();
405
406            all_messages.push(RecentMsg {
407                project: file.project_name.clone(),
408                session_id: file.session_id.clone(),
409                timestamp: ts,
410                role,
411                preview: preview.replace('\n', " ↵ "),
412            });
413        }
414    }
415
416    // Sort by timestamp descending
417    all_messages.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
418
419    let show = std::cmp::min(limit, all_messages.len());
420    println!("Recent messages (showing {})\n", show);
421
422    for msg in all_messages.iter().take(show) {
423        let role_colored = match msg.role.as_str() {
424            "user" => "user".green(),
425            "assistant" => "asst".blue(),
426            _ => msg.role.dimmed(),
427        };
428
429        let ts_short = msg.timestamp.get(..19).unwrap_or(&msg.timestamp);
430
431        println!(
432            "{} [{}] {} {}",
433            msg.project.cyan(),
434            role_colored,
435            ts_short.dimmed(),
436            msg.preview
437        );
438    }
439
440    Ok(())
441}
442
443#[allow(dead_code)]
444struct SessionListEntry {
445    file: SessionFile,
446    timestamp: Option<String>,
447    preview: Option<String>,
448    msg_count: u32,
449}