Skip to main content

agx_core/
browser.rs

1use crate::format::Format;
2use anyhow::{Context, Result};
3use std::io::{self, BufRead, Write};
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7#[derive(Debug, Clone)]
8pub struct SessionFile {
9    pub path: PathBuf,
10    pub format: Format,
11    pub modified_secs: Option<u64>,
12}
13
14/// Scan the three known session-storage locations and return all discovered
15/// session files, sorted by modified time (newest first).
16pub fn discover_all() -> Vec<SessionFile> {
17    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
18        return Vec::new();
19    };
20
21    let mut files = Vec::new();
22    files.extend(discover_claude_code(&home));
23    files.extend(discover_codex(&home));
24    files.extend(discover_gemini(&home));
25
26    // Sort by modified time descending (newest first). Files without mtime
27    // end up at the bottom.
28    files.sort_by_key(|f| std::cmp::Reverse(f.modified_secs));
29    files
30}
31
32fn discover_claude_code(home: &Path) -> Vec<SessionFile> {
33    // ~/.claude/projects/<project-dir>/<session-uuid>.jsonl
34    let root = home.join(".claude").join("projects");
35    let mut out = Vec::new();
36    let Ok(projects) = std::fs::read_dir(&root) else {
37        return out;
38    };
39    for project in projects.flatten() {
40        let Ok(files) = std::fs::read_dir(project.path()) else {
41            continue;
42        };
43        for file in files.flatten() {
44            let path = file.path();
45            if path.extension().and_then(|e| e.to_str()) == Some("jsonl")
46                && let Some(modified_secs) = mtime_secs(&file)
47            {
48                out.push(SessionFile {
49                    path,
50                    format: Format::ClaudeCode,
51                    modified_secs: Some(modified_secs),
52                });
53            }
54        }
55    }
56    out
57}
58
59fn discover_codex(home: &Path) -> Vec<SessionFile> {
60    // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
61    let root = home.join(".codex").join("sessions");
62    let mut out = Vec::new();
63    walk_depth(&root, 3, &mut |entry| {
64        let path = entry.path();
65        let name_ok = path
66            .file_name()
67            .and_then(|f| f.to_str())
68            .is_some_and(|n| n.starts_with("rollout-"));
69        let ext_ok = path
70            .extension()
71            .and_then(|e| e.to_str())
72            .is_some_and(|e| e.eq_ignore_ascii_case("jsonl"));
73        if name_ok
74            && ext_ok
75            && let Some(modified_secs) = mtime_secs(entry)
76        {
77            out.push(SessionFile {
78                path,
79                format: Format::Codex,
80                modified_secs: Some(modified_secs),
81            });
82        }
83    });
84    out
85}
86
87fn discover_gemini(home: &Path) -> Vec<SessionFile> {
88    // ~/.gemini/tmp/<project>/chats/session-*.json
89    let root = home.join(".gemini").join("tmp");
90    let mut out = Vec::new();
91    let Ok(projects) = std::fs::read_dir(&root) else {
92        return out;
93    };
94    for project in projects.flatten() {
95        let chats = project.path().join("chats");
96        let Ok(files) = std::fs::read_dir(&chats) else {
97            continue;
98        };
99        for file in files.flatten() {
100            let path = file.path();
101            let name_ok = path
102                .file_name()
103                .and_then(|f| f.to_str())
104                .is_some_and(|n| n.starts_with("session-"));
105            let ext_ok = path
106                .extension()
107                .and_then(|e| e.to_str())
108                .is_some_and(|e| e.eq_ignore_ascii_case("json"));
109            if name_ok
110                && ext_ok
111                && let Some(modified_secs) = mtime_secs(&file)
112            {
113                out.push(SessionFile {
114                    path,
115                    format: Format::Gemini,
116                    modified_secs: Some(modified_secs),
117                });
118            }
119        }
120    }
121    out
122}
123
124fn walk_depth(root: &Path, depth: usize, visit: &mut dyn FnMut(&std::fs::DirEntry)) {
125    let Ok(entries) = std::fs::read_dir(root) else {
126        return;
127    };
128    for entry in entries.flatten() {
129        let path = entry.path();
130        if path.is_dir() {
131            if depth > 0 {
132                walk_depth(&path, depth - 1, visit);
133            }
134        } else {
135            visit(&entry);
136        }
137    }
138}
139
140fn mtime_secs(entry: &std::fs::DirEntry) -> Option<u64> {
141    let meta = entry.metadata().ok()?;
142    let modified = meta.modified().ok()?;
143    modified
144        .duration_since(UNIX_EPOCH)
145        .ok()
146        .map(|d| d.as_secs())
147}
148
149/// Format a duration-since-modified in short relative form.
150pub fn format_relative_time(modified_secs: Option<u64>) -> String {
151    let Some(m) = modified_secs else {
152        return "?".into();
153    };
154    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
155        return "?".into();
156    };
157    let now_secs = now.as_secs();
158    if m > now_secs {
159        return "future".into();
160    }
161    let delta = now_secs - m;
162    if delta < 60 {
163        "just now".into()
164    } else if delta < 3_600 {
165        format!("{}m ago", delta / 60)
166    } else if delta < 86_400 {
167        format!("{}h ago", delta / 3_600)
168    } else if delta < 2_592_000 {
169        format!("{}d ago", delta / 86_400)
170    } else {
171        format!("{}mo ago", delta / 2_592_000)
172    }
173}
174
175fn short_path(path: &Path) -> String {
176    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
177        && let Ok(rest) = path.strip_prefix(&home)
178    {
179        return format!("~/{}", rest.display());
180    }
181    path.display().to_string()
182}
183
184/// Print a numbered list of session files and read the user's choice from
185/// stdin. Returns Ok(None) on `q`/empty/Ctrl-D, Ok(Some(path)) on valid
186/// numeric input, or bubbles up IO errors.
187pub fn prompt_user_to_choose(files: &[SessionFile]) -> Result<Option<PathBuf>> {
188    const MAX_SHOWN: usize = 30;
189    if files.is_empty() {
190        println!("agx: no session files found in ~/.claude, ~/.codex, or ~/.gemini");
191        return Ok(None);
192    }
193
194    let shown = files.len().min(MAX_SHOWN);
195    println!(
196        "agx — {} recent session(s) found, showing {shown}:\n",
197        files.len()
198    );
199    for (i, f) in files.iter().take(MAX_SHOWN).enumerate() {
200        let format_tag = match f.format {
201            Format::ClaudeCode => "[Claude]",
202            Format::Codex => "[Codex ]",
203            Format::Gemini => "[Gemini]",
204            Format::Generic => "[Generic]",
205            Format::Langchain => "[LChain]",
206            Format::OtelJson => "[OTelJS]",
207            Format::OtelProto => "[OTelPB]",
208            Format::VercelAi => "[Vercel]",
209        };
210        let when = format_relative_time(f.modified_secs);
211        let display_path = short_path(&f.path);
212        println!("  {:>3}. {format_tag}  {:>9}  {display_path}", i + 1, when);
213    }
214    if files.len() > MAX_SHOWN {
215        println!("  ... ({} more, not shown)", files.len() - MAX_SHOWN);
216    }
217    print!("\nEnter number (1-{shown}) or q to quit: ");
218    io::stdout().flush().context("flushing stdout")?;
219
220    let mut line = String::new();
221    let stdin = io::stdin();
222    let read = stdin.lock().read_line(&mut line).context("reading stdin")?;
223    if read == 0 {
224        // EOF
225        return Ok(None);
226    }
227    let trimmed = line.trim();
228    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("q") {
229        return Ok(None);
230    }
231    match trimmed.parse::<usize>() {
232        Ok(n) if n >= 1 && n <= shown => Ok(Some(files[n - 1].path.clone())),
233        _ => {
234            println!("agx: invalid selection '{trimmed}'");
235            Ok(None)
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn format_relative_time_handles_recent() {
246        // Using mtime = now - 30s
247        let now = SystemTime::now()
248            .duration_since(UNIX_EPOCH)
249            .unwrap()
250            .as_secs();
251        assert_eq!(format_relative_time(Some(now - 30)), "just now");
252    }
253
254    #[test]
255    fn format_relative_time_handles_minutes() {
256        let now = SystemTime::now()
257            .duration_since(UNIX_EPOCH)
258            .unwrap()
259            .as_secs();
260        let s = format_relative_time(Some(now - 120));
261        assert!(s.ends_with("m ago"));
262    }
263
264    #[test]
265    fn format_relative_time_handles_hours() {
266        let now = SystemTime::now()
267            .duration_since(UNIX_EPOCH)
268            .unwrap()
269            .as_secs();
270        let s = format_relative_time(Some(now - 7_200));
271        assert!(s.ends_with("h ago"));
272    }
273
274    #[test]
275    fn format_relative_time_handles_days() {
276        let now = SystemTime::now()
277            .duration_since(UNIX_EPOCH)
278            .unwrap()
279            .as_secs();
280        let s = format_relative_time(Some(now - 172_800));
281        assert!(s.ends_with("d ago"));
282    }
283
284    #[test]
285    fn format_relative_time_handles_none() {
286        assert_eq!(format_relative_time(None), "?");
287    }
288
289    #[test]
290    fn format_relative_time_handles_future() {
291        let now = SystemTime::now()
292            .duration_since(UNIX_EPOCH)
293            .unwrap()
294            .as_secs();
295        assert_eq!(format_relative_time(Some(now + 1000)), "future");
296    }
297
298    #[test]
299    fn short_path_shortens_home_prefix() {
300        if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
301            let p = home.join("foo/bar");
302            let s = short_path(&p);
303            assert!(s.starts_with("~/"), "expected tilde prefix, got: {s}");
304        }
305    }
306}