1use std::time::Instant;
9
10use cinch_rs::ui::{AgentEntry, LogLine, UiState, UserQuestion};
11use serde::Serialize;
12
13const SNAPSHOT_MAX_LOGS: usize = 200;
15
16#[derive(Debug, Serialize)]
21pub struct UiStateSnapshot {
22 pub phase: String,
24 pub round: u32,
25 pub max_rounds: u32,
26 pub context_pct: f64,
27 pub model: String,
28 pub cycle: u32,
29
30 pub agent_output: Vec<AgentEntry>,
32 pub streaming_buffer: String,
33
34 pub logs: Vec<LogLine>,
36
37 pub running: bool,
39
40 pub next_cycle_secs: Option<f64>,
43
44 pub active_question: Option<ActiveQuestionSnapshot>,
46
47 pub extension: Option<serde_json::Value>,
49}
50
51#[derive(Debug, Serialize)]
53pub struct ActiveQuestionSnapshot {
54 pub question: UserQuestion,
55 pub remaining_secs: Option<f64>,
57 pub done: bool,
58}
59
60impl UiStateSnapshot {
61 pub fn from_ui_state(state: &UiState) -> Self {
66 let now = Instant::now();
67
68 let next_cycle_secs = state.next_cycle_at.map(|t| {
69 if t > now {
70 t.duration_since(now).as_secs_f64()
71 } else {
72 0.0
73 }
74 });
75
76 let active_question = state.active_question.as_ref().map(|aq| {
77 let remaining_secs = aq.deadline.map(|d| {
78 if d > now {
79 d.duration_since(now).as_secs_f64()
80 } else {
81 0.0
82 }
83 });
84
85 ActiveQuestionSnapshot {
86 question: aq.question.clone(),
87 remaining_secs,
88 done: aq.done,
89 }
90 });
91
92 let log_start = state.logs.len().saturating_sub(SNAPSHOT_MAX_LOGS);
94 let logs: Vec<LogLine> = state.logs[log_start..].to_vec();
95
96 Self {
97 phase: state.phase.clone(),
98 round: state.round,
99 max_rounds: state.max_rounds,
100 context_pct: state.context_pct,
101 model: state.model.clone(),
102 cycle: state.cycle,
103 agent_output: state.agent_output.clone(),
104 streaming_buffer: state.streaming_buffer.clone(),
105 logs,
106 running: state.running,
107 next_cycle_secs,
108 active_question,
109 extension: state.extensions.to_json(),
110 }
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn snapshot_from_default_state() {
120 let state = UiState::default();
121 let snap = UiStateSnapshot::from_ui_state(&state);
122
123 assert_eq!(snap.phase, "Initializing");
124 assert_eq!(snap.round, 0);
125 assert_eq!(snap.max_rounds, 0);
126 assert!(snap.running);
127 assert!(snap.agent_output.is_empty());
128 assert!(snap.logs.is_empty());
129 assert!(snap.active_question.is_none());
130 assert!(snap.next_cycle_secs.is_none());
131 assert!(snap.extension.is_none());
132 }
133
134 #[test]
135 fn snapshot_serializes_to_json() {
136 let state = UiState::default();
137 let snap = UiStateSnapshot::from_ui_state(&state);
138
139 let json = serde_json::to_value(&snap).unwrap();
140 assert_eq!(json["phase"], "Initializing");
141 assert_eq!(json["running"], true);
142 assert!(json["next_cycle_secs"].is_null());
143 }
144
145 #[test]
146 fn snapshot_caps_logs() {
147 let mut state = UiState::default();
148 for i in 0..300 {
149 state.logs.push(LogLine {
150 time: format!("{i:03}"),
151 level: cinch_rs::ui::LogLevel::Info,
152 message: format!("msg {i}"),
153 });
154 }
155
156 let snap = UiStateSnapshot::from_ui_state(&state);
157 assert_eq!(snap.logs.len(), 200);
158 assert_eq!(snap.logs[0].time, "100");
160 assert_eq!(snap.logs[199].time, "299");
161 }
162}