Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-12 11:48:52 -04:00
commit c31786af3f
5 changed files with 592 additions and 626 deletions

View file

@ -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) {

View file

@ -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…");
}
}

View file

@ -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);
}
}

View file

@ -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
View 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…");
}
}