返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / pet_watch / mod.rs
1 //! Views of one durable local pet. Engine events are projected here once;
2 //! the companion owns simulation, persistence and the sole audio output.
3 use crate::core::{
4 events::Event,
5 protocol_parity::{ProtocolIds, event_to_protocol},
6 };
7 use crate::tui::{
8 app::{App, StatusToastLevel},
9 underwater::ShellPhase,
10 views::ModalKind,
11 };
12 use codewhale_localization::{MessageId, tr};
13 use codewhale_palette::{ChromeInk, chrome_style};
14 use ratatui::{
15 Frame,
16 layout::Rect,
17 style::{Color, Style},
18 widgets::{Block, Paragraph},
19 };
20 use serde_json::{Value, json};
21 use std::{
22 io::{self, Write},
23 sync::{Arc, Mutex},
24 time::{Duration, Instant},
25 };
26 mod appearance;
27 mod audio;
28 mod audio_cursor;
29 mod graphics;
30 mod habitat;
31 mod live;
32 pub(crate) mod owner;
33 mod persistence;
34 #[cfg(test)]
35 mod worker;
36 use live::{Command, Notice, Presentation, Worker};
37 #[derive(Clone, Copy)]
38 pub enum Control {
39 Sound,
40 Browser,
41 Window,
42 Select,
43 Scroll(i16),
44 }
45 #[derive(Default)]
46 pub struct PetWatch {
47 worker: Option<Worker>,
48 session: Option<String>,
49 last_tick: Option<Instant>,
50 failed: bool,
51 exporting: bool,
52 sound_requested: bool,
53 pub(crate) area: Option<Rect>,
54 raster: Option<Presentation>,
55 controls: Arc<Mutex<Vec<Control>>>,
56 desired: Option<Rect>,
57 painted: Option<Rect>,
58 sent: Option<Instant>,
59 started: Option<Instant>,
60 frames: u64,
61 bytes: u64,
62 render_ms: f64,
63 output_ms: f64,
64 /// `/pet on`: accepted turns enter the full habitat automatically.
65 pub(crate) enabled: bool,
66 work_enter_pending: bool,
67 work_complete: bool,
68 work_history_start: usize,
69 result_scroll: u16,
70 }
71 impl PetWatch {
72 pub fn set_sound(&mut self, enabled: bool) {
73 self.sound_requested = enabled;
74 }
75 pub fn sound_label(&self) -> MessageId {
76 if !self.sound_requested {
77 MessageId::PetWatchSoundOff
78 } else if self.raster.as_ref().is_some_and(|r| {
79 r.scene.audio_owner.as_deref() == Some(r.client.as_str()) && !r.scene.audio_unavailable
80 }) {
81 MessageId::PetWatchSoundOn
82 } else {
83 MessageId::PetWatchSoundPaused
84 }
85 }
86 fn reset(&mut self, session: Option<String>) {
87 self.worker = None;
88 self.session = session;
89 self.raster = None;
90 self.failed = false;
91 self.last_tick = None;
92 self.work_enter_pending = false;
93 self.work_complete = false;
94 self.result_scroll = 0;
95 }
96 fn ensure(&mut self, session: Option<String>) {
97 if self.session != session {
98 self.reset(session)
99 }
100 if self.worker.is_none() && !self.failed {
101 match Worker::start(self.session.clone()) {
102 Ok(worker) => self.worker = Some(worker),
103 Err(_) => self.failed = true,
104 }
105 }
106 }
107 pub fn export(&mut self) -> bool {
108 if self.worker.is_none() || self.exporting {
109 return false;
110 }
111 self.send(Command::Export);
112 self.exporting = !self.failed;
113 self.exporting
114 }
115 /// Tests drive the shell without a companion: never start a view worker.
116 #[cfg(test)]
117 pub(crate) fn detach_for_test(&mut self) {
118 self.worker = None;
119 self.failed = true;
120 }
121 pub fn observe(&mut self, event: &Event, session: Option<&str>, _now: Instant) {
122 if self.session.as_deref() != session {
123 self.reset(session.map(str::to_owned));
124 return;
125 }
126 if let Some(text) = metadata(event) {
127 self.send(Command::Observe(text));
128 }
129 }
130 fn send(&mut self, command: Command) {
131 if self
132 .worker
133 .as_ref()
134 .is_some_and(|w| w.tx.try_send(command).is_err())
135 {
136 // A gap drops this producer lease. Restart from a fresh unobserved
137 // handshake; never infer continuity from events we could not queue.
138 self.worker = None;
139 self.raster = None;
140 self.failed = true;
141 }
142 }
143 pub fn prepare_frame(&mut self) {
144 self.desired = None;
145 }
146 pub fn present(&mut self, output: &mut impl Write) -> io::Result<()> {
147 let raster = self
148 .raster
149 .as_ref()
150 .filter(|r| r.frame_changed.elapsed() < Duration::from_millis(800));
151 let desired = self.desired.filter(|a| {
152 raster.is_some_and(|r| r.image.is_some() && r.width == a.width && r.height == a.height)
153 });
154 if desired.is_none() {
155 if self.painted.take().is_some() {
156 graphics::clear(output)?;
157 }
158 self.sent = None;
159 return Ok(());
160 }
161 let area = desired.unwrap();
162 let raster = raster.unwrap();
163 if self.painted == Some(area) && self.sent == Some(raster.created) {
164 return Ok(());
165 }
166 let began = Instant::now();
167 // Within the existing synchronized frame: delete this process's one
168 // image, replace it, restore the cursor. No terminal-side frame queue.
169 graphics::clear(output)?;
170 write!(output, "\x1b7\x1b[{};{}H", area.y + 1, area.x + 1)?;
171 output.write_all(raster.image.as_ref().unwrap())?;
172 output.write_all(b"\x1b8")?;
173 self.painted = Some(area);
174 self.sent = Some(raster.created);
175 self.started.get_or_insert(began);
176 self.frames += 1;
177 self.bytes += raster.bytes as u64;
178 self.render_ms += raster.render_ms;
179 self.output_ms += began.elapsed().as_secs_f64() * 1000.0;
180 Ok(())
181 }
182 pub fn status(&self) -> String {
183 let seconds = self.started.map_or(0.0, |s| s.elapsed().as_secs_f64());
184 let identity = self
185 .raster
186 .as_ref()
187 .map(|r| {
188 format!(
189 "{} · tick {} · {} · {}",
190 r.scene.identity, r.scene.tick, r.scene.digest, r.scene.source
191 )
192 })
193 .unwrap_or_default();
194 format!(
195 "{identity} · {} pixel frames / {:.1}s · {:.1} fps · {:.2} MiB/s · render {:.2}ms · write {:.2}ms",
196 self.frames,
197 seconds,
198 self.frames as f64 / seconds.max(0.001),
199 self.bytes as f64 / 1048576.0 / seconds.max(0.001),
200 self.render_ms / self.frames.max(1) as f64,
201 self.output_ms / self.frames.max(1) as f64
202 )
203 }
204 }
205 /// Existing protocol projection defines the variant names. This allowlist
206 /// removes payloads before the bounded worker queue; no model text escapes.
207 fn metadata(event: &Event) -> Option<String> {
208 if !matches!(
209 event,
210 Event::TurnStarted { .. }
211 | Event::TurnComplete { .. }
212 | Event::MessageStarted { .. }
213 | Event::MessageDelta { .. }
214 | Event::MessageComplete { .. }
215 | Event::ThinkingStarted { .. }
216 | Event::ThinkingDelta { .. }
217 | Event::ThinkingComplete { .. }
218 | Event::ToolCallStarted { .. }
219 | Event::ToolCallHeartbeat
220 | Event::ToolCallComplete { .. }
221 | Event::AgentSpawned { .. }
222 | Event::AgentProgress { .. }
223 | Event::AgentComplete { .. }
224 | Event::ApprovalRequired { .. }
225 | Event::UserInputRequired { .. }
226 | Event::Error { .. }
227 ) {
228 return None;
229 }
230 let ids = ProtocolIds {
231 thread_id: "foreground".to_owned().into(),
232 session_id: "foreground".to_owned().into(),
233 };
234 let projected = serde_json::to_value(event_to_protocol(event, &ids)).ok()?;
235 let mut out = serde_json::Map::new();
236 for key in [
237 "event",
238 "index",
239 "channel",
240 "tool_call_id",
241 "tool_name",
242 "id",
243 "worker_status",
244 ] {
245 if let Some(value) = projected.get(key) {
246 out.insert(key.to_owned(), value.clone());
247 }
248 }
249 if let Some(status) = projected.pointer("/activity/worker_status") {
250 out.insert("worker_status".into(), status.clone());
251 }
252 let failed = projected.get("status") == Some(&json!("failed"))
253 || projected.get("worker_status") == Some(&json!("failed"))
254 || projected.pointer("/activity/worker_status") == Some(&json!("failed"))
255 || projected.pointer("/result/outcome") == Some(&json!("err"))
256 || projected.pointer("/result/success") == Some(&Value::Bool(false));
257 if failed {
258 out.insert("failed".into(), Value::Bool(true));
259 }
260 let json = serde_json::to_string(&out).ok()?;
261 // Producer data is bounded before crossing the queue, including tool names.
262 (json.len() <= 16_384).then_some(json)
263 }
264
265 pub fn command(app: &mut App, control: Control) {
266 app.pet_watch.ensure(app.current_session_id.clone());
267 apply(&mut app.pet_watch, control);
268 app.needs_redraw = true;
269 }
270 fn apply(state: &mut PetWatch, control: Control) {
271 match control {
272 Control::Browser => state.send(Command::Browser),
273 Control::Window => state.send(Command::Window),
274 Control::Select => state.send(Command::Select),
275 Control::Sound => state.sound_requested = !state.sound_requested,
276 Control::Scroll(delta) => {
277 state.result_scroll = state.result_scroll.saturating_add_signed(delta)
278 }
279 }
280 }
281 pub fn open_habitat(app: &mut App) {
282 app.pet_watch.ensure(app.current_session_id.clone());
283 if app.view_stack.top_kind() != Some(ModalKind::PetHabitat) {
284 app.view_stack
285 .push(habitat::Habitat::new(app.pet_watch.controls.clone()));
286 }
287 app.needs_redraw = true;
288 }
289 pub fn is_open(app: &App) -> bool {
290 app.view_stack.top_kind() == Some(ModalKind::PetHabitat)
291 }
292 /// `/pet on|off`. Enabling enters the habitat now and lets every accepted
293 /// turn re-enter it; disabling closes the view and stops automatic entry.
294 /// The durable pet keeps living in its companion either way, and the
295 /// composer draft, transcript and active Engine turn are never touched.
296 pub fn set_enabled(app: &mut App, enabled: bool) {
297 app.pet_watch.enabled = enabled;
298 if enabled {
299 open_habitat(app);
300 return;
301 }
302 app.pet_watch.work_enter_pending = false;
303 app.pet_watch.work_complete = false;
304 if is_open(app) {
305 app.view_stack.pop();
306 }
307 let session = app.pet_watch.session.clone();
308 app.pet_watch.reset(session);
309 app.needs_redraw = true;
310 }
311 /// The existing Engine determines work boundaries. Only the shell reads the
312 /// answer; no conversation text enters the pet owner or recording.
313 pub fn observe(app: &mut App, event: &Event, now: Instant) {
314 app.pet_watch
315 .observe(event, app.current_session_id.as_deref(), now);
316 if matches!(event, Event::TurnStarted { .. }) {
317 app.pet_watch.work_history_start = app.history.len();
318 app.pet_watch.work_complete = false;
319 app.pet_watch.result_scroll = 0;
320 app.pet_watch.work_enter_pending = app.pet_watch.enabled;
321 } else if matches!(event, Event::TurnComplete { .. }) {
322 app.pet_watch.work_enter_pending = false;
323 app.pet_watch.work_complete = app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
324 app.needs_redraw = true;
325 }
326 }
327 pub fn tick(app: &mut App, now: Instant) {
328 if app.pet_watch.work_enter_pending
329 && app.view_stack.is_empty()
330 && !app.redaction_gate
331 && app.onboarding == crate::tui::app::OnboardingState::None
332 {
333 app.pet_watch.work_enter_pending = false;
334 open_habitat(app);
335 }
336 // The habitat is the pet's only terminal view: it owns the whole content
337 // viewport or nothing. Reduced motion follows the shell's motion setting.
338 let visible = !app.redaction_gate
339 && app.onboarding == crate::tui::app::OnboardingState::None
340 && is_open(app);
341 let motion = visible && crate::tui::underwater::decorative_shell_motion_enabled(app);
342 let waiting = matches!(
343 ShellPhase::from_app(app),
344 ShellPhase::Waiting | ShellPhase::Approval
345 );
346 let sound_allowed = visible
347 && app.onboarding == crate::tui::app::OnboardingState::None
348 && !app.notification_settings.quiet
349 && !app.notification_settings.event_sound.quiet;
350 let controls = app
351 .pet_watch
352 .controls
353 .lock()
354 .map(|mut c| std::mem::take(&mut *c))
355 .unwrap_or_default();
356 let state = &mut app.pet_watch;
357 if state.session != app.current_session_id {
358 state.reset(app.current_session_id.clone());
359 }
360 if visible {
361 state.ensure(app.current_session_id.clone());
362 }
363 for control in controls {
364 apply(state, control)
365 }
366 if let Some(update) = state
367 .worker
368 .as_ref()
369 .and_then(|w| w.latest.lock().ok().and_then(|mut s| s.take()))
370 {
371 if update.scene.audio_unavailable {
372 state.sound_requested = false;
373 }
374 state.raster = Some(update);
375 if visible {
376 app.needs_redraw = true;
377 }
378 }
379 if state.raster.as_ref().is_some_and(|r| {
380 now.saturating_duration_since(r.frame_changed) > Duration::from_millis(800)
381 }) {
382 state.raster = None;
383 if visible {
384 app.needs_redraw = true;
385 }
386 }
387 if state
388 .last_tick
389 .is_none_or(|t| now.saturating_duration_since(t) >= Duration::from_millis(30))
390 {
391 let a = state.area.unwrap_or(Rect::new(0, 0, 40, 8));
392 let cell = crossterm::terminal::window_size()
393 .ok()
394 .filter(|s| s.columns > 0 && s.rows > 0 && s.width > 0 && s.height > 0)
395 .map(|s| {
396 (
397 f64::from(s.width) / f64::from(s.columns),
398 f64::from(s.height) / f64::from(s.rows),
399 )
400 })
401 .unwrap_or((8.0, 16.0));
402 let next = live::View {
403 width: a.width.clamp(1, 512),
404 height: a.height.saturating_sub(1).clamp(1, 256),
405 cell_width: cell.0,
406 cell_height: cell.1,
407 motion,
408 pixels: crate::tui::mark::kitty_graphics_supported()
409 && app.synchronized_output_enabled
410 && std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() != Ok("braille"),
411 visible,
412 waiting,
413 sound: state.sound_requested && sound_allowed,
414 };
415 if let Some(worker) = &state.worker
416 && let Ok(mut view) = worker.view.lock()
417 {
418 *view = next;
419 }
420 state.last_tick = Some(now);
421 }
422 let notices: Vec<_> = state
423 .worker
424 .as_ref()
425 .map(|w| w.notices.try_iter().take(16).collect())
426 .unwrap_or_default();
427 for notice in notices {
428 app.pet_watch.exporting = false;
429 let (text, level) = match notice {
430 Notice::Exported(path) => (
431 tr(app.ui_locale, MessageId::PetWatchExported)
432 .replace("{path}", &path.display().to_string()),
433 StatusToastLevel::Info,
434 ),
435 Notice::Message(message) => (
436 format!(
437 "{} · {message}",
438 tr(app.ui_locale, MessageId::PetWatchUnavailable)
439 ),
440 StatusToastLevel::Warning,
441 ),
442 };
443 app.add_message(crate::tui::history::HistoryCell::System {
444 content: text.clone(),
445 });
446 app.push_status_toast(text, level, Some(12000));
447 app.needs_redraw = true;
448 }
449 }
450 fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
451 app.pet_watch.area = Some(area);
452 let raster = app.pet_watch.raster.as_ref();
453 let hollow = raster.is_none_or(|r| !r.scene.producer_connected || r.scene.style.hollow);
454 let mut label = raster
455 .map(|r| {
456 let mut text = format!(
457 "{} · {} · {}",
458 r.scene.style.channel, r.scene.style.arch, r.scene.behaviour
459 );
460 if let Some(activity) = &r.scene.activity
461 && activity.observed
462 && r.frame_changed.elapsed().as_millis() < 800
463 {
464 text = format!(
465 "{} · {}",
466 activity.tool.as_deref().unwrap_or(&activity.label),
467 text
468 );
469 if activity.parallel > 0 {
470 text.push_str(&format!(" · ×{}", activity.parallel));
471 }
472 }
473 text
474 })
475 .unwrap_or_default();
476 if hollow {
477 label.push_str(&format!(
478 " · {}",
479 tr(app.ui_locale, MessageId::PetUnobserved)
480 ));
481 }
482 label.push_str(&format!(
483 " · {}",
484 tr(app.ui_locale, app.pet_watch.sound_label())
485 ));
486 let image = (app.view_stack.is_empty()
487 || app.view_stack.top_kind() == Some(ModalKind::PetHabitat))
488 && raster.is_some_and(|r| {
489 r.image.is_some() && r.width == area.width && r.height == area.height.saturating_sub(1)
490 })
491 && area.height >= 4;
492 let bg = raster
493 .map(|r| r.scene.appearance.background)
494 .unwrap_or([8, 15, 21]);
495 let ink = raster
496 .map(|r| {
497 Color::Rgb(
498 r.scene.style.r.clamp(0.0, 255.0) as u8,
499 r.scene.style.g.clamp(0.0, 255.0) as u8,
500 r.scene.style.b.clamp(0.0, 255.0) as u8,
501 )
502 })
503 .unwrap_or(Color::Rgb(180, 210, 216));
504 frame.render_widget(
505 Block::default().style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2]))),
506 area,
507 );
508 if image {
509 let tank = Rect {
510 height: area.height.saturating_sub(1),
511 ..area
512 };
513 app.pet_watch.desired = Some(tank);
514 frame.render_widget(
515 Paragraph::new(label).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
516 Rect {
517 y: area.bottom() - 1,
518 height: 1,
519 ..area
520 },
521 );
522 } else {
523 crate::tui::ambient_life::pet_widget::render_grid(
524 area,
525 frame.buffer_mut(),
526 raster
527 .filter(|r| r.width == area.width && r.height == area.height.saturating_sub(1))
528 .map_or(&[], |r| r.cells.as_slice()),
529 &label,
530 Style::default().fg(ink),
531 );
532 }
533 }
534 pub fn render_full(frame: &mut Frame, app: &mut App) {
535 let area = frame.area();
536 frame.render_widget(
537 Block::default().style(Style::default().bg(app.ui_theme.surface_bg)),
538 area,
539 );
540 frame.render_widget(
541 Paragraph::new(tr(app.ui_locale, MessageId::PetHabitatTitle))
542 .style(chrome_style(&app.ui_theme, ChromeInk::Active)),
543 Rect { height: 1, ..area },
544 );
545 let tank = Rect {
546 x: area.x,
547 y: area.y + 2,
548 width: area.width,
549 height: if app.pet_watch.work_complete {
550 area.height.saturating_sub(5) / 3
551 } else {
552 area.height.saturating_sub(5)
553 },
554 };
555 render_tank(frame, tank, app);
556 if app.pet_watch.work_complete {
557 let result_area = Rect {
558 x: area.x.saturating_add(3),
559 y: tank.bottom().saturating_add(1),
560 width: area.width.saturating_sub(6),
561 height: area
562 .bottom()
563 .saturating_sub(tank.bottom())
564 .saturating_sub(4),
565 };
566 let result = app
567 .history
568 .iter()
569 .skip(app.pet_watch.work_history_start)
570 .rfind(|cell| {
571 matches!(
572 cell,
573 crate::tui::history::HistoryCell::Assistant { .. }
574 | crate::tui::history::HistoryCell::Error { .. }
575 )
576 });
577 let lines = result
578 .map(|cell| cell.transcript_lines(result_area.width))
579 .unwrap_or_else(|| {
580 vec![ratatui::text::Line::from(
581 tr(app.ui_locale, MessageId::NotificationTurnComplete).into_owned(),
582 )]
583 });
584 app.pet_watch.result_scroll = app.pet_watch.result_scroll.min(
585 lines
586 .len()
587 .saturating_sub(usize::from(result_area.height))
588 .min(usize::from(u16::MAX)) as u16,
589 );
590 frame.render_widget(
591 Paragraph::new(lines).scroll((app.pet_watch.result_scroll, 0)),
592 result_area,
593 );
594 }
595 let hints = if app.pet_watch.work_complete {
596 format!(
597 "↑↓ / PgUp/PgDn {} · {}",
598 tr(app.ui_locale, MessageId::SetupActionScrollBody),
599 habitat::hints(app.ui_locale)
600 )
601 } else {
602 habitat::hints(app.ui_locale)
603 };
604 frame.render_widget(
605 Paragraph::new(hints).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
606 Rect {
607 x: area.x,
608 y: area.bottom().saturating_sub(2),
609 width: area.width,
610 height: 2,
611 },
612 );
613 }
614 pub(crate) fn clear_images(output: &mut impl Write) -> io::Result<()> {
615 graphics::clear(output)
616 }
617
618 #[cfg(test)]
619 mod tests {
620 use super::*;
621
622 #[test]
623 fn work_completion_reveals_existing_answer_without_sending_text_to_owner() {
624 use crate::core::events::TurnOutcomeStatus;
625 let mut app =
626 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
627 app.onboarding = crate::tui::app::OnboardingState::None;
628 app.redaction_gate = false;
629 assert!(app.view_stack.is_empty());
630 app.input = "retained draft".into();
631 app.pet_watch.session = app.current_session_id.clone();
632 app.pet_watch.failed = true; // No connection or provider for this shell test.
633 app.pet_watch.enabled = true;
634 observe(
635 &mut app,
636 &Event::TurnStarted {
637 turn_id: "preview-turn".into(),
638 created_at: chrono::Utc::now(),
639 route: None,
640 },
641 Instant::now(),
642 );
643 tick(&mut app, Instant::now());
644 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::PetHabitat));
645 app.add_message(crate::tui::history::HistoryCell::Assistant {
646 content: "Prepared result stays in the transcript".into(),
647 streaming: false,
648 });
649 observe(
650 &mut app,
651 &Event::TurnComplete {
652 usage: Default::default(),
653 parent_route_usage: Default::default(),
654 routed_usage_dropped_records: 0,
655 status: TurnOutcomeStatus::Completed,
656 error: None,
657 tool_catalog: None,
658 base_url: None,
659 },
660 Instant::now(),
661 );
662 assert!(app.pet_watch.work_complete);
663 let mut terminal =
664 ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 40)).unwrap();
665 terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
666 let text = terminal
667 .backend()
668 .buffer()
669 .content
670 .iter()
671 .map(|cell| cell.symbol())
672 .collect::<String>();
673 assert!(text.contains("Prepared result stays in the transcript"));
674 assert_eq!(app.input, "retained draft");
675 assert!(app.pet_watch.worker.is_none());
676 }
677
678 #[test]
679 fn pet_off_stops_automatic_entry_and_keeps_the_draft() {
680 let mut app =
681 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
682 app.onboarding = crate::tui::app::OnboardingState::None;
683 app.redaction_gate = false;
684 app.input = "kept draft".into();
685 app.pet_watch.session = app.current_session_id.clone();
686 app.pet_watch.detach_for_test();
687 app.pet_watch.enabled = true;
688 observe(
689 &mut app,
690 &Event::TurnStarted {
691 turn_id: "turn".into(),
692 created_at: chrono::Utc::now(),
693 route: None,
694 },
695 Instant::now(),
696 );
697 assert!(app.pet_watch.work_enter_pending);
698 set_enabled(&mut app, false);
699 tick(&mut app, Instant::now());
700 assert!(!app.pet_watch.enabled);
701 assert!(!app.pet_watch.work_enter_pending);
702 assert!(app.view_stack.is_empty());
703 assert_eq!(app.input, "kept draft");
704 assert!(app.pet_watch.worker.is_none());
705 }
706
707 #[test]
708 fn foreground_projection_keeps_lifecycle_and_excludes_private_payloads() {
709 let call = metadata(&Event::ToolCallStarted {
710 id: "call-a".into(),
711 name: "exec_command".into(),
712 input: json!({"command":"PRIVATE TOOL INPUT"}),
713 })
714 .unwrap();
715 assert_eq!(
716 serde_json::from_str::<Value>(&call).unwrap(),
717 json!({
718 "event":"tool_call_started", "tool_call_id":"call-a", "tool_name":"exec_command",
719 })
720 );
721 let thought = metadata(&Event::ThinkingDelta {
722 index: 2,
723 content: "PRIVATE REASONING".into(),
724 })
725 .unwrap();
726 assert_eq!(
727 serde_json::from_str::<Value>(&thought).unwrap(),
728 json!({"event":"response_delta","index":2,"channel":"reasoning"})
729 );
730 let message = metadata(&Event::MessageDelta {
731 index: 3,
732 content: "PRIVATE MESSAGE".into(),
733 })
734 .unwrap();
735 assert!(!message.contains("PRIVATE"));
736 assert!(!call.contains("PRIVATE"));
737 assert!(!thought.contains("PRIVATE"));
738 }
739 }
740
740 lines RUST