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, ×tamps))
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}