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 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 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 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 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}