zeph_tui/app/
transcript.rs1use tokio::sync::oneshot;
8
9use super::{
10 AgentViewTarget, App, ChatMessage, MessageRole, TRANSCRIPT_MAX_ENTRIES, TranscriptCache,
11 TuiTranscriptEntry, load_transcript_file,
12};
13
14impl App {
15 pub fn set_view_target(&mut self, target: AgentViewTarget) {
18 if self.sessions.current().view_target == target {
19 return;
20 }
21 self.sessions.current_mut().view_target = target;
22 self.sessions.current_mut().render_cache.clear();
23 self.sessions.current_mut().scroll_offset = 0;
24 self.sessions.current_mut().transcript_cache = None;
25 if self
26 .sessions
27 .current_mut()
28 .pending_transcript
29 .take()
30 .is_some()
31 {
32 self.sessions.current_mut().status_label = None;
38 }
39 if let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target {
41 let id = id.clone();
42 self.start_transcript_load(&id);
43 }
44 }
45
46 fn start_transcript_load(&mut self, agent_id: &str) {
48 let transcript_path = self
50 .metrics
51 .sub_agents
52 .iter()
53 .find(|sa| sa.id == agent_id)
54 .and_then(|sa| sa.transcript_dir.as_deref())
55 .map(|dir| std::path::PathBuf::from(dir).join(format!("{agent_id}.jsonl")));
56
57 let Some(path) = transcript_path else {
58 return;
59 };
60
61 let (tx, rx) = oneshot::channel();
62 self.sessions.current_mut().pending_transcript = Some(rx);
63 self.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
64 let is_active = self
66 .metrics
67 .sub_agents
68 .iter()
69 .find(|sa| sa.id == agent_id)
70 .is_some_and(|sa| matches!(sa.state.as_str(), "working" | "submitted"));
71
72 tokio::task::spawn_blocking(move || {
73 let result = load_transcript_file(&path, is_active);
75 let _ = tx.send(result);
76 });
77 }
78
79 pub fn poll_pending_transcript(&mut self) {
81 let Some(rx) = self.sessions.current_mut().pending_transcript.as_mut() else {
82 return;
83 };
84 match rx.try_recv() {
85 Ok((entries, total)) => {
86 self.sessions.current_mut().pending_transcript = None;
87 self.sessions.current_mut().status_label = None;
88 let turns_at_load = self
89 .sessions
90 .current()
91 .view_target
92 .subagent_id()
93 .and_then(|id| self.metrics.sub_agents.iter().find(|sa| sa.id == id))
94 .map_or(0, |sa| sa.turns_used);
95 if let AgentViewTarget::SubAgent { ref id, .. } =
96 self.sessions.current().view_target.clone()
97 {
98 self.sessions.current_mut().transcript_cache = Some(TranscriptCache {
99 agent_id: id.clone(),
100 entries,
101 turns_at_load,
102 total_in_file: total,
103 });
104 }
105 self.sessions.current_mut().render_cache.clear();
106 }
107 Err(oneshot::error::TryRecvError::Empty) => {}
108 Err(oneshot::error::TryRecvError::Closed) => {
109 self.sessions.current_mut().pending_transcript = None;
110 self.sessions.current_mut().status_label = None;
111 }
112 }
113 }
114
115 pub(super) fn maybe_reload_transcript(&mut self) {
117 let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target.clone()
118 else {
119 return;
120 };
121 if self.sessions.current().pending_transcript.is_some() {
123 return;
124 }
125 let current_turns = self
126 .metrics
127 .sub_agents
128 .iter()
129 .find(|sa| sa.id == *id)
130 .map_or(0, |sa| sa.turns_used);
131 let cached_turns = self
132 .sessions
133 .current()
134 .transcript_cache
135 .as_ref()
136 .map_or(0, |c| c.turns_at_load);
137 if current_turns > cached_turns {
138 let agent_id = id.to_owned();
139 self.start_transcript_load(&agent_id);
140 }
141 }
142
143 #[must_use]
150 pub fn visible_messages(&self) -> Vec<ChatMessage> {
151 let slot = self.sessions.current();
152 if slot.view_target.is_main() {
153 return slot.messages.clone();
154 }
155 if let Some(ref cache) = slot.transcript_cache {
156 return cache
157 .entries
158 .iter()
159 .map(TuiTranscriptEntry::to_chat_message)
160 .collect();
161 }
162 if slot.pending_transcript.is_some() {
163 return vec![ChatMessage::new(
164 MessageRole::System,
165 "Loading transcript...".to_owned(),
166 )];
167 }
168 let name = slot.view_target.subagent_name().unwrap_or("unknown");
169 vec![ChatMessage::new(
170 MessageRole::System,
171 format!("Transcript not available for {name}."),
172 )]
173 }
174
175 #[must_use]
177 pub fn transcript_truncation_info(&self) -> Option<String> {
178 let cache = self.sessions.current().transcript_cache.as_ref()?;
179 if cache.total_in_file > TRANSCRIPT_MAX_ENTRIES {
180 Some(format!(
181 "[showing last {TRANSCRIPT_MAX_ENTRIES} of {} messages]",
182 cache.total_in_file
183 ))
184 } else {
185 None
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use tokio::sync::{mpsc, oneshot};
193 use zeph_core::metrics::SubAgentMetrics;
194
195 use super::*;
196
197 fn make_app() -> App {
198 let (user_tx, _) = mpsc::channel(1);
199 let (_, agent_rx) = mpsc::channel(1);
200 App::new(user_tx, agent_rx)
201 }
202
203 fn sub_agent(id: &str, transcript_dir: Option<&str>) -> SubAgentMetrics {
204 SubAgentMetrics {
205 id: id.to_owned(),
206 name: "test-agent".to_owned(),
207 state: "completed".to_owned(),
208 transcript_dir: transcript_dir.map(str::to_owned),
209 ..Default::default()
210 }
211 }
212
213 #[tokio::test]
216 async fn start_transcript_load_sets_status_label_before_dispatch() {
217 let mut app = make_app();
219 app.metrics.sub_agents = vec![sub_agent("sa-1", Some("/tmp/zeph-test-nonexistent-dir"))];
220 app.start_transcript_load("sa-1");
221 assert_eq!(
222 app.status_label(),
223 Some("loading transcript..."),
224 "status_label must be set synchronously before the spawn_blocking dispatch"
225 );
226 assert!(app.sessions.current().pending_transcript.is_some());
227 }
228
229 #[test]
230 fn start_transcript_load_noop_when_no_transcript_dir() {
231 let mut app = make_app();
233 app.metrics.sub_agents = vec![sub_agent("sa-1", None)];
234 app.start_transcript_load("sa-1");
235 assert_eq!(app.status_label(), None);
236 assert!(app.sessions.current().pending_transcript.is_none());
237 }
238
239 #[test]
240 fn poll_pending_transcript_clears_status_label_on_success() {
241 let mut app = make_app();
242 app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
243 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
244 id: "sa-1".to_owned(),
245 name: "Planner".to_owned(),
246 };
247 let (tx, rx) = oneshot::channel();
248 app.sessions.current_mut().pending_transcript = Some(rx);
249 tx.send((Vec::new(), 0)).expect("receiver still open");
250
251 app.poll_pending_transcript();
252
253 assert_eq!(app.status_label(), None);
254 assert!(app.sessions.current().pending_transcript.is_none());
255 }
256
257 #[test]
258 fn poll_pending_transcript_clears_status_label_when_task_panics() {
259 let mut app = make_app();
262 app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
263 let (tx, rx) = oneshot::channel::<(Vec<TuiTranscriptEntry>, usize)>();
264 app.sessions.current_mut().pending_transcript = Some(rx);
265 drop(tx);
266
267 app.poll_pending_transcript();
268
269 assert_eq!(app.status_label(), None);
270 assert!(app.sessions.current().pending_transcript.is_none());
271 }
272
273 #[test]
274 fn poll_pending_transcript_is_noop_while_still_pending() {
275 let mut app = make_app();
276 app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
277 let (_tx, rx) = oneshot::channel();
278 app.sessions.current_mut().pending_transcript = Some(rx);
279
280 app.poll_pending_transcript();
281
282 assert_eq!(app.status_label(), Some("loading transcript..."));
284 assert!(app.sessions.current().pending_transcript.is_some());
285 }
286
287 #[test]
290 fn set_view_target_cancels_pending_load_and_clears_status_label() {
291 let mut app = make_app();
297 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
298 id: "sa-1".to_owned(),
299 name: "Planner".to_owned(),
300 };
301 let (_tx, rx) = oneshot::channel();
302 app.sessions.current_mut().pending_transcript = Some(rx);
303 app.sessions.current_mut().status_label = Some("loading transcript...".to_owned());
304
305 app.set_view_target(AgentViewTarget::Main);
306
307 assert!(
308 app.sessions.current().pending_transcript.is_none(),
309 "pending load must be cancelled"
310 );
311 assert_eq!(
312 app.status_label(),
313 None,
314 "cancelling the in-flight transcript load must clear its status_label"
315 );
316 }
317
318 #[test]
319 fn set_view_target_preserves_unrelated_status_label_when_nothing_pending() {
320 let mut app = make_app();
324 assert!(app.metrics.sub_agents.is_empty());
325 app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
326
327 app.set_view_target(AgentViewTarget::SubAgent {
328 id: "sa-1".to_owned(),
329 name: "Planner".to_owned(),
330 });
331
332 assert!(app.sessions.current().pending_transcript.is_none());
333 assert_eq!(
334 app.status_label(),
335 Some("indexing files..."),
336 "unrelated status_label must not be wiped when there was no in-flight \
337 transcript load to cancel"
338 );
339 }
340}