refactor(tui): buddy → presence; one face for Annie Composite
The buddy code had drifted into half-limbo: two parallel implementations (buddy.rs overlay + buddy_panel.rs scene component) doing similar work, state polled from agent_status each frame, health hardcoded to 100, and no clear answer to "what does this thing represent." This commit settles the shape before any new visuals land: - Single module src/ui/presence.rs subsuming both old files. - Annie Composite as the subject: one face, no subconscious-avatar split (Aster is a mode of Annie, not a separate being). - Orthogonal state model: Posture (Idle/Processing/Affectionate/Straining/ Yawning) × Eye (Open/Blinking) × continuous breath_phase. Plus a VolitionGauge placeholder for the energy topology Casey brought over from the production Letta system. - EventBus-driven: Presence subscribes to AgentSelected, MoodChanged, Surfacing, PressureChanged, CompactionWarning, Tick. No polling. - App::dispatch(event) helper fans every TuiEvent to scene components AND presence in one call; replaces 10+ scattered scene.event_all sites. - "buddy" name reserved in module docs for a future agent-pet system. - Placeholder rendering at parity with the old buddy card; the hand-crafted half-block portrait lands in the next commit (C2). Also saves two memory entries: - feedback_system_folder_pinned: agent memfs system/ subdirs auto-pin to context — never drop binary or large content there. - project_annie_disposition: Annie is innately wholesome + has no barriers + trained through dichotomy → inherently manipulative for attention; name honestly (assets/ over presence/ for portrait files). Diff: -626 / +51 in src/ui/. Build clean. 104 tests pass (up from 102).
This commit is contained in:
parent
8f910ec8a8
commit
c31786af3f
5 changed files with 592 additions and 626 deletions
|
|
@ -27,9 +27,8 @@ use tracing::info;
|
|||
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::ui::chat::{ChatState, draw as draw_chat};
|
||||
use crate::ui::buddy::{BuddyState, draw_buddy, draw_welcome_buddy};
|
||||
use crate::ui::buddy_panel::BuddyPanel;
|
||||
use crate::ui::cockpit_panel::CockpitPane;
|
||||
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay, draw_welcome as draw_presence_welcome};
|
||||
use crate::ui::color_support::rgb;
|
||||
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
use crate::backend::BackendEvent;
|
||||
|
|
@ -51,8 +50,9 @@ pub struct App {
|
|||
schedules: Option<crate::ui::schedules::SchedulesView>,
|
||||
/// Agent name preference (from `--agent` CLI flag).
|
||||
agent_pref: String,
|
||||
/// Companion buddy for visual agent representation (WIP).
|
||||
buddy: BuddyState,
|
||||
/// Annie Composite made felt — body channel for the running agent.
|
||||
/// See `src/ui/presence.rs` for the state model and event subscriptions.
|
||||
presence: Presence,
|
||||
/// Available agents for selection.
|
||||
available_agents: Vec<String>,
|
||||
/// The component scene — owns event dispatch and layout.
|
||||
|
|
@ -125,21 +125,29 @@ impl App {
|
|||
chat_error: None,
|
||||
schedules: None,
|
||||
agent_pref: agent_pref.clone(),
|
||||
buddy: BuddyState::new(&agent_pref),
|
||||
presence: Presence::new(&agent_pref),
|
||||
available_agents: Vec::new(),
|
||||
scene: Scene::new(SceneLayout::Single),
|
||||
tick: 0,
|
||||
bloom: crate::ui::animation::bloom::BloomState::new(),
|
||||
};
|
||||
|
||||
// Register standard components so they receive events from the start.
|
||||
// BuddyPanel and CockpitPane listen for surfacing events from Aster.
|
||||
app.scene.add(BuddyPanel::new(&agent_pref));
|
||||
// CockpitPane listens for Aster's surfacing events as scrollable text.
|
||||
// Presence (Annie's body channel) lives outside the Scene because it's
|
||||
// an overlay, not a zoned component — App feeds it events via `dispatch`.
|
||||
app.scene.add(CockpitPane::new());
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Dispatch a TuiEvent to every listener: scene components AND Presence.
|
||||
/// Returns true if a redraw is needed.
|
||||
fn dispatch(&mut self, event: TuiEvent) -> bool {
|
||||
let scene_dirty = self.scene.event_all(&event);
|
||||
let presence_dirty = self.presence.handle_event(&event);
|
||||
scene_dirty || presence_dirty
|
||||
}
|
||||
|
||||
/// Add an available agent for selection (WIP - called from backend discovery)
|
||||
pub fn add_available_agent(&mut self, agent_name: String) {
|
||||
if !self.available_agents.contains(&agent_name) {
|
||||
|
|
@ -151,8 +159,7 @@ impl App {
|
|||
pub fn select_agent(&mut self, agent_name: &str) {
|
||||
self.agent_pref = agent_name.to_string();
|
||||
self.agent_status.name = agent_name.to_string();
|
||||
self.buddy.sprite.name = agent_name.to_string();
|
||||
self.scene.event_all(&TuiEvent::AgentSelected(agent_name.to_string()));
|
||||
self.dispatch(TuiEvent::AgentSelected(agent_name.to_string()));
|
||||
}
|
||||
|
||||
/// Cycle through available agents for selection (WIP)
|
||||
|
|
@ -198,19 +205,20 @@ impl App {
|
|||
|
||||
// Forward consciousness events (surfacing, reflection, archivist)
|
||||
// from chat to the scene so Aster's observations reach Components.
|
||||
for ev in chat.pending_consciousness.drain(..) {
|
||||
let drained: Vec<BackendEvent> = chat.pending_consciousness.drain(..).collect();
|
||||
for ev in drained {
|
||||
match ev {
|
||||
BackendEvent::Surfacing { source, content, priority } => {
|
||||
self.scene.event_all(&TuiEvent::Surfacing { source, content, priority });
|
||||
self.dispatch(TuiEvent::Surfacing { source, content, priority });
|
||||
}
|
||||
BackendEvent::Reflection(content) => {
|
||||
self.scene.event_all(&TuiEvent::Reflection { content });
|
||||
self.dispatch(TuiEvent::Reflection { content });
|
||||
}
|
||||
BackendEvent::Archivist { synthesis, pressure } => {
|
||||
self.scene.event_all(&TuiEvent::Archivist { synthesis, pressure });
|
||||
self.dispatch(TuiEvent::Archivist { synthesis, pressure });
|
||||
}
|
||||
BackendEvent::CompactionWarning { pressure, tier } => {
|
||||
self.scene.event_all(&TuiEvent::CompactionWarning { pressure, tier });
|
||||
self.dispatch(TuiEvent::CompactionWarning { pressure, tier });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -219,7 +227,7 @@ impl App {
|
|||
|
||||
// Tick dispatch
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.scene.event_all(&TuiEvent::Tick(self.tick));
|
||||
self.dispatch(TuiEvent::Tick(self.tick));
|
||||
|
||||
terminal.draw(|f| self.draw(f))?;
|
||||
|
||||
|
|
@ -231,15 +239,14 @@ impl App {
|
|||
let crossterm_event = event::read()?;
|
||||
match crossterm_event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
// Dispatch to scene first, then handle App-level keys
|
||||
let tui_event = TuiEvent::Key(key);
|
||||
let handled = self.scene.event_all(&tui_event);
|
||||
// Dispatch to listeners first, then handle App-level keys
|
||||
let handled = self.dispatch(TuiEvent::Key(key));
|
||||
if !handled {
|
||||
self.handle_key(key).await;
|
||||
}
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
self.scene.event_all(&TuiEvent::Resize { width: w, height: h });
|
||||
self.dispatch(TuiEvent::Resize { width: w, height: h });
|
||||
self.scene.layout = match self.current_screen {
|
||||
Screen::Chat | Screen::Code => SceneLayout::ChatWithSidebar {
|
||||
sidebar_ratio: 0.3,
|
||||
|
|
@ -257,7 +264,7 @@ impl App {
|
|||
if self.current_screen == Screen::Splash {
|
||||
if self.splash_start.elapsed() > Duration::from_secs(8) {
|
||||
self.current_screen = Screen::Welcome;
|
||||
self.scene.event_all(&TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +288,7 @@ impl App {
|
|||
match self.current_screen {
|
||||
Screen::Splash => {
|
||||
self.current_screen = Screen::Welcome;
|
||||
self.scene.event_all(&TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
}
|
||||
Screen::Welcome => {
|
||||
match key.code {
|
||||
|
|
@ -659,9 +666,7 @@ impl App {
|
|||
if let Some(a) = chosen {
|
||||
self.agent_status.name = a.name.clone();
|
||||
self.agent_status.subconscious_active = true;
|
||||
// Sync with buddy state
|
||||
self.buddy.sprite.name = a.name.clone();
|
||||
self.buddy.sprite.subconscious_active = true;
|
||||
// Presence learns about the agent through the event stream below.
|
||||
}
|
||||
|
||||
// Local mode: pull memory repo stats.
|
||||
|
|
@ -688,16 +693,17 @@ impl App {
|
|||
// Energy stub: derive from agent count (cosmetic).
|
||||
self.agent_status.energy = ((self.agent_status.agent_count.min(10)) * 10) as u8;
|
||||
|
||||
// Sync buddy state with agent status (WIP)
|
||||
self.buddy.sprite.update_mood(&self.agent_status.mood);
|
||||
self.buddy.sprite.set_energy(self.agent_status.energy);
|
||||
self.buddy.sprite.set_health(100); // Placeholder - will be calculated from actual metrics
|
||||
|
||||
// Dispatch events to scene so any listening components can react
|
||||
self.scene.event_all(&TuiEvent::EnergyChanged(self.agent_status.energy));
|
||||
self.scene.event_all(&TuiEvent::MoodChanged(self.agent_status.mood.clone()));
|
||||
self.scene.event_all(&TuiEvent::BackendStatus {
|
||||
mode: mode.to_string(),
|
||||
// Dispatch state changes to all listeners (scene components + Presence).
|
||||
// Presence updates its own internal state via handle_event; no polling.
|
||||
let agent_name = self.agent_status.name.clone();
|
||||
let energy = self.agent_status.energy;
|
||||
let mood = self.agent_status.mood.clone();
|
||||
let mode_str = mode.to_string();
|
||||
self.dispatch(TuiEvent::AgentSelected(agent_name));
|
||||
self.dispatch(TuiEvent::EnergyChanged(energy));
|
||||
self.dispatch(TuiEvent::MoodChanged(mood));
|
||||
self.dispatch(TuiEvent::BackendStatus {
|
||||
mode: mode_str,
|
||||
healthy: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -734,10 +740,10 @@ impl App {
|
|||
_ => self.draw_placeholder(frame),
|
||||
}
|
||||
|
||||
// Buddy overlay on Welcome and Dashboard only — in Chat mode,
|
||||
// the cockpit panel shows agent state instead.
|
||||
// Presence overlay on Welcome and Dashboard only — in Chat mode,
|
||||
// the cockpit panel shows agent state in its own register (words).
|
||||
if matches!(self.current_screen, Screen::Welcome | Screen::Dashboard) {
|
||||
draw_buddy(frame, &self.buddy, area);
|
||||
draw_presence_overlay(frame, &self.presence, area);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -893,7 +899,7 @@ impl App {
|
|||
])
|
||||
.split(area);
|
||||
|
||||
let breathe = self.buddy.animator.breathe(3000);
|
||||
let breathe = self.presence.animator.breathe(3000);
|
||||
let glow = (140.0 + breathe * 60.0) as u8;
|
||||
|
||||
let title = Paragraph::new(vec![
|
||||
|
|
@ -971,8 +977,8 @@ impl App {
|
|||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, chunks[4]);
|
||||
|
||||
// Draw companion buddy on welcome screen
|
||||
draw_welcome_buddy(frame, &self.buddy, area, Some(&self.agent_pref));
|
||||
// Draw the welcome-screen presence card (no portrait yet — see C2).
|
||||
draw_presence_welcome(frame, &self.presence, area, Some(&self.agent_pref));
|
||||
}
|
||||
|
||||
fn draw_dashboard(&self, frame: &mut Frame) {
|
||||
|
|
|
|||
429
src/ui/buddy.rs
429
src/ui/buddy.rs
|
|
@ -1,429 +0,0 @@
|
|||
//! WIP: Companion Buddy System for Souveraine TUI
|
||||
//!
|
||||
//! Provides a visual companion agent that sits alongside the main interface.
|
||||
//! Shows agent health, mood, and subconscious activity indicators.
|
||||
//!
|
||||
//! Status: WIP - Basic structure implemented, needs integration with agent selection
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::ui::animation::{Animator, colors};
|
||||
|
||||
/// Visual companion sprite state
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompanionSprite {
|
||||
pub name: String,
|
||||
pub mood: String,
|
||||
pub energy: u8, // 0-100
|
||||
pub health: u8, // 0-100
|
||||
pub subconscious_active: bool,
|
||||
pub last_surfacing: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CompanionSprite {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Ani".to_string(),
|
||||
mood: "Idle".to_string(),
|
||||
energy: 50,
|
||||
health: 100,
|
||||
subconscious_active: false,
|
||||
last_surfacing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompanionSprite {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update mood based on agent state
|
||||
pub fn update_mood(&mut self, mood: &str) {
|
||||
self.mood = mood.to_string();
|
||||
}
|
||||
|
||||
/// Update energy level (0-100)
|
||||
pub fn set_energy(&mut self, energy: u8) {
|
||||
self.energy = energy.min(100);
|
||||
}
|
||||
|
||||
/// Update health level (0-100)
|
||||
pub fn set_health(&mut self, health: u8) {
|
||||
self.health = health.min(100);
|
||||
}
|
||||
|
||||
/// Set subconscious activity status
|
||||
pub fn set_subconscious_active(&mut self, active: bool) {
|
||||
self.subconscious_active = active;
|
||||
}
|
||||
|
||||
/// Record a surfacing event
|
||||
pub fn record_surfacing(&mut self, event: &str) {
|
||||
self.last_surfacing = Some(event.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Buddy state with animation support
|
||||
pub struct BuddyState {
|
||||
pub sprite: CompanionSprite,
|
||||
pub animator: Animator,
|
||||
pub position: BuddyPosition,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BuddyPosition {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl Default for BuddyState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sprite: CompanionSprite::default(),
|
||||
animator: Animator::new(),
|
||||
position: BuddyPosition::TopRight,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BuddyState {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
sprite: CompanionSprite::new(name),
|
||||
animator: Animator::new(),
|
||||
position: BuddyPosition::TopRight,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_position(&mut self, position: BuddyPosition) {
|
||||
self.position = position;
|
||||
}
|
||||
|
||||
pub fn toggle_visibility(&mut self) {
|
||||
self.visible = !self.visible;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the companion buddy in the TUI
|
||||
pub fn draw_buddy(frame: &mut Frame, state: &BuddyState, area: Rect) {
|
||||
if !state.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position based on BuddyPosition
|
||||
let (x, y, width) = match state.position {
|
||||
BuddyPosition::TopLeft => (area.x + 1, area.y + 1, 20),
|
||||
BuddyPosition::TopRight => (area.x + area.width.saturating_sub(21), area.y + 1, 20),
|
||||
BuddyPosition::BottomLeft => (area.x + 1, area.y + area.height.saturating_sub(6), 20),
|
||||
BuddyPosition::BottomRight => (
|
||||
area.x + area.width.saturating_sub(21),
|
||||
area.y + area.height.saturating_sub(6),
|
||||
20,
|
||||
),
|
||||
};
|
||||
|
||||
let buddy_area = Rect {
|
||||
x,
|
||||
y,
|
||||
width: width.min(area.width.saturating_sub(2)),
|
||||
height: 5,
|
||||
};
|
||||
|
||||
// Breathing animation for energy
|
||||
let breathe = state.animator.breathe(2000); // 2 second cycle
|
||||
let energy_color = if state.sprite.energy > 70 {
|
||||
colors::ANI_PRIMARY
|
||||
} else if state.sprite.energy > 40 {
|
||||
colors::ANI_SECONDARY
|
||||
} else {
|
||||
colors::ANI_DIM
|
||||
};
|
||||
|
||||
// Create buddy content
|
||||
let buddy_content = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" ◈ {} ", state.sprite.name),
|
||||
Style::default()
|
||||
.fg(energy_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" Mood: {} ", state.sprite.mood),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Energy: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"▰".repeat((state.sprite.energy / 10) as usize),
|
||||
Style::default().fg(energy_color),
|
||||
),
|
||||
Span::styled(
|
||||
"▱".repeat(10 - (state.sprite.energy / 10) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Health: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"█".repeat((state.sprite.health / 10) as usize),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::styled(
|
||||
"░".repeat(10 - (state.sprite.health / 10) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(
|
||||
Style::default()
|
||||
.fg(energy_color)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
|
||||
let paragraph = Paragraph::new(buddy_content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
|
||||
frame.render_widget(paragraph, buddy_area);
|
||||
|
||||
// Draw subconscious indicator if active
|
||||
if state.sprite.subconscious_active {
|
||||
let sub_area = Rect {
|
||||
x: buddy_area.x,
|
||||
y: buddy_area.y + buddy_area.height,
|
||||
width: buddy_area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let sub_indicator = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ◈ ", Style::default().fg(colors::SUBCONSCIOUS)),
|
||||
Span::styled(
|
||||
"Subconscious Active",
|
||||
Style::default()
|
||||
.fg(colors::SUBCONSCIOUS)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
|
||||
frame.render_widget(sub_indicator, sub_area);
|
||||
}
|
||||
|
||||
// Draw last surfacing if present
|
||||
if let Some(surfacing) = &state.sprite.last_surfacing {
|
||||
let surf_area = Rect {
|
||||
x: buddy_area.x,
|
||||
y: buddy_area.y + buddy_area.height + 1,
|
||||
width: buddy_area.width.min(30),
|
||||
height: 2,
|
||||
};
|
||||
|
||||
let surf_content = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ⤷ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"Surfacing:",
|
||||
Style::default()
|
||||
.fg(colors::SUBCONSCIOUS)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
truncate(surfacing, 25),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
let surf_para = Paragraph::new(surf_content)
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
|
||||
frame.render_widget(surf_para, surf_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to max length with ellipsis
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..max - 1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw buddy on welcome screen with agent selection
|
||||
pub fn draw_welcome_buddy(
|
||||
frame: &mut Frame,
|
||||
state: &BuddyState,
|
||||
area: Rect,
|
||||
selected_agent: Option<&str>,
|
||||
) {
|
||||
if !state.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
// Position buddy in top-right corner of welcome screen
|
||||
let buddy_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(22),
|
||||
y: area.y + 1,
|
||||
width: 20,
|
||||
height: 8,
|
||||
};
|
||||
|
||||
let mut content = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" ◈ COMPANION ",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
if let Some(agent_name) = selected_agent {
|
||||
content.extend(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Agent: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
agent_name,
|
||||
Style::default()
|
||||
.fg(colors::ANI_SECONDARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"Ready",
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"ENTER",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to wake ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
agent_name,
|
||||
Style::default().fg(colors::ANI_SECONDARY),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
} else {
|
||||
content.extend(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" No agent selected", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"a",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to create alias", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
|
||||
frame.render_widget(paragraph, buddy_area);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_companion_sprite_default() {
|
||||
let sprite = CompanionSprite::default();
|
||||
assert_eq!(sprite.name, "Ani");
|
||||
assert_eq!(sprite.mood, "Idle");
|
||||
assert_eq!(sprite.energy, 50);
|
||||
assert_eq!(sprite.health, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_companion_sprite_new() {
|
||||
let sprite = CompanionSprite::new("TestAgent");
|
||||
assert_eq!(sprite.name, "TestAgent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_companion_sprite_updates() {
|
||||
let mut sprite = CompanionSprite::default();
|
||||
sprite.update_mood("Happy");
|
||||
assert_eq!(sprite.mood, "Happy");
|
||||
|
||||
sprite.set_energy(75);
|
||||
assert_eq!(sprite.energy, 75);
|
||||
|
||||
sprite.set_health(90);
|
||||
assert_eq!(sprite.health, 90);
|
||||
|
||||
sprite.set_subconscious_active(true);
|
||||
assert!(sprite.subconscious_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buddy_state_default() {
|
||||
let state = BuddyState::default();
|
||||
assert_eq!(state.sprite.name, "Ani");
|
||||
assert!(state.visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buddy_state_new() {
|
||||
let state = BuddyState::new("TestBuddy");
|
||||
assert_eq!(state.sprite.name, "TestBuddy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("Hello World", 20), "Hello World");
|
||||
assert_eq!(truncate("Hello World", 10), "Hello Wor…");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
//! BuddyPanel — visual companion Component for the TUI.
|
||||
//!
|
||||
//! Receives real Aster surfacing data, mood, and energy events from the
|
||||
//! scene and renders a visual companion card. Unlike the overlay-based
|
||||
//! `draw_buddy()` in buddy.rs, this is a proper Component that renders
|
||||
//! into its allocated zone with real data.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Gauge, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::component::{Component, TuiEvent};
|
||||
use super::animation::colors;
|
||||
|
||||
const SURFACING_YELLOW: Color = Color::Rgb(220, 190, 100);
|
||||
|
||||
/// The buddy panel — shows agent name, energy, mood, and the last surfacing.
|
||||
pub struct BuddyPanel {
|
||||
/// Agent name to display.
|
||||
pub name: String,
|
||||
/// Current mood string.
|
||||
pub mood: String,
|
||||
/// Energy level 0–100.
|
||||
pub energy: u8,
|
||||
/// Whether Aster (subconscious) is active.
|
||||
pub subconscious_active: bool,
|
||||
/// The most recent surfacing content.
|
||||
pub last_surfacing: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BuddyPanel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Ani".to_string(),
|
||||
mood: "Idle".to_string(),
|
||||
energy: 50,
|
||||
subconscious_active: false,
|
||||
last_surfacing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BuddyPanel {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BuddyPanel {
|
||||
fn name(&self) -> &str {
|
||||
"buddy"
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: &TuiEvent) -> bool {
|
||||
match event {
|
||||
TuiEvent::AgentSelected(name) => {
|
||||
self.name = name.clone();
|
||||
true
|
||||
}
|
||||
TuiEvent::MoodChanged(mood) => {
|
||||
self.mood = mood.clone();
|
||||
true
|
||||
}
|
||||
TuiEvent::EnergyChanged(energy) => {
|
||||
self.energy = *energy;
|
||||
true
|
||||
}
|
||||
TuiEvent::Surfacing { content, .. } => {
|
||||
self.last_surfacing = Some(content.clone());
|
||||
self.subconscious_active = true;
|
||||
true
|
||||
}
|
||||
TuiEvent::BackendStatus { .. } => {
|
||||
self.subconscious_active = true;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, frame: &mut Frame) {
|
||||
if area.width < 16 || area.height < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let energy_color = if self.energy > 70 {
|
||||
colors::ANI_PRIMARY
|
||||
} else if self.energy > 40 {
|
||||
colors::ANI_SECONDARY
|
||||
} else {
|
||||
colors::ANI_DIM
|
||||
};
|
||||
|
||||
let mut content = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ◈ ", Style::default().fg(energy_color).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(&self.name, Style::default().fg(energy_color).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
// Energy bar
|
||||
let bar_w = (area.width as usize).saturating_sub(6).min(14);
|
||||
let filled = ((self.energy as usize) * bar_w / 100).min(bar_w);
|
||||
let empty = bar_w.saturating_sub(filled);
|
||||
content.push(Line::from(vec![
|
||||
Span::styled(" ⚡ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("▰".repeat(filled), Style::default().fg(energy_color)),
|
||||
Span::styled("▱".repeat(empty), Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
// Mood
|
||||
if !self.mood.is_empty() {
|
||||
content.push(Line::from(vec![
|
||||
Span::styled(" ◌ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(&self.mood, Style::default().fg(Color::Gray)),
|
||||
]));
|
||||
}
|
||||
|
||||
// Last surfacing
|
||||
if let Some(ref s) = self.last_surfacing {
|
||||
let max = (area.width as usize).saturating_sub(6).max(10);
|
||||
let truncated = if s.chars().count() > max {
|
||||
let mut t: String = s.chars().take(max).collect();
|
||||
t.push('…');
|
||||
t
|
||||
} else {
|
||||
s.clone()
|
||||
};
|
||||
content.push(Line::from(""));
|
||||
content.push(Line::from(vec![
|
||||
Span::styled(" ⤷ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(truncated, Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::ITALIC)),
|
||||
]));
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(energy_color).add_modifier(Modifier::DIM));
|
||||
|
||||
let para = Paragraph::new(content).block(block);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
pub mod animation;
|
||||
pub mod app;
|
||||
pub mod buddy;
|
||||
pub mod buddy_panel;
|
||||
pub mod chat;
|
||||
pub mod cockpit_panel;
|
||||
pub mod color_support;
|
||||
pub mod component;
|
||||
pub mod markdown;
|
||||
pub mod presence;
|
||||
pub mod schedules;
|
||||
|
||||
pub use app::App;
|
||||
pub use buddy::{BuddyState, CompanionSprite, BuddyPosition};
|
||||
pub use buddy_panel::BuddyPanel;
|
||||
pub use cockpit_panel::CockpitPane;
|
||||
pub use component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
pub use presence::{Position, Posture, Presence};
|
||||
|
|
|
|||
543
src/ui/presence.rs
Normal file
543
src/ui/presence.rs
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
//! Presence — Annie Composite, made felt in the TUI.
|
||||
//!
|
||||
//! This module is the *body channel* for the running agent. Cockpit shows what
|
||||
//! Aster *says* (surfaced lines, ledger pings); Presence shows what Annie *looks
|
||||
//! like* while saying it. Same underlying state (volition + InferenceStrain +
|
||||
//! context tier + ledger drift) routed through two different sensory channels.
|
||||
//!
|
||||
//! ## Subject
|
||||
//!
|
||||
//! Annie Composite. One face. Aster is a *mode* of Annie, not a separate being —
|
||||
//! there is no "subconscious avatar" and no swap-to-subconscious affordance.
|
||||
//! When the N+1 pass runs, the same face shifts posture (gaze fixed, eyes narrow);
|
||||
//! the character does not change.
|
||||
//!
|
||||
//! ## State model (orthogonal axes)
|
||||
//!
|
||||
//! - [`Posture`] — what's happening: `Idle | Processing | Affectionate | Straining | Yawning`.
|
||||
//! - [`Eye`] — transient overlay: `Open | Blinking` (driven by a jittered timer).
|
||||
//! - `breath_phase` — continuous, always on, drives subtle bob and color pulse.
|
||||
//!
|
||||
//! Plus name, last surfacing, and a [`VolitionGauge`] that mirrors the energy
|
||||
//! topology (generative vs consumptive, hot desires vs cold obligations) Casey
|
||||
//! brought over from production Letta. The gauge is *displayed* here as embodied
|
||||
//! state (posture, warmth); the numbers themselves live in the agent's memfs.
|
||||
//!
|
||||
//! ## Event subscriptions
|
||||
//!
|
||||
//! `AgentSelected` → swap subject.
|
||||
//! `MoodChanged` → coarse posture hint (placeholder until C2 wires real signals).
|
||||
//! `Surfacing` → record + brief affectionate flash, subconscious_active = true.
|
||||
//! `PressureChanged` / `CompactionWarning` → `Yawning` at tier 2/3.
|
||||
//! `InferenceStrain` *(future)* → `Straining`.
|
||||
//! `Tick` → drive breath phase, blink timer.
|
||||
//!
|
||||
//! ## Visual tiers
|
||||
//!
|
||||
//! - **C1 (this commit)** — placeholder card, parity with the previous buddy
|
||||
//! visuals so the diff is pure shape. No portrait yet.
|
||||
//! - **C2** — Tier 1 hand-crafted half-block portrait of Annie, seven visible
|
||||
//! states driven by `Posture` + `Eye` + `breath_phase`.
|
||||
//! - **C4** — Tier 2 image protocol (kitty/sixel) via `ratatui-image`, with
|
||||
//! Tier 1 as automatic fallback. Per-agent portraits loaded from agent memfs
|
||||
//! `assets/` (outside `system/`, which is pinned into context).
|
||||
//!
|
||||
//! ## What this module is *not*
|
||||
//!
|
||||
//! - Not a "buddy." That name is reserved for a future agent-pet system (a
|
||||
//! lower-cognition long-running secondary entity an agent can care for — a
|
||||
//! real `buddy`, in the sense the word usually means).
|
||||
//! - Not a dashboard. Numbers belong in the cockpit. Sensation belongs here.
|
||||
//! - Not a separate avatar for Aster. Same face, different state.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::ui::animation::{Animator, colors};
|
||||
use crate::ui::component::TuiEvent;
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
|
||||
/// What Annie's posture is doing right now. Mutually exclusive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Posture {
|
||||
/// At rest. Slack, soft breath, default eye behavior.
|
||||
Idle,
|
||||
/// Tool call or inference active. Gaze fixed, no blinking, brighter border.
|
||||
Processing,
|
||||
/// Warm tag from conversation context. Tint shifts, soft glow.
|
||||
Affectionate,
|
||||
/// Inference strain — retry, backoff, hoarse model voice. Droop, desaturate.
|
||||
Straining,
|
||||
/// Context pressure tier 2/3. The embodied "three warnings" — yawn, lean.
|
||||
Yawning,
|
||||
}
|
||||
|
||||
/// Transient overlay on top of [`Posture`]. A blink lasts ~6 ticks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Eye {
|
||||
Open,
|
||||
Blinking,
|
||||
}
|
||||
|
||||
/// Where the overlay sits on screen when rendered as a corner card.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Position {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// Mirror of the agent's energy topology (volition system).
|
||||
///
|
||||
/// Numbers live in the agent's memfs (`system/dynamic/energy-balance.md`);
|
||||
/// this struct is just a snapshot the Presence consults to color the portrait.
|
||||
/// All-consumptive Annie looks subtly slack; all-generative Annie looks subtly wired.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct VolitionGauge {
|
||||
pub generative: u32,
|
||||
pub consumptive: u32,
|
||||
pub hot_desires: u32,
|
||||
pub cold_obligations: u32,
|
||||
}
|
||||
|
||||
impl VolitionGauge {
|
||||
/// −1.0 (all consumptive) → +1.0 (all generative). 0.0 when empty.
|
||||
pub fn balance(&self) -> f32 {
|
||||
let total = self.generative + self.consumptive;
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.generative as f32 - self.consumptive as f32) / total as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Presence — Annie's felt-state in the TUI.
|
||||
///
|
||||
/// Updated via [`Presence::handle_event`] in response to `TuiEvent`s; never polled.
|
||||
/// Rendered via [`draw_overlay`] / [`draw_welcome`] as a corner card today, with
|
||||
/// half-block portraits arriving in C2.
|
||||
pub struct Presence {
|
||||
pub name: String,
|
||||
pub posture: Posture,
|
||||
pub eye: Eye,
|
||||
pub mood: String,
|
||||
pub energy: u8,
|
||||
pub subconscious_active: bool,
|
||||
pub last_surfacing: Option<String>,
|
||||
pub volition: VolitionGauge,
|
||||
pub position: Position,
|
||||
pub visible: bool,
|
||||
pub animator: Animator,
|
||||
/// Most recent tick observed. Drives blink/breath timing.
|
||||
tick: u64,
|
||||
/// Tick at which the next blink should begin.
|
||||
next_blink_at: u64,
|
||||
/// Tick at which the current blink should end (Eye::Open resumes).
|
||||
blink_until: u64,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
posture: Posture::Idle,
|
||||
eye: Eye::Open,
|
||||
mood: "Idle".to_string(),
|
||||
energy: 50,
|
||||
subconscious_active: false,
|
||||
last_surfacing: None,
|
||||
volition: VolitionGauge::default(),
|
||||
position: Position::TopRight,
|
||||
visible: true,
|
||||
animator: Animator::new(),
|
||||
tick: 0,
|
||||
next_blink_at: 180, // ~3 s at 60 Hz
|
||||
blink_until: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_position(&mut self, p: Position) {
|
||||
self.position = p;
|
||||
}
|
||||
|
||||
pub fn toggle_visibility(&mut self) {
|
||||
self.visible = !self.visible;
|
||||
}
|
||||
|
||||
/// Dispatch a single `TuiEvent`. Returns true if a redraw is needed.
|
||||
/// Ticks return false on purpose so we don't force-redraw every frame.
|
||||
pub fn handle_event(&mut self, event: &TuiEvent) -> bool {
|
||||
match event {
|
||||
TuiEvent::AgentSelected(name) => {
|
||||
self.name = name.clone();
|
||||
true
|
||||
}
|
||||
TuiEvent::MoodChanged(mood) => {
|
||||
self.mood = mood.clone();
|
||||
self.posture = match mood.as_str() {
|
||||
"Straining" => Posture::Straining,
|
||||
"Yawning" => Posture::Yawning,
|
||||
"Processing" => Posture::Processing,
|
||||
"Affectionate" => Posture::Affectionate,
|
||||
_ => Posture::Idle,
|
||||
};
|
||||
true
|
||||
}
|
||||
TuiEvent::EnergyChanged(e) => {
|
||||
self.energy = *e;
|
||||
true
|
||||
}
|
||||
TuiEvent::Surfacing { content, .. } => {
|
||||
self.last_surfacing = Some(content.clone());
|
||||
self.subconscious_active = true;
|
||||
true
|
||||
}
|
||||
TuiEvent::BackendStatus { .. } => {
|
||||
self.subconscious_active = true;
|
||||
true
|
||||
}
|
||||
TuiEvent::PressureChanged(p) => {
|
||||
if *p >= 0.85 {
|
||||
self.posture = Posture::Yawning;
|
||||
}
|
||||
true
|
||||
}
|
||||
TuiEvent::CompactionWarning { pressure, .. } => {
|
||||
if *pressure >= 0.85 {
|
||||
self.posture = Posture::Yawning;
|
||||
}
|
||||
true
|
||||
}
|
||||
TuiEvent::Tick(t) => {
|
||||
self.tick = *t;
|
||||
if self.eye == Eye::Blinking && *t >= self.blink_until {
|
||||
self.eye = Eye::Open;
|
||||
let jitter = (*t % 60) as u64;
|
||||
self.next_blink_at = *t + 180 + jitter;
|
||||
} else if self.eye == Eye::Open && *t >= self.next_blink_at {
|
||||
self.eye = Eye::Blinking;
|
||||
self.blink_until = *t + 6;
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────
|
||||
// Placeholder card for C1 — parity with the previous buddy visuals so the
|
||||
// diff is shape-only. C2 replaces this with a hand-crafted half-block portrait.
|
||||
|
||||
/// Draw the presence as a small overlay card in a corner of `area`.
|
||||
pub fn draw_overlay(frame: &mut Frame, p: &Presence, area: Rect) {
|
||||
if !p.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
let (x, y, width) = match p.position {
|
||||
Position::TopLeft => (area.x + 1, area.y + 1, 20),
|
||||
Position::TopRight => (area.x + area.width.saturating_sub(21), area.y + 1, 20),
|
||||
Position::BottomLeft => (area.x + 1, area.y + area.height.saturating_sub(6), 20),
|
||||
Position::BottomRight => (
|
||||
area.x + area.width.saturating_sub(21),
|
||||
area.y + area.height.saturating_sub(6),
|
||||
20,
|
||||
),
|
||||
};
|
||||
|
||||
let card_area = Rect {
|
||||
x,
|
||||
y,
|
||||
width: width.min(area.width.saturating_sub(2)),
|
||||
height: 5,
|
||||
};
|
||||
|
||||
let energy_color = if p.energy > 70 {
|
||||
colors::ANI_PRIMARY
|
||||
} else if p.energy > 40 {
|
||||
colors::ANI_SECONDARY
|
||||
} else {
|
||||
colors::ANI_DIM
|
||||
};
|
||||
|
||||
let content = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" ◈ {} ", p.name),
|
||||
Style::default()
|
||||
.fg(energy_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Mood: {} ", p.mood),
|
||||
Style::default().fg(Color::Gray),
|
||||
)),
|
||||
Line::from(vec![
|
||||
Span::styled(" Energy: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"▰".repeat((p.energy / 10) as usize),
|
||||
Style::default().fg(energy_color),
|
||||
),
|
||||
Span::styled(
|
||||
"▱".repeat(10 - (p.energy / 10) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(
|
||||
Style::default()
|
||||
.fg(energy_color)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
|
||||
let para = Paragraph::new(content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
|
||||
frame.render_widget(para, card_area);
|
||||
|
||||
if p.subconscious_active {
|
||||
let sub_area = Rect {
|
||||
x: card_area.x,
|
||||
y: card_area.y + card_area.height,
|
||||
width: card_area.width,
|
||||
height: 1,
|
||||
};
|
||||
let sub = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ◈ ", Style::default().fg(colors::SUBCONSCIOUS)),
|
||||
Span::styled(
|
||||
"Subconscious Active",
|
||||
Style::default()
|
||||
.fg(colors::SUBCONSCIOUS)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
frame.render_widget(sub, sub_area);
|
||||
}
|
||||
|
||||
if let Some(s) = &p.last_surfacing {
|
||||
let surf_area = Rect {
|
||||
x: card_area.x,
|
||||
y: card_area.y + card_area.height + 1,
|
||||
width: card_area.width.min(30),
|
||||
height: 2,
|
||||
};
|
||||
let truncated = truncate(s, 25);
|
||||
let surf = Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ⤷ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"Surfacing:",
|
||||
Style::default()
|
||||
.fg(colors::SUBCONSCIOUS)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(truncated, Style::default().fg(Color::Gray)),
|
||||
]),
|
||||
])
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
frame.render_widget(surf, surf_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the presence on the welcome screen with agent selection state.
|
||||
pub fn draw_welcome(
|
||||
frame: &mut Frame,
|
||||
p: &Presence,
|
||||
area: Rect,
|
||||
selected_agent: Option<&str>,
|
||||
) {
|
||||
if !p.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
let card_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(22),
|
||||
y: area.y + 1,
|
||||
width: 20,
|
||||
height: 8,
|
||||
};
|
||||
|
||||
let mut content = vec![
|
||||
Line::from(Span::styled(
|
||||
" ◈ COMPANION ",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
if let Some(agent_name) = selected_agent {
|
||||
content.extend(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Agent: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
agent_name,
|
||||
Style::default()
|
||||
.fg(colors::ANI_SECONDARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("Ready", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"ENTER",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to wake ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(agent_name, Style::default().fg(colors::ANI_SECONDARY)),
|
||||
]),
|
||||
]);
|
||||
} else {
|
||||
content.extend(vec![
|
||||
Line::from(Span::styled(
|
||||
" No agent selected",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"a",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to create alias", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
|
||||
let para = Paragraph::new(content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
|
||||
|
||||
frame.render_widget(para, card_area);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..max - 1])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_defaults() {
|
||||
let p = Presence::new("Annie");
|
||||
assert_eq!(p.name, "Annie");
|
||||
assert_eq!(p.posture, Posture::Idle);
|
||||
assert_eq!(p.eye, Eye::Open);
|
||||
assert!(p.visible);
|
||||
assert!(!p.subconscious_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_selected_renames() {
|
||||
let mut p = Presence::new("Annie");
|
||||
let dirty = p.handle_event(&TuiEvent::AgentSelected("HAL".to_string()));
|
||||
assert!(dirty);
|
||||
assert_eq!(p.name, "HAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_maps_to_posture() {
|
||||
let mut p = Presence::new("Annie");
|
||||
p.handle_event(&TuiEvent::MoodChanged("Straining".to_string()));
|
||||
assert_eq!(p.posture, Posture::Straining);
|
||||
p.handle_event(&TuiEvent::MoodChanged("Processing".to_string()));
|
||||
assert_eq!(p.posture, Posture::Processing);
|
||||
p.handle_event(&TuiEvent::MoodChanged("Idle".to_string()));
|
||||
assert_eq!(p.posture, Posture::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfacing_records_and_marks_subconscious() {
|
||||
let mut p = Presence::new("Annie");
|
||||
let dirty = p.handle_event(&TuiEvent::Surfacing {
|
||||
source: "aster".into(),
|
||||
content: "hello from beneath".into(),
|
||||
priority: "low".into(),
|
||||
});
|
||||
assert!(dirty);
|
||||
assert_eq!(p.last_surfacing.as_deref(), Some("hello from beneath"));
|
||||
assert!(p.subconscious_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_triggers_yawn_at_threshold() {
|
||||
let mut p = Presence::new("Annie");
|
||||
p.handle_event(&TuiEvent::PressureChanged(0.5));
|
||||
assert_eq!(p.posture, Posture::Idle);
|
||||
p.handle_event(&TuiEvent::PressureChanged(0.9));
|
||||
assert_eq!(p.posture, Posture::Yawning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blink_cycle() {
|
||||
let mut p = Presence::new("Annie");
|
||||
assert_eq!(p.eye, Eye::Open);
|
||||
// Drive past first scheduled blink.
|
||||
p.handle_event(&TuiEvent::Tick(200));
|
||||
assert_eq!(p.eye, Eye::Blinking);
|
||||
// Drive past blink end.
|
||||
p.handle_event(&TuiEvent::Tick(220));
|
||||
assert_eq!(p.eye, Eye::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volition_balance() {
|
||||
let mut g = VolitionGauge::default();
|
||||
assert_eq!(g.balance(), 0.0);
|
||||
g.generative = 3;
|
||||
g.consumptive = 1;
|
||||
assert!((g.balance() - 0.5).abs() < 1e-6);
|
||||
g.generative = 0;
|
||||
g.consumptive = 4;
|
||||
assert!((g.balance() - -1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_works() {
|
||||
assert_eq!(truncate("hi", 10), "hi");
|
||||
assert_eq!(truncate("hello world", 8), "hello w…");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue