1mod app;
2mod async_ops;
3mod cli_export;
4mod config;
5mod platform_api_storage;
6mod session_timeline;
7mod theme;
8mod timeline_summary;
9mod ui;
10mod views;
11
12use anyhow::Result;
13use app::{App, ServerInfo, ServerStatus, SetupStep, StartupStatus, UploadPhase, View};
14use crossterm::{
15 event::{self, Event, KeyEventKind},
16 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
17 ExecutableCommand,
18};
19use opensession_core::trace::{Agent, Session, SessionContext, Stats};
20use opensession_local_db::git::extract_git_context;
21use opensession_local_db::{LocalDb, LocalSessionFilter, LocalSessionRow};
22use ratatui::prelude::*;
23use std::collections::HashMap;
24use std::io::stdout;
25use std::path::{Path, PathBuf};
26use std::sync::mpsc;
27use std::sync::Arc;
28use std::time::Duration;
29
30pub use cli_export::{
31 export_session_timeline, CliTimelineExport, CliTimelineExportOptions, CliTimelineView,
32};
33
34enum BgEvent {
35 SessionsLoaded(Vec<opensession_core::trace::Session>),
36 DbReady { repos: Vec<String>, count: usize },
37}
38
39#[derive(Clone)]
40struct LoadedSession {
41 source_path: PathBuf,
42 session: opensession_core::trace::Session,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct SummaryLaunchOverride {
47 pub provider: Option<String>,
48 pub model: Option<String>,
49 pub content_mode: Option<String>,
50 pub disk_cache_enabled: Option<bool>,
51 pub openai_compat_endpoint: Option<String>,
52 pub openai_compat_base: Option<String>,
53 pub openai_compat_path: Option<String>,
54 pub openai_compat_style: Option<String>,
55 pub openai_compat_api_key: Option<String>,
56 pub openai_compat_api_key_header: Option<String>,
57}
58
59impl SummaryLaunchOverride {
60 pub fn has_any_override(&self) -> bool {
61 self.provider.is_some()
62 || self.model.is_some()
63 || self.content_mode.is_some()
64 || self.disk_cache_enabled.is_some()
65 || self.openai_compat_endpoint.is_some()
66 || self.openai_compat_base.is_some()
67 || self.openai_compat_path.is_some()
68 || self.openai_compat_style.is_some()
69 || self.openai_compat_api_key.is_some()
70 || self.openai_compat_api_key_header.is_some()
71 }
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct RunOptions {
76 pub paths: Option<Vec<String>>,
77 pub auto_enter_detail: bool,
78 pub summary_override: Option<SummaryLaunchOverride>,
79 pub focus_detail_view: bool,
80}
81
82pub fn run(paths: Option<Vec<String>>) -> Result<()> {
84 run_with_options(RunOptions {
85 paths,
86 ..RunOptions::default()
87 })
88}
89
90pub fn run_with_options(options: RunOptions) -> Result<()> {
92 run_with_options_sync(options)
93}
94
95fn run_with_options_sync(options: RunOptions) -> Result<()> {
96 let mut app = App::new(vec![]);
98 app.loading_sessions = true;
99
100 let mut daemon_config = config::load_daemon_config();
102 if let Some(summary_override) = options.summary_override.as_ref() {
103 apply_summary_launch_override(&mut daemon_config, summary_override);
104 }
105 let config_exists = config::config_dir()
106 .map(|d| d.join("daemon.toml").exists())
107 .unwrap_or(false);
108
109 app.server_info = build_server_info(&daemon_config);
111 app.team_id = if daemon_config.identity.team_id.is_empty() {
112 None
113 } else {
114 Some(daemon_config.identity.team_id.clone())
115 };
116 app.daemon_config = daemon_config;
117 app.realtime_preview_enabled = app.daemon_config.daemon.detail_realtime_preview_enabled;
118 app.connection_ctx = App::derive_connection_ctx(&app.daemon_config);
119 app.focus_detail_view = options.focus_detail_view;
120
121 let status = StartupStatus {
123 sessions_cached: 0,
124 repos_detected: 0,
125 daemon_pid: config::daemon_pid(),
126 config_exists,
127 };
128 app.startup_status = status;
129
130 if let Ok(db) = LocalDb::open() {
132 app.db = Some(Arc::new(db));
133 }
134
135 if options.paths.is_none() {
137 if let Some(db) = app.db.clone() {
138 app.sessions = load_cached_sessions_from_db(&db);
139 app.rebuild_session_agent_metrics();
140 app.filtered_sessions = (0..app.sessions.len()).collect();
141 app.rebuild_available_tools();
142 if !app.sessions.is_empty() {
143 app.list_state.select(Some(0));
144 }
145 app.repos = db.list_repos().unwrap_or_default();
146 app.startup_status.sessions_cached = app.sessions.len();
147 app.startup_status.repos_detected = app.repos.len();
148 app.loading_sessions = app.sessions.is_empty();
149 }
150 }
151
152 if !config_exists && !options.auto_enter_detail {
154 app.view = View::Setup;
155 app.setup_step = SetupStep::Scenario;
156 app.setup_scenario_index = 0;
157 app.settings_index = 0;
158 }
159
160 enable_raw_mode()?;
162 stdout().execute(EnterAlternateScreen)?;
163 let backend = CrosstermBackend::new(stdout());
164 let mut terminal = Terminal::new(backend)?;
165
166 let (tx, bg_rx) = mpsc::channel::<BgEvent>();
168 let paths = options.paths.clone();
169 let should_refresh_from_disk =
170 paths.is_some() || app.sessions.is_empty() || refresh_discovery_on_start();
171 if should_refresh_from_disk {
172 std::thread::spawn(move || {
173 let sessions = match paths {
174 Some(ref paths) => load_from_paths(paths),
175 None => load_sessions(),
176 };
177 let sessions_for_ui = if paths.is_none() {
178 filter_visible_discovered_sessions(
179 sessions.iter().map(|entry| entry.session.clone()).collect(),
180 )
181 } else {
182 sessions.iter().map(|entry| entry.session.clone()).collect()
183 };
184 let ui_sessions = sessions_for_ui;
185 let for_db = sessions.clone();
186 if tx.send(BgEvent::SessionsLoaded(ui_sessions)).is_err() {
187 return;
188 }
189
190 if let Ok(bg_db) = LocalDb::open() {
192 cache_sessions_to_db(&bg_db, &for_db);
193 let repos = bg_db.list_repos().unwrap_or_default();
194 let _ = tx.send(BgEvent::DbReady {
195 repos,
196 count: for_db.len(),
197 });
198 }
199 });
200 }
201
202 let result = event_loop(&mut terminal, &mut app, bg_rx, options.auto_enter_detail);
204
205 disable_raw_mode()?;
207 stdout().execute(LeaveAlternateScreen)?;
208
209 result
210}
211
212fn apply_summary_launch_override(
213 daemon_config: &mut config::DaemonConfig,
214 summary_override: &SummaryLaunchOverride,
215) {
216 if let Some(provider) = summary_override.provider.clone() {
217 daemon_config.daemon.summary_provider = Some(provider);
218 }
219 if let Some(model) = summary_override.model.clone() {
220 daemon_config.daemon.summary_model = Some(model);
221 }
222 if let Some(mode) = summary_override.content_mode.clone() {
223 daemon_config.daemon.summary_content_mode = mode;
224 }
225 if let Some(enabled) = summary_override.disk_cache_enabled {
226 daemon_config.daemon.summary_disk_cache_enabled = enabled;
227 }
228 if let Some(endpoint) = summary_override.openai_compat_endpoint.clone() {
229 daemon_config.daemon.summary_openai_compat_endpoint = Some(endpoint);
230 }
231 if let Some(base) = summary_override.openai_compat_base.clone() {
232 daemon_config.daemon.summary_openai_compat_base = Some(base);
233 }
234 if let Some(path) = summary_override.openai_compat_path.clone() {
235 daemon_config.daemon.summary_openai_compat_path = Some(path);
236 }
237 if let Some(style) = summary_override.openai_compat_style.clone() {
238 daemon_config.daemon.summary_openai_compat_style = Some(style);
239 }
240 if let Some(key) = summary_override.openai_compat_api_key.clone() {
241 daemon_config.daemon.summary_openai_compat_key = Some(key);
242 }
243 if let Some(key_header) = summary_override.openai_compat_api_key_header.clone() {
244 daemon_config.daemon.summary_openai_compat_key_header = Some(key_header);
245 }
246}
247
248fn is_local_url(url: &str) -> bool {
249 let lower = url.to_lowercase();
250 lower.contains("localhost")
251 || lower.contains("127.0.0.1")
252 || lower.contains("192.168.")
253 || lower.contains("10.")
254 || lower.contains("172.16.")
255}
256
257fn build_server_info(config: &config::DaemonConfig) -> Option<ServerInfo> {
258 if config.server.url.is_empty() {
259 return None;
260 }
261
262 let home = std::env::var("HOME")
264 .or_else(|_| std::env::var("USERPROFILE"))
265 .unwrap_or_default();
266 let state_path = PathBuf::from(&home)
267 .join(".config")
268 .join("opensession")
269 .join("state.json");
270
271 let last_upload = std::fs::read_to_string(&state_path)
272 .ok()
273 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
274 .and_then(|v| {
275 v.get("uploaded")?
276 .as_object()?
277 .values()
278 .filter_map(|v| v.as_str().map(String::from))
279 .max()
280 });
281
282 Some(ServerInfo {
283 url: config.server.url.clone(),
284 status: ServerStatus::Unknown,
285 last_upload,
286 })
287}
288
289fn parse_cached_datetime(value: &str) -> chrono::DateTime<chrono::Utc> {
290 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
291 return dt.with_timezone(&chrono::Utc);
292 }
293 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
294 return dt.and_utc();
295 }
296 chrono::DateTime::<chrono::Utc>::from(std::time::SystemTime::UNIX_EPOCH)
297}
298
299fn parse_cached_tags(tags: Option<&str>) -> Vec<String> {
300 let Some(raw) = tags.map(str::trim).filter(|v| !v.is_empty()) else {
301 return Vec::new();
302 };
303 if let Ok(json_tags) = serde_json::from_str::<Vec<String>>(raw) {
304 return json_tags
305 .into_iter()
306 .map(|tag| tag.trim().to_string())
307 .filter(|tag| !tag.is_empty())
308 .collect();
309 }
310 raw.split_whitespace()
311 .map(|tag| tag.trim_start_matches('#').to_string())
312 .filter(|tag| !tag.is_empty())
313 .collect()
314}
315
316fn session_from_cached_row(row: &LocalSessionRow) -> Session {
317 let created_at = parse_cached_datetime(&row.created_at);
318 let mut session = Session::new(
319 row.id.clone(),
320 Agent {
321 provider: row
322 .agent_provider
323 .as_deref()
324 .filter(|v| !v.trim().is_empty())
325 .unwrap_or("unknown")
326 .to_string(),
327 model: row
328 .agent_model
329 .as_deref()
330 .filter(|v| !v.trim().is_empty())
331 .unwrap_or("unknown")
332 .to_string(),
333 tool: row.tool.clone(),
334 tool_version: None,
335 },
336 );
337
338 let mut attributes: HashMap<String, serde_json::Value> = HashMap::new();
339 if let Some(source_path) = row.source_path.as_deref().filter(|v| !v.trim().is_empty()) {
340 attributes.insert(
341 "source_path".to_string(),
342 serde_json::Value::String(source_path.to_string()),
343 );
344 }
345 if let Some(working_directory) = row
346 .working_directory
347 .as_deref()
348 .filter(|v| !v.trim().is_empty())
349 {
350 attributes.insert(
351 "working_directory".to_string(),
352 serde_json::Value::String(working_directory.to_string()),
353 );
354 }
355 if let Some(nickname) = row.nickname.as_deref().filter(|v| !v.trim().is_empty()) {
356 attributes.insert(
357 "nickname".to_string(),
358 serde_json::Value::String(nickname.to_string()),
359 );
360 }
361 if let Some(user_id) = row.user_id.as_deref().filter(|v| !v.trim().is_empty()) {
362 attributes.insert(
363 "user_id".to_string(),
364 serde_json::Value::String(user_id.to_string()),
365 );
366 }
367 if let Some(team_id) = row.team_id.as_deref().filter(|v| !v.trim().is_empty()) {
368 attributes.insert(
369 "team_id".to_string(),
370 serde_json::Value::String(team_id.to_string()),
371 );
372 }
373 if let Some(git_repo) = row
374 .git_repo_name
375 .as_deref()
376 .filter(|v| !v.trim().is_empty())
377 {
378 attributes.insert(
379 "git_repo_name".to_string(),
380 serde_json::Value::String(git_repo.to_string()),
381 );
382 }
383 if let Some(git_branch) = row.git_branch.as_deref().filter(|v| !v.trim().is_empty()) {
384 attributes.insert(
385 "git_branch".to_string(),
386 serde_json::Value::String(git_branch.to_string()),
387 );
388 }
389
390 session.context = SessionContext {
391 title: row.title.clone(),
392 description: row.description.clone(),
393 tags: parse_cached_tags(row.tags.as_deref()),
394 created_at,
395 updated_at: created_at,
396 related_session_ids: Vec::new(),
397 attributes,
398 };
399 session.stats = Stats {
400 event_count: row.event_count.max(0) as u64,
401 message_count: row.message_count.max(0) as u64,
402 tool_call_count: 0,
403 task_count: row.task_count.max(0) as u64,
404 duration_seconds: row.duration_seconds.max(0) as u64,
405 total_input_tokens: row.total_input_tokens.max(0) as u64,
406 total_output_tokens: row.total_output_tokens.max(0) as u64,
407 user_message_count: row.user_message_count.max(0) as u64,
408 files_changed: 0,
409 lines_added: 0,
410 lines_removed: 0,
411 };
412 session
413}
414
415fn load_cached_sessions_from_db(db: &LocalDb) -> Vec<Session> {
416 let rows = db
417 .list_sessions(&LocalSessionFilter::default())
418 .unwrap_or_default();
419 let mut sessions: Vec<Session> = rows
420 .iter()
421 .map(session_from_cached_row)
422 .filter(|session| !App::is_internal_summary_session(session))
423 .collect();
424 sessions.sort_by(|a, b| b.context.created_at.cmp(&a.context.created_at));
425 sessions
426}
427
428fn refresh_discovery_on_start() -> bool {
429 let Ok(raw) = std::env::var("OPS_TUI_REFRESH_DISCOVERY_ON_START") else {
430 return false;
431 };
432 matches!(
433 raw.trim().to_ascii_lowercase().as_str(),
434 "1" | "true" | "yes" | "on"
435 )
436}
437
438async fn check_health(server_url: &str) -> ServerStatus {
439 let client = match opensession_api_client::ApiClient::new(server_url, Duration::from_secs(1)) {
440 Ok(c) => c,
441 Err(_) => return ServerStatus::Offline,
442 };
443 match client.health().await {
444 Ok(resp) => ServerStatus::Online(resp.version),
445 Err(_) => ServerStatus::Offline,
446 }
447}
448
449fn cache_sessions_to_db(db: &LocalDb, sessions: &[LoadedSession]) {
452 let existing = db.existing_session_ids();
453
454 for item in sessions {
455 let session = &item.session;
456 let source = item.source_path.to_string_lossy();
457
458 if App::is_internal_summary_session(session) {
459 continue;
460 }
461
462 if existing.contains(&session.session_id) {
463 let _ = db.update_session_stats(session);
465 let _ = db.set_session_sync_path(&session.session_id, &source);
466 continue;
467 }
468
469 let cwd = session
471 .context
472 .attributes
473 .get("cwd")
474 .or_else(|| session.context.attributes.get("working_directory"))
475 .and_then(|v| v.as_str().map(String::from));
476 let git = cwd.as_deref().map(extract_git_context).unwrap_or_default();
477
478 let _ = db.upsert_local_session(session, &source, &git);
479 }
480}
481
482fn event_loop(
483 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
484 app: &mut App,
485 bg_rx: mpsc::Receiver<BgEvent>,
486 auto_enter_detail: bool,
487) -> Result<()> {
488 let rt = tokio::runtime::Runtime::new()?;
489 let (summary_tx, summary_rx) = mpsc::channel::<async_ops::CommandResult>();
490 let mut auto_enter_detail_pending = auto_enter_detail;
491
492 loop {
493 while let Ok(ev) = bg_rx.try_recv() {
495 match ev {
496 BgEvent::SessionsLoaded(sessions) => {
497 app.sessions = sessions
498 .into_iter()
499 .filter(|session| !App::is_internal_summary_session(session))
500 .collect();
501 app.rebuild_session_agent_metrics();
502 app.filtered_sessions = (0..app.sessions.len()).collect();
503 app.rebuild_available_tools();
504 if !app.sessions.is_empty() {
505 app.list_state.select(Some(0));
506 }
507 app.loading_sessions = false;
508 }
509 BgEvent::DbReady { repos, count } => {
510 app.repos = repos;
511 app.startup_status.sessions_cached = count;
512 app.startup_status.repos_detected = app.repos.len();
513 }
514 }
515 }
516
517 if auto_enter_detail_pending && !app.loading_sessions && !app.sessions.is_empty() {
518 app.enter_detail_for_startup();
519 auto_enter_detail_pending = false;
520 }
521
522 if app.focus_detail_view
523 && !matches!(app.view, View::SessionDetail | View::Help | View::Setup)
524 && !app.loading_sessions
525 && !app.sessions.is_empty()
526 {
527 if app.list_state.selected().is_none() {
528 app.list_state.select(Some(0));
529 }
530 app.enter_detail_for_startup();
531 }
532
533 while let Ok(result) = summary_rx.try_recv() {
535 app.apply_command_result(result);
536 }
537
538 if let Some(cmd) = app.pending_command.take() {
540 let result = rt.block_on(async_ops::execute(cmd, &app.daemon_config));
541 app.apply_command_result(result);
542 }
543
544 if app.login_state.loading {
547 app.pending_command = Some(async_ops::AsyncCommand::Login {
548 email: app.login_state.email.clone(),
549 password: app.login_state.password.clone(),
550 });
551 }
552
553 if let Some(ref popup) = app.upload_popup {
555 if matches!(popup.phase, UploadPhase::FetchingTeams) {
556 app.pending_command = Some(async_ops::AsyncCommand::FetchUploadTeams);
557 }
558 }
559
560 if let Some(ref popup) = app.upload_popup {
562 if matches!(popup.phase, UploadPhase::Uploading) {
563 let uploaded_names: Vec<_> =
565 popup.results.iter().map(|(name, _)| name.clone()).collect();
566 let next_target = popup
567 .teams
568 .iter()
569 .enumerate()
570 .find(|(i, t)| popup.checked[*i] && !uploaded_names.contains(&t.name));
571
572 if let Some((_idx, team)) = next_target {
573 let team_id = if team.is_personal {
574 None
575 } else {
576 Some(team.id.clone())
577 };
578 let team_name = team.name.clone();
579 let is_personal = team.is_personal;
580
581 let session_clone = app.selected_session().cloned();
582
583 if let Some(session) = session_clone {
584 let body_url = if is_personal {
585 try_git_store(&session, &app.daemon_config)
586 } else {
587 None
588 };
589
590 let session_json = serde_json::to_value(&session).ok();
591 if let Some(json) = session_json {
592 app.pending_command = Some(async_ops::AsyncCommand::UploadSession {
593 session_json: json,
594 team_id,
595 team_name,
596 body_url,
597 });
598 }
599 } else if let Some(ref mut popup) = app.upload_popup {
600 popup.status = Some("No session selected".to_string());
601 popup.phase = UploadPhase::Done;
602 }
603 } else if let Some(ref mut popup) = app.upload_popup {
604 popup.phase = UploadPhase::Done;
606 popup.status = None;
607 }
608 }
609 }
610
611 if let Some(cmd) = app.pending_command.take() {
613 let result = rt.block_on(async_ops::execute(cmd, &app.daemon_config));
614 app.apply_command_result(result);
615 }
616
617 if let Some(path) = app.take_detail_hydrate_path() {
619 if let Some(reloaded) = parse_single_session(&path) {
620 app.apply_reloaded_session(reloaded);
621 }
622 }
623
624 if let Some(path) = app.take_realtime_reload_path() {
626 if let Some(reloaded) = parse_single_session(&path) {
627 app.apply_reloaded_session(reloaded);
628 }
629 }
630
631 terminal.draw(|frame| ui::render(frame, app))?;
632
633 if !app.health_check_done {
635 app.health_check_done = true;
636 if let Some(ref mut info) = app.server_info {
637 if is_local_url(&info.url) {
638 let url = info.url.clone();
639 info.status = rt.block_on(check_health(&url));
640 }
641 }
642 }
643
644 let mut handled_key_press = false;
645 if event::poll(Duration::from_millis(100))? {
646 if let Event::Key(key) = event::read()? {
647 if key.kind != KeyEventKind::Press {
648 continue;
649 }
650 handled_key_press = true;
651 if app.handle_key(key.code) {
652 break;
653 }
654 }
655 }
656
657 if !handled_key_press {
660 if let Some(cmd) = app.schedule_detail_summary_jobs() {
661 spawn_summary_worker(cmd, app.daemon_config.clone(), summary_tx.clone());
662 }
663 }
664 }
665 Ok(())
666}
667
668fn spawn_summary_worker(
669 cmd: async_ops::AsyncCommand,
670 config: config::DaemonConfig,
671 tx: mpsc::Sender<async_ops::CommandResult>,
672) {
673 std::thread::spawn(move || {
674 let result = match cmd {
675 async_ops::AsyncCommand::GenerateTimelineSummary {
676 key,
677 epoch,
678 provider,
679 context,
680 agent_tool,
681 } => {
682 let runtime = tokio::runtime::Builder::new_current_thread()
683 .enable_all()
684 .build();
685 match runtime {
686 Ok(rt) => rt.block_on(async_ops::execute(
687 async_ops::AsyncCommand::GenerateTimelineSummary {
688 key,
689 epoch,
690 provider,
691 context,
692 agent_tool,
693 },
694 &config,
695 )),
696 Err(err) => async_ops::CommandResult::SummaryDone {
697 key,
698 epoch,
699 result: Box::new(Err(format!("failed to start summary runtime: {err}"))),
700 },
701 }
702 }
703 _ => return,
704 };
705 let _ = tx.send(result);
706 });
707}
708
709fn try_git_store(
715 session: &opensession_core::trace::Session,
716 config: &config::DaemonConfig,
717) -> Option<String> {
718 if !matches!(
719 config.git_storage.method,
720 config::GitStorageMethod::PlatformApi
721 ) {
722 return None;
723 }
724
725 if config.git_storage.token.is_empty() {
726 return None;
727 }
728
729 let cwd = session
731 .context
732 .attributes
733 .get("cwd")
734 .or_else(|| session.context.attributes.get("working_directory"))
735 .and_then(|v| v.as_str())?;
736
737 let git_ctx = opensession_local_db::git::extract_git_context(cwd);
739 let remote_url = git_ctx.remote?;
740
741 let jsonl = session.to_jsonl().ok()?;
742
743 let storage = platform_api_storage::PlatformApiStorage::new(config.git_storage.token.clone());
744 match storage.store(&remote_url, &session.session_id, jsonl.as_bytes()) {
745 Ok(url) => Some(url),
746 Err(e) => {
747 eprintln!("git storage: {e}");
748 None
749 }
750 }
751}
752
753fn load_from_paths(args: &[String]) -> Vec<LoadedSession> {
755 let parsers = opensession_parsers::all_parsers();
756 let mut sessions = Vec::new();
757
758 for arg in args {
759 let path = PathBuf::from(arg);
760 if !path.exists() {
761 eprintln!("Warning: file not found: {}", path.display());
762 continue;
763 }
764 if let Some(parser) = parsers.iter().find(|p| p.can_parse(&path)) {
765 match parser.parse(&path) {
766 Ok(session) => {
767 if session.stats.event_count > 0 && !App::is_internal_summary_session(&session)
768 {
769 sessions.push(LoadedSession {
770 source_path: path.clone(),
771 session,
772 });
773 } else if session.stats.event_count == 0 {
774 eprintln!(
775 "Warning: skipping empty session from {} ({})",
776 path.display(),
777 parser.name()
778 );
779 }
780 }
781 Err(e) => eprintln!("Warning: failed to parse {}: {}", path.display(), e),
782 }
783 } else {
784 eprintln!("Warning: no parser for {}", path.display());
785 }
786 }
787
788 sessions.sort_by(|a, b| {
789 b.session
790 .context
791 .created_at
792 .cmp(&a.session.context.created_at)
793 });
794 sessions
795}
796
797fn is_hidden_opencode_child_session(session: &opensession_core::trace::Session) -> bool {
798 if session.agent.tool != "opencode" {
799 return false;
800 }
801
802 if !session.context.related_session_ids.is_empty() {
803 return true;
804 }
805
806 if session
807 .context
808 .attributes
809 .iter()
810 .any(|(key, value)| opencode_parent_session_id(value, key))
811 {
812 return true;
813 }
814
815 let session_id = session.session_id.to_ascii_lowercase();
816 if session_id.starts_with("agent-") || session_id.starts_with("agent_") {
817 return true;
818 }
819
820 if session.stats.user_message_count == 0
821 && session.stats.message_count <= 4
822 && session.stats.task_count <= 4
823 && session.stats.event_count > 0
824 && session.stats.event_count <= 16
825 {
826 return true;
827 }
828
829 if let Some(path) = session.context.attributes.get("source_path") {
830 let path = path.as_str().unwrap_or_default().to_ascii_lowercase();
831 if path.contains("/subagents/") || path.contains("\\subagents\\") {
832 return true;
833 }
834 }
835
836 false
837}
838
839fn opencode_parent_session_id(value: &serde_json::Value, key: &str) -> bool {
840 if let Some(parent_id) = value.as_str() {
841 let compact_key = key
842 .chars()
843 .filter(|c| c.is_ascii_alphanumeric())
844 .collect::<String>()
845 .to_ascii_lowercase();
846 if !parent_id.trim().is_empty() {
847 return matches!(
848 compact_key.as_str(),
849 "parentsessionid" | "parentid" | "parentuuid"
850 );
851 }
852 }
853 false
854}
855
856fn is_hidden_claude_code_child_session(session: &opensession_core::trace::Session) -> bool {
857 if session.agent.tool != "claude-code" {
858 return false;
859 }
860
861 if !session.context.related_session_ids.is_empty() {
862 return true;
863 }
864
865 let Some(path) = session.context.attributes.get("source_path") else {
866 return false;
867 };
868 let Some(path) = path.as_str() else {
869 return false;
870 };
871
872 opensession_parsers::claude_code::is_claude_subagent_path(std::path::Path::new(path))
873}
874
875fn is_hidden_codex_child_session(session: &opensession_core::trace::Session) -> bool {
876 if session.agent.tool != "codex" {
877 return false;
878 }
879
880 session.stats.user_message_count == 0
881}
882
883fn filter_visible_discovered_sessions(
884 sessions: Vec<opensession_core::trace::Session>,
885) -> Vec<opensession_core::trace::Session> {
886 sessions
887 .into_iter()
888 .filter(|session| {
889 !is_hidden_opencode_child_session(session)
890 && !is_hidden_codex_child_session(session)
891 && !is_hidden_claude_code_child_session(session)
892 })
893 .collect()
894}
895
896fn load_sessions() -> Vec<LoadedSession> {
898 let locations = opensession_parsers::discover::discover_sessions();
899 let parsers = opensession_parsers::all_parsers();
900 let mut sessions = Vec::new();
901
902 for location in &locations {
903 for path in &location.paths {
904 if opensession_parsers::claude_code::is_claude_subagent_path(path) {
906 continue;
907 }
908
909 if let Some(parser) = parsers.iter().find(|p| p.can_parse(path)) {
910 if let Ok(session) = parser.parse(path) {
911 if session.stats.event_count > 0 && !App::is_internal_summary_session(&session)
913 {
914 sessions.push(LoadedSession {
915 source_path: path.clone(),
916 session,
917 });
918 }
919 }
920 }
921 }
922 }
923
924 sessions.sort_by(|a, b| {
925 b.session
926 .context
927 .created_at
928 .cmp(&a.session.context.created_at)
929 });
930 sessions
931}
932
933fn parse_single_session(path: &Path) -> Option<opensession_core::trace::Session> {
934 let parsers = opensession_parsers::all_parsers();
935 let parser = parsers.iter().find(|p| p.can_parse(path))?;
936 let session = parser.parse(path).ok()?;
937 if session.stats.event_count == 0 || App::is_internal_summary_session(&session) {
938 return None;
939 }
940 Some(session)
941}
942
943#[cfg(test)]
944mod tests {
945 use super::{
946 apply_summary_launch_override, is_hidden_codex_child_session,
947 is_hidden_opencode_child_session, SummaryLaunchOverride,
948 };
949 use chrono::Utc;
950 use opensession_core::trace::{Agent, Session, SessionContext};
951 use serde_json::json;
952
953 #[test]
954 fn summary_override_updates_runtime_config_only() {
955 let mut cfg = crate::config::DaemonConfig::default();
956 let override_cfg = SummaryLaunchOverride {
957 provider: Some("cli:codex".to_string()),
958 model: Some("gpt-4o-mini".to_string()),
959 content_mode: Some("minimal".to_string()),
960 disk_cache_enabled: Some(false),
961 openai_compat_endpoint: Some("https://example.com/v1/chat/completions".to_string()),
962 openai_compat_base: Some("https://example.com/v1".to_string()),
963 openai_compat_path: Some("/chat/completions".to_string()),
964 openai_compat_style: Some("chat".to_string()),
965 openai_compat_api_key: Some("test-key".to_string()),
966 openai_compat_api_key_header: Some("Authorization".to_string()),
967 };
968
969 apply_summary_launch_override(&mut cfg, &override_cfg);
970
971 assert_eq!(cfg.daemon.summary_provider.as_deref(), Some("cli:codex"));
972 assert_eq!(cfg.daemon.summary_model.as_deref(), Some("gpt-4o-mini"));
973 assert_eq!(cfg.daemon.summary_content_mode, "minimal");
974 assert!(!cfg.daemon.summary_disk_cache_enabled);
975 assert_eq!(
976 cfg.daemon.summary_openai_compat_endpoint.as_deref(),
977 Some("https://example.com/v1/chat/completions")
978 );
979 assert_eq!(
980 cfg.daemon.summary_openai_compat_base.as_deref(),
981 Some("https://example.com/v1")
982 );
983 assert_eq!(
984 cfg.daemon.summary_openai_compat_path.as_deref(),
985 Some("/chat/completions")
986 );
987 assert_eq!(
988 cfg.daemon.summary_openai_compat_style.as_deref(),
989 Some("chat")
990 );
991 assert_eq!(
992 cfg.daemon.summary_openai_compat_key.as_deref(),
993 Some("test-key")
994 );
995 assert_eq!(
996 cfg.daemon.summary_openai_compat_key_header.as_deref(),
997 Some("Authorization")
998 );
999 }
1000
1001 fn make_opencode_session(session_id: &str, related_session_ids: Vec<&str>) -> Session {
1002 let mut session = Session::new(
1003 session_id.to_string(),
1004 Agent {
1005 provider: "provider".to_string(),
1006 model: "model".to_string(),
1007 tool: "opencode".to_string(),
1008 tool_version: None,
1009 },
1010 );
1011 session.context = SessionContext {
1012 created_at: Utc::now(),
1013 updated_at: Utc::now(),
1014 related_session_ids: related_session_ids
1015 .into_iter()
1016 .map(|s| s.to_string())
1017 .collect(),
1018 ..SessionContext::default()
1019 };
1020 session
1021 }
1022
1023 fn make_codex_session(session_id: &str, tool: &str, user_message_count: u64) -> Session {
1024 let mut session = Session::new(
1025 session_id.to_string(),
1026 Agent {
1027 provider: "provider".to_string(),
1028 model: "model".to_string(),
1029 tool: tool.to_string(),
1030 tool_version: None,
1031 },
1032 );
1033 session.stats.user_message_count = user_message_count;
1034 session
1035 }
1036
1037 #[test]
1038 fn opencode_child_session_is_hidden_in_discovery_list() {
1039 let child = make_opencode_session("ses_child", vec!["ses_parent"]);
1040 let parent = make_opencode_session("ses_parent", vec![]);
1041 assert!(is_hidden_opencode_child_session(&child));
1042 assert!(!is_hidden_opencode_child_session(&parent));
1043 }
1044
1045 #[test]
1046 fn opencode_zero_user_message_short_session_is_hidden_in_discovery_list() {
1047 let mut child = make_opencode_session("ses_short", vec![]);
1048 child.stats.user_message_count = 0;
1049 child.stats.event_count = 4;
1050 child.stats.message_count = 0;
1051 child.stats.task_count = 0;
1052
1053 let mut parent = make_opencode_session("ses_visible", vec![]);
1054 parent.stats.user_message_count = 2;
1055 parent.stats.message_count = 3;
1056 parent.stats.event_count = 40;
1057
1058 assert!(is_hidden_opencode_child_session(&child));
1059 assert!(!is_hidden_opencode_child_session(&parent));
1060 }
1061
1062 #[test]
1063 fn opencode_session_with_parent_id_attr_alias_is_hidden_in_discovery_list() {
1064 let mut child = make_opencode_session("ses_short", vec![]);
1065 child
1066 .context
1067 .attributes
1068 .insert("parentSessionId".to_string(), json!("ses_parent_alias"));
1069 assert!(is_hidden_opencode_child_session(&child));
1070 }
1071
1072 #[test]
1073 fn codex_session_without_user_message_is_hidden_in_discovery_list() {
1074 let summary_session = make_codex_session("summary", "codex", 0);
1075 let normal_session = make_codex_session("normal", "codex", 1);
1076
1077 assert!(is_hidden_codex_child_session(&summary_session));
1078 assert!(!is_hidden_codex_child_session(&normal_session));
1079 }
1080}