返回 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::events::{Event, TurnOutcomeStatus};
4 use crate::tui::{
5 app::{App, StatusToastLevel},
6 underwater::ShellPhase,
7 views::ModalKind,
8 };
9 use codewhale_localization::{MessageId, tr};
10 use codewhale_palette::{ChromeInk, chrome_style};
11 use ratatui::{
12 Frame,
13 layout::Rect,
14 style::{Color, Style},
15 widgets::{Block, Paragraph},
16 };
17 use serde_json::json;
18 use std::{
19 io::{self, Write},
20 sync::{Arc, Mutex},
21 time::{Duration, Instant},
22 };
23 mod appearance;
24 mod audio;
25 mod audio_cursor;
26 mod graphics;
27 mod habitat;
28 mod live;
29 pub(crate) mod owner;
30 mod persistence;
31 #[cfg(test)]
32 mod worker;
33 use live::{Command, Notice, Presentation, Worker};
34 #[derive(Clone, Copy)]
35 pub enum Control {
36 Sound,
37 Browser,
38 Window,
39 Select,
40 Scroll(i16),
41 }
42 #[derive(Default)]
43 pub struct PetWatch {
44 worker: Option<Worker>,
45 session: Option<String>,
46 active_turn_id: Option<String>,
47 last_tick: Option<Instant>,
48 failed: bool,
49 /// The companion said it cannot be reached. Cleared by the next frame.
50 unavailable: 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.active_turn_id = None;
90 self.raster = None;
91 self.failed = false;
92 self.unavailable = false;
93 self.last_tick = None;
94 self.work_enter_pending = false;
95 self.work_complete = false;
96 self.result_scroll = 0;
97 }
98 fn ensure(&mut self, session: Option<String>) {
99 if self.session != session {
100 self.reset(session)
101 }
102 if self.worker.is_none() && !self.failed {
103 match Worker::start(self.session.clone()) {
104 Ok(worker) => self.worker = Some(worker),
105 Err(_) => self.failed = true,
106 }
107 }
108 }
109 pub fn export(&mut self) -> bool {
110 if self.worker.is_none() || self.exporting {
111 return false;
112 }
113 self.send(Command::Export);
114 self.exporting = !self.failed;
115 self.exporting
116 }
117 /// Tests drive the shell without a companion: never start a view worker.
118 #[cfg(test)]
119 pub(crate) fn detach_for_test(&mut self) {
120 self.worker = None;
121 self.failed = true;
122 }
123 pub fn observe(&mut self, event: &Event, session: Option<&str>, _now: Instant) {
124 if self.session.as_deref() != session {
125 // A live view follows the session switch: restart it for the new
126 // session and forward the event that switched, so the reducer
127 // sees the turn that opened it. Without a live view, the next
128 // render starts one; turn tracking below still records the turn.
129 let had_worker = self.worker.is_some();
130 self.reset(session.map(str::to_owned));
131 if had_worker {
132 self.ensure(self.session.clone());
133 }
134 }
135 if let Event::TurnStarted { turn_id, .. } = event {
136 self.active_turn_id = Some(turn_id.clone());
137 }
138 if let Some(text) = metadata(event, self.active_turn_id.as_deref()) {
139 self.send(Command::Observe(text));
140 }
141 if matches!(event, Event::TurnComplete { .. }) {
142 self.active_turn_id = None;
143 }
144 }
145 fn send(&mut self, command: Command) {
146 if self
147 .worker
148 .as_ref()
149 .is_some_and(|w| w.tx.try_send(command).is_err())
150 {
151 // A gap drops this producer lease. Restart from a fresh unobserved
152 // handshake; never infer continuity from events we could not queue.
153 self.worker = None;
154 self.raster = None;
155 self.failed = true;
156 }
157 }
158 pub fn prepare_frame(&mut self) {
159 self.desired = None;
160 }
161 pub fn present(&mut self, output: &mut impl Write) -> io::Result<()> {
162 let raster = self
163 .raster
164 .as_ref()
165 .filter(|r| r.frame_changed.elapsed() < Duration::from_millis(800));
166 let desired = self.desired.filter(|a| {
167 raster.is_some_and(|r| r.image.is_some() && r.width == a.width && r.height == a.height)
168 });
169 if desired.is_none() {
170 if self.painted.take().is_some() {
171 graphics::clear(output)?;
172 }
173 self.sent = None;
174 return Ok(());
175 }
176 let area = desired.unwrap();
177 let raster = raster.unwrap();
178 if self.painted == Some(area) && self.sent == Some(raster.created) {
179 return Ok(());
180 }
181 let began = Instant::now();
182 // Within the existing synchronized frame: delete this process's one
183 // image, replace it, restore the cursor. No terminal-side frame queue.
184 graphics::clear(output)?;
185 write!(output, "\x1b7\x1b[{};{}H", area.y + 1, area.x + 1)?;
186 output.write_all(raster.image.as_ref().unwrap())?;
187 output.write_all(b"\x1b8")?;
188 self.painted = Some(area);
189 self.sent = Some(raster.created);
190 self.started.get_or_insert(began);
191 self.frames += 1;
192 self.bytes += raster.bytes as u64;
193 self.render_ms += raster.render_ms;
194 self.output_ms += began.elapsed().as_secs_f64() * 1000.0;
195 Ok(())
196 }
197 pub fn status(&self) -> String {
198 let seconds = self.started.map_or(0.0, |s| s.elapsed().as_secs_f64());
199 let identity = self
200 .raster
201 .as_ref()
202 .map(|r| {
203 format!(
204 "{} · tick {} · {} · {}",
205 r.scene.identity, r.scene.tick, r.scene.digest, r.scene.source
206 )
207 })
208 .unwrap_or_default();
209 format!(
210 "{identity} · {} pixel frames / {:.1}s · {:.1} fps · {:.2} MiB/s · render {:.2}ms · write {:.2}ms",
211 self.frames,
212 seconds,
213 self.frames as f64 / seconds.max(0.001),
214 self.bytes as f64 / 1048576.0 / seconds.max(0.001),
215 self.render_ms / self.frames.max(1) as f64,
216 self.output_ms / self.frames.max(1) as f64
217 )
218 }
219 }
220 /// Existing protocol projection defines the variant names. This allowlist
221 /// removes payloads before the bounded worker queue; no model text escapes.
222 fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
223 let value = match event {
224 Event::TurnStarted { turn_id, .. } => json!({"event":"turn_started","turn_id":turn_id}),
225 Event::TurnComplete { status, .. } => {
226 let mut value = json!({
227 "event":"turn_complete",
228 "turn_outcome":match status {
229 TurnOutcomeStatus::Completed => "completed",
230 TurnOutcomeStatus::Interrupted => "interrupted",
231 TurnOutcomeStatus::Failed => "failed",
232 }
233 });
234 // An untracked turn omits the id; the reducer rejects `null`, and
235 // without an id a completed turn cannot claim Done.
236 if let Some(turn_id) = turn_id {
237 value["turn_id"] = json!(turn_id);
238 }
239 value
240 }
241 Event::MessageStarted { index } => json!({"event":"message_started","index":index}),
242 Event::MessageDelta { index, .. } => {
243 json!({"event":"response_delta","index":index,"channel":"text"})
244 }
245 Event::MessageComplete { index } => json!({"event":"message_complete","index":index}),
246 Event::ThinkingStarted { index } => json!({"event":"thinking_started","index":index}),
247 Event::ThinkingDelta { index, .. } => {
248 json!({"event":"response_delta","index":index,"channel":"reasoning"})
249 }
250 Event::ThinkingComplete { index } => json!({"event":"thinking_complete","index":index}),
251 Event::OperationActivityStarted {
252 span_id,
253 activity_kind,
254 } => json!({
255 "event":"operation_activity_started",
256 "span_id":span_id,
257 "activity_kind":activity_kind
258 }),
259 Event::OperationActivityCompleted {
260 span_id,
261 activity_kind,
262 outcome,
263 } => json!({
264 "event":"operation_activity_completed",
265 "span_id":span_id,
266 "activity_kind":activity_kind,
267 "outcome":outcome
268 }),
269 Event::ToolCallHeartbeat => json!({"event":"tool_call_heartbeat"}),
270 // A denied (by a human or by policy) or cancelled call ends any wait
271 // on it. Only the Engine call id and typed outcome cross; the tool
272 // name and error text stay in the transcript. Approvals that run are
273 // cleared by their operation start and by the shell's typed
274 // `waiting` flag on every tick.
275 Event::ToolCallComplete {
276 id,
277 result: Err(crate::tools::spec::ToolError::PermissionDenied { .. }),
278 ..
279 } => json!({
280 "event":"approval_resolved","id":id,"outcome":"denied"
281 }),
282 Event::ToolCallComplete {
283 id,
284 result: Err(crate::tools::spec::ToolError::Cancelled { .. }),
285 ..
286 } => json!({
287 "event":"approval_resolved","id":id,"outcome":"cancelled"
288 }),
289 Event::AgentSpawned { id, .. } => json!({"event":"agent_spawned","id":id}),
290 Event::AgentProgress { id, activity, .. } => json!({
291 "event":"agent_progress",
292 "id":id,
293 "worker_status":crate::core::protocol_parity::worker_status_str(activity.worker_status)
294 }),
295 Event::AgentComplete { id, .. } => json!({"event":"agent_complete","id":id}),
296 Event::ApprovalRequired { id, .. } | Event::UserInputRequired { id, .. } => json!({
297 "event":if matches!(event, Event::ApprovalRequired { .. }) {"approval_required"} else {"user_input_required"},
298 "id":id
299 }),
300 _ => return None,
301 };
302 let json = serde_json::to_string(&value).ok()?;
303 (json.len() <= 16_384).then_some(json)
304 }
305
306 pub fn command(app: &mut App, control: Control) {
307 app.pet_watch.ensure(app.current_session_id.clone());
308 apply(&mut app.pet_watch, control);
309 app.needs_redraw = true;
310 }
311 fn apply(state: &mut PetWatch, control: Control) {
312 match control {
313 Control::Browser => state.send(Command::Browser),
314 Control::Window => state.send(Command::Window),
315 Control::Select => state.send(Command::Select),
316 Control::Sound => state.sound_requested = !state.sound_requested,
317 Control::Scroll(delta) => {
318 state.result_scroll = state.result_scroll.saturating_add_signed(delta)
319 }
320 }
321 }
322 pub fn open_habitat(app: &mut App) {
323 app.pet_watch.ensure(app.current_session_id.clone());
324 if app.view_stack.top_kind() != Some(ModalKind::PetHabitat) {
325 app.view_stack
326 .push(habitat::Habitat::new(app.pet_watch.controls.clone()));
327 }
328 app.needs_redraw = true;
329 }
330 pub fn is_open(app: &App) -> bool {
331 app.view_stack.top_kind() == Some(ModalKind::PetHabitat)
332 }
333 /// `/pet on|off`. Enabling enters the habitat now and lets every accepted
334 /// turn re-enter it; disabling closes the view and stops automatic entry.
335 /// The durable pet keeps living in its companion either way, and the
336 /// composer draft, transcript and active Engine turn are never touched.
337 pub fn set_enabled(app: &mut App, enabled: bool) {
338 app.pet_watch.enabled = enabled;
339 if enabled {
340 open_habitat(app);
341 return;
342 }
343 app.pet_watch.work_enter_pending = false;
344 app.pet_watch.work_complete = false;
345 if is_open(app) {
346 app.view_stack.pop();
347 }
348 let session = app.pet_watch.session.clone();
349 app.pet_watch.reset(session);
350 app.needs_redraw = true;
351 }
352 /// The existing Engine determines work boundaries. Only the shell reads the
353 /// answer; no conversation text enters the pet owner or recording.
354 pub fn observe(app: &mut App, event: &Event, now: Instant) {
355 app.pet_watch
356 .observe(event, app.current_session_id.as_deref(), now);
357 if matches!(event, Event::TurnStarted { .. }) {
358 app.pet_watch.work_history_start = app.history.len();
359 app.pet_watch.work_complete = false;
360 app.pet_watch.result_scroll = 0;
361 app.pet_watch.work_enter_pending = app.pet_watch.enabled;
362 } else if let Event::TurnComplete { status, .. } = event {
363 app.pet_watch.work_enter_pending = false;
364 app.pet_watch.work_complete = *status == TurnOutcomeStatus::Completed
365 && app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
366 app.needs_redraw = true;
367 }
368 }
369 pub fn tick(app: &mut App, now: Instant) {
370 if app.pet_watch.work_enter_pending
371 && app.view_stack.is_empty()
372 && !app.redaction_gate
373 && app.onboarding == crate::tui::app::OnboardingState::None
374 {
375 app.pet_watch.work_enter_pending = false;
376 // Pet mode never hides a running turn behind a companion that is
377 // not there; the transcript stays in view until it answers again.
378 if !app.pet_watch.unavailable {
379 open_habitat(app);
380 }
381 }
382 // The habitat is the pet's only terminal view: it owns the whole content
383 // viewport or nothing. Reduced motion follows the shell's motion setting.
384 let visible = !app.redaction_gate
385 && app.onboarding == crate::tui::app::OnboardingState::None
386 && is_open(app);
387 let motion = visible && crate::tui::underwater::decorative_shell_motion_enabled(app);
388 let waiting = matches!(
389 ShellPhase::from_app(app),
390 ShellPhase::Waiting | ShellPhase::Approval
391 );
392 let sound_allowed = visible
393 && app.onboarding == crate::tui::app::OnboardingState::None
394 && !app.notification_settings.quiet
395 && !app.notification_settings.event_sound.quiet;
396 let controls = app
397 .pet_watch
398 .controls
399 .lock()
400 .map(|mut c| std::mem::take(&mut *c))
401 .unwrap_or_default();
402 let state = &mut app.pet_watch;
403 if state.session != app.current_session_id {
404 state.reset(app.current_session_id.clone());
405 }
406 if visible {
407 state.ensure(app.current_session_id.clone());
408 }
409 for control in controls {
410 apply(state, control)
411 }
412 if let Some(update) = state
413 .worker
414 .as_ref()
415 .and_then(|w| w.latest.lock().ok().and_then(|mut s| s.take()))
416 {
417 if update.scene.audio_unavailable {
418 state.sound_requested = false;
419 }
420 state.raster = Some(update);
421 state.unavailable = false;
422 if visible {
423 app.needs_redraw = true;
424 }
425 }
426 if state.raster.as_ref().is_some_and(|r| {
427 now.saturating_duration_since(r.frame_changed) > Duration::from_millis(800)
428 }) {
429 state.raster = None;
430 if visible {
431 app.needs_redraw = true;
432 }
433 }
434 if state
435 .last_tick
436 .is_none_or(|t| now.saturating_duration_since(t) >= Duration::from_millis(30))
437 {
438 let a = state.area.unwrap_or(Rect::new(0, 0, 40, 8));
439 let cell = crossterm::terminal::window_size()
440 .ok()
441 .filter(|s| s.columns > 0 && s.rows > 0 && s.width > 0 && s.height > 0)
442 .map(|s| {
443 (
444 f64::from(s.width) / f64::from(s.columns),
445 f64::from(s.height) / f64::from(s.rows),
446 )
447 })
448 .unwrap_or((8.0, 16.0));
449 let next = live::View {
450 width: a.width.clamp(1, 512),
451 height: a.height.saturating_sub(1).clamp(1, 256),
452 cell_width: cell.0,
453 cell_height: cell.1,
454 motion,
455 pixels: crate::tui::mark::kitty_graphics_supported()
456 && app.synchronized_output_enabled
457 && std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() != Ok("braille"),
458 visible,
459 waiting,
460 sound: state.sound_requested && sound_allowed,
461 };
462 if let Some(worker) = &state.worker
463 && let Ok(mut view) = worker.view.lock()
464 {
465 *view = next;
466 }
467 state.last_tick = Some(now);
468 }
469 let notices: Vec<_> = state
470 .worker
471 .as_ref()
472 .map(|w| w.notices.try_iter().take(16).collect())
473 .unwrap_or_default();
474 for notice in notices {
475 app.pet_watch.exporting = false;
476 let (text, level) = match notice {
477 Notice::Exported(path) => (
478 tr(app.ui_locale, MessageId::PetWatchExported)
479 .replace("{path}", &path.display().to_string()),
480 StatusToastLevel::Info,
481 ),
482 // A refused action (select, export, open) leaves a reachable
483 // companion and the habitat as they are.
484 Notice::Message(message) => (
485 format!(
486 "{} · {message}",
487 tr(app.ui_locale, MessageId::PetWatchUnavailable)
488 ),
489 StatusToastLevel::Warning,
490 ),
491 Notice::Unreachable(message) => {
492 app.pet_watch.unavailable = true;
493 if app.is_loading && is_open(app) {
494 app.view_stack.pop();
495 }
496 (
497 format!(
498 "{} · {message}",
499 tr(app.ui_locale, MessageId::PetWatchUnavailable)
500 ),
501 StatusToastLevel::Warning,
502 )
503 }
504 };
505 app.add_message(crate::tui::history::HistoryCell::System {
506 content: text.clone(),
507 });
508 app.push_status_toast(text, level, Some(12000));
509 app.needs_redraw = true;
510 }
511 }
512 fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
513 app.pet_watch.area = Some(area);
514 let raster = app.pet_watch.raster.as_ref();
515 let hollow = raster.is_none_or(|r| !r.scene.producer_connected || r.scene.style.hollow);
516 let scene = raster
517 .map(|r| {
518 let mut text = format!(
519 "{} · {} · {}",
520 r.scene.style.channel, r.scene.style.arch, r.scene.behaviour
521 );
522 if let Some(activity) = &r.scene.activity
523 && activity.observed
524 && activity.freshness == codewhale_protocol::engine_owner::OwnerFreshness::Fresh
525 && r.frame_changed.elapsed().as_millis() < 800
526 {
527 if let Some(kind) = activity.activity_kind {
528 text = format!("{} · {}", kind.as_str(), text);
529 }
530 if activity.parallel_agent_count > 0 {
531 text.push_str(&format!(" · ×{}", activity.parallel_agent_count));
532 }
533 }
534 text
535 })
536 .unwrap_or_default();
537 // With no companion frame the tank still paints the resting whale and
538 // says why, instead of a blank tank under an orphan separator.
539 let label = if raster.is_none() && app.pet_watch.unavailable {
540 tr(app.ui_locale, MessageId::PetOffline).into_owned()
541 } else {
542 let presence = hollow.then(|| tr(app.ui_locale, MessageId::PetUnobserved));
543 let sound = tr(app.ui_locale, app.pet_watch.sound_label());
544 [
545 Some(scene.as_str()),
546 presence.as_deref(),
547 Some(sound.as_ref()),
548 ]
549 .into_iter()
550 .flatten()
551 .filter(|part| !part.is_empty())
552 .collect::<Vec<_>>()
553 .join(" · ")
554 };
555 let image = (app.view_stack.is_empty()
556 || app.view_stack.top_kind() == Some(ModalKind::PetHabitat))
557 && raster.is_some_and(|r| {
558 r.image.is_some() && r.width == area.width && r.height == area.height.saturating_sub(1)
559 })
560 && area.height >= 4;
561 let bg = raster
562 .map(|r| r.scene.appearance.background)
563 .unwrap_or([8, 15, 21]);
564 let ink = raster
565 .map(|r| {
566 Color::Rgb(
567 r.scene.style.r.clamp(0.0, 255.0) as u8,
568 r.scene.style.g.clamp(0.0, 255.0) as u8,
569 r.scene.style.b.clamp(0.0, 255.0) as u8,
570 )
571 })
572 .unwrap_or(Color::Rgb(180, 210, 216));
573 frame.render_widget(
574 Block::default().style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2]))),
575 area,
576 );
577 if image {
578 let tank = Rect {
579 height: area.height.saturating_sub(1),
580 ..area
581 };
582 app.pet_watch.desired = Some(tank);
583 frame.render_widget(
584 Paragraph::new(label).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
585 Rect {
586 y: area.bottom() - 1,
587 height: 1,
588 ..area
589 },
590 );
591 } else {
592 crate::tui::ambient_life::pet_widget::render_grid(
593 area,
594 frame.buffer_mut(),
595 raster
596 .filter(|r| r.width == area.width && r.height == area.height.saturating_sub(1))
597 .map_or(&[], |r| r.cells.as_slice()),
598 &label,
599 Style::default().fg(ink),
600 );
601 if raster.is_none() {
602 paint_resting_whale(frame, area, Style::default().fg(ink));
603 }
604 }
605 }
606
607 /// The launch screen's braille whale, centred in the tank above its caption,
608 /// at the largest rung that fits. Static: it rests until the companion's own
609 /// frames take over the tank.
610 fn paint_resting_whale(frame: &mut Frame, area: Rect, style: Style) {
611 use crate::tui::mark::MarkSize;
612 let tank_height = area.height.saturating_sub(1);
613 let Some(size) = [MarkSize::Large, MarkSize::Small, MarkSize::Tiny]
614 .into_iter()
615 .find(|size| {
616 let (cols, rows) = size.cells();
617 cols <= area.width && rows <= tank_height
618 })
619 else {
620 return;
621 };
622 let (cols, rows) = size.cells();
623 let x0 = area.x + (area.width - cols) / 2;
624 let y0 = area.y + (tank_height - rows) / 2;
625 let buf = frame.buffer_mut();
626 for (dy, row) in size.rows().iter().enumerate() {
627 for (dx, ch) in row.chars().enumerate() {
628 if ch == ' ' {
629 continue;
630 }
631 if let Some(cell) = buf.cell_mut((x0 + dx as u16, y0 + dy as u16)) {
632 cell.set_char(ch).set_style(style);
633 }
634 }
635 }
636 }
637 pub fn render_full(frame: &mut Frame, app: &mut App) {
638 let area = frame.area();
639 frame.render_widget(
640 Block::default().style(Style::default().bg(app.ui_theme.surface_bg)),
641 area,
642 );
643 frame.render_widget(
644 Paragraph::new(tr(app.ui_locale, MessageId::PetHabitatTitle))
645 .style(chrome_style(&app.ui_theme, ChromeInk::Active)),
646 Rect { height: 1, ..area },
647 );
648 let tank = Rect {
649 x: area.x,
650 y: area.y + 2,
651 width: area.width,
652 height: if app.pet_watch.work_complete {
653 area.height.saturating_sub(5) / 3
654 } else {
655 area.height.saturating_sub(5)
656 },
657 };
658 render_tank(frame, tank, app);
659 if app.pet_watch.work_complete {
660 let result_area = Rect {
661 x: area.x.saturating_add(3),
662 y: tank.bottom().saturating_add(1),
663 width: area.width.saturating_sub(6),
664 height: area
665 .bottom()
666 .saturating_sub(tank.bottom())
667 .saturating_sub(4),
668 };
669 let result = app
670 .history
671 .iter()
672 .skip(app.pet_watch.work_history_start)
673 .rfind(|cell| {
674 matches!(
675 cell,
676 crate::tui::history::HistoryCell::Assistant { .. }
677 | crate::tui::history::HistoryCell::Error { .. }
678 )
679 });
680 let lines = result
681 .map(|cell| cell.transcript_lines(result_area.width))
682 .unwrap_or_else(|| {
683 vec![ratatui::text::Line::from(
684 tr(app.ui_locale, MessageId::NotificationTurnComplete).into_owned(),
685 )]
686 });
687 app.pet_watch.result_scroll = app.pet_watch.result_scroll.min(
688 lines
689 .len()
690 .saturating_sub(usize::from(result_area.height))
691 .min(usize::from(u16::MAX)) as u16,
692 );
693 frame.render_widget(
694 Paragraph::new(lines).scroll((app.pet_watch.result_scroll, 0)),
695 result_area,
696 );
697 }
698 let hints = if app.pet_watch.work_complete {
699 format!(
700 "↑↓ / PgUp/PgDn {} · {}",
701 tr(app.ui_locale, MessageId::SetupActionScrollBody),
702 habitat::hints(app.ui_locale)
703 )
704 } else {
705 habitat::hints(app.ui_locale)
706 };
707 frame.render_widget(
708 Paragraph::new(hints).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
709 Rect {
710 x: area.x,
711 y: area.bottom().saturating_sub(2),
712 width: area.width,
713 height: 2,
714 },
715 );
716 }
717 pub(crate) fn clear_images(output: &mut impl Write) -> io::Result<()> {
718 graphics::clear(output)
719 }
720
721 #[cfg(test)]
722 mod tests {
723 use super::*;
724 use serde_json::Value;
725
726 #[test]
727 fn work_completion_reveals_existing_answer_without_sending_text_to_owner() {
728 use crate::core::events::TurnOutcomeStatus;
729 let mut app =
730 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
731 app.onboarding = crate::tui::app::OnboardingState::None;
732 app.redaction_gate = false;
733 assert!(app.view_stack.is_empty());
734 app.input = "retained draft".into();
735 app.pet_watch.session = app.current_session_id.clone();
736 app.pet_watch.failed = true; // No connection or provider for this shell test.
737 app.pet_watch.enabled = true;
738 observe(
739 &mut app,
740 &Event::TurnStarted {
741 turn_id: "preview-turn".into(),
742 created_at: chrono::Utc::now(),
743 route: None,
744 submission_id: None,
745 },
746 Instant::now(),
747 );
748 tick(&mut app, Instant::now());
749 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::PetHabitat));
750 app.add_message(crate::tui::history::HistoryCell::Assistant {
751 content: "Prepared result stays in the transcript".into(),
752 streaming: false,
753 });
754 observe(
755 &mut app,
756 &Event::TurnComplete {
757 usage: Default::default(),
758 parent_route_usage: Default::default(),
759 routed_usage_dropped_records: 0,
760 status: TurnOutcomeStatus::Completed,
761 error: None,
762 tool_catalog: None,
763 base_url: None,
764 },
765 Instant::now(),
766 );
767 assert!(app.pet_watch.work_complete);
768 let mut terminal =
769 ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 40)).unwrap();
770 terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
771 let text = terminal
772 .backend()
773 .buffer()
774 .content
775 .iter()
776 .map(|cell| cell.symbol())
777 .collect::<String>();
778 assert!(text.contains("Prepared result stays in the transcript"));
779 assert_eq!(app.input, "retained draft");
780 assert!(app.pet_watch.worker.is_none());
781 }
782
783 #[test]
784 fn unavailable_companion_paints_the_resting_whale_and_keeps_the_turn_visible() {
785 let mut app =
786 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
787 app.onboarding = crate::tui::app::OnboardingState::None;
788 app.redaction_gate = false;
789 app.pet_watch.session = app.current_session_id.clone();
790 app.pet_watch.detach_for_test();
791 app.pet_watch.unavailable = true;
792 let mut terminal =
793 ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 16)).unwrap();
794 terminal
795 .draw(|frame| render_tank(frame, frame.area(), &mut app))
796 .unwrap();
797 let text = terminal
798 .backend()
799 .buffer()
800 .content
801 .iter()
802 .map(|cell| cell.symbol())
803 .collect::<String>();
804 assert!(
805 text.contains(crate::tui::mark::MarkSize::Large.rows()[3].trim()),
806 "the tank paints the resting whale: {text}"
807 );
808 assert!(
809 text.contains("offline — codewhale pet serve wakes it"),
810 "{text}"
811 );
812 assert!(!text.contains(" · offline"), "no orphan separator: {text}");
813
814 app.pet_watch.enabled = true;
815 observe(
816 &mut app,
817 &Event::TurnStarted {
818 turn_id: "turn".into(),
819 created_at: chrono::Utc::now(),
820 route: None,
821 submission_id: None,
822 },
823 Instant::now(),
824 );
825 tick(&mut app, Instant::now());
826 assert!(
827 app.view_stack.is_empty(),
828 "pet mode must not cover a turn while the companion is unavailable"
829 );
830 }
831
832 #[test]
833 fn a_refused_pet_action_keeps_the_habitat_and_only_unreachable_marks_offline() {
834 let mut app =
835 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
836 app.onboarding = crate::tui::app::OnboardingState::None;
837 app.redaction_gate = false;
838 app.pet_watch.session = app.current_session_id.clone();
839 app.pet_watch.detach_for_test();
840 let (tx, _commands) = std::sync::mpsc::sync_channel(4);
841 let (notices_tx, notices) = std::sync::mpsc::sync_channel(4);
842 app.pet_watch.worker = Some(Worker {
843 tx,
844 latest: std::sync::Arc::new(std::sync::Mutex::new(None)),
845 view: std::sync::Arc::new(std::sync::Mutex::new(live::View::default())),
846 notices,
847 });
848 open_habitat(&mut app);
849 app.is_loading = true;
850
851 notices_tx
852 .send(Notice::Message(
853 "Save the terminal session before exporting".into(),
854 ))
855 .unwrap();
856 tick(&mut app, Instant::now());
857 assert!(is_open(&app), "a refused export must not close pet mode");
858 assert!(
859 !app.pet_watch.unavailable,
860 "a refused export is not offline"
861 );
862
863 notices_tx
864 .send(Notice::Unreachable("Shared pet reconnecting".into()))
865 .unwrap();
866 tick(&mut app, Instant::now());
867 assert!(app.pet_watch.unavailable);
868 assert!(
869 !is_open(&app),
870 "an unreachable companion hands the running turn back"
871 );
872 }
873
874 #[test]
875 fn pet_off_stops_automatic_entry_and_keeps_the_draft() {
876 let mut app =
877 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
878 app.onboarding = crate::tui::app::OnboardingState::None;
879 app.redaction_gate = false;
880 app.input = "kept draft".into();
881 app.pet_watch.session = app.current_session_id.clone();
882 app.pet_watch.detach_for_test();
883 app.pet_watch.enabled = true;
884 observe(
885 &mut app,
886 &Event::TurnStarted {
887 turn_id: "turn".into(),
888 created_at: chrono::Utc::now(),
889 route: None,
890 submission_id: None,
891 },
892 Instant::now(),
893 );
894 assert!(app.pet_watch.work_enter_pending);
895 set_enabled(&mut app, false);
896 tick(&mut app, Instant::now());
897 assert!(!app.pet_watch.enabled);
898 assert!(!app.pet_watch.work_enter_pending);
899 assert!(app.view_stack.is_empty());
900 assert_eq!(app.input, "kept draft");
901 assert!(app.pet_watch.worker.is_none());
902 }
903
904 #[test]
905 fn only_completed_turns_set_the_pet_done_state() {
906 let mut app =
907 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
908 app.onboarding = crate::tui::app::OnboardingState::None;
909 app.redaction_gate = false;
910 app.pet_watch.detach_for_test();
911 open_habitat(&mut app);
912
913 let finish = |status| Event::TurnComplete {
914 usage: Default::default(),
915 parent_route_usage: Default::default(),
916 routed_usage_dropped_records: 0,
917 status,
918 error: None,
919 tool_catalog: None,
920 base_url: None,
921 };
922 for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
923 observe(
924 &mut app,
925 &Event::TurnStarted {
926 turn_id: format!("turn-{:?}", status),
927 created_at: chrono::Utc::now(),
928 route: None,
929 submission_id: None,
930 },
931 Instant::now(),
932 );
933 observe(&mut app, &finish(status), Instant::now());
934 assert!(!app.pet_watch.work_complete, "{status:?} cannot mark Done");
935 }
936
937 observe(
938 &mut app,
939 &Event::TurnStarted {
940 turn_id: "turn-completed".into(),
941 created_at: chrono::Utc::now(),
942 route: None,
943 submission_id: None,
944 },
945 Instant::now(),
946 );
947 observe(
948 &mut app,
949 &finish(TurnOutcomeStatus::Completed),
950 Instant::now(),
951 );
952 assert!(app.pet_watch.work_complete);
953 assert!(app.pet_watch.worker.is_none());
954 }
955
956 #[test]
957 fn session_switch_resets_the_view_and_tracks_the_switching_turn() {
958 let mut watch = PetWatch::default();
959 watch.detach_for_test();
960 watch.session = Some("session-a".into());
961 watch.active_turn_id = Some("turn-a".into());
962 watch.work_complete = true;
963
964 watch.observe(
965 &Event::TurnStarted {
966 turn_id: "turn-b".into(),
967 created_at: chrono::Utc::now(),
968 route: None,
969 submission_id: None,
970 },
971 Some("session-b"),
972 Instant::now(),
973 );
974 assert_eq!(watch.session.as_deref(), Some("session-b"));
975 // The event that switched sessions is not dropped: its turn is the
976 // one a later `turn_complete` must carry to claim Done.
977 assert_eq!(watch.active_turn_id.as_deref(), Some("turn-b"));
978 assert!(!watch.work_complete, "Done never survives a session switch");
979 assert!(!watch.failed, "a switch clears a prior failure");
980 assert!(
981 watch.worker.is_none(),
982 "with no live view running, an Engine event never starts one"
983 );
984
985 let done = metadata(
986 &Event::TurnComplete {
987 usage: Default::default(),
988 parent_route_usage: Default::default(),
989 routed_usage_dropped_records: 0,
990 status: TurnOutcomeStatus::Completed,
991 error: None,
992 tool_catalog: None,
993 base_url: None,
994 },
995 watch.active_turn_id.as_deref(),
996 )
997 .unwrap();
998 assert_eq!(
999 serde_json::from_str::<Value>(&done).unwrap(),
1000 json!({"event":"turn_complete","turn_id":"turn-b","turn_outcome":"completed"})
1001 );
1002 }
1003
1004 #[test]
1005 fn foreground_projection_forwards_only_typed_owner_metadata() {
1006 let call = metadata(
1007 &Event::ToolCallStarted {
1008 model_call: None,
1009 id: "call-a".into(),
1010 name: "exec_command".into(),
1011 input: json!({"command":"PRIVATE TOOL INPUT"}),
1012 },
1013 Some("turn-a"),
1014 );
1015 assert!(
1016 call.is_none(),
1017 "tool names and inputs are not activity facts"
1018 );
1019
1020 let thought = metadata(
1021 &Event::ThinkingDelta {
1022 index: 2,
1023 content: "PRIVATE REASONING".into(),
1024 },
1025 None,
1026 )
1027 .unwrap();
1028 assert_eq!(
1029 serde_json::from_str::<Value>(&thought).unwrap(),
1030 json!({"event":"response_delta","index":2,"channel":"reasoning"})
1031 );
1032 let message = metadata(
1033 &Event::MessageDelta {
1034 index: 3,
1035 content: "PRIVATE MESSAGE".into(),
1036 },
1037 None,
1038 )
1039 .unwrap();
1040
1041 let operation = metadata(
1042 &Event::OperationActivityStarted {
1043 span_id: "private-internal-span".into(),
1044 activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind::Computer,
1045 },
1046 None,
1047 )
1048 .unwrap();
1049 assert_eq!(
1050 serde_json::from_str::<Value>(&operation).unwrap(),
1051 json!({
1052 "event":"operation_activity_started",
1053 "span_id":"private-internal-span",
1054 "activity_kind":"computer"
1055 })
1056 );
1057 let completed = metadata(
1058 &Event::OperationActivityCompleted {
1059 span_id: "private-internal-span".into(),
1060 activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind::Editing,
1061 outcome: codewhale_protocol::engine_owner::OwnerOperationOutcome::Denied,
1062 },
1063 None,
1064 )
1065 .unwrap();
1066 assert_eq!(
1067 serde_json::from_str::<Value>(&completed).unwrap(),
1068 json!({
1069 "event":"operation_activity_completed",
1070 "span_id":"private-internal-span",
1071 "activity_kind":"editing",
1072 "outcome":"denied"
1073 })
1074 );
1075
1076 let turn = metadata(
1077 &Event::TurnComplete {
1078 usage: Default::default(),
1079 parent_route_usage: Default::default(),
1080 routed_usage_dropped_records: 0,
1081 status: TurnOutcomeStatus::Completed,
1082 error: None,
1083 tool_catalog: None,
1084 base_url: None,
1085 },
1086 Some("stable-turn-a"),
1087 )
1088 .unwrap();
1089 assert_eq!(
1090 serde_json::from_str::<Value>(&turn).unwrap(),
1091 json!({
1092 "event":"turn_complete",
1093 "turn_id":"stable-turn-a",
1094 "turn_outcome":"completed"
1095 })
1096 );
1097
1098 assert!(!message.contains("PRIVATE"));
1099 assert!(call.is_none());
1100 assert!(!thought.contains("PRIVATE"));
1101 assert!(!operation.contains("tool_name"));
1102 assert!(!operation.contains("PRIVATE"));
1103 }
1104
1105 /// The pet's multi-agent count is derived on the JS side from
1106 /// `agent:`-prefixed spans, keyed by the `id` this projection forwards
1107 /// (app-side issue #12). The counting itself is proven in
1108 /// `pet/tests/pet-engine.test.mjs`; the handoff is the half that fails
1109 /// silently — a trimmed allowlist or a dropped id zeroes `parallel` with
1110 /// no error and no log line, and nothing else here covers agent events.
1111 /// Pin the wire shape the JS dispatches on, and keep child text off it.
1112 #[test]
1113 fn agent_events_forward_span_identity_without_child_text() {
1114 use crate::core::events::AgentProgressEventMeta;
1115 use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus};
1116
1117 let spawned = metadata(
1118 &Event::AgentSpawned {
1119 display_name: None,
1120 owner_session_id: "session-a".into(),
1121 id: "agent-1".into(),
1122 prompt: "PRIVATE CHILD PROMPT".into(),
1123 worker_status: Some(AgentWorkerStatus::Running),
1124 parent_run_id: Some("run-9".into()),
1125 spawn_depth: 2,
1126 model: "PRIVATE CHILD MODEL".into(),
1127 route_source: Some("task.model".into()),
1128 },
1129 None,
1130 )
1131 .expect("agent spawns are observed");
1132 assert_eq!(
1133 serde_json::from_str::<Value>(&spawned).unwrap(),
1134 // A spawn opens the span; the reducer reads no status from it.
1135 json!({"event":"agent_spawned","id":"agent-1"})
1136 );
1137
1138 // The JS finishes a span when progress reports a terminal status.
1139 let progress = metadata(
1140 &Event::AgentProgress {
1141 owner_session_id: "session-a".into(),
1142 id: "agent-1".into(),
1143 status: "PRIVATE PROGRESS TEXT".into(),
1144 activity: AgentProgressEventMeta {
1145 worker_status: AgentWorkerStatus::Completed,
1146 step: Some(3),
1147 tool_name: Some("exec_command".into()),
1148 routine_wait: false,
1149 approval_id: None,
1150 },
1151 parent_run_id: Some("run-9".into()),
1152 spawn_depth: 2,
1153 },
1154 None,
1155 )
1156 .expect("agent progress is observed");
1157 assert_eq!(
1158 serde_json::from_str::<Value>(&progress).unwrap(),
1159 json!({"event":"agent_progress","id":"agent-1","worker_status":"completed"})
1160 );
1161
1162 let complete = metadata(
1163 &Event::AgentComplete {
1164 display_name: None,
1165 owner_session_id: "session-a".into(),
1166 id: "agent-1".into(),
1167 result: "PRIVATE CHILD RESULT".into(),
1168 outcome: Some(SubAgentStatus::Completed),
1169 parent_run_id: Some("run-9".into()),
1170 spawn_depth: Some(2),
1171 continuable: Some(false),
1172 usage: None,
1173 },
1174 None,
1175 )
1176 .expect("agent completions are observed");
1177 assert_eq!(
1178 serde_json::from_str::<Value>(&complete).unwrap(),
1179 json!({"event":"agent_complete","id":"agent-1"})
1180 );
1181
1182 for (label, payload) in [
1183 ("spawned", &spawned),
1184 ("progress", &progress),
1185 ("complete", &complete),
1186 ] {
1187 assert!(
1188 !payload.contains("PRIVATE"),
1189 "{label} leaked child text: {payload}"
1190 );
1191 }
1192 }
1193 }
1194
1194 lines RUST