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 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 }
169 display::print_record(record, index);
170 }
171
172 index += 1;
173
174 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 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 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 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 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}