Skip to main content

opensession_tui/
lib.rs

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
82/// Launch the TUI. Optionally pass file paths to open specific sessions.
83pub fn run(paths: Option<Vec<String>>) -> Result<()> {
84    run_with_options(RunOptions {
85        paths,
86        ..RunOptions::default()
87    })
88}
89
90/// Launch the TUI with startup/runtime overrides.
91pub fn run_with_options(options: RunOptions) -> Result<()> {
92    run_with_options_sync(options)
93}
94
95fn run_with_options_sync(options: RunOptions) -> Result<()> {
96    // Start with empty sessions — they'll load in the background
97    let mut app = App::new(vec![]);
98    app.loading_sessions = true;
99
100    // ── Load full daemon config ──────────────────────────────────────
101    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    // Build server info from daemon config
110    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    // ── Build startup status ─────────────────────────────────────────
122    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    // ── Open main DB connection (fast — just opens SQLite file) ──────
131    if let Ok(db) = LocalDb::open() {
132        app.db = Some(Arc::new(db));
133    }
134
135    // ── Fast bootstrap from local SQLite cache (avoid full repo scan on every start) ──
136    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 file doesn't exist yet, start in Setup view ──────
153    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    // Terminal setup — show UI immediately
161    enable_raw_mode()?;
162    stdout().execute(EnterAlternateScreen)?;
163    let backend = CrosstermBackend::new(stdout());
164    let mut terminal = Terminal::new(backend)?;
165
166    // ── Spawn background session loading thread ─────────────────────
167    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            // Separate DB connection for this thread (Connection is Send but not Sync)
191            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    // Main loop
203    let result = event_loop(&mut terminal, &mut app, bg_rx, options.auto_enter_detail);
204
205    // Restore terminal
206    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    // Try to read last upload time from state.json fallback.
263    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
449/// Cache parsed sessions into the local DB with git context.
450/// Skips git extraction for sessions already in the DB (only updates stats).
451fn 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            // Already cached → only update stats and keep source path in sync.
464            let _ = db.update_session_stats(session);
465            let _ = db.set_session_sync_path(&session.session_id, &source);
466            continue;
467        }
468
469        // New session → full insert with git context extraction
470        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        // ── Poll background session loading ──────────────────────────
494        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        // ── Poll summary worker results ─────────────────────────────
534        while let Ok(result) = summary_rx.try_recv() {
535            app.apply_command_result(result);
536        }
537
538        // ── Handle pending async command ─────────────────────────────
539        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        // ── Handle upload popup async ops ────────────────────────────
545        // Login (triggered from Setup view)
546        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        // Fetch teams for upload popup
554        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        // Upload session — sequential multi-target dispatch
561        if let Some(ref popup) = app.upload_popup {
562            if matches!(popup.phase, UploadPhase::Uploading) {
563                // Find the next checked team that hasn't been uploaded yet
564                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                    // All checked teams uploaded
605                    popup.phase = UploadPhase::Done;
606                    popup.status = None;
607                }
608            }
609        }
610
611        // Process any command generated above
612        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        // ── Lazy hydrate stub sessions from source_path on first detail enter ──
618        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        // ── Realtime detail preview (mtime polling + selective reparse) ──
625        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        // ── Deferred health check (runs once, after first render) ────
634        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        // ── Timeline summary queue (non-stream sessions, visible-first) ──
658        // Keep input responsive: only schedule heavy summary work when idle.
659        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
709/// Try to store a session via platform API and return the body_url.
710///
711/// NOTE: This uses `reqwest::blocking` which must NOT be called inside
712/// `tokio::Runtime::block_on()`. Currently safe because `try_git_store`
713/// is called before the `rt.block_on(upload_session(...))` call.
714fn 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    // Get working directory from session context
730    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    // Extract remote URL via git CLI
738    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
753/// Load sessions from explicit file paths passed as CLI args.
754fn 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
896/// Auto-discover sessions from known local paths.
897fn 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            // Skip subagent session files
905            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                    // Skip empty sessions (0 events usually means parse was incomplete)
912                    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}