Skip to main content

agg/cmds/sessions/
list.rs

1use std::collections::HashMap;
2use std::fmt::Display;
3use std::fmt::Formatter;
4#[cfg(unix)]
5use std::os::unix::process::CommandExt;
6use std::path::Path;
7use std::process::Command;
8use std::process::Stdio;
9
10use jiff::Timestamp;
11use owo_colors::OwoColorize;
12use rootcause::prelude::ResultExt;
13use rootcause::report;
14use serde::Serialize;
15use strum::EnumIter;
16use strum::IntoEnumIterator;
17use ytil_agents::agent::Agent;
18use ytil_agents::agent::session::Session;
19use ytil_agents::agent::session::SessionKey;
20use ytil_ext::path_ext::PathExt;
21use ytil_ext::string_ext::StringExt as _;
22
23pub fn run(home_dir: &Path) -> rootcause::Result<()> {
24    let sessions = load_sorted_sessions()?;
25
26    if sessions.is_empty() {
27        println!("No sessions");
28        return Ok(());
29    }
30
31    let renderable_sessions = RenderableSession::from_sessions(sessions, home_dir);
32    let Some(selected) = ytil_tui::minimal_multi_select_with_preview(
33        renderable_sessions,
34        ToString::to_string,
35        RenderableSession::preview,
36        |session| session.session.search_text.clone(),
37    )?
38    else {
39        println!("No sessions selected");
40        return Ok(());
41    };
42
43    let Some(op) = ytil_tui::minimal_select::<Op>(Op::iter().collect())? else {
44        println!("No action selected");
45        return Ok(());
46    };
47
48    match op {
49        Op::Resume => ytil_tui::require_single(&selected, "sessions").and_then(launch_session),
50        Op::Delete => super::delete::delete_selected_sessions(&selected, home_dir),
51    }
52}
53
54pub fn run_json(args: &[String], home_dir: &Path) -> rootcause::Result<()> {
55    let session_keys = match parse_json_session_keys(args) {
56        Ok(session_keys) => session_keys,
57        Err(error) => {
58            eprintln!("{}", crate::cmds::Help::SessionsList.text());
59            return Err(error);
60        }
61    };
62    let sessions = load_sorted_sessions_by_key(&session_keys)?;
63    let rows = RenderableSession::from_sessions(sessions, home_dir)
64        .into_iter()
65        .map(|session| JsonSession::new(&session))
66        .collect::<rootcause::Result<Vec<_>>>()?;
67
68    println!(
69        "{}",
70        serde_json::to_string(&rows).context("failed to serialize sessions")?
71    );
72    Ok(())
73}
74
75fn parse_json_session_keys(args: &[String]) -> rootcause::Result<Vec<SessionKey>> {
76    let mut session_keys = Vec::new();
77    let mut args = args.iter();
78    while let Some(arg) = args.next() {
79        match arg.as_str() {
80            "--session" => {
81                let Some(key) = args.next() else {
82                    return Err(report!("missing --session value"));
83                };
84                session_keys.push(key.parse()?);
85            }
86            unexpected => {
87                return Err(report!("unknown agg sessions list --json arg").attach(format!("arg={unexpected}")));
88            }
89        }
90    }
91    if session_keys.is_empty() {
92        return Err(report!("agg sessions list --json requires at least one --session"));
93    }
94    session_keys.sort();
95    session_keys.dedup();
96    Ok(session_keys)
97}
98
99fn load_sorted_sessions() -> rootcause::Result<Vec<Session>> {
100    let mut sessions = Vec::new();
101    sessions.extend(ytil_agents::agent::session_loader::load_sessions()?);
102    sort_sessions(&mut sessions);
103    Ok(sessions)
104}
105
106fn load_sorted_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
107    let mut sessions = ytil_agents::agent::session_loader::load_sessions_by_key(keys)?;
108    sort_sessions(&mut sessions);
109    Ok(sessions)
110}
111
112fn sort_sessions(sessions: &mut [Session]) {
113    sessions.sort_by(|a, b| {
114        b.updated_at
115            .cmp(&a.updated_at)
116            .then_with(|| b.created_at.cmp(&a.created_at))
117            .then_with(|| a.name.cmp(&b.name))
118            .then_with(|| a.id.cmp(&b.id))
119    });
120}
121
122pub(super) struct RenderableSession {
123    pub(super) session: Session,
124    branch: Option<String>,
125    home_dir: std::path::PathBuf,
126}
127
128impl RenderableSession {
129    #[cfg(test)]
130    pub(super) const fn for_test(session: Session, home_dir: std::path::PathBuf) -> Self {
131        Self {
132            session,
133            branch: None,
134            home_dir,
135        }
136    }
137
138    fn from_sessions(sessions: Vec<Session>, home_dir: &Path) -> Vec<Self> {
139        let mut timestamps_by_workspace = HashMap::<std::path::PathBuf, Vec<(usize, Timestamp)>>::new();
140        for (index, session) in sessions.iter().enumerate() {
141            timestamps_by_workspace
142                .entry(session.workspace.clone())
143                .or_default()
144                .push((index, session.created_at));
145        }
146
147        let mut branches = vec![None; sessions.len()];
148        for (workspace, timestamp_entries) in timestamps_by_workspace {
149            let timestamps: Vec<Timestamp> = timestamp_entries.iter().map(|(_, timestamp)| *timestamp).collect();
150            for ((index, _), branch) in timestamp_entries
151                .into_iter()
152                .zip(ytil_git::branch::get_at_many(&workspace, &timestamps))
153            {
154                if let Some(branch_slot) = branches.get_mut(index) {
155                    *branch_slot = branch;
156                }
157            }
158        }
159
160        sessions
161            .into_iter()
162            .zip(branches)
163            .map(|(session, branch)| Self {
164                session,
165                branch,
166                home_dir: home_dir.to_path_buf(),
167            })
168            .collect()
169    }
170
171    fn can_resume(&self) -> bool {
172        self.session.workspace.is_dir()
173    }
174
175    fn workspace_status(&self) -> &'static str {
176        if self.can_resume() { "" } else { " [missing workspace]" }
177    }
178
179    fn branch(&self) -> Option<&str> {
180        self.branch.as_deref()
181    }
182
183    fn colored_agent_name(&self) -> String {
184        match self.session.agent {
185            Agent::Claude => self.session.agent.short_name().red().bold().to_string(),
186            Agent::Codex => self.session.agent.short_name().green().bold().to_string(),
187            Agent::Cursor => self.session.agent.short_name().bright_black().bold().to_string(),
188            Agent::Gemini | Agent::Opencode => self.session.agent.short_name().bold().to_string(),
189        }
190    }
191
192    fn plain_summary(&self) -> String {
193        let path_label = self.session.workspace.short_path(&self.home_dir);
194        let session_name = self.session.name.trim_end_at_with(42, None);
195        let updated_label = self.session.updated_at.strftime("%d/%m/%Y-%H:%M").to_string();
196        let created_label = self.session.created_at.strftime("%d/%m/%Y-%H:%M").to_string();
197        let agent = self.session.agent.short_name();
198
199        self.branch().map_or_else(
200            || {
201                format!(
202                    "{agent} {path_label}{} {session_name} {updated_label} {created_label}",
203                    self.workspace_status()
204                )
205            },
206            |branch| {
207                format!(
208                    "{agent} {path_label} {branch}{} {session_name} {updated_label} {created_label}",
209                    self.workspace_status()
210                )
211            },
212        )
213    }
214
215    fn preview(&self) -> String {
216        let first_prompt = self.session.name.as_str();
217        let last_prompt = self.session.last_user_prompt.as_deref().unwrap_or("—");
218        let updated = self.session.updated_at.strftime("%d/%m/%Y-%H:%M");
219        let created = self.session.created_at.strftime("%d/%m/%Y-%H:%M");
220
221        format!(
222            "\n{}\n{}\n{}\n\n{}\n\n{}",
223            self.session.id.white().bold(),
224            updated.blue(),
225            created.blue(),
226            first_prompt.white(),
227            last_prompt.white()
228        )
229    }
230}
231
232impl Display for RenderableSession {
233    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
234        let agent_name = self.colored_agent_name();
235        let path_label = self.session.workspace.short_path(&self.home_dir);
236        let updated_label = self.session.updated_at.strftime("%d/%m/%Y-%H:%M").to_string();
237        let created_label = self.session.created_at.strftime("%d/%m/%Y-%H:%M").to_string();
238
239        if let Some(branch) = self.branch() {
240            write!(
241                f,
242                "{agent_name} {} {} {} {}",
243                path_label.cyan().bold(),
244                branch.dimmed().bold(),
245                updated_label.blue(),
246                created_label.blue(),
247            )
248        } else {
249            write!(
250                f,
251                "{agent_name} {} {} {}",
252                path_label.cyan().bold(),
253                updated_label.blue(),
254                created_label.blue()
255            )
256        }
257    }
258}
259
260#[derive(Debug, Serialize)]
261struct JsonSession {
262    agent: &'static str,
263    workspace: std::path::PathBuf,
264    session_id: String,
265    summary: String,
266    display: String,
267    search: String,
268    updated_at: Timestamp,
269    resume_program: String,
270    resume_args: Vec<String>,
271}
272
273impl JsonSession {
274    fn new(session: &RenderableSession) -> rootcause::Result<Self> {
275        let display = session.plain_summary();
276        let search = search_corpus(&display, &session.session.search_text);
277        let (resume_program, resume_args) = session.session.build_resume_command()?;
278        Ok(Self {
279            agent: session.session.agent.name(),
280            workspace: session.session.workspace.clone(),
281            session_id: session.session.id.clone(),
282            summary: session.session.name.clone(),
283            display,
284            search,
285            updated_at: session.session.updated_at,
286            resume_program: resume_program.to_string(),
287            resume_args,
288        })
289    }
290}
291
292fn search_corpus(display_text: &str, hidden_search: &str) -> String {
293    let visible_match_text = normalize_search(display_text);
294    let hidden_search = normalize_search(hidden_search);
295    if hidden_search.is_empty() || hidden_search == visible_match_text {
296        visible_match_text
297    } else {
298        format!("{visible_match_text} {hidden_search}")
299    }
300}
301
302fn normalize_search(value: &str) -> String {
303    value.split_whitespace().collect::<Vec<_>>().join(" ")
304}
305
306#[derive(Debug, EnumIter)]
307enum Op {
308    Resume,
309    Delete,
310}
311
312impl Display for Op {
313    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
314        match self {
315            Self::Resume => write!(f, "{}", "Resume".green().bold()),
316            Self::Delete => write!(f, "{}", "Delete".red().bold()),
317        }
318    }
319}
320
321fn launch_session(session: &RenderableSession) -> rootcause::Result<()> {
322    if !session.can_resume() {
323        return Err(report!("cannot resume session because its workspace is missing")
324            .attach(format!("workspace={}", session.session.workspace.display()))
325            .attach(format!("session_id={}", session.session.id)));
326    }
327
328    let session = &session.session;
329    let (program, args) = session.build_resume_command()?;
330
331    let mut cmd = Command::new(program);
332    cmd.args(args)
333        .current_dir(&session.workspace)
334        .stdin(Stdio::inherit())
335        .stdout(Stdio::inherit())
336        .stderr(Stdio::inherit());
337
338    #[cfg(unix)]
339    {
340        let error = cmd.exec();
341        Err(report!("failed to exec agent CLI")
342            .attach(format!("error={error}"))
343            .attach(format!("agent={}", session.agent.name()))
344            .attach(format!("workspace={}", session.workspace.display()))
345            .attach(format!("session_id={}", session.id)))
346    }
347
348    #[cfg(not(unix))]
349    {
350        let status = cmd
351            .status()
352            .context("failed to launch agent CLI")
353            .attach_with(|| format!("agent={}", session.agent.name()))
354            .attach_with(|| format!("workspace={}", session.workspace.display()))
355            .attach_with(|| format!("session_id={}", session.id))?;
356
357        if !status.success() {
358            return Err(report!("agent CLI exited with non-zero status")
359                .attach_with(|| format!("agent={}", session.agent.name()))
360                .attach_with(|| format!("workspace={}", session.workspace.display()))
361                .attach_with(|| format!("session_id={}", session.id))
362                .attach_with(|| format!("status={status}")));
363        }
364
365        Ok(())
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use jiff::Timestamp;
372    use tempfile::tempdir;
373    use test_that::prelude::*;
374
375    use super::*;
376
377    #[test]
378    fn test_search_corpus_matches_agg_visible_plus_hidden_filtering() {
379        let display = "cx  ~/repo   branch   session name  09/05/2026-10:00";
380        let hidden = "first user prompt\nassistant reply";
381
382        let search = search_corpus(display, hidden);
383
384        assert_that!(
385            search,
386            eq("cx ~/repo branch session name 09/05/2026-10:00 first user prompt assistant reply")
387        );
388    }
389
390    #[test]
391    fn test_json_session_renders_plain_agg_summary_and_resume_command() {
392        let dir = tempdir().expect("tempdir should be created");
393        let workspace = dir.path().join("repo");
394        std::fs::create_dir_all(&workspace).expect("workspace should be created");
395        let created_at = Timestamp::from_second(1_700_000_000).expect("test timestamp should be valid");
396        let updated_at = Timestamp::from_second(1_700_000_100).expect("test timestamp should be valid");
397        let session = Session {
398            id: "session-id".to_string(),
399            agent: Agent::Codex,
400            name: "fix issue".to_string(),
401            last_user_prompt: Some("finish the fix".to_string()),
402            search_text: "hidden prompt".to_string(),
403            workspace: workspace.clone(),
404            path: dir.path().join("session.jsonl"),
405            created_at,
406            updated_at,
407        };
408        let renderable_sessions = RenderableSession::from_sessions(vec![session], dir.path());
409        assert_that!(renderable_sessions.len(), eq(1));
410        let renderable = &renderable_sessions[0];
411
412        assert_renderable_session_agg_listing(renderable);
413        assert_renderable_session_agg_json_listing(workspace, updated_at, renderable);
414
415        assert_that!(
416            renderable.preview(),
417            all!(
418                contains_substring("session-id"),
419                contains_substring("fix issue"),
420                contains_substring("finish the fix")
421            )
422        );
423
424        let cursor_preview = RenderableSession {
425            session: Session {
426                agent: Agent::Cursor,
427                last_user_prompt: None,
428                ..renderable.session.clone()
429            },
430            branch: None,
431            home_dir: renderable.home_dir.clone(),
432        }
433        .preview();
434        assert_that!(cursor_preview, all!(contains_substring("—")));
435
436        let prompt = "x".repeat(43);
437        let full_prompt_preview = RenderableSession {
438            session: Session {
439                name: prompt.clone(),
440                last_user_prompt: Some(prompt.clone()),
441                ..renderable.session.clone()
442            },
443            branch: None,
444            home_dir: renderable.home_dir.clone(),
445        }
446        .preview();
447
448        assert_that!(full_prompt_preview, all!(contains_substring(prompt)));
449    }
450
451    #[test]
452    fn test_parse_json_session_keys_requires_at_least_one_session_key() {
453        assert_that!(
454            (parse_json_session_keys(&[])).map(|_| ()),
455            err(displays_as(contains_substring("requires at least one --session")))
456        );
457    }
458
459    #[test]
460    fn test_parse_json_session_keys_parses_and_dedupes_requested_session_keys() {
461        assert_that!(
462            parse_json_session_keys(&[
463                String::from("--session"),
464                String::from("codex:target"),
465                String::from("--session"),
466                String::from("codex:target"),
467            ]),
468            ok(eq([SessionKey::new(Agent::Codex, "target")]))
469        );
470    }
471
472    fn assert_renderable_session_agg_listing(renderable: &RenderableSession) {
473        let display = RenderableSession {
474            session: renderable.session.clone(),
475            branch: Some("main".to_string()),
476            home_dir: renderable.home_dir.clone(),
477        }
478        .to_string();
479        assert_that!(
480            display,
481            all!(
482                contains_substring("cx"),
483                contains_substring("main"),
484                contains_substring("14/11/2023-22:15"),
485                contains_substring("14/11/2023-22:13"),
486                contains_substring("~/repo"),
487                not(contains_substring("fix issue"))
488            )
489        );
490    }
491
492    fn assert_renderable_session_agg_json_listing(
493        workspace: std::path::PathBuf,
494        updated_at: Timestamp,
495        renderable: &RenderableSession,
496    ) {
497        assert_that!(
498            JsonSession::new(renderable),
499            ok(all!(
500                result_of!(
501                    |row: &JsonSession| row.display.as_str(),
502                    starts_with("cx ~/repo fix issue")
503                ),
504                result_of!(
505                    |row: &JsonSession| row.search.as_str(),
506                    contains_substring("hidden prompt")
507                ),
508                result_of!(|row: &JsonSession| row.agent, eq("codex")),
509                result_of!(|row: &JsonSession| &row.workspace, points_to(eq(workspace))),
510                result_of!(|row: &JsonSession| row.session_id.as_str(), eq("session-id")),
511                result_of!(|row: &JsonSession| row.summary.as_str(), eq("fix issue")),
512                result_of!(|row: &JsonSession| row.updated_at, eq(updated_at)),
513                result_of!(|row: &JsonSession| row.resume_program.as_str(), eq("codex")),
514                result_of!(
515                    |row: &JsonSession| row.resume_args.first().map(String::as_str),
516                    eq(Some("resume"))
517                ),
518            ))
519        );
520    }
521}