Skip to main content

opensession_tui/
cli_export.rs

1use anyhow::Result;
2use opensession_core::trace::{ContentBlock, Event, EventType, Session};
3use serde::Serialize;
4use std::time::{Duration, Instant};
5use tokio::runtime::{Handle, Runtime};
6use tokio::task::block_in_place;
7
8use crate::app::{extract_turns, App, DetailViewMode, DisplayEvent, View};
9use crate::async_ops::{self, CommandResult};
10use crate::session_timeline::LaneMarker;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CliTimelineView {
14    Linear,
15    Turn,
16}
17
18#[derive(Debug, Clone)]
19pub struct CliTimelineExportOptions {
20    pub view: CliTimelineView,
21    pub collapse_consecutive: bool,
22    pub include_summaries: bool,
23    pub generate_summaries: bool,
24    pub summary_provider_override: Option<String>,
25    pub summary_content_mode_override: Option<String>,
26    pub summary_disk_cache_override: Option<bool>,
27    pub max_rows: Option<usize>,
28    pub summary_budget: Option<usize>,
29    pub summary_timeout_ms: Option<u64>,
30}
31
32impl Default for CliTimelineExportOptions {
33    fn default() -> Self {
34        Self {
35            view: CliTimelineView::Linear,
36            collapse_consecutive: false,
37            include_summaries: true,
38            generate_summaries: false,
39            summary_provider_override: None,
40            summary_content_mode_override: None,
41            summary_disk_cache_override: None,
42            max_rows: None,
43            summary_budget: None,
44            summary_timeout_ms: None,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct CliTimelineExport {
51    pub session_id: String,
52    pub tool: String,
53    pub model: String,
54    pub total_events: usize,
55    pub rendered_rows: usize,
56    pub max_active_agents: usize,
57    pub max_lane_index: usize,
58    pub generated_summaries: usize,
59    pub lines: Vec<String>,
60}
61
62pub fn export_session_timeline(
63    session: Session,
64    options: CliTimelineExportOptions,
65) -> Result<CliTimelineExport> {
66    let mut app = App::new(vec![session]);
67    app.view = View::SessionDetail;
68    if app.filtered_sessions.is_empty() && !app.sessions.is_empty() {
69        app.filtered_sessions = vec![0];
70    }
71    app.list_state.select(Some(0));
72    app.daemon_config = crate::config::load_daemon_config();
73    app.collapse_consecutive = options.collapse_consecutive;
74    app.detail_view_mode = match options.view {
75        CliTimelineView::Linear => DetailViewMode::Linear,
76        CliTimelineView::Turn => DetailViewMode::Turn,
77    };
78    app.detail_viewport_height = u16::MAX;
79    app.detail_event_index = 0;
80    app.realtime_preview_enabled = app.daemon_config.daemon.detail_realtime_preview_enabled;
81    // CLI export is non-interactive; skip detail warmup gating so summary jobs can run immediately.
82    app.detail_entered_at = Instant::now() - Duration::from_secs(1);
83
84    if !options.include_summaries {
85        app.daemon_config.daemon.summary_enabled = false;
86    }
87    if let Some(provider) = options.summary_provider_override {
88        app.daemon_config.daemon.summary_provider = Some(provider);
89    }
90    if let Some(mode) = options.summary_content_mode_override {
91        app.daemon_config.daemon.summary_content_mode = mode;
92    }
93    if let Some(enabled) = options.summary_disk_cache_override {
94        app.daemon_config.daemon.summary_disk_cache_enabled = enabled;
95    }
96
97    let mut generated_summaries = 0usize;
98    if options.include_summaries
99        && options.generate_summaries
100        && app.daemon_config.daemon.summary_enabled
101    {
102        // Keep CLI export responsive on large sessions by default.
103        let summary_budget = options.summary_budget.unwrap_or(96).max(1);
104        let summary_timeout = match options.summary_timeout_ms {
105            Some(0) => None,
106            Some(ms) => Some(Duration::from_millis(ms.max(200))),
107            None => Some(Duration::from_millis(12_000)),
108        };
109        let loop_started = Instant::now();
110        let mut owned_runtime = if Handle::try_current().is_err() {
111            Some(Runtime::new()?)
112        } else {
113            None
114        };
115        // Drive the same scheduler used by TUI until queue drains (or guard trips).
116        let mut idle_ticks = 0u32;
117        for _ in 0..4096 {
118            if generated_summaries >= summary_budget {
119                break;
120            }
121            if let Some(timeout) = summary_timeout {
122                if loop_started.elapsed() >= timeout {
123                    break;
124                }
125            }
126            if let Some(cmd) = app.schedule_detail_summary_jobs() {
127                let result = if let Some(rt) = owned_runtime.as_mut() {
128                    rt.block_on(async_ops::execute(cmd, &app.daemon_config))
129                } else {
130                    let handle = Handle::current();
131                    block_in_place(|| handle.block_on(async_ops::execute(cmd, &app.daemon_config)))
132                };
133                if matches!(result, CommandResult::SummaryDone { .. }) {
134                    generated_summaries += 1;
135                }
136                app.apply_command_result(result);
137                idle_ticks = 0;
138                continue;
139            }
140
141            if app.timeline_summary_pending.is_empty() && app.timeline_summary_inflight.is_empty() {
142                break;
143            }
144
145            // Scheduler can defer background anchors; give it short ticks.
146            std::thread::sleep(Duration::from_millis(50));
147            idle_ticks += 1;
148            if idle_ticks > 80 {
149                break;
150            }
151        }
152    }
153
154    let selected = app
155        .selected_session()
156        .cloned()
157        .expect("single-session app must have selected session");
158
159    let base = app.get_base_visible_events(&selected);
160    let max_lane_index = base
161        .iter()
162        .flat_map(|de| {
163            de.active_lanes()
164                .iter()
165                .copied()
166                .chain(std::iter::once(de.lane()))
167        })
168        .max()
169        .unwrap_or(0);
170    let max_active_agents = base
171        .iter()
172        .map(|de| de.active_lanes().iter().filter(|lane| **lane > 0).count())
173        .max()
174        .unwrap_or(0);
175
176    let mut lines = match options.view {
177        CliTimelineView::Linear => {
178            let visible = if options.include_summaries {
179                app.get_visible_events(&selected)
180            } else {
181                base.clone()
182            };
183            render_linear_lines(&visible)
184        }
185        CliTimelineView::Turn => render_turn_lines(&app, &selected.session_id, &base),
186    };
187
188    if let Some(max_rows) = options.max_rows {
189        lines.truncate(max_rows);
190    }
191
192    Ok(CliTimelineExport {
193        session_id: selected.session_id.clone(),
194        tool: selected.agent.tool.clone(),
195        model: selected.agent.model.clone(),
196        total_events: selected.events.len(),
197        rendered_rows: lines.len(),
198        max_active_agents,
199        max_lane_index,
200        generated_summaries,
201        lines,
202    })
203}
204
205fn render_linear_lines(events: &[DisplayEvent<'_>]) -> Vec<String> {
206    let max_lane = events
207        .iter()
208        .flat_map(|de| {
209            de.active_lanes()
210                .iter()
211                .copied()
212                .chain(std::iter::once(de.lane()))
213        })
214        .max()
215        .unwrap_or(0);
216    let lane_count = max_lane + 1;
217
218    let mut out = Vec::with_capacity(events.len());
219    for (idx, display_event) in events.iter().enumerate() {
220        let event = display_event.event();
221        let ts = event.timestamp.format("%H:%M:%S").to_string();
222        let lane_text = lane_cells(display_event, lane_count);
223        let body = match display_event {
224            DisplayEvent::SummaryRow {
225                summary, window_id, ..
226            } => format!("[llm #{window_id}] {summary}"),
227            DisplayEvent::Collapsed { count, kind, .. } => format!("{kind} x{count}"),
228            DisplayEvent::Single {
229                event,
230                lane,
231                marker,
232                ..
233            } => {
234                let (kind, summary) = event_display(event);
235                let mut body = format!("{kind:>10} {summary}");
236                if let Some(badge) = lane_assignment_badge(event, *lane, *marker) {
237                    body.push(' ');
238                    body.push_str(&badge);
239                }
240                body
241            }
242        };
243
244        out.push(format!("{idx:>4} {ts}  {lane_text} {body}"));
245    }
246    out
247}
248
249fn render_turn_lines(app: &App, session_id: &str, events: &[DisplayEvent<'_>]) -> Vec<String> {
250    let turns = extract_turns(events);
251    let mut out = Vec::with_capacity(turns.len());
252    for turn in turns {
253        let turn_key = App::turn_summary_key(session_id, turn.turn_index, turn.anchor_source_index);
254        let llm_summary = app
255            .timeline_summary_cache
256            .get(&turn_key)
257            .map(|entry| entry.compact.clone())
258            .unwrap_or_else(|| {
259                if !app.daemon_config.daemon.summary_enabled {
260                    "(LLM summary off)".to_string()
261                } else if app.should_skip_realtime_for_selected() {
262                    "(LLM summary ignored by Neglect Live Session rule)".to_string()
263                } else {
264                    "(LLM summary pending)".to_string()
265                }
266            });
267
268        let user_preview = turn
269            .user_events
270            .first()
271            .map(|event| event_summary(&event.event_type, &event.content.blocks))
272            .filter(|line| !line.is_empty())
273            .unwrap_or_else(|| "(no user message)".to_string());
274
275        out.push(format!(
276            "Turn {:>3} | {} agent events | user: {} | llm: {}",
277            turn.turn_index + 1,
278            turn.agent_events.len(),
279            truncate(&user_preview, 80),
280            truncate(&llm_summary, 120),
281        ));
282    }
283    out
284}
285
286fn lane_cells(event: &DisplayEvent<'_>, lane_count: usize) -> String {
287    let mut out = String::with_capacity(lane_count * 2);
288    for lane in 0..lane_count {
289        let active = event.active_lanes().contains(&lane);
290        let ch = if lane == event.lane() {
291            match event {
292                DisplayEvent::SummaryRow { .. } => 'S',
293                _ => match event.marker() {
294                    LaneMarker::Fork => '+',
295                    LaneMarker::Merge => '-',
296                    LaneMarker::None => '*',
297                },
298            }
299        } else if active {
300            '|'
301        } else {
302            ' '
303        };
304        out.push(ch);
305        if lane + 1 < lane_count {
306            out.push(' ');
307        }
308    }
309    out
310}
311
312fn event_display(event: &Event) -> (&'static str, String) {
313    let kind = match event.event_type {
314        EventType::UserMessage => "user",
315        EventType::AgentMessage => "agent",
316        EventType::SystemMessage => "system",
317        EventType::Thinking => "think",
318        EventType::ToolCall { .. } => "tool",
319        EventType::ToolResult { is_error: true, .. } => "error",
320        EventType::ToolResult { .. } => "result",
321        EventType::FileRead { .. } => "read",
322        EventType::CodeSearch { .. } => "search",
323        EventType::FileSearch { .. } => "find",
324        EventType::FileEdit { .. } => "edit",
325        EventType::FileCreate { .. } => "create",
326        EventType::FileDelete { .. } => "delete",
327        EventType::ShellCommand { .. } => "shell",
328        EventType::WebSearch { .. } => "web",
329        EventType::WebFetch { .. } => "fetch",
330        EventType::ImageGenerate { .. } => "image",
331        EventType::VideoGenerate { .. } => "video",
332        EventType::AudioGenerate { .. } => "audio",
333        EventType::TaskStart { .. } => "start",
334        EventType::TaskEnd { .. } => "end",
335        EventType::Custom { .. } => "custom",
336        _ => "other",
337    };
338    (
339        kind,
340        event_summary(&event.event_type, &event.content.blocks),
341    )
342}
343
344fn event_summary(event_type: &EventType, blocks: &[ContentBlock]) -> String {
345    match event_type {
346        EventType::UserMessage | EventType::AgentMessage => first_text_line(blocks, 96),
347        EventType::SystemMessage => String::new(),
348        EventType::Thinking => "thinking".to_string(),
349        EventType::ToolCall { name } => format!("{name}()"),
350        EventType::ToolResult { name, is_error, .. } => {
351            if *is_error {
352                format!("{name} failed")
353            } else {
354                format!("{name} ok")
355            }
356        }
357        EventType::FileRead { path } => short_path(path).to_string(),
358        EventType::CodeSearch { query } => truncate(query, 80),
359        EventType::FileSearch { pattern } => truncate(pattern, 80),
360        EventType::FileEdit { path, diff } => {
361            if let Some(d) = diff {
362                let (add, del) = count_diff_lines(d);
363                format!("{} +{} -{}", short_path(path), add, del)
364            } else {
365                short_path(path).to_string()
366            }
367        }
368        EventType::FileCreate { path } => short_path(path).to_string(),
369        EventType::FileDelete { path } => short_path(path).to_string(),
370        EventType::ShellCommand { command, exit_code } => match exit_code {
371            Some(code) => format!("{} => {}", truncate(command, 80), code),
372            None => truncate(command, 80),
373        },
374        EventType::WebSearch { query } => truncate(query, 80),
375        EventType::WebFetch { url } => truncate(url, 80),
376        EventType::ImageGenerate { prompt }
377        | EventType::VideoGenerate { prompt }
378        | EventType::AudioGenerate { prompt } => truncate(prompt, 80),
379        EventType::TaskStart { title } => format!("start {}", title.clone().unwrap_or_default()),
380        EventType::TaskEnd { summary } => format!("end {}", summary.clone().unwrap_or_default()),
381        EventType::Custom { kind } => kind.clone(),
382        _ => String::new(),
383    }
384}
385
386fn lane_assignment_badge(event: &Event, lane: usize, marker: LaneMarker) -> Option<String> {
387    if lane == 0 || marker != LaneMarker::Fork {
388        return None;
389    }
390    if !matches!(event.event_type, EventType::TaskStart { .. }) {
391        return None;
392    }
393
394    let task = event
395        .task_id
396        .as_deref()
397        .map(compact_task_id)
398        .unwrap_or_default();
399    if task.is_empty() {
400        Some(format!("[L{lane}]"))
401    } else {
402        Some(format!("[L{lane} {task}]"))
403    }
404}
405
406fn compact_task_id(task_id: &str) -> String {
407    let trimmed = task_id.trim();
408    if trimmed.is_empty() {
409        return String::new();
410    }
411    if trimmed.chars().count() <= 18 {
412        return trimmed.to_string();
413    }
414    let head: String = trimmed.chars().take(12).collect();
415    let tail: String = trimmed
416        .chars()
417        .rev()
418        .take(4)
419        .collect::<Vec<_>>()
420        .into_iter()
421        .rev()
422        .collect();
423    format!("{head}...{tail}")
424}
425
426fn first_text_line(blocks: &[ContentBlock], max_chars: usize) -> String {
427    for block in blocks {
428        if let ContentBlock::Text { text } = block {
429            if let Some(line) = text.lines().next() {
430                let trimmed = line.trim();
431                if !trimmed.is_empty() {
432                    return truncate(trimmed, max_chars);
433                }
434            }
435        }
436    }
437    String::new()
438}
439
440fn short_path(path: &str) -> &str {
441    let parts: Vec<&str> = path.rsplitn(3, '/').collect();
442    if parts.len() >= 2 {
443        let start = path.len() - parts[0].len() - parts[1].len() - 1;
444        &path[start..]
445    } else {
446        path
447    }
448}
449
450fn truncate(s: &str, max_len: usize) -> String {
451    if s.chars().count() <= max_len {
452        s.to_string()
453    } else {
454        let mut out = String::new();
455        for ch in s.chars().take(max_len.saturating_sub(1)) {
456            out.push(ch);
457        }
458        out.push('…');
459        out
460    }
461}
462
463fn count_diff_lines(diff: &str) -> (usize, usize) {
464    let mut added = 0;
465    let mut removed = 0;
466    for line in diff.lines() {
467        if line.starts_with('+') && !line.starts_with("+++") {
468            added += 1;
469        } else if line.starts_with('-') && !line.starts_with("---") {
470            removed += 1;
471        }
472    }
473    (added, removed)
474}