feat(tui): use native full pet mode for the real Engine session

Replace the full-screen tank layout with the shared GPUI whale, current-session retained agents, streamed replies/errors and existing notices. Preserve composer/session state, route Enter through the existing agent transcript/approval event, and revalidate painted worker identities. Use the pet view motion policy and canonical completion clip without a second world, prompt or execution owner.

Validation: focused pet/owner/audio/persistence tests 30 passed, 0 failed; keyboard routing 17 passed, 0 failed; draft/focus binding guard 1 passed, 0 failed. Scoped production Clippy passed with the three deliberate project-style allowances from CI; changed-path formatting passed. Library PR #23 merged after all 10 hosted checks passed; local component tests 5+1 passed and idle PTY 7/7 passed, with both 200-frame previews matching actual buffers.

Earlier raw Clippy hit 20 findings in unchanged code covered by those documented style allowances; its failure receipt is retained. Updated two old test assumptions for native availability and context-owned Tab. macOS warned that the large unit-test binary exceeds the compact unwind section limit; test exit was 0. No full local suite, live providers, physical terminal/audio, installed release or simultaneous desktop-owner acceptance claimed.

Stage only 13 declared paths. Saved foreign avatar hunks remain unstaged, including the two adjacent preview guards removed from the index by exact baseline subtraction. This feature stays separate from release-candidate PR #6907. Evidence: artifacts/ratatui-full-pet-20261008; SHA-6533.
This commit is contained in:
CodeWhale Bot committed 2026-10-08 06:29:27 -07:00
1 parent 9524531de7
commit 5e2e59e87b
13 files changed
+2605 -174

No files matched your search

+475
View File
@@ -0,0 +1,475 @@
//! The actual `/pet on` surface over the foreground Engine session. This
//! replaces the old full-screen tank layout; the companion still owns its
//! durable world and audio. No prompt, worker or execution authority lives here.
use super::{PetWatch, habitat};
use crate::{
agent_roster::RosterState,
tools::subagent::AgentWorkerStatus,
tui::{
app::App,
history::HistoryCell,
shell_key_routing::{self, ShellBindingId as Id},
underwater::ShellPhase,
},
};
use codewhale_localization::{MessageId, tr};
use codewhale_protocol::engine_owner::OwnerFreshness;
use codewhale_ratatui::{
AgentCard, Caps, MotionMode, PetMode, PetModeAreas, State, StatusMark, Subagent,
SubagentControls, SubagentUsage, SubagentViewWords, Theme,
whale_motion::{Activity, Context, Inputs, Presence, Span},
};
use ratatui::{Frame, text::Line};
use std::time::{Duration, Instant};
pub(super) fn canonical(state: &PetWatch) -> bool {
matches!(state.avatars.key.as_str(), "" | "whale")
&& state.avatars.action.is_none()
&& state.avatars.view.is_none()
}
fn theme(app: &App) -> Theme {
let mut caps = Caps::detect();
if let Some(appearance) =
codewhale_ratatui::detect::appearance_for_background(app.ui_theme.surface_bg)
{
caps.appearance = appearance;
}
Theme::new(caps).tui_palette(codewhale_ratatui::TuiPalette::Underwater)
}
pub(super) fn agents(app: &App) -> Vec<Subagent<'static>> {
app.current_agent_roster()
.iter()
.map(|row| {
let (state, label) = match row.state {
RosterState::Running if row.status == AgentWorkerStatus::Queued => {
(State::Ready, MessageId::AgentRailQueuedCount)
}
RosterState::Running => (State::Working, MessageId::SubagentsStatusRunning),
RosterState::Waiting => (State::NeedsYou, MessageId::PhaseWaitingOnYou),
RosterState::Parked => (State::Stopped, MessageId::AgentStatusParked),
RosterState::Done => (State::Done, MessageId::SubagentsStatusCompleted),
RosterState::Failed => (State::Failed, MessageId::SubagentsStatusFailed),
RosterState::Cancelled => (State::Stopped, MessageId::SubagentsStatusCancelled),
};
let mut card = AgentCard::new(row.display_name.clone(), state)
.status_word(tr(app.ui_locale, label).replace("{count}", "1"))
.task(row.activity.clone().unwrap_or_default());
if !row.model.trim().is_empty() {
card = card.route(row.model.clone());
}
let mut agent = Subagent::new(row.worker_id.clone(), card);
agent.elapsed = row.millis.map(Duration::from_millis);
agent.usage = Some(SubagentUsage {
input_tokens: row.input_tokens,
output_tokens: row.output_tokens,
cost_microusd: row.cost_microusd,
});
agent.steps = Some(row.steps_taken);
// This pane is the retained roster. Enter opens the existing full
// agent transcript, so long outcomes are not cloned on animation frames.
agent.controls = SubagentControls {
open: true,
..Default::default()
};
agent
})
.collect()
}
fn inputs(app: &App, now: Instant) -> Inputs {
let presence = match ShellPhase::from_app(app) {
ShellPhase::Working | ShellPhase::Verifying => Presence::Working,
ShellPhase::Waiting | ShellPhase::Approval => Presence::NeedsYou,
ShellPhase::Typing => Presence::Listening,
ShellPhase::Done => Presence::Done,
ShellPhase::Idle | ShellPhase::Failed => Presence::Idle,
};
let raster = app.pet_watch.raster.as_ref().filter(|r| {
r.scene.producer_connected
&& now.saturating_duration_since(r.frame_changed) < Duration::from_millis(800)
&& Some(r.scene.source.as_str()) == app.current_session_id.as_deref()
});
let owner = raster.and_then(|r| r.scene.activity.as_ref()).filter(|a| {
a.observed && a.freshness == OwnerFreshness::Fresh && a.session_id == app.current_session_id
});
Inputs {
presence,
activity: owner.map(|a| Activity {
kind: a.activity_kind.map(|k| k.as_str().into()),
observed: true,
parallel: Some(f64::from(a.parallel_agent_count)),
active: a
.active_spans
.iter()
.map(|s| Span {
kind: s.activity_kind.as_str().into(),
since_ms: s.started_at_ms,
})
.collect(),
}),
context: Context {
live: owner.is_some(),
turn_id: app.runtime_turn_id.clone(),
status: app.runtime_turn_status.clone(),
// Only owner time, never a local timer pretending to be an error receipt.
now_ms: raster.map(|r| r.scene.time_ms),
..Default::default()
},
}
}
pub(super) fn motion(app: &App) -> MotionMode {
// The habitat owns the viewport through the modal stack. The ordinary
// shell's ambient helper intentionally stops under every modal, so using
// it here would freeze the pet simply because its own view is open.
match app.motion_policy().mode() {
crate::tui::motion::MotionMode::Still => MotionMode::Still,
crate::tui::motion::MotionMode::Reduced => MotionMode::Reduced,
crate::tui::motion::MotionMode::Full
if matches!(
ShellPhase::from_app(app),
ShellPhase::Approval | ShellPhase::Waiting
) =>
{
MotionMode::Reduced
}
crate::tui::motion::MotionMode::Full => MotionMode::Full,
}
}
pub(super) fn tick(app: &mut App, visible: bool, now: Instant) {
app.pet_watch.full.set_visible(visible);
if !visible {
app.pet_watch.full_next = None;
return;
}
let agents = agents(app);
// Revalidate the painted identity against the current session before the
// modal can return an open intent. A removed/ambiguous worker never turns
// into the new fallback selection before it has actually been painted.
if let Ok(mut selected) = app.pet_watch.selection.lock()
&& selected.as_deref().is_some_and(|id| {
agents
.iter()
.filter(|agent| agent.id == id && agent.controls.open)
.count()
!= 1
})
{
*selected = None;
}
let inputs = inputs(app, now);
let changed = app.pet_watch.full_inputs.as_ref().is_none_or(|old| {
old.presence != inputs.presence
|| old.activity != inputs.activity
|| old.context.live != inputs.context.live
|| old.context.turn_id != inputs.context.turn_id
|| old.context.status != inputs.context.status
});
let motion = motion(app);
let theme = theme(app);
let state = &mut app.pet_watch;
state.full.update(
app.current_session_id.as_deref(),
inputs.clone(),
&agents,
now,
motion,
);
state.full_inputs = Some(inputs);
if changed {
state.full_next = None;
app.needs_redraw = true;
}
if let Some(interval) = state.full.next_frame_in(&agents, &theme) {
if state.full_next.is_none_or(|deadline| now >= deadline) {
state.full_next = now.checked_add(interval);
app.needs_redraw = true;
}
} else {
state.full_next = None;
}
}
pub fn render_full(frame: &mut Frame, app: &mut App) {
let agents = agents(app);
let theme = theme(app);
let phase = ShellPhase::from_app(app);
let notice = app.active_status_toast(phase).map(|toast| {
Line::styled(
toast.text,
codewhale_palette::chrome_style(&app.ui_theme, toast.level.ink()),
)
});
let mark = StatusMark::new(match phase {
ShellPhase::Working | ShellPhase::Verifying => State::Working,
ShellPhase::Approval | ShellPhase::Waiting => State::NeedsYou,
ShellPhase::Done => State::Done,
ShellPhase::Failed => State::Failed,
ShellPhase::Idle | ShellPhase::Typing => State::Ready,
})
.word(phase.label(app.ui_locale).into_owned());
// ActiveCell is the same live transcript the normal shell reads. Include
// streamed replies and terminal errors, without waiting for a successful turn.
let response = app
.history
.iter()
.skip(app.pet_watch.work_history_start)
.chain(
app.active_cell
.as_ref()
.into_iter()
.flat_map(|cell| cell.entries()),
)
.rfind(|cell| {
matches!(
cell,
HistoryCell::Assistant { .. } | HistoryCell::Error { .. }
)
});
let plan = PetModeAreas::new(
frame.area(),
!agents.is_empty(),
response.is_some(),
app.pet_watch.full.focus_agents,
);
let owner = inputs(app, Instant::now());
let motion = motion(app);
let key = (
app.history_version,
app.active_cell.as_ref().map_or(0, |cell| cell.revision()),
app.pet_watch.work_history_start,
);
if app.pet_watch.full_response_key != Some(key) {
app.pet_watch.full_response = None;
app.pet_watch.full_response_key = Some(key);
}
let mut options = app.transcript_render_options();
options.low_motion = true;
options.motion_mode = crate::tui::motion::MotionMode::Reduced;
let cache = app
.pet_watch
.full_response
.get_or_insert_with(crate::tui::transcript::TranscriptViewCache::new);
cache.ensure_split(
&[response.map_or(&[], std::slice::from_ref)],
if response.is_some() { &[0] } else { &[] },
plan.output.width.max(1),
options,
&std::collections::HashMap::new(),
None,
None,
);
let lines = cache.lines();
let mut hints = if frame.area().width >= 100 {
habitat::hints(app.ui_locale)
} else {
format!(
"{} {}",
shell_key_routing::binding(Id::PetBack).footer_chord,
tr(app.ui_locale, MessageId::SetupActionBack)
)
};
if !agents.is_empty() {
hints.push_str(&format!(
" · {} {}",
shell_key_routing::binding(Id::PetFocusAgents).footer_chord,
tr(app.ui_locale, MessageId::SubagentsHeaderRoster)
));
if app.pet_watch.full.focus_agents {
hints.push_str(&format!(
" · {} {}",
shell_key_routing::binding(Id::PetOpenAgent).footer_chord,
tr(app.ui_locale, MessageId::CtxInspTranscript)
));
}
}
if !lines.is_empty() {
hints.push_str(&format!(
" · {}{} {}",
shell_key_routing::binding(Id::PetResultUp).footer_chord,
shell_key_routing::binding(Id::PetResultDown).footer_chord,
tr(app.ui_locale, MessageId::SetupActionScrollBody)
));
}
let mut view = PetMode::new(&theme, mark);
view.notice = notice;
view.title = "Codewhale".into();
view.agents = &agents;
view.agent_words = SubagentViewWords {
title: tr(app.ui_locale, MessageId::SubagentsHeaderRoster),
empty: tr(
app.ui_locale,
MessageId::SubagentsNoCurrentSessionFleetWorkers,
),
hints: "".into(),
open: "".into(),
message: "".into(),
stop: "".into(),
summary: Some(agents.len().to_string().into()),
steps_label: Some(tr(app.ui_locale, MessageId::SessionMetricsSteps)),
..Default::default()
};
view.output = lines;
view.output_title = tr(app.ui_locale, MessageId::AgentFocusResult);
view.hints = hints.into();
app.pet_watch.full.set_visible(true);
app.pet_watch.full.update(
app.current_session_id.as_deref(),
owner,
&agents,
Instant::now(),
motion,
);
frame.render_stateful_widget(view, frame.area(), &mut app.pet_watch.full);
if let Ok(mut selection) = app.pet_watch.selection.lock() {
*selection = app.pet_watch.full.selected_agent_id().map(str::to_owned);
}
if canonical(&app.pet_watch) {
app.pet_watch.area = Some(plan.pet);
} else {
// Existing reviewed avatar packs and explicit action/view previews keep
// their renderer and permissions; only the full-screen layout is shared.
super::render_tank(frame, plan.pet, app);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::{active_cell::ActiveCell, app::OnboardingState};
fn app() -> App {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = OnboardingState::None;
app.redaction_gate = false;
app.pet_watch.session = app.current_session_id.clone();
app.pet_watch.detach_for_test();
app
}
fn paint(app: &mut App, width: u16, height: u16) -> String {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| render_full(frame, app)).unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn full_mode_animates_under_its_own_modal_and_respects_motion_preferences() {
let mut app = app();
app.is_loading = true;
app.low_motion = false;
app.fancy_animations = true;
super::super::open_habitat(&mut app);
assert!(super::super::is_open(&app));
assert_eq!(motion(&app), MotionMode::Full);
paint(&mut app, 100, 32);
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.full_next.is_some());
app.low_motion = true;
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.full_next.is_none());
app.low_motion = false;
app.fancy_animations = false;
assert_eq!(motion(&app), MotionMode::Still);
}
#[test]
fn roster_scope_and_missing_vs_zero_receipts_come_from_the_existing_owner_guard() {
let mut app = app();
app.current_session_id = Some("foreground".into());
app.agent_roster_session_id = Some("other-session".into());
app.agent_roster = vec![crate::agent_roster::AgentRosterRow {
worker_id: "retained-worker".into(),
display_name: "Retained worker".into(),
model: "reported-model".into(),
state: RosterState::Waiting,
status: AgentWorkerStatus::WaitingForUser,
activity: Some("Owner activity".into()),
outcome: None,
millis: None,
input_tokens: Some(0),
output_tokens: None,
cost_microusd: Some(1),
steps_taken: 0,
parent_run_id: None,
run_id: "actual-run".into(),
}];
assert!(agents(&app).is_empty());
*app.pet_watch.selection.lock().unwrap() = Some("retained-worker".into());
tick(&mut app, true, Instant::now());
assert!(app.pet_watch.selection.lock().unwrap().is_none());
app.agent_roster_session_id = app.current_session_id.clone();
let rows = agents(&app);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "retained-worker");
assert_eq!(rows[0].card.status.state, State::NeedsYou);
assert_eq!(rows[0].usage.unwrap().input_tokens, Some(0));
assert_eq!(rows[0].usage.unwrap().output_tokens, None);
assert_eq!(rows[0].usage.unwrap().cost_microusd, Some(1));
assert_eq!(rows[0].elapsed, None);
app.agent_roster[0].state = RosterState::Parked;
assert_eq!(
agents(&app)[0].card.status.word,
tr(app.ui_locale, MessageId::AgentStatusParked)
);
}
#[test]
fn full_mode_reads_streaming_response_then_failure_and_preserves_the_session() {
let mut app = app();
app.is_loading = true;
app.input = "retained draft".into();
let session = app.current_session_id.clone();
let mut active = ActiveCell::new();
active.push_untracked(HistoryCell::Assistant {
content: "Streaming response from this session".into(),
streaming: true,
});
app.active_cell = Some(active);
assert!(paint(&mut app, 80, 24).contains("Streaming response from this session"));
app.active_cell = None;
app.is_loading = false;
app.runtime_turn_status = Some("failed".into());
app.add_message(HistoryCell::Error {
message: "Actual failure receipt".into(),
severity: crate::error_taxonomy::ErrorSeverity::Error,
});
assert!(paint(&mut app, 40, 12).contains("Actual failure receipt"));
assert_eq!(app.input, "retained draft");
assert_eq!(app.current_session_id, session);
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn reduced_completed_view_reuses_the_response_and_does_not_schedule_paints() {
let mut app = app();
app.runtime_turn_status = Some("completed".into());
app.add_message(HistoryCell::Assistant {
content: "Finished answer".into(),
streaming: false,
});
let first = paint(&mut app, 100, 32);
let lines = app
.pet_watch
.full_response
.as_ref()
.unwrap()
.lines()
.as_ptr();
assert_eq!(paint(&mut app, 100, 32), first);
assert_eq!(
app.pet_watch
.full_response
.as_ref()
.unwrap()
.lines()
.as_ptr(),
lines
);
tick(&mut app, true, Instant::now() + Duration::from_secs(2));
assert!(app.pet_watch.full_next.is_none());
tick(&mut app, false, Instant::now() + Duration::from_secs(3));
assert!(app.pet_watch.full_next.is_none());
}
}
+36 -5
View File
@@ -3,17 +3,21 @@
use super::Control;
use crate::tui::{
shell_key_routing::{self, Focus, ShellBindingId as Id},
views::{ModalKind, ModalView, ViewAction},
views::{ModalKind, ModalView, ViewAction, ViewEvent},
};
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{buffer::Buffer, layout::Rect};
use std::sync::{Arc, Mutex};
pub struct Habitat {
controls: Arc<Mutex<Vec<Control>>>,
selection: Arc<Mutex<Option<String>>>,
}
impl Habitat {
pub fn new(controls: Arc<Mutex<Vec<Control>>>) -> Self {
Self { controls }
pub fn new(controls: Arc<Mutex<Vec<Control>>>, selection: Arc<Mutex<Option<String>>>) -> Self {
Self {
controls,
selection,
}
}
}
impl ModalView for Habitat {
@@ -27,6 +31,15 @@ impl ModalView for Habitat {
Some(Id::PetResultPageUp) => Some(Control::Scroll(-10)),
Some(Id::PetResultPageDown) => Some(Control::Scroll(10)),
Some(Id::PetBack) => return ViewAction::Close,
Some(Id::PetFocusAgents) => Some(Control::FocusAgents),
Some(Id::PetResultStart) => Some(Control::ScrollEnd(false)),
Some(Id::PetResultEnd) => Some(Control::ScrollEnd(true)),
Some(Id::PetOpenAgent) => {
if let Some(agent_id) = self.selection.lock().ok().and_then(|s| s.clone()) {
return ViewAction::EmitAndClose(ViewEvent::OpenAgentTranscript { agent_id });
}
None
}
Some(Id::PetSound) => Some(Control::Sound),
Some(Id::PetBrowser) => Some(Control::Browser),
Some(Id::PetWindow) => Some(Control::Window),
@@ -40,7 +53,12 @@ impl ModalView for Habitat {
}
ViewAction::None
}
fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if let Ok(mut queue) = self.controls.lock()
&& queue.len() < 16
{
queue.push(Control::Mouse(mouse));
}
ViewAction::None
}
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
@@ -70,6 +88,18 @@ mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyModifiers};
#[test]
fn enter_opens_only_the_painted_worker_through_the_existing_event_and_closes_pet_focus() {
let controls = Arc::new(Mutex::new(Vec::new()));
let selection = Arc::new(Mutex::new(None));
let mut view = Habitat::new(controls, selection.clone());
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(matches!(view.handle_key(enter), ViewAction::None));
*selection.lock().unwrap() = Some("worker-id".into());
assert!(
matches!(view.handle_key(enter), ViewAction::EmitAndClose(ViewEvent::OpenAgentTranscript { agent_id }) if agent_id == "worker-id")
);
}
#[test]
fn pet_habitat_full_view_preserves_composer_history_and_session_on_escape() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
@@ -81,7 +111,8 @@ mod tests {
let session = app.current_session_id.clone();
// Push the actual focus owner without starting a network client.
let controls = Arc::new(Mutex::new(Vec::new()));
app.view_stack.push(Habitat::new(controls.clone()));
app.view_stack
.push(Habitat::new(controls.clone(), Arc::new(Mutex::new(None))));
assert_eq!(app.focus(), Focus::Modal(ModalKind::PetHabitat));
assert_eq!(
shell_key_routing::route(
+76 -166
View File
@@ -24,8 +24,10 @@ mod appearance;
mod audio;
mod audio_cursor;
mod avatars;
mod full;
mod graphics;
mod habitat;
pub use full::render_full;
mod live;
pub(crate) mod owner;
mod persistence;
@@ -39,6 +41,9 @@ pub enum Control {
Window,
Select,
Scroll(i16),
FocusAgents,
ScrollEnd(bool),
Mouse(crossterm::event::MouseEvent),
}
#[derive(Default)]
pub struct PetWatch {
@@ -66,9 +71,13 @@ pub struct PetWatch {
/// `/pet on`: accepted turns enter the full habitat automatically.
pub(crate) enabled: bool,
work_enter_pending: bool,
work_complete: bool,
work_history_start: usize,
result_scroll: u16,
full: codewhale_ratatui::PetModeState,
full_next: Option<Instant>,
full_response: Option<crate::tui::transcript::TranscriptViewCache>,
full_response_key: Option<(u64, u64, usize)>,
full_inputs: Option<codewhale_ratatui::whale_motion::Inputs>,
selection: Arc<Mutex<Option<String>>>,
}
impl PetWatch {
pub fn avatar_choices(&self) -> String {
@@ -103,8 +112,15 @@ impl PetWatch {
self.unavailable = false;
self.last_tick = None;
self.work_enter_pending = false;
self.work_complete = false;
self.result_scroll = 0;
self.work_history_start = 0;
self.full = codewhale_ratatui::PetModeState::default();
self.full_next = None;
self.full_response = None;
self.full_response_key = None;
self.full_inputs = None;
if let Ok(mut selection) = self.selection.lock() {
*selection = None;
}
self.avatars.view = None;
self.avatars.action = None;
}
@@ -318,25 +334,31 @@ fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
pub fn command(app: &mut App, control: Control) {
app.pet_watch.ensure(app.current_session_id.clone());
apply(&mut app.pet_watch, control);
let agents = full::agents(app);
apply(&mut app.pet_watch, control, &agents);
app.needs_redraw = true;
}
fn apply(state: &mut PetWatch, control: Control) {
fn apply(state: &mut PetWatch, control: Control, agents: &[codewhale_ratatui::Subagent<'_>]) {
match control {
Control::Browser => state.send(Command::Browser),
Control::Window => state.send(Command::Window),
Control::Select => state.send(Command::Select),
Control::Sound => state.sound_requested = !state.sound_requested,
Control::Scroll(delta) => {
state.result_scroll = state.result_scroll.saturating_add_signed(delta)
Control::Scroll(delta) => state.full.scroll(agents, delta),
Control::FocusAgents => state.full.toggle_agents(agents),
Control::ScrollEnd(end) => state.full.scroll_end(agents, end),
Control::Mouse(mouse) => {
state.full.handle_mouse(agents, mouse);
}
}
}
pub fn open_habitat(app: &mut App) {
app.pet_watch.ensure(app.current_session_id.clone());
if app.view_stack.top_kind() != Some(ModalKind::PetHabitat) {
app.view_stack
.push(habitat::Habitat::new(app.pet_watch.controls.clone()));
app.view_stack.push(habitat::Habitat::new(
app.pet_watch.controls.clone(),
app.pet_watch.selection.clone(),
));
}
app.needs_redraw = true;
}
@@ -354,7 +376,6 @@ pub fn set_enabled(app: &mut App, enabled: bool) {
return;
}
app.pet_watch.work_enter_pending = false;
app.pet_watch.work_complete = false;
if is_open(app) {
app.view_stack.pop();
}
@@ -369,13 +390,10 @@ pub fn observe(app: &mut App, event: &Event, now: Instant) {
.observe(event, app.current_session_id.as_deref(), now);
if matches!(event, Event::TurnStarted { .. }) {
app.pet_watch.work_history_start = app.history.len();
app.pet_watch.work_complete = false;
app.pet_watch.result_scroll = 0;
app.pet_watch.full.output_scroll = 0;
app.pet_watch.work_enter_pending = app.pet_watch.enabled;
} else if let Event::TurnComplete { status, .. } = event {
} else if matches!(event, Event::TurnComplete { .. }) {
app.pet_watch.work_enter_pending = false;
app.pet_watch.work_complete = *status == TurnOutcomeStatus::Completed
&& app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
app.needs_redraw = true;
}
}
@@ -386,9 +404,9 @@ pub fn tick(app: &mut App, now: Instant) {
&& app.onboarding == crate::tui::app::OnboardingState::None
{
app.pet_watch.work_enter_pending = false;
// Pet mode never hides a running turn behind a companion that is
// not there; the transcript stays in view until it answers again.
if !app.pet_watch.unavailable {
// Alternate avatars need their companion raster; the native whale
// and Engine response remain available while it reconnects.
if full::canonical(&app.pet_watch) || !app.pet_watch.unavailable {
open_habitat(app);
}
}
@@ -397,7 +415,7 @@ pub fn tick(app: &mut App, now: Instant) {
let visible = !app.redaction_gate
&& app.onboarding == crate::tui::app::OnboardingState::None
&& is_open(app);
let motion = visible && crate::tui::underwater::decorative_shell_motion_enabled(app);
let motion = visible && full::motion(app).animates();
if visible && app.pet_watch.avatars.refresh(&app.workspace, now) {
app.needs_redraw = true;
}
@@ -415,6 +433,11 @@ pub fn tick(app: &mut App, now: Instant) {
.lock()
.map(|mut c| std::mem::take(&mut *c))
.unwrap_or_default();
let agents = if controls.is_empty() {
Vec::new()
} else {
full::agents(app)
};
let state = &mut app.pet_watch;
if state.session != app.current_session_id {
state.reset(app.current_session_id.clone());
@@ -423,7 +446,8 @@ pub fn tick(app: &mut App, now: Instant) {
state.ensure(app.current_session_id.clone());
}
for control in controls {
apply(state, control)
apply(state, control, &agents);
app.needs_redraw = true;
}
if let Some(update) = state
.worker
@@ -435,7 +459,7 @@ pub fn tick(app: &mut App, now: Instant) {
}
state.raster = Some(update);
state.unavailable = false;
if visible {
if visible && !full::canonical(state) {
app.needs_redraw = true;
}
}
@@ -467,8 +491,9 @@ pub fn tick(app: &mut App, now: Instant) {
height: a.height.saturating_sub(1).clamp(1, 256),
cell_width: cell.0,
cell_height: cell.1,
motion,
pixels: matches!(state.avatars.key.as_str(), "" | "whale")
motion: motion && !full::canonical(state),
pixels: !full::canonical(state)
&& matches!(state.avatars.key.as_str(), "" | "whale")
&& crate::tui::mark::kitty_graphics_supported()
&& app.synchronized_output_enabled
&& std::env::var("CODEWHALE_PET_GRAPHICS").as_deref() != Ok("braille"),
@@ -507,7 +532,7 @@ pub fn tick(app: &mut App, now: Instant) {
),
Notice::Unreachable(message) => {
app.pet_watch.unavailable = true;
if app.is_loading && is_open(app) {
if app.is_loading && is_open(app) && !full::canonical(&app.pet_watch) {
app.view_stack.pop();
}
(
@@ -525,6 +550,7 @@ pub fn tick(app: &mut App, now: Instant) {
app.push_status_toast(text, level, Some(12000));
app.needs_redraw = true;
}
full::tick(app, visible, now);
}
fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
app.pet_watch.area = Some(area);
@@ -592,7 +618,7 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
area,
);
let (act, clock) = avatars::act(raster, app.is_loading);
let reduced = !crate::tui::underwater::decorative_shell_motion_enabled(app);
let reduced = !full::motion(app).animates();
let tank = Rect {
height: area.height.saturating_sub(1),
..area
@@ -678,86 +704,6 @@ fn paint_resting_whale(frame: &mut Frame, area: Rect, style: Style) {
}
}
}
pub fn render_full(frame: &mut Frame, app: &mut App) {
let area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(app.ui_theme.surface_bg)),
area,
);
frame.render_widget(
Paragraph::new(tr(app.ui_locale, MessageId::PetHabitatTitle))
.style(chrome_style(&app.ui_theme, ChromeInk::Active)),
Rect { height: 1, ..area },
);
let tank = Rect {
x: area.x,
y: area.y + 2,
width: area.width,
height: if app.pet_watch.work_complete {
area.height.saturating_sub(5) / 3
} else {
area.height.saturating_sub(5)
},
};
render_tank(frame, tank, app);
if app.pet_watch.work_complete {
let result_area = Rect {
x: area.x.saturating_add(3),
y: tank.bottom().saturating_add(1),
width: area.width.saturating_sub(6),
height: area
.bottom()
.saturating_sub(tank.bottom())
.saturating_sub(4),
};
let result = app
.history
.iter()
.skip(app.pet_watch.work_history_start)
.rfind(|cell| {
matches!(
cell,
crate::tui::history::HistoryCell::Assistant { .. }
| crate::tui::history::HistoryCell::Error { .. }
)
});
let lines = result
.map(|cell| cell.transcript_lines(result_area.width))
.unwrap_or_else(|| {
vec![ratatui::text::Line::from(
tr(app.ui_locale, MessageId::NotificationTurnComplete).into_owned(),
)]
});
app.pet_watch.result_scroll = app.pet_watch.result_scroll.min(
lines
.len()
.saturating_sub(usize::from(result_area.height))
.min(usize::from(u16::MAX)) as u16,
);
frame.render_widget(
Paragraph::new(lines).scroll((app.pet_watch.result_scroll, 0)),
result_area,
);
}
let hints = if app.pet_watch.work_complete {
format!(
"↑↓ / PgUp/PgDn {} · {}",
tr(app.ui_locale, MessageId::SetupActionScrollBody),
habitat::hints(app.ui_locale)
)
} else {
habitat::hints(app.ui_locale)
};
frame.render_widget(
Paragraph::new(hints).style(chrome_style(&app.ui_theme, ChromeInk::Metadata)),
Rect {
x: area.x,
y: area.bottom().saturating_sub(2),
width: area.width,
height: 2,
},
);
}
pub(crate) fn clear_images(output: &mut impl Write) -> io::Result<()> {
graphics::clear(output)
}
@@ -808,7 +754,6 @@ mod tests {
},
Instant::now(),
);
assert!(app.pet_watch.work_complete);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 40)).unwrap();
terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
@@ -825,7 +770,7 @@ mod tests {
}
#[test]
fn unavailable_companion_paints_the_resting_whale_and_keeps_the_turn_visible() {
fn unavailable_companion_keeps_legacy_fallback_and_native_response_available() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
@@ -856,6 +801,7 @@ mod tests {
assert!(!text.contains(" · offline"), "no orphan separator: {text}");
app.pet_watch.enabled = true;
app.is_loading = true;
observe(
&mut app,
&Event::TurnStarted {
@@ -866,11 +812,25 @@ mod tests {
},
Instant::now(),
);
app.add_message(crate::tui::history::HistoryCell::Assistant {
content: "Fixture response while telemetry reconnects".into(),
streaming: true,
});
tick(&mut app, Instant::now());
assert!(
app.view_stack.is_empty(),
"pet mode must not cover a turn while the companion is unavailable"
is_open(&app),
"native pet mode keeps the actual turn available without a companion raster"
);
terminal.draw(|frame| render_full(frame, &mut app)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Fixture response while telemetry reconnects"));
assert!(app.pet_watch.worker.is_none());
}
#[test]
@@ -910,8 +870,8 @@ mod tests {
tick(&mut app, Instant::now());
assert!(app.pet_watch.unavailable);
assert!(
!is_open(&app),
"an unreachable companion hands the running turn back"
is_open(&app),
"the canonical whale and Engine response remain available while telemetry reconnects"
);
}
@@ -945,65 +905,14 @@ mod tests {
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn only_completed_turns_set_the_pet_done_state() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.onboarding = crate::tui::app::OnboardingState::None;
app.redaction_gate = false;
app.pet_watch.detach_for_test();
open_habitat(&mut app);
let finish = |status| Event::TurnComplete {
usage: Default::default(),
parent_route_usage: Default::default(),
routed_usage_dropped_records: 0,
status,
error: None,
tool_catalog: None,
base_url: None,
};
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
observe(
&mut app,
&Event::TurnStarted {
turn_id: format!("turn-{:?}", status),
created_at: chrono::Utc::now(),
route: None,
submission_id: None,
},
Instant::now(),
);
observe(&mut app, &finish(status), Instant::now());
assert!(!app.pet_watch.work_complete, "{status:?} cannot mark Done");
}
observe(
&mut app,
&Event::TurnStarted {
turn_id: "turn-completed".into(),
created_at: chrono::Utc::now(),
route: None,
submission_id: None,
},
Instant::now(),
);
observe(
&mut app,
&finish(TurnOutcomeStatus::Completed),
Instant::now(),
);
assert!(app.pet_watch.work_complete);
assert!(app.pet_watch.worker.is_none());
}
#[test]
fn session_switch_resets_the_view_and_tracks_the_switching_turn() {
let mut watch = PetWatch::default();
watch.detach_for_test();
watch.session = Some("session-a".into());
watch.active_turn_id = Some("turn-a".into());
watch.work_complete = true;
watch.full.output_scroll = 100;
watch.work_history_start = 50;
watch.observe(
&Event::TurnStarted {
@@ -1019,7 +928,8 @@ mod tests {
// The event that switched sessions is not dropped: its turn is the
// one a later `turn_complete` must carry to claim Done.
assert_eq!(watch.active_turn_id.as_deref(), Some("turn-b"));
assert!(!watch.work_complete, "Done never survives a session switch");
assert_eq!(watch.full.output_scroll, 0);
assert_eq!(watch.work_history_start, 0);
assert!(!watch.failed, "a switch clears a prior failure");
assert!(
watch.worker.is_none(),
+32
View File
@@ -99,6 +99,10 @@ pub enum ShellBindingId {
PetResultPageUp,
PetResultPageDown,
PetBack,
PetResultStart,
PetResultEnd,
PetFocusAgents,
PetOpenAgent,
PetSound,
PetBrowser,
PetWindow,
@@ -155,6 +159,10 @@ impl ShellBinding {
key.code == KeyCode::PageDown && key.modifiers.is_empty()
}
ShellBindingId::PetBack => key.code == KeyCode::Esc && key.modifiers.is_empty(),
ShellBindingId::PetResultStart => key.code == KeyCode::Home && key.modifiers.is_empty(),
ShellBindingId::PetResultEnd => key.code == KeyCode::End && key.modifiers.is_empty(),
ShellBindingId::PetFocusAgents => key.code == KeyCode::Tab && key.modifiers.is_empty(),
ShellBindingId::PetOpenAgent => key.code == KeyCode::Enter && key.modifiers.is_empty(),
ShellBindingId::PetSound => key.code == KeyCode::F(6) && key.modifiers.is_empty(),
ShellBindingId::PetBrowser => key.code == KeyCode::F(8) && key.modifiers.is_empty(),
ShellBindingId::PetWindow => key.code == KeyCode::F(9) && key.modifiers.is_empty(),
@@ -252,6 +260,30 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
footer_chord: "Esc",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetResultStart,
catalog_chord: "Home",
footer_chord: "Home",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetResultEnd,
catalog_chord: "End",
footer_chord: "End",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetFocusAgents,
catalog_chord: "Tab",
footer_chord: "Tab",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetOpenAgent,
catalog_chord: "Enter",
footer_chord: "Enter",
focus: FocusScope::PetHabitat,
},
ShellBinding {
id: ShellBindingId::PetSound,
catalog_chord: "F6",
+11 -3
View File
@@ -895,6 +895,10 @@ fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
ShellBindingId::PetResultPageUp => KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
ShellBindingId::PetResultPageDown => KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
ShellBindingId::PetBack => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::PetResultStart => KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
ShellBindingId::PetResultEnd => KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
ShellBindingId::PetFocusAgents => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
ShellBindingId::PetOpenAgent => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
ShellBindingId::PetSound => KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE),
ShellBindingId::PetBrowser => KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE),
ShellBindingId::PetWindow => KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE),
@@ -962,9 +966,13 @@ fn no_shell_binding_changes_meaning_once_the_composer_has_text() {
.focus
.admits(crate::tui::shell_key_routing::Focus::Composer)
{
assert_eq!(
on_typed, None,
"exclusive view keys must not act on a draft"
// A chord may serve a different owner: Tab cycles the composer's
// mode and switches panes inside pet mode. The modal binding
// itself must never act on the composer draft.
assert_ne!(
on_typed,
Some(binding.id),
"exclusive view bindings must not act on a draft"
);
continue;
}
+5
View File
@@ -22,6 +22,11 @@ pub struct Sprite<'a> {
frame: usize,
}
impl<'a> Sprite<'a> {
/// One bounded RGBA image, such as the canonical pet's live cove.
pub fn image(pixels: &'a [u8], width: usize, height: usize) -> Result<Self, String> {
Self::tiles(pixels, width, height, 0, 1)
}
pub fn new(
pack: &crate::avatar::Pack,
pixels: &'a [u8],
+7
View File
@@ -118,3 +118,10 @@ pub use dock_tabs::*;
mod braille_frame;
pub(crate) use braille_frame::paint_braille_cells;
pub use braille_frame::*;
mod whale_pet;
pub use whale_pet::*;
mod subagents;
pub use subagents::*;
mod pet_mode;
pub use pet_mode::*;
+476
View File
@@ -0,0 +1,476 @@
//! The full pet work surface. Replaces host-specific full-screen tank layout;
//! the host still owns sessions, roster receipts, responses and every action.
use crate::{
MotionMode, Role, StatusMark, Subagent, SubagentIntent, SubagentView, SubagentViewState,
SubagentViewWords, Theme, WhalePet, text,
whale_motion::{Inputs, Presence, Stage, Tier},
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget, Wrap},
};
use std::{
borrow::Cow,
time::{Duration, Instant},
};
/// Geometry shared by paint, response wrapping and host pointer routing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PetModeAreas {
pub header: Rect,
pub pet: Rect,
pub agents: Rect,
pub output: Rect,
pub footer: Rect,
}
impl PetModeAreas {
#[must_use]
pub fn new(area: Rect, agents: bool, output: bool, focus_agents: bool) -> Self {
let header = Rect {
height: area.height.min(2),
..area
};
let footer_height = area.height.saturating_sub(header.height).min(2);
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(footer_height),
area.width,
footer_height,
);
let body = Rect::new(
area.x,
header.bottom(),
area.width,
area.height.saturating_sub(header.height + footer_height),
);
let mut plan = Self {
header,
footer,
..Self::default()
};
let wide = agents && body.width >= 100 && body.height >= 12;
let main = if wide {
let width = (body.width / 3).clamp(32, 44);
plan.agents = Rect::new(body.right() - width, body.y, width, body.height);
Rect {
width: body.width.saturating_sub(width + 2),
..body
}
} else if agents && focus_agents {
plan.agents = body;
return plan;
} else {
body
};
if output && main.height >= 8 {
// Reading has priority over scenery, including at 80x24.
let height = (u32::from(main.height) * 2 / 3).max(6) as u16;
plan.output = Rect::new(main.x, main.bottom() - height, main.width, height);
plan.pet = Rect {
height: main.height - height,
..main
};
} else if output {
plan.output = main;
} else {
plan.pet = main;
}
plan
}
}
/// One canonical parent performance plus local selection/scroll. Never Clone.
#[derive(Default)]
pub struct PetModeState {
stage: Stage,
agents: SubagentViewState,
pub output_scroll: usize,
pub focus_agents: bool,
areas: PetModeAreas,
session: Option<String>,
visible: bool,
motion: MotionMode,
stage_animates: bool,
}
impl PetModeState {
/// The owner supplies current facts and one clock before painting.
pub fn update(
&mut self,
session: Option<&str>,
inputs: Inputs,
agents: &[Subagent<'_>],
now: Instant,
motion: MotionMode,
) {
if self.session.as_deref() != session {
self.agents = SubagentViewState::default();
self.agents.set_visible(self.visible);
self.output_scroll = 0;
self.focus_agents = false;
self.session = session.map(str::to_owned);
}
let settled = inputs.presence == Presence::Done
|| (matches!(
inputs.presence,
Presence::Idle | Presence::Listening | Presence::Offline
) && matches!(
inputs.context.status.as_deref(),
Some("completed" | "failed" | "error" | "cancelled" | "canceled" | "interrupted")
));
self.motion = motion;
self.stage.set_visible(self.visible);
self.agents.set_visible(self.visible);
self.stage
.observe(session, inputs.clone(), !motion.animates());
self.stage.advance(now);
self.stage_animates = !settled || self.stage.director().completing();
if !self.stage_animates {
self.stage.observe(session, inputs, true);
}
self.agents.update(agents, now, motion);
if agents.is_empty() {
self.focus_agents = false;
}
}
pub fn set_visible(&mut self, visible: bool) {
self.visible = visible;
self.stage.set_visible(visible);
self.agents.set_visible(visible);
}
#[must_use]
pub fn next_frame_in(&self, agents: &[Subagent<'_>], theme: &Theme) -> Option<Duration> {
if !self.visible || !self.motion.animates() {
return None;
}
let pet = (!self.areas.pet.is_empty() && self.stage_animates)
.then(|| {
self.stage.cadence(if theme.paints_grounds() {
Tier::Hero
} else {
Tier::Terminal
})
})
.flatten();
[pet, self.agents.next_frame_in(agents, theme)]
.into_iter()
.flatten()
.min()
}
/// Painted selection only; hosts validate the ID again before opening.
#[must_use]
pub fn selected_agent_id(&self) -> Option<&str> {
(self.visible && self.focus_agents)
.then(|| self.agents.selected_id())
.flatten()
}
pub fn toggle_agents(&mut self, agents: &[Subagent<'_>]) {
if !agents.is_empty() {
self.focus_agents = !self.focus_agents;
}
}
/// Result scrolling or roster navigation, depending on the visible focus.
pub fn scroll(&mut self, agents: &[Subagent<'_>], lines: i16) {
if lines == 0 {
return;
}
if self.focus_agents {
let index = self
.agents
.selected_id()
.and_then(|id| agents.iter().position(|agent| agent.id == id))
.unwrap_or(0);
let target = index
.saturating_add_signed(isize::from(lines))
.min(agents.len().saturating_sub(1));
if let Some(agent) = agents.get(target) {
self.agents.select(agents, &agent.id);
}
} else {
self.output_scroll = self.output_scroll.saturating_add_signed(isize::from(lines));
}
}
/// Home/End route to the focused roster or the complete response.
pub fn scroll_end(&mut self, agents: &[Subagent<'_>], end: bool) {
if self.focus_agents {
self.agents.handle_key(
agents,
KeyEvent::from(if end { KeyCode::End } else { KeyCode::Home }),
);
} else {
self.output_scroll = if end { usize::MAX } else { 0 };
}
}
pub fn open_agent(&mut self, agents: &[Subagent<'_>]) -> Option<SubagentIntent> {
if !self.focus_agents {
return None;
}
self.agents
.handle_key(agents, KeyEvent::from(KeyCode::Enter))
}
/// Tab switches the composed pane. Focused roster keys return the same
/// capability-gated intents as SubagentView; the host owns every action.
pub fn handle_key(&mut self, agents: &[Subagent<'_>], key: KeyEvent) -> Option<SubagentIntent> {
if !self.visible
|| key.kind == KeyEventKind::Release
|| !key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
{
return None;
}
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
self.toggle_agents(agents);
return None;
}
if self.focus_agents {
return self.agents.handle_key(agents, key);
}
match key.code {
KeyCode::Up => self.scroll(agents, -1),
KeyCode::Down => self.scroll(agents, 1),
KeyCode::PageUp => self.scroll(agents, -10),
KeyCode::PageDown => self.scroll(agents, 10),
KeyCode::Home => self.scroll_end(agents, false),
KeyCode::End => self.scroll_end(agents, true),
_ => {}
}
None
}
/// A click/wheel on the roster uses its recorded IDs; a wheel on the
/// response scrolls it. No pointer input executes a worker action.
pub fn handle_mouse(&mut self, agents: &[Subagent<'_>], mouse: MouseEvent) -> bool {
if !self.visible {
return false;
}
let point = (mouse.column, mouse.row).into();
let cove_point = WhalePet::point(self.areas.pet, mouse.column, mouse.row);
if cove_point.is_none() {
self.stage.cove_leave();
}
if self.areas.agents.contains(point) && self.agents.handle_mouse(agents, mouse) {
self.focus_agents = true;
return true;
}
if self.areas.output.contains(point) {
match mouse.kind {
MouseEventKind::ScrollUp => {
self.output_scroll = self.output_scroll.saturating_sub(3)
}
MouseEventKind::ScrollDown => {
self.output_scroll = self.output_scroll.saturating_add(3)
}
_ => return false,
}
self.focus_agents = false;
return true;
}
if let Some((x, y)) = cove_point {
self.stage.cove_observe(x, y);
if matches!(
mouse.kind,
MouseEventKind::Down(crossterm::event::MouseButton::Left)
) {
self.stage.cove_tap(x, y);
}
return true;
}
false
}
}
/// Full pet-mode composition. Response lines are already wrapped by the host's
/// existing Markdown/transcript renderer at [`PetModeAreas::output`] width.
/// Pass only actual roster facts and localized words. Paint never advances time.
pub struct PetMode<'a> {
pub theme: &'a Theme,
pub title: Cow<'a, str>,
pub status: StatusMark,
/// The host's existing notice, using its semantic severity ink.
pub notice: Option<Line<'a>>,
pub agents: &'a [Subagent<'a>],
pub agent_words: SubagentViewWords<'a>,
pub output: &'a [Line<'a>],
pub output_title: Cow<'a, str>,
pub hints: Cow<'a, str>,
}
impl<'a> PetMode<'a> {
#[must_use]
pub fn new(theme: &'a Theme, status: StatusMark) -> Self {
Self {
theme,
title: "Codewhale".into(),
status,
notice: None,
agents: &[],
agent_words: SubagentViewWords::default(),
output: &[],
output_title: "Response".into(),
hints: "Esc back".into(),
}
}
}
impl StatefulWidget for PetMode<'_> {
type State = PetModeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let area = area.intersection(buf.area);
state.areas = PetModeAreas::default();
if !state.visible || area.is_empty() {
return;
}
buf.set_style(
area,
self.theme
.bg(Role::Background)
.patch(self.theme.fg(Role::Foreground)),
);
let plan = PetModeAreas::new(
area,
!self.agents.is_empty(),
!self.output.is_empty(),
state.focus_agents,
);
state.areas = plan;
if !plan.header.is_empty() {
Paragraph::new(Line::from(
vec![
Span::styled(
text::display_safe(&self.title).into_owned(),
self.theme.fg(Role::Primary),
),
Span::raw(" "),
]
.into_iter()
.chain(self.status.spans(self.theme))
.collect::<Vec<_>>(),
))
.render(
Rect {
height: 1,
..plan.header
},
buf,
);
}
if plan.header.height > 1
&& let Some(line) = self.notice
{
let safe = Line::from(
line.spans
.iter()
.map(|span| {
Span::styled(text::display_safe(&span.content).into_owned(), span.style)
})
.collect::<Vec<_>>(),
)
.style(line.style);
Paragraph::new(safe).render(
Rect::new(plan.header.x, plan.header.y + 1, plan.header.width, 1),
buf,
);
}
if !plan.pet.is_empty() {
WhalePet::new(self.theme)
.words(self.status.word.to_string())
.paint(plan.pet, buf, &mut state.stage);
}
// Render even the hidden roster's empty area to clear stale hitboxes.
SubagentView::new(self.agents, self.theme)
.words(self.agent_words)
.render(plan.agents, buf, &mut state.agents);
if !plan.output.is_empty() {
Paragraph::new(text::display_safe(&self.output_title).into_owned())
.style(self.theme.fg(Role::Primary))
.render(
Rect {
height: 1,
..plan.output
},
buf,
);
let body = Rect::new(
plan.output.x,
plan.output.y + 1,
plan.output.width,
plan.output.height.saturating_sub(1),
);
state.output_scroll = state
.output_scroll
.min(self.output.len().saturating_sub(usize::from(body.height)));
// usize selection preserves arbitrarily long already-wrapped output.
for (y, line) in self
.output
.iter()
.skip(state.output_scroll)
.take(usize::from(body.height))
.enumerate()
{
let safe = Line::from(
line.spans
.iter()
.map(|span| {
Span::styled(text::display_safe(&span.content).into_owned(), span.style)
})
.collect::<Vec<_>>(),
)
.style(line.style);
Paragraph::new(safe)
.render(Rect::new(body.x, body.y + y as u16, body.width, 1), buf);
}
}
if !plan.footer.is_empty() {
Paragraph::new(text::display_safe(&self.hints).into_owned())
.wrap(Wrap { trim: true })
.style(self.theme.fg(Role::Muted))
.render(plan.footer, buf);
}
}
}
#[cfg(test)]
mod visibility_tests {
use super::*;
#[test]
fn default_hidden_updates_do_not_advance_the_parent_clock() {
let mut state = PetModeState::default();
let now = Instant::now();
let inputs = Inputs {
presence: Presence::Working,
activity: None,
context: Default::default(),
};
state.update(Some("session"), inputs.clone(), &[], now, MotionMode::Full);
state.update(
Some("session"),
inputs.clone(),
&[],
now + Duration::from_secs(30),
MotionMode::Full,
);
assert!(!state.stage.is_visible());
assert_eq!(state.stage.director().f, 0.);
state.set_visible(true);
state.update(
Some("session"),
inputs,
&[],
now + Duration::from_secs(30),
MotionMode::Full,
);
assert_eq!(state.stage.director().f, 0.);
}
}
File diff suppressed because it is too large. Load diff
+211
View File
@@ -0,0 +1,211 @@
//! The fully animated GPUI pet as a stateful Ratatui component. The host
//! observes and advances one shared Stage; painting never advances time.
use std::borrow::Cow;
use ratatui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use crate::whale_motion::{CoveScene, Stage, View, pixels, rasterize_colored, rig};
use crate::{Paint, Theme, Whale, WhaleState, avatar_sprite::Sprite};
/// The native pet's rendering style. Both read the same live contours.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PetStyle {
/// Full color curves, apertures, props and cove, using terminal half-blocks.
#[default]
Color,
/// The canonical colored Braille geometry, without scenery.
Braille,
}
/// A live whale in its GPUI cove: springs, fins, eyes, blinks, props, particles
/// and calves all come from the existing canonical performance. Pointer input
/// is decorative and enters through the host's [`Stage::cove_observe`] and
/// [`Stage::cove_tap`]; it never changes Engine state.
///
/// The host supplies authoritative [`crate::whale_motion::Inputs`], calls
/// [`Stage::advance`] once per frame, and schedules with
/// [`Stage::cadence`] (Hero for color, Terminal for Braille). Reduced motion
/// paints a settled poster and schedules no animation. Unsupported color
/// profiles use Braille; ASCII and small areas show the status words alone.
///
/// ```no_run
/// use codewhale_ratatui::{Theme, WhalePet, whale_motion::Stage};
/// # fn draw(frame: &mut ratatui::Frame<'_>, stage: &mut Stage) {
/// let theme = Theme::detect();
/// stage.advance(std::time::Instant::now());
/// frame.render_stateful_widget(WhalePet::new(&theme), frame.area(), stage);
/// # }
/// ```
pub struct WhalePet<'a> {
theme: &'a Theme,
words: Option<Cow<'a, str>>,
cove: bool,
style: PetStyle,
direction: rig::Direction,
}
impl<'a> WhalePet<'a> {
#[must_use]
pub const fn new(theme: &'a Theme) -> Self {
Self {
theme,
words: None,
cove: true,
style: PetStyle::Color,
direction: rig::MARK_DIRECTION,
}
}
/// Localized words from the host. The default names the current action.
#[must_use]
pub fn words(mut self, words: impl Into<Cow<'a, str>>) -> Self {
self.words = Some(words.into());
self
}
#[must_use]
pub const fn cove(mut self, shown: bool) -> Self {
self.cove = shown;
self
}
#[must_use]
pub const fn style(mut self, style: PetStyle) -> Self {
self.style = style;
self
}
/// The shared rig's mark, cruise or open view; never a separate animation.
#[must_use]
pub const fn direction(mut self, direction: rig::Direction) -> Self {
self.direction = direction;
self
}
/// The bounded square art rectangle, reserving one row for state words.
/// Hosts use this same rectangle to map mouse input with [`Self::point`].
#[must_use]
pub fn art_area(area: Rect) -> Rect {
let side = area
.width
.min(area.height.saturating_sub(1).saturating_mul(2))
.min(pixels::MAX_SIDE as u16);
let side = side - side % 2;
Rect::new(
area.x.saturating_add((area.width - side) / 2),
area.y
.saturating_add((area.height.saturating_sub(1) - side / 2) / 2),
side,
side / 2,
)
}
/// Convert a terminal cell to the GPUI cove's −62…62 design coordinates.
/// Outside points return `None` so the host can release the resting gaze.
#[must_use]
pub fn point(area: Rect, column: u16, row: u16) -> Option<(f64, f64)> {
let art = Self::art_area(area);
if art.is_empty() || !art.contains((column, row).into()) {
return None;
}
Some((
(f64::from(column - art.x) + 0.5) / f64::from(art.width) * 124. - 62.,
(f64::from(row - art.y) + 0.5) / f64::from(art.height) * 124. - 62.,
))
}
/// Paint the current shared frame. The only mutable decoration is the
/// cove's idempotent gaze sample; time and owner truth remain host-owned.
pub fn paint(&self, area: Rect, buf: &mut Buffer, stage: &mut Stage) {
let area = area.intersection(buf.area);
if area.is_empty() {
return;
}
let mut state = WhaleState::from_key(stage.acting().id()).unwrap_or(WhaleState::Rest);
let mut words = None;
if matches!(state, WhaleState::Pod { .. }) {
let calves = stage
.director()
.activity
.as_ref()
.and_then(|a| a.parallel)
.filter(|n| n.is_finite() && *n >= 0. && n.fract() == 0.)
.unwrap_or(0.);
state = WhaleState::Pod {
calves: calves.min(f64::from(u8::MAX)) as u8,
};
if calves > f64::from(u8::MAX) {
words = Some(format!("Working with {calves:.0} agents"));
}
}
let mut whale = Whale::new(state);
if let Some(words) = words {
whale = whale.words(words);
}
if let Some(words) = &self.words {
whale = whale.words(words.to_string());
}
let art = Self::art_area(area);
let words_only = crate::whale::Grid {
cols: 0,
rows: 0,
cells: Vec::new(),
};
if self.theme.ascii() || art.width < 16 || art.height < 8 {
// A deliberately invalid frame asks the shared painter for words.
whale.paint_frame(area, buf, self.theme, &words_only);
return;
}
let dark = self.theme.caps().appearance != crate::detect::Appearance::Light;
let view = View {
dir: self.direction,
..View::hero(f64::from(art.width), 1.)
};
if self.style == PetStyle::Braille || !self.theme.paints_grounds() {
let parts = stage.scene(view);
rasterize_colored(
&parts,
usize::from(art.width),
usize::from(art.height),
0.5,
dark,
)
.paint_with_contrast(&whale, area, buf, self.theme, 3.);
return;
}
let scene = if self.cove {
stage.cove_scene(view, dark)
} else {
CoveScene {
behind: Vec::new(),
parts: stage.scene(view),
front: Vec::new(),
}
};
let pixels = pixels::render(&scene, usize::from(art.width), dark);
if let Ok(sprite) = Sprite::image(&pixels, usize::from(art.width), usize::from(art.width)) {
sprite.paint(art, buf, self.theme);
}
whale.paint_frame(
Rect::new(area.x, art.bottom(), area.width, 1),
buf,
self.theme,
&words_only,
);
}
}
impl StatefulWidget for WhalePet<'_> {
type State = Stage;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Stage) {
self.paint(area, buf, state);
}
}
impl StatefulWidget for &WhalePet<'_> {
type State = Stage;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Stage) {
self.paint(area, buf, state);
}
}
+8
View File
@@ -907,6 +907,14 @@ impl Director {
self.pose_out
}
/// An authorized, once-per-turn completion clip is still playing.
/// Surfaces use the canonical clip to settle without a second timer.
pub fn completing(&self) -> bool {
self.shots
.iter()
.any(|shot| shot.clip == ClipId::Enter(Act::Done))
}
/// Whether anything is still moving beyond the settled loop: a clip, a
/// departure fade, a particle or a calf in transit. Surfaces use it to
/// choose their active or resting paint cap.
+1
View File
@@ -18,6 +18,7 @@ pub mod data;
pub mod habitat;
pub mod ink;
pub mod math;
pub(crate) mod pixels;
pub mod props;
pub mod rig;
pub mod scene;
+123
View File
@@ -0,0 +1,123 @@
//! The GPUI cove/contour paint order in a bounded RGBA plane. The existing
//! Sprite adapter handles terminal half-blocks, alpha and color capabilities.
use super::{CoveScene, Layer, Path, Role, ink::Fill, rig::holes_for, scene::segments};
pub(crate) const MAX_SIDE: usize = 96;
struct Canvas {
side: usize,
// Premultiplied RGBA: averaging transparent edges must not darken them.
pixels: Vec<[f64; 4]>,
}
impl Canvas {
fn fill(&mut self, paths: &[Path], fill: Fill, alpha: f64) {
if !alpha.is_finite() || alpha <= 0. {
return;
}
let alpha = alpha.min(1.);
let side = self.side as f64;
let scale = side / 124.;
let edges: Vec<_> = paths
.iter()
.filter(|path| {
path.iter()
.flat_map(super::rig::Cmd::coords)
.all(|v| v.is_finite())
})
.flat_map(segments)
.map(|[a, b, c, d]| {
[
side / 2. + a * scale,
side / 2. + b * scale,
side / 2. + c * scale,
side / 2. + d * scale,
]
})
.collect();
let mut crossings = Vec::new();
for y in 0..self.side {
let cy = y as f64 + 0.5;
crossings.clear();
for &[x1, y1, x2, y2] in &edges {
if (y1 > cy) != (y2 > cy) {
crossings.push(x1 + (cy - y1) * (x2 - x1) / (y2 - y1));
}
}
crossings.sort_by(f64::total_cmp);
for pair in crossings.as_chunks::<2>().0 {
let left = (pair[0] - 0.5).ceil().clamp(0., side) as usize;
let right = (pair[1] - 0.5).ceil().clamp(0., side) as usize;
for x in left..right {
let t = (((x as f64 + cy + 0.5 - side) / scale + 100.) / 200.).clamp(0., 1.);
let channel = |hex: u32, shift: u32| f64::from((hex >> shift) & 255_u32);
let color = match fill {
Fill::Solid(hex) => [16, 8, 0].map(|s| channel(hex, s)),
Fill::Gradient([a, b]) => {
[16, 8, 0].map(|s| channel(a, s) + (channel(b, s) - channel(a, s)) * t)
}
};
let pixel = &mut self.pixels[y * self.side + x];
for (dst, src) in pixel[..3].iter_mut().zip(color) {
*dst = src * alpha + *dst * (1. - alpha);
}
pixel[3] = alpha + pixel[3] * (1. - alpha);
}
}
}
}
fn layers(&mut self, layers: &[Layer]) {
for layer in layers {
self.fill(&layer.paths, Fill::Solid(layer.color), layer.alpha);
}
}
}
/// Square design space, with two samples per axis for clean curved edges.
/// Public terminal rectangles never control an unbounded allocation.
pub(crate) fn render(scene: &CoveScene, side: usize, dark: bool) -> Vec<u8> {
if !(1..=MAX_SIDE).contains(&side) {
return Vec::new();
}
let mut canvas = Canvas {
side: side * 2,
pixels: vec![[0.; 4]; side * side * 4],
};
canvas.layers(&scene.behind);
for shape in &scene.parts.shapes {
if shape.role == Role::Cutout {
continue;
}
if let Some(fill) = super::ink::fill(shape.role, dark) {
let mut paths = vec![shape.path.clone()];
paths.extend(holes_for(&shape.id, &scene.parts.shapes, false));
canvas.fill(&paths, fill, shape.opacity);
}
}
canvas.layers(&scene.front);
let mut out = Vec::with_capacity(side * side * 4);
for y in 0..side {
for x in 0..side {
let mut rgba = [0.; 4];
for dy in 0..2 {
for dx in 0..2 {
let p = canvas.pixels[(y * 2 + dy) * canvas.side + x * 2 + dx];
for (dst, src) in rgba.iter_mut().zip(p) {
*dst += src / 4.;
}
}
}
for c in &rgba[..3] {
out.push(if rgba[3] > 0. {
(c / rgba[3]).round() as u8
} else {
0
});
}
out.push((rgba[3] * 255.).round() as u8);
}
}
out
}