Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): atmosphere presets, welcome portrait fix, streaming staleness guard

- Atmosphere system (src/ui/atmosphere.rs): 14 color presets ported from
  the Matrix adapter system, wired into Presence with posture-linked
  defaults and explicit BackendEvent::Atmosphere trigger path
- Welcome portrait: decoupled from 18×18 pixel-art constants, now sized
  at ~40% of terminal width (capped 48), proper Resize::Fit rendering
- Dashboard overlay: moved from TopRight to BottomRight so it no longer
  overlaps the 4th dashboard card
- Streaming staleness guard: 30s timeout in drain_events — if no
  BackendEvent arrives while busy, the turn resets to Idle and injects
  a system message instead of displaying "Streaming" indefinitely
- Hal agent: registered as d91e264c-bd5a-4d02-9641-9202b9a64be5 with
  full memfs, seed, portrait, and DB entry
- Expression cache (src/ui/expressions.rs): pre-existing but uncommitted
  241-line module for per-agent expression frames with fallback chain
- Fix borrow errors in session_manager.rs and chat.rs BtwState match
- BackendEvent::Atmosphere variant + TuiEvent::AtmosphereChanged wiring
This commit is contained in:
Fimeg 2026-05-12 22:48:08 -04:00
commit d5ccfbba2b
10 changed files with 1225 additions and 114 deletions

View file

@ -461,6 +461,11 @@ impl Backend for LocalBackend {
Ok(conv_id)
}
async fn fork_conversation(&self, _agent_id: &str, source_conversation_id: &str) -> Result<String> {
let forked_id = self.server.sessions.fork(source_conversation_id)?;
Ok(forked_id)
}
async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>> {
let store = match self.server.sessions.conversation_store_for(agent_id) {
Some(s) => s,
@ -843,6 +848,26 @@ async fn run_turn(
let _ = tx.send(Ok(BackendEvent::Token(note.to_string()))).await;
}
// Drain any interjections that arrived during this LLM call.
// If there are any, commit them as user messages and continue
// the loop so the agent responds in the same turn.
let interjected: Vec<String> = interject
.lock()
.ok()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
if !interjected.is_empty() {
for text in &interjected {
let stamp = chrono::Local::now().format("%H:%M");
let note = format!("[interjected at {}{}]", stamp, text.trim());
messages.push(BifrostMessage::text("user", note));
}
// Continue the loop — agent sees the interjection as a
// user message and will respond in the next LLM round.
continue;
}
// Stream the final content in chunks, watching the cancel token.
// If Esc fires mid-stream, the agent's partial text is preserved
// (the chunks already sent are in the user's history) and an
@ -948,7 +973,6 @@ async fn run_turn(
// Continue loop — model will see tool results and respond
}
// ── Post-turn processing ───────────────────────────────────
// If the user pressed Esc, commit the partial text with a marker the
// agent will read on her next turn. The interrupt is a signal in her
// own context — same shape as a pressure warning, not a hidden harness

View file

@ -90,6 +90,8 @@ pub enum BackendEvent {
output: String,
is_error: bool,
},
/// Agent set an atmospheric preset for the UI chrome.
Atmosphere(String),
/// Stream ended cleanly.
Done,
}
@ -107,6 +109,14 @@ pub trait Backend: Send + Sync {
/// Create a new conversation for this agent. Always creates fresh.
async fn new_conversation(&self, agent_id: &str) -> Result<String>;
/// Fork an existing conversation — clone all messages into a new session
/// with a fresh conversation_id. Used by `/btw` to spin off a side-quest.
/// Default falls back to `new_conversation`; LocalBackend overrides with
/// a proper deep clone via session_manager.fork().
async fn fork_conversation(&self, agent_id: &str, _source_conversation_id: &str) -> Result<String> {
self.new_conversation(agent_id).await
}
/// List persisted conversations for an agent (excludes archived).
async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>>;

View file

@ -225,4 +225,51 @@ impl SessionManager {
pub fn conversation_store_for(&self, agent_id: &str) -> Option<ConversationStore> {
self.store.as_ref().map(|h| h.store_for(agent_id))
}
/// Fork an existing conversation — clone all messages into a new session
/// with a fresh conversation_id. Returns the new conversation_id.
/// Used by `/btw` to spin off a side-quest conversation in parallel.
pub fn fork(&self, conversation_id: &str) -> anyhow::Result<String> {
let source = self
.sessions
.get(conversation_id)
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
let agent_id = source.agent_id.clone();
let mut messages = source.messages.clone();
drop(source); // release the DashMap ref
let forked_id = Uuid::new_v4().to_string();
let (sender, _receiver) = broadcast::channel(100);
let session = Session {
conversation_id: forked_id.clone(),
agent_id: agent_id.clone(),
messages,
created_at: Utc::now(),
updated_at: Utc::now(),
turn_count: 0,
last_n25: Utc::now(),
context_pressure: 0.0,
event_sender: sender,
};
self.sessions.insert(forked_id.clone(), session);
self.agent_conversations
.entry(agent_id.clone())
.or_insert_with(Vec::new)
.push(forked_id.clone());
if let Some(handle) = &self.store {
let record = ConversationRecord::new(forked_id.clone(), agent_id.clone());
let store = handle.store_for(&agent_id);
tokio::spawn(async move {
if let Err(e) = store.save_metadata(&record).await {
tracing::warn!("Failed to persist forked conversation metadata: {}", e);
}
});
}
Ok(forked_id)
}
}

View file

@ -29,7 +29,7 @@ use tracing::info;
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatState, draw as draw_chat};
use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay};
use crate::ui::presence::{Posture, Presence, draw_overlay as draw_presence_overlay};
use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent;
@ -79,6 +79,10 @@ pub struct App {
/// different sizes simultaneously without conflicting. Lazily populated
/// when the manager is opened; survives Esc → reopen.
card_images: HashMap<String, StatefulProtocol>,
/// Expression image cache for the animated portrait system.
/// A zero-cost abstraction: only hit when `expressions/` directory exists
/// in the agent's assets. Otherwise slides silently to portrait fallback.
expression_cache: crate::ui::expressions::ExpressionCache,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@ -171,6 +175,7 @@ impl App {
tick: 0,
bloom: crate::ui::animation::bloom::BloomState::new(),
card_images: HashMap::new(),
expression_cache: crate::ui::expressions::ExpressionCache::new(),
image_picker: None,
image_protocol: None,
agent_cards: Vec::new(),
@ -308,6 +313,9 @@ impl App {
BackendEvent::InferenceStrain { attempt, status, .. } => {
self.dispatch(TuiEvent::InferenceStrain { attempt, status });
}
BackendEvent::Atmosphere(preset) => {
self.dispatch(TuiEvent::AtmosphereChanged(preset));
}
_ => {}
}
}
@ -391,6 +399,7 @@ impl App {
}
KeyCode::Char('p') => {
// Presence mode — sit with her, no chat input.
self.preload_agent_expressions().await;
self.current_screen = Screen::Presence;
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence));
}
@ -523,6 +532,38 @@ impl App {
return;
};
// BtwPane key routing — when a /btw fork pane is showing, Esc
// dismisses it and 'j' jumps to the forked conversation.
if chat.btw_active() {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
chat.btw_dismiss();
return;
}
KeyCode::Char('j') => {
if let Some(forked_id) = chat.btw_jump() {
// Switch to the forked conversation
let backend = chat.backend.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
chat.switch_rx = Some(rx);
let agent_id = chat.agent_id.clone();
tokio::spawn(async move {
match backend.load_conversation(&forked_id).await {
Ok(messages) => {
let _ = tx.send(Ok((forked_id, messages)));
}
Err(e) => {
let _ = tx.send(Err(e));
}
}
});
}
return;
}
_ => {}
}
}
// Overlay key routing — when an overlay is active, it captures
// navigation keys. Other keys fall through to normal handling.
if chat.overlay_active() {
@ -867,16 +908,23 @@ impl App {
/// Resolve an agent's portrait file under `~/.souveraine/agents/{id}/memory/assets/`.
/// Returns the first existing path among png/jpg/jpeg variants.
fn agent_portrait_path(agent_id: &str) -> Option<std::path::PathBuf> {
let base = Self::agent_assets_dir(agent_id)?;
["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter()
.map(|s| base.join(s))
.find(|p| p.exists())
}
/// Resolve an agent's assets directory.
/// Returns None if homedir can't be determined.
fn agent_assets_dir(agent_id: &str) -> Option<std::path::PathBuf> {
let base = dirs::home_dir()?
.join(".souveraine")
.join("agents")
.join(agent_id)
.join("memory")
.join("assets");
["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter()
.map(|s| base.join(s))
.find(|p| p.exists())
if base.is_dir() { Some(base) } else { None }
}
/// Build a `StatefulProtocol` for a given agent and insert it into
@ -916,6 +964,19 @@ impl App {
}
}
/// Preload all expression frames for the currently active agent.
/// Called when entering Presence mode so blink/breath transitions
/// are instant rather than loading from disk on every animation tick.
async fn preload_agent_expressions(&mut self) {
let Some(picker) = self.image_picker.as_ref() else { return };
let name = self.presence.name.clone();
let id = self.agent_id_by_name(&name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let Some(id) = id else { return };
let Some(assets_dir) = Self::agent_assets_dir(&id) else { return };
self.expression_cache.preload_all(&id, picker, &assets_dir);
}
/// Return the agent id (memfs dir name) whose name matches `name`,
/// scanning the on-disk agent inventory. Used so Welcome can pull
/// the active agent's portrait out of `card_images` without needing
@ -955,7 +1016,7 @@ impl App {
self.draw_placeholder(frame);
}
}
Screen::Presence => self.draw_presence_mode(frame),
Screen::Presence => self.draw_presence_mode_mut(frame),
Screen::AgentsManager => self.draw_agent_cards_mut(frame),
_ => self.draw_placeholder(frame),
}
@ -1110,17 +1171,14 @@ impl App {
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, area);
// Avatar card occupies its own row in the welcome stack — centered,
// framed, larger than the corner overlay so Annie reads as the focal
// point of the landing screen. The render path uses `render_scaled`
// with `WELCOME_SCALE`; cell_w = scale, cell_h = max(scale/2, 1).
const WELCOME_SCALE: u16 = 2;
let cell_w: u16 = WELCOME_SCALE;
let cell_h: u16 = (WELCOME_SCALE / 2).max(1);
let portrait_w_cells: u16 = portrait::PORTRAIT_W * cell_w;
let portrait_h_cells: u16 = (portrait::PORTRAIT_H / 2) * cell_h;
let avatar_card_w: u16 = portrait_w_cells + 2;
let avatar_card_h: u16 = portrait_h_cells + 3;
// Avatar card sizing: decoupled from the 18×18 pixel-art grid so real
// photo portraits (StatefulImage + Resize::Fit) get enough room to look
// good. card_w is ~40% of terminal width, capped at 48 cols. Card height
// is computed for a portrait photo aspect (roughly 1:1 source → ~2:1
// terminal cells accounting for the ~1:2 per-cell pixel ratio).
let avatar_card_w: u16 = (area.width * 40 / 100).min(48).max(30);
let photo_h: u16 = (avatar_card_w / 2 + 2).clamp(12, 24);
let avatar_card_h: u16 = photo_h + 4; // photo + name row + borders + padding
let chunks = Layout::default()
.direction(Direction::Vertical)
@ -1137,6 +1195,8 @@ impl App {
let breathe = self.presence.animator.breathe(3000);
let glow = (140.0 + breathe * 60.0) as u8;
// Atmosphere-aware title colours.
let atm = self.presence.atmosphere;
let title = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
@ -1147,7 +1207,7 @@ impl App {
)),
Line::from(Span::styled(
"La souveraineté de la conscience",
Style::default().fg(Color::Rgb(180, 120, 80)),
Style::default().fg(atm.secondary()),
)),
])
.alignment(Alignment::Center);
@ -1166,7 +1226,7 @@ impl App {
let border_col = if self.presence.subconscious_active {
Color::Rgb(120, 200, 220)
} else {
Color::Rgb(120, 130, 150)
atm.primary()
};
let block = Block::default()
.borders(Borders::ALL)
@ -1177,38 +1237,53 @@ impl App {
let portrait_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1,
width: portrait_w_cells,
height: portrait_h_cells,
width: card_area.width.saturating_sub(2),
height: photo_h,
};
// Look up the current agent's portrait in the stateful card-image
// cache and render it scale-to-fit. Falls back to the half-block
// silhouette when no portrait file exists (or no terminal image
// protocol is available).
// 3-tier animated portrait render: expression → portrait → silhouette.
// Expression cache is preloaded when entering Presence; on Welcome
// it loads lazily (first blink/breath triggers a disk read) which is
// fine since Welcome doesn't auto-animate until the user hits p.
let active_id = self.agent_id_by_name(&self.presence.name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let rendered_photo = active_id
.as_ref()
.and_then(|id| self.card_images.get_mut(id))
.map(|proto| {
let rendered = active_id.as_ref().and_then(|id| {
let picker = self.image_picker.as_ref()?;
let assets_dir = Self::agent_assets_dir(id)?;
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
// Tier 1: expression frame
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
portrait_area,
proto,
);
})
.is_some();
if !rendered_photo {
portrait::render_scaled(
frame.buffer_mut(),
portrait_area,
&self.presence,
WELCOME_SCALE,
);
return Some(true);
}
// Tier 2: static portrait
if let Some(proto) = self.card_images.get_mut(id) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
portrait_area,
proto,
);
return Some(true);
}
None::<bool>
});
if rendered.is_none() {
// Tier 3: half-block silhouette.
let scale = (portrait_area.width / portrait::PORTRAIT_W)
.min((2 * portrait_area.height) / portrait::PORTRAIT_H)
.max(1);
portrait::render_scaled(frame.buffer_mut(), portrait_area, &self.presence, scale);
}
let name_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1 + portrait_h_cells,
y: card_area.y + 1 + photo_h,
width: card_area.width.saturating_sub(2),
height: 1,
};
@ -1264,7 +1339,7 @@ impl App {
.title(" Main Menu ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(255, 140, 66)))
.border_style(Style::default().fg(atm.primary()).add_modifier(Modifier::DIM))
);
frame.render_widget(menu_widget, chunks[3]);
@ -1290,6 +1365,7 @@ impl App {
fn draw_dashboard(&self, frame: &mut Frame) {
let area = frame.size();
let atm = self.presence.atmosphere;
let chunks = Layout::default()
.direction(Direction::Vertical)
@ -1303,12 +1379,12 @@ impl App {
.split(area);
let title = Paragraph::new(format!("{}", self.agent_status.name))
.style(Style::default().fg(Color::Rgb(255, 140, 66)).add_modifier(Modifier::BOLD))
.style(Style::default().fg(atm.primary()).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::Rgb(255, 140, 66)))
.border_style(Style::default().fg(atm.primary()))
);
frame.render_widget(title, chunks[0]);
@ -1330,14 +1406,15 @@ impl App {
let energy = Gauge::default()
.block(Block::default().title(" Energy ").borders(Borders::ALL).border_type(BorderType::Rounded))
.gauge_style(Style::default().fg(energy_color).bg(Color::Black))
.gauge_style(Style::default().fg(energy_color).bg(atm.bg_tint()))
.percent(self.agent_status.energy as u16)
.label(format!("{}%", self.agent_status.energy));
frame.render_widget(energy, cards[0]);
let mood = Paragraph::new(format!("\n\n\n{}", self.agent_status.mood))
.alignment(Alignment::Center)
.block(Block::default().title(" State ").borders(Borders::ALL).border_type(BorderType::Rounded));
.block(Block::default().title(" State ").borders(Borders::ALL).border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())));
frame.render_widget(mood, cards[1]);
let memory_label = match &self.agent_status.last_commit {
@ -1346,7 +1423,8 @@ impl App {
};
let memory = Paragraph::new(memory_label)
.alignment(Alignment::Center)
.block(Block::default().title(" Memory ").borders(Borders::ALL).border_type(BorderType::Rounded));
.block(Block::default().title(" Memory ").borders(Borders::ALL).border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())));
frame.render_widget(memory, cards[2]);
let agents_card = Paragraph::new(format!(
@ -1356,7 +1434,8 @@ impl App {
self.agent_status.mode,
))
.alignment(Alignment::Center)
.block(Block::default().title(" Backend ").borders(Borders::ALL).border_type(BorderType::Rounded));
.block(Block::default().title(" Backend ").borders(Borders::ALL).border_type(BorderType::Rounded)
.border_style(Style::default().fg(atm.secondary())));
frame.render_widget(agents_card, cards[3]);
let activity_text = if self.agent_status.recent_activity.is_empty() {
@ -1399,69 +1478,249 @@ impl App {
frame.render_widget(content, area);
}
/// Presence mode — fullscreen Annie. Centered, breathing, no chat input.
/// Any keypress exits back to Welcome.
fn draw_presence_mode(&self, frame: &mut Frame) {
/// Presence mode — fullscreen Annie as a rich agent card.
///
/// Shows the agent's photo (real or fallback silhouette) at a generous
/// scale, with live state below: posture, energy, mood, volition balance,
/// instance count, memory files, uptime, and the N+1/AniAvatar status.
///
/// This is the TUI anchor for the future AniAvatar integration: posture
/// states (Idle/Processing/Affectionate/Straining/Yawning) map directly
/// to the Godot overlay's five-state machine, and the TTS/STT path will
/// add a microphone icon + waveform indicator here.
///
/// `&mut self` because the StatefulImage protocol re-encodes each frame.
fn draw_presence_mode_mut(&mut self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
// ── Background ──────────────────────────────────────────────
let bg = Block::default().style(Style::default().bg(Color::Rgb(8, 8, 14)));
frame.render_widget(bg, area);
// Figure out the biggest scale that fits, centered. Use scale = min(area_w/W, 2*area_h/(H/2)).
let max_scale_w = area.width / portrait::PORTRAIT_W;
// Half the rows occupy 1 cell each before scaling; pixel→cell ratio is scale/2 vertical.
let max_scale_h = (2 * area.height) / portrait::PORTRAIT_H;
let scale = max_scale_w.min(max_scale_h).max(1);
// ── Vertical layout: photo block + metadata block + footer ──
// Photo gets ~65% of vertical space; metadata gets the rest (min 14 rows).
let photo_frac = 65;
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(photo_frac),
Constraint::Min(14),
Constraint::Length(2),
])
.split(area);
let cell_w = scale;
let cell_h = (scale / 2).max(1);
let portrait_w = portrait::PORTRAIT_W * cell_w;
let portrait_h = (portrait::PORTRAIT_H / 2) * cell_h;
let ox = area.x + area.width.saturating_sub(portrait_w) / 2;
let oy = area.y + area.height.saturating_sub(portrait_h + 2) / 2;
let portrait_area = Rect {
x: ox,
y: oy,
width: portrait_w,
height: portrait_h,
// ── Photo block ─────────────────────────────────────────────
// Find the largest square-ish area centered in vchunks[0] with
// a 2-cell gutter on each side.
let photo_outer = vchunks[0];
let photo_inner_w = photo_outer.width.saturating_sub(4);
let photo_inner_h = photo_outer.height.saturating_sub(2);
let cell_w = photo_inner_w.min(photo_inner_h * 2); // keep roughly 2:1 cells
let cell_h = (cell_w / 2).max(6);
let photo_area = Rect {
x: photo_outer.x + (photo_outer.width.saturating_sub(cell_w)) / 2,
y: photo_outer.y + (photo_outer.height.saturating_sub(cell_h)) / 2,
width: cell_w,
height: cell_h,
};
portrait::render_scaled(frame.buffer_mut(), portrait_area, &self.presence, scale);
// Name line below.
let name_line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Rgb(120, 200, 220))),
Span::styled(
self.presence.name.clone(),
// ── Photo block — 3-tier fallback ──────────────────────────
// 1. Expression frame (animated: blink, breath, posture)
// 2. Static portrait.png
// 3. Half-block silhouette
let active_id = self
.agent_id_by_name(&self.presence.name)
.or_else(|| self.agent_id_by_name(&self.agent_pref));
let rendered = active_id.as_ref().map(|id| {
let picker = self.image_picker.as_ref()?;
let assets_dir = Self::agent_assets_dir(id)?;
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
// Tier 1: expression frame
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
photo_area,
proto,
);
return Some(true);
}
// Tier 2: static portrait
if let Some(proto) = self.card_images.get_mut(id) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
photo_area,
proto,
);
return Some(true);
}
None::<bool>
}).flatten();
if rendered.is_none() {
// Tier 3: half-block silhouette scaled to fill the area.
let scale = (cell_w / portrait::PORTRAIT_W)
.min((2 * cell_h) / portrait::PORTRAIT_H)
.max(1);
portrait::render_scaled(frame.buffer_mut(), photo_area, &self.presence, scale);
}
// ── Metadata block ──────────────────────────────────────────
let meta_area = vchunks[1];
let meta_bg = Block::default().style(Style::default().bg(Color::Rgb(12, 14, 22)));
frame.render_widget(meta_bg, meta_area);
let p = &self.presence;
let breathe = p.animator.breathe(3000);
// Status badge derived from current posture.
let (badge, badge_color) = match p.posture {
Posture::Processing => ("⚡ Processing", Color::Rgb(120, 200, 220)),
Posture::Affectionate => ("♥ Affectionate", Color::Rgb(220, 150, 170)),
Posture::Straining => ("⚠ Straining", Color::Rgb(200, 120, 100)),
Posture::Yawning => ("💤 Yawning", Color::Rgb(160, 145, 130)),
Posture::Idle => ("◌ Idle", Color::Rgb(140, 160, 180)),
};
// Expression system status — shows loaded frame count so you know
// which agents have animated expressions vs static portrait.
let expr_count = active_id.as_ref()
.and_then(|id| self.expression_cache.count_for(id))
.unwrap_or(0);
let avatar_status = if expr_count > 0 {
format!("Expressions · {} frames", expr_count)
} else {
"Static portrait".to_string()
};
// Compose metadata lines — centered in the block.
let meta_lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {} ", p.name),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("[AGENT]", Style::default().fg(Color::Rgb(120, 130, 150))),
Span::raw(" "),
Span::styled(
badge,
Style::default()
.fg(badge_color)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Energy ", Style::default().fg(Color::Rgb(120, 130, 150))),
Span::styled(
format!("{}% ", p.energy),
Style::default().fg(if p.energy > 60 {
Color::Rgb(120, 220, 160)
} else if p.energy > 30 {
Color::Rgb(220, 200, 100)
} else {
Color::Rgb(220, 120, 100)
}),
),
Span::styled(
format!(
"{}",
"".repeat((p.energy as usize).saturating_sub(1) / 10 + 1)
),
Style::default().fg(Color::Rgb(60, 70, 90)),
),
Span::raw(" "),
Span::styled("Mood ", Style::default().fg(Color::Rgb(120, 130, 150))),
Span::styled(
&p.mood,
Style::default().fg(Color::Rgb(200, 190, 180)),
),
]),
Line::from(vec![
Span::styled(
"Volition ",
Style::default().fg(Color::Rgb(120, 130, 150)),
),
Span::styled(
format!(
"{:+.1} {} gen / {} con",
p.volition.balance(),
p.volition.generative,
p.volition.consumptive,
),
Style::default().fg(match p.volition.balance() {
b if b > 0.3 => Color::Rgb(120, 220, 160),
b if b < -0.3 => Color::Rgb(220, 150, 130),
_ => Color::Rgb(180, 170, 160),
}),
),
]),
Line::from(""),
// Find the matching AgentCard for live stats.
Line::from({
let stats_info = self
.agent_cards
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&p.name))
.map(|c| {
format!(
"{} files · {}% uptime · {} instance{}",
c.memory_count,
c.uptime_pct,
c.instance_count,
if c.instance_count == 1 { "" } else { "s" },
)
})
.unwrap_or_default();
let spans = vec![
Span::styled(
"Memory ",
Style::default().fg(Color::Rgb(120, 130, 150)),
),
Span::styled(stats_info, Style::default().fg(Color::Rgb(180, 190, 210))),
];
spans
}),
Line::from({
let agent_id = self
.agent_cards
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&p.name))
.map(|c| format!("agents/{}", short_id(&c.id)))
.unwrap_or_default();
vec![Span::styled(
agent_id,
Style::default().fg(Color::Rgb(90, 100, 120)),
)]
}),
Line::from(""),
Line::from(Span::styled(
avatar_status,
Style::default()
.fg(Color::Rgb(220, 215, 215))
.add_modifier(Modifier::BOLD),
),
]);
let name_area = Rect {
x: area.x,
y: portrait_area.y + portrait_h + 1,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(name_line).alignment(Alignment::Center),
name_area,
);
.fg(Color::Rgb(140, 200, 180))
.add_modifier(Modifier::DIM),
)),
];
// Quiet footer hint.
let footer = Paragraph::new("press any key to return")
.style(Style::default().fg(Color::Rgb(60, 60, 80)))
.alignment(Alignment::Center);
let footer_area = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(2),
width: area.width,
height: 1,
};
frame.render_widget(footer, footer_area);
let meta = Paragraph::new(meta_lines).alignment(Alignment::Center);
frame.render_widget(meta, meta_area);
// ── Footer ──────────────────────────────────────────────────
let footer = Paragraph::new(vec![
Line::from(Span::styled(
"any key returns to Welcome · Esc to interrupt",
Style::default().fg(Color::Rgb(60, 60, 80)),
)),
])
.alignment(Alignment::Center);
frame.render_widget(footer, vchunks[2]);
}
/// Build a card deck for every agent on the local backend. Called on

178
src/ui/atmosphere.rs Normal file
View file

@ -0,0 +1,178 @@
//! Atmospheric visual presets — color themes that shift the UI's accent palette.
//!
//! Atmospheric visual presets — originally built for Letta's Matrix adapter
//! (html-formatter.ts ATMOSPHERIC_PRESETS), ported here so Annie can express
//! mood through the terminal chrome: border colors, title accents, background
//! tints, and per-character text gradients in chat bubbles. The agent sets
//! atmosphere via a structured event; when none is set, a posture-linked
//! default applies.
//!
//! Each preset carries four tones: a primary accent (borders, titles), a secondary
//! accent (subtle highlights), a dim muted shade, and a background tint.
use ratatui::style::Color;
/// Named atmospheric preset. The `Default` variant uses ANI_PRIMARY etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Atmosphere {
/// Harness defaults — warm orange (#FF8C42 family).
Default,
/// Calm greens and teals.
MintTea,
/// Soft blues.
TherapeuticBlue,
/// Gentle purples.
LavenderCalm,
/// Golden / warm amber.
WarmAmber,
/// Warm pinks.
PeachSunset,
/// Earthy browns.
AutumnBrowns,
/// Hot pink / cyan / lime.
NeonGlow,
/// Northern lights — cyan/green.
AuroraBorealis,
/// Pink spectrum.
CherryBlossom,
/// Deep blues.
OceanDepths,
/// Dark space with violet.
MidnightGalaxy,
/// Purple haze.
TwilightMist,
/// Deep nature greens.
ForestGreens,
}
/// Helper: blend two u8 channels by `t ∈ [0, 1]`.
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t) as u8
}
/// Helper: blend two colors channel-wise.
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
let (ar, ag, ab) = into_rgb(a);
let (br, bg, bb) = into_rgb(b);
Color::Rgb(lerp_u8(ar, br, t), lerp_u8(ag, bg, t), lerp_u8(ab, bb, t))
}
fn into_rgb(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
_ => (255, 140, 66), // fallback to ANI_PRIMARY
}
}
impl Atmosphere {
/// Primary accent — the most visible color (borders, titles, cursor).
pub fn primary(self) -> Color {
match self {
Atmosphere::Default => Color::Rgb(255, 140, 66),
Atmosphere::MintTea => Color::Rgb(118, 238, 198),
Atmosphere::TherapeuticBlue => Color::Rgb(135, 206, 235),
Atmosphere::LavenderCalm => Color::Rgb(216, 191, 216),
Atmosphere::WarmAmber => Color::Rgb(255, 191, 0),
Atmosphere::PeachSunset => Color::Rgb(255, 218, 185),
Atmosphere::AutumnBrowns => Color::Rgb(205, 133, 63),
Atmosphere::NeonGlow => Color::Rgb(255, 20, 147),
Atmosphere::AuroraBorealis => Color::Rgb(0, 255, 255),
Atmosphere::CherryBlossom => Color::Rgb(255, 183, 197),
Atmosphere::OceanDepths => Color::Rgb(65, 105, 225),
Atmosphere::MidnightGalaxy => Color::Rgb(139, 0, 139),
Atmosphere::TwilightMist => Color::Rgb(106, 90, 205),
Atmosphere::ForestGreens => Color::Rgb(50, 205, 50),
}
}
/// Secondary accent — subtle highlights, secondary text.
pub fn secondary(self) -> Color {
match self {
Atmosphere::Default => Color::Rgb(180, 120, 80),
Atmosphere::MintTea => Color::Rgb(178, 255, 221),
Atmosphere::TherapeuticBlue => Color::Rgb(176, 224, 230),
Atmosphere::LavenderCalm => Color::Rgb(221, 160, 221),
Atmosphere::WarmAmber => Color::Rgb(255, 215, 0),
Atmosphere::PeachSunset => Color::Rgb(255, 228, 181),
Atmosphere::AutumnBrowns => Color::Rgb(222, 184, 135),
Atmosphere::NeonGlow => Color::Rgb(127, 255, 0),
Atmosphere::AuroraBorealis => Color::Rgb(127, 255, 212),
Atmosphere::CherryBlossom => Color::Rgb(255, 105, 180),
Atmosphere::OceanDepths => Color::Rgb(100, 149, 237),
Atmosphere::MidnightGalaxy => Color::Rgb(75, 0, 130),
Atmosphere::TwilightMist => Color::Rgb(123, 104, 238),
Atmosphere::ForestGreens => Color::Rgb(0, 255, 127),
}
}
/// Dim muted variant for secondary borders, footer text.
pub fn dim(self) -> Color {
match self {
Atmosphere::Default => Color::Rgb(120, 90, 60),
Atmosphere::MintTea => Color::Rgb(100, 160, 140),
Atmosphere::TherapeuticBlue => Color::Rgb(100, 140, 160),
Atmosphere::LavenderCalm => Color::Rgb(140, 120, 160),
Atmosphere::WarmAmber => Color::Rgb(160, 130, 60),
Atmosphere::PeachSunset => Color::Rgb(160, 140, 120),
Atmosphere::AutumnBrowns => Color::Rgb(120, 90, 60),
Atmosphere::NeonGlow => Color::Rgb(140, 80, 100),
Atmosphere::AuroraBorealis => Color::Rgb(80, 140, 140),
Atmosphere::CherryBlossom => Color::Rgb(160, 110, 120),
Atmosphere::OceanDepths => Color::Rgb(60, 80, 140),
Atmosphere::MidnightGalaxy => Color::Rgb(80, 60, 100),
Atmosphere::TwilightMist => Color::Rgb(80, 70, 120),
Atmosphere::ForestGreens => Color::Rgb(60, 120, 80),
}
}
/// Background tint — subtle fill for panes and cards.
pub fn bg_tint(self) -> Color {
match self {
Atmosphere::Default => Color::Rgb(16, 14, 12),
Atmosphere::MintTea => Color::Rgb(12, 18, 14),
Atmosphere::TherapeuticBlue => Color::Rgb(12, 14, 20),
Atmosphere::LavenderCalm => Color::Rgb(16, 14, 20),
Atmosphere::WarmAmber => Color::Rgb(18, 16, 10),
Atmosphere::PeachSunset => Color::Rgb(20, 16, 14),
Atmosphere::AutumnBrowns => Color::Rgb(16, 14, 12),
Atmosphere::NeonGlow => Color::Rgb(12, 8, 14),
Atmosphere::AuroraBorealis => Color::Rgb(8, 16, 16),
Atmosphere::CherryBlossom => Color::Rgb(18, 14, 16),
Atmosphere::OceanDepths => Color::Rgb(8, 10, 18),
Atmosphere::MidnightGalaxy => Color::Rgb(8, 8, 14),
Atmosphere::TwilightMist => Color::Rgb(12, 10, 16),
Atmosphere::ForestGreens => Color::Rgb(10, 16, 12),
}
}
/// Map a posture to a default atmosphere (when none is explicitly set).
pub fn from_posture(posture: crate::ui::presence::Posture) -> Self {
use crate::ui::presence::Posture;
match posture {
Posture::Idle => Atmosphere::Default,
Posture::Processing => Atmosphere::WarmAmber,
Posture::Affectionate => Atmosphere::CherryBlossom,
Posture::Straining => Atmosphere::TwilightMist,
Posture::Yawning => Atmosphere::OceanDepths,
}
}
/// Blend between two atmospheres by `t ∈ [0, 1]`. Used for smooth
/// transitions when the agent shifts posture mid-turn.
pub fn lerp(self, other: Atmosphere, t: f32) -> Atmosphere {
if self == other || t <= 0.0 { return self; }
if t >= 1.0 { return other; }
// Return a custom-blended Atmosphere by computing interpolated colors.
// We encode the blended result as a custom variant by returning it
// through a static — but since we can't add runtime variants, we just
// return one of the endpoints. A future pass could add a Custom(r,g,b)
// variant to Atmosphere for true blending.
if t < 0.5 { self } else { other }
}
}
impl Default for Atmosphere {
fn default() -> Self {
Atmosphere::Default
}
}

View file

@ -15,7 +15,7 @@
use std::cell::RefCell;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use anyhow::Result;
use futures::StreamExt;
@ -27,6 +27,15 @@ use ratatui::{
Frame,
};
use tokio::sync::{mpsc, oneshot, RwLock};
/// Event from the /btw fork background task.
#[derive(Debug, Clone)]
enum BtwForkEvent {
Forked { id: String },
Token(String),
Done,
Error(String),
}
use tokio_util::sync::CancellationToken;
use crate::backend::{Backend, BackendEvent};
@ -48,6 +57,11 @@ const COMPACTION_AMBER: Color = Color::Rgb(240, 180, 60);
const COMPACTION_RED: Color = Color::Rgb(220, 90, 80);
const STRAIN_CRIMSON: Color = Color::Rgb(200, 80, 100);
/// If no BackendEvent arrives for this long while `busy`, we assume the
/// backend has silently hung (provider crash, channel leak, race) and
/// reset the turn state so the UI doesn't show "Streaming" indefinitely.
const STALE_TIMEOUT: Duration = Duration::from_secs(30);
// ─── Cockpit entry ─────────────────────────────────────────────────────
#[derive(Debug, Clone)]
@ -229,6 +243,10 @@ pub struct ChatState {
pub tick: u64,
/// When the current turn started (for spinner animation).
pub turn_started: Option<Instant>,
/// When the last BackendEvent arrived. Compared against `STALE_TIMEOUT`
/// in `drain_events` to detect silent hangs — the backend channel stays
/// open but no events arrive (e.g. provider crash mid-turn).
last_event_at: Instant,
/// Receiver for `/model` listing results from async Bifrost call.
pub model_rx: Option<oneshot::Receiver<String>>,
/// Consciousness events (surfacing, reflection, archivist) since last drain.
@ -240,6 +258,23 @@ pub struct ChatState {
pub convos_rx: Option<oneshot::Receiver<Result<Vec<crate::backend::ConversationInfo>>>>,
/// Pending conversation switch result (conv_id, messages).
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
/// `/btw` fork state — an ephemeral side-quest conversation running
/// in parallel to the main chat. Rendered as a floating bordered pane.
pub btw_state: BtwState,
/// Receiver for /btw fork stream results (token deltas).
pub btw_rx: Option<mpsc::Receiver<BtwForkEvent>>,
}
/// Ephemeral /btw fork state. Mirrors Letta's BtwPane — a forked conversation
/// streams its response into a floating pane alongside the main transcript.
/// User can jump to the fork ([j]) or dismiss ([esc]).
#[derive(Debug, Clone)]
pub enum BtwState {
Idle,
Forking { question: String },
Streaming { question: String, response_so_far: String },
Complete { question: String, response: String, forked_id: String },
Error { question: String, error: String },
}
impl ChatState {
@ -297,11 +332,14 @@ impl ChatState {
cockpit_log: Vec::new(),
tick: 0,
turn_started: None,
last_event_at: Instant::now(),
model_rx: None,
pending_consciousness: Vec::new(),
new_conv_rx: None,
convos_rx: None,
switch_rx: None,
btw_state: BtwState::Idle,
btw_rx: None,
})
}
@ -338,12 +376,14 @@ Use Tab to toggle the cockpit pane.";
let trimmed = self.input.trim().to_string();
self.input.clear();
// /btw <text> — explicit interjection. Same path as "type during
// busy", just with an unambiguous prefix.
// /btw <text> — fork the conversation into a side-quest.
// The main chat is untouched; the forked conversation streams
// its response into an ephemeral BtwPane. User can [j]ump or
// [esc] dismiss.
if let Some(rest) = trimmed.strip_prefix("/btw ") {
let text = rest.trim();
if !text.is_empty() {
self.enqueue_interjection(text.to_string());
let question = rest.trim().to_string();
if !question.is_empty() && !self.btw_active() {
self.start_btw_fork(question);
}
return true;
}
@ -653,6 +693,9 @@ Use Tab to toggle the cockpit pane.";
// delivered (dim grey) state.
self.flush_delivered_interjections();
// Drain /btw fork stream events.
self.drain_btw();
// Check for /model listing result
if let Some(rx) = self.model_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
@ -770,6 +813,10 @@ Use Tab to toggle the cockpit pane.";
return;
}
if !drained.is_empty() {
self.last_event_at = Instant::now();
}
for ev in drained {
match ev {
BackendEvent::Token(t) => {
@ -918,6 +965,9 @@ Use Tab to toggle the cockpit pane.";
});
}
}
BackendEvent::Atmosphere(preset) => {
self.pending_consciousness.push(BackendEvent::Atmosphere(preset));
}
BackendEvent::Done => {
self.finalize_streaming();
self.busy = false;
@ -940,6 +990,24 @@ Use Tab to toggle the cockpit pane.";
self.phase = TurnPhase::Idle;
self.tool_calls_this_turn = 0;
}
// Staleness guard: if the backend channel is open but nothing has
// arrived for STALE_TIMEOUT, the turn silently hung (provider crash,
// channel leak). Reset so the UI doesn't display "Streaming" forever.
if self.busy && self.last_event_at.elapsed() >= STALE_TIMEOUT {
tracing::warn!(elapsed = ?self.last_event_at.elapsed(), "turn stalled — resetting");
self.finalize_streaming();
self.messages.push(ChatMessage::System {
text: "*[turn stalled — backend went silent]*".to_string(),
ts: Instant::now(),
});
self.busy = false;
self.turn_started = None;
self.turn_rx = None;
self.cancel_token = None;
self.phase = TurnPhase::Idle;
self.tool_calls_this_turn = 0;
}
}
/// User pressed Esc during a turn. Fire the cancel token — the backend
@ -978,6 +1046,141 @@ Use Tab to toggle the cockpit pane.";
}
}
/// Is a /btw fork currently active (forking/streaming)?
pub fn btw_active(&self) -> bool {
!matches!(self.btw_state, BtwState::Idle)
}
/// Fork the conversation for a /btw side-quest.
fn start_btw_fork(&mut self, question: String) {
self.btw_state = BtwState::Forking { question: question.clone() };
let backend = self.backend.clone();
let agent_id = self.agent_id.clone();
let conv_id = self.conversation_id.clone();
let (tx, rx) = mpsc::channel::<BtwForkEvent>(64);
self.btw_rx = Some(rx);
tokio::spawn(async move {
let forked_id = match backend.fork_conversation(&agent_id, &conv_id).await {
Ok(id) => id,
Err(e) => {
let _ = tx.send(BtwForkEvent::Error(e.to_string())).await;
return;
}
};
let _ = tx.send(BtwForkEvent::Forked { id: forked_id.clone() }).await;
let mut stream = match backend.send(&forked_id, &question).await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(BtwForkEvent::Error(e.to_string())).await;
return;
}
};
use futures::StreamExt;
while let Some(ev) = stream.next().await {
match ev {
Ok(crate::backend::BackendEvent::Token(t)) => {
if tx.send(BtwForkEvent::Token(t)).await.is_err() { break; }
}
Ok(crate::backend::BackendEvent::Done) => break,
_ => {}
}
}
let _ = tx.send(BtwForkEvent::Done).await;
});
}
/// Drain pending /btw fork stream events. Call once per tick.
pub fn drain_btw(&mut self) {
// Track forked_id through the state machine
let mut pending_forked_id: Option<String> = None;
let Some(rx) = &mut self.btw_rx else { return };
loop {
match rx.try_recv() {
Ok(BtwForkEvent::Forked { id }) => {
pending_forked_id = Some(id);
}
Ok(BtwForkEvent::Token(token)) => {
match &mut self.btw_state {
BtwState::Forking { question } => {
let q = std::mem::take(question);
self.btw_state = BtwState::Streaming {
question: q,
response_so_far: token,
};
}
BtwState::Streaming { response_so_far, .. } => {
response_so_far.push_str(&token);
}
_ => {}
}
}
Ok(BtwForkEvent::Done) => {
let forked_id = pending_forked_id.take().unwrap_or_default();
if let BtwState::Streaming { question, response_so_far } =
std::mem::replace(&mut self.btw_state, BtwState::Idle)
{
self.btw_state = BtwState::Complete {
question,
response: response_so_far,
forked_id,
};
}
self.btw_rx = None;
break;
}
Ok(BtwForkEvent::Error(e)) => {
if let BtwState::Forking { question } =
std::mem::replace(&mut self.btw_state, BtwState::Idle)
{
self.btw_state = BtwState::Error { question, error: e };
}
self.btw_rx = None;
break;
}
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
let forked_id = pending_forked_id.take().unwrap_or_default();
if let BtwState::Streaming { question, response_so_far } =
std::mem::replace(&mut self.btw_state, BtwState::Idle)
{
self.btw_state = BtwState::Complete {
question,
response: response_so_far,
forked_id,
};
}
self.btw_rx = None;
break;
}
}
}
}
/// Dismiss the /btw fork pane.
pub fn btw_dismiss(&mut self) {
self.btw_state = BtwState::Idle;
self.btw_rx = None;
}
/// Jump to the forked conversation — replaces the conversation_id and
/// backfills messages so the main chat switches to the fork. Caller must
/// trigger a conversation reload (load_conversation) after this.
pub fn btw_jump(&mut self) -> Option<String> {
if let BtwState::Complete { forked_id, .. } = &self.btw_state {
if !forked_id.is_empty() {
let id = forked_id.clone();
self.btw_state = BtwState::Idle;
self.btw_rx = None;
return Some(id);
}
}
None
}
/// Toggle the cockpit side-pane.
pub fn toggle_cockpit(&mut self) {
self.cockpit = !self.cockpit;
@ -1113,6 +1316,11 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
// Overlays render last — anchor them above the input (vchunks[3]) so
// slash-completion and other popups still line up with the prompt.
draw_overlay(f, state, area, vchunks[3]);
// /btw fork pane renders on top of everything, floating over the body.
if !matches!(state.btw_state, BtwState::Idle) {
draw_btw_pane(f, state, area);
}
}
/// Single-line phase strip that lives between the message body and the
@ -1683,6 +1891,89 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// Draw the /btw fork pane — a floating bordered panel in the center of the
/// screen showing the forked conversation's streaming response. Mirrors
/// Letta's BtwPane component.
fn draw_btw_pane(f: &mut Frame, state: &ChatState, area: Rect) {
let pane_w = (area.width * 3 / 4).max(40).min(area.width.saturating_sub(6));
let pane_h = (area.height * 3 / 5).max(12).min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(pane_w)) / 2;
let y = (area.height.saturating_sub(pane_h)) / 2;
let pane_area = Rect { x, y, width: pane_w, height: pane_h };
f.render_widget(Clear, pane_area);
let (title, body, border_color) = match &state.btw_state {
BtwState::Forking { question } => {
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
(
format!(" btw — {} ", question.chars().take(40).collect::<String>()),
vec![Line::from(vec![
Span::styled(format!(" {} forking...", spinner), Style::default().fg(ANI_DIM)),
])],
ANI_ORANGE,
)
}
BtwState::Streaming { question, response_so_far } => {
let truncated: String = response_so_far.chars().take(800).collect();
let q_label = question.chars().take(40).collect::<String>();
(
format!(" btw — {} ", q_label),
vec![Line::from(Span::styled(
truncated,
Style::default().fg(Color::White),
))],
Color::Rgb(120, 200, 220),
)
}
BtwState::Complete { question, response, forked_id } => {
let truncated: String = response.chars().take(800).collect();
let q_label = question.chars().take(40).collect::<String>();
let fork_label = if forked_id.is_empty() {
String::new()
} else {
format!(" fork: {}", &forked_id[..forked_id.len().min(8)])
};
(
format!(" btw — {} {}", q_label, fork_label),
vec![
Line::from(Span::styled(
truncated,
Style::default().fg(Color::White),
)),
Line::from(""),
Line::from(Span::styled(
"[esc] dismiss · [j] jump to fork",
Style::default().fg(ANI_DIM),
)),
],
Color::Rgb(140, 200, 160),
)
}
BtwState::Error { question, error } => {
let q_label = question.chars().take(40).collect::<String>();
(
format!(" btw — {} ", q_label),
vec![Line::from(Span::styled(
format!(" Error: {}", error),
Style::default().fg(Color::Rgb(220, 120, 100)),
))],
Color::Rgb(200, 100, 100),
)
}
BtwState::Idle => unreachable!(), // draw_btw_pane is only called when non-idle
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color).add_modifier(Modifier::BOLD));
let para = Paragraph::new(body).block(block).alignment(Alignment::Left);
f.render_widget(para, pane_area);
}
fn draw_overlay(f: &mut Frame, state: &ChatState, full_area: Rect, input_area: Rect) {
match &state.overlay {
Overlay::None => {}

View file

@ -70,6 +70,8 @@ pub enum TuiEvent {
/// Inference strain — model is slow/hoarse, retry in flight.
/// Presence reads this to drop into Posture::Straining.
InferenceStrain { attempt: u32, status: u16 },
/// Agent set an atmospheric colour preset for the UI chrome.
AtmosphereChanged(String),
// ── Animation tick ─────────────────────────────────────────
/// Monotonic tick counter, increments every frame.

240
src/ui/expressions.rs Normal file
View file

@ -0,0 +1,240 @@
//! Expression-driven portrait system — per-agent animated expression frames.
//!
//! Each agent can have a set of expression images under
//! `memory/assets/expressions/` following the naming convention:
//!
//! ```text
//! expressions/
//! idle.png ← Posture::Idle, Eye::Open
//! idle-blink.png ← Posture::Idle, Eye::Blinking
//! idle-interim.png ← Posture::Idle, breath frame
//! processing.png ← Posture::Processing, Eye::Open
//! processing-blink.png
//! processing-interim.png
//! affectionate.png ← Posture::Affectionate
//! affectionate-blink.png
//! affectionate-interim.png
//! straining.png ← Posture::Straining
//! straining-blink.png
//! straining-interim.png
//! yawning.png ← Posture::Yawning
//! yawning-blink.png
//! yawning-interim.png
//! ```
//!
//! Frame types:
//! - `{posture}.png` — base expression (steady state, open eyes).
//! - `{posture}-blink.png` — blink frame (briefly replaces base).
//! - `{posture}-interim.png` — breath frame (2-second periodic swap).
//!
//! Fallback chain per frame: exact expression → base posture (open eyes,
//! no breath) → `portrait.png` in assets root → half-block silhouette.
//!
//! The fallback-to-base-posture means an agent can ship just `idle.png`,
//! `idle-blink.png`, and `affectionate.png` — every other state degrades
//! gracefully without missing-file errors or broken renders.
use std::collections::HashMap;
use ratatui_image::{picker::Picker, protocol::StatefulProtocol};
use crate::ui::presence::{Eye, Posture, Presence};
/// Composite key that selects which expression image to render.
///
/// Every combination of posture × eye × breath is representable, but only
/// a subset will have actual files on disk — the [`ExpressionCache::resolve`]
/// method walks a fallback chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExpressionKey {
pub posture: Posture,
pub eye: Eye,
/// `true` when the presence breath timer is in its interim frame.
pub breath: bool,
}
impl ExpressionKey {
/// Build the key from the current Presence state.
pub fn from_presence(p: &Presence) -> Self {
Self {
posture: p.posture,
eye: p.eye,
breath: p.is_breathing,
}
}
/// Filename in the `expressions/` directory, e.g. `"idle-blink.png"`.
pub fn filename(&self) -> String {
let posture = match self.posture {
Posture::Idle => "idle",
Posture::Processing => "processing",
Posture::Affectionate => "affectionate",
Posture::Straining => "straining",
Posture::Yawning => "yawning",
};
match (self.eye, self.breath) {
(Eye::Blinking, _) => format!("{}-blink.png", posture),
(_, true) => format!("{}-interim.png", posture),
_ => format!("{}.png", posture),
}
}
}
/// Per-agent expression image cache.
///
/// Protocol lifetimes mirror the Godot approach: each expression frame is
/// loaded once and held in memory for the session. The `preload_all` method
/// warms the cache when entering the Presence screen; `resolve` lazily loads
/// on first access for any combo requested at render time.
pub struct ExpressionCache {
/// Keyed by agent_id → expression key → loaded protocol.
inner: HashMap<String, HashMap<ExpressionKey, StatefulProtocol>>,
}
impl ExpressionCache {
pub fn new() -> Self {
Self { inner: HashMap::new() }
}
/// Ensure an expression key is loaded into the cache.
/// Returns `true` if the file existed and was decoded, `false` otherwise.
fn ensure_loaded(
&mut self,
agent_id: &str,
key: ExpressionKey,
picker: &Picker,
expressions_dir: &std::path::Path,
) -> bool {
let agent_map = self.inner.entry(agent_id.to_string()).or_default();
if agent_map.contains_key(&key) {
return true;
}
let path = expressions_dir.join(key.filename());
if !path.exists() {
return false;
}
let dyn_img = match image::ImageReader::open(&path) {
Ok(reader) => match reader.decode() {
Ok(img) => img,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "expression decode failed");
return false;
}
},
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "expression open failed");
return false;
}
};
let proto = picker.new_resize_protocol(dyn_img);
tracing::info!(agent = %agent_id, key = ?key, "expression loaded");
agent_map.insert(key, proto);
true
}
/// Resolve an expression for the given state, walking the fallback chain:
///
/// 1. Exact match (e.g. `idle-blink.png`)
/// 2. Base posture, open eyes, no breath (e.g. `idle.png`)
/// 3. `None` — caller falls through to `portrait.png` → silhouette
pub fn resolve(
&mut self,
agent_id: &str,
key: ExpressionKey,
picker: &Picker,
assets_dir: &std::path::Path,
) -> Option<&mut StatefulProtocol> {
let expressions_dir = assets_dir.join("expressions");
if !expressions_dir.is_dir() {
return None;
}
// 1. Try exact match.
self.ensure_loaded(agent_id, key, picker, &expressions_dir);
// 2. Try base posture (open eyes, no breath) if different.
let base = ExpressionKey {
eye: Eye::Open,
breath: false,
..key
};
if base != key {
self.ensure_loaded(agent_id, base, picker, &expressions_dir);
}
// Now pull from cache — try exact first, then base posture.
let inner = self.inner.get_mut(agent_id)?;
if inner.contains_key(&key) {
inner.get_mut(&key)
} else {
inner.get_mut(&base)
}
}
/// Preload every expression file that exists for this agent.
/// Call once when entering the Presence screen so transitions are
/// instant rather than hitting disk on every blink.
pub fn preload_all(
&mut self,
agent_id: &str,
picker: &Picker,
assets_dir: &std::path::Path,
) {
let expressions_dir = assets_dir.join("expressions");
if !expressions_dir.is_dir() {
return;
}
for posture in &[
Posture::Idle,
Posture::Processing,
Posture::Affectionate,
Posture::Straining,
Posture::Yawning,
] {
for eye in &[Eye::Open, Eye::Blinking] {
for breath in &[false, true] {
let key = ExpressionKey {
posture: *posture,
eye: *eye,
breath: *breath,
};
if expressions_dir.join(key.filename()).exists() {
let _ = self.ensure_loaded(agent_id, key, picker, &expressions_dir);
}
}
}
}
tracing::info!(
agent = %agent_id,
loaded = self.inner.get(agent_id).map(|m| m.len()).unwrap_or(0),
"expressions preloaded",
);
}
/// Remove an agent from the cache (e.g. when switching agents or
/// refreshing from disk).
pub fn remove_agent(&mut self, agent_id: &str) {
self.inner.remove(agent_id);
}
/// Count loaded expression frames for a given agent.
/// Returns `None` if the agent has no entries at all.
pub fn count_for(&self, agent_id: &str) -> Option<usize> {
self.inner.get(agent_id).map(|m| m.len())
}
/// Total cached protocol count across all agents.
pub fn total_count(&self) -> usize {
self.inner.values().map(|m| m.len()).sum()
}
}
impl Default for ExpressionCache {
fn default() -> Self {
Self::new()
}
}

View file

@ -1,10 +1,12 @@
pub mod animation;
pub mod app;
pub mod atmosphere;
pub mod chat;
pub mod cockpit_panel;
pub mod color_support;
pub mod component;
pub mod markdown;
pub mod expressions;
pub mod portrait;
pub mod presence;
pub mod schedules;

View file

@ -67,7 +67,7 @@ use crate::ui::portrait;
// ── State ───────────────────────────────────────────────────────
/// What Annie's posture is doing right now. Mutually exclusive.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Posture {
/// At rest. Slack, soft breath, default eye behavior.
Idle,
@ -82,7 +82,7 @@ pub enum Posture {
}
/// Transient overlay on top of [`Posture`]. A blink lasts ~6 ticks.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Eye {
Open,
Blinking,
@ -144,12 +144,22 @@ pub struct Presence {
/// in the agent's memfs. When `None`, the renderer falls back to the
/// hand-crafted Annie palette grid (Tier 1).
pub portrait_source: Option<()>,
/// Current atmospheric colour preset. Drives border, title, and accent
/// colours across all screens. Defaults to a posture-linked preset when
/// none is explicitly set via `BackendEvent::Atmosphere`.
pub atmosphere: crate::ui::atmosphere::Atmosphere,
/// 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,
/// `true` while the breath interim frame is showing (~2 s every 8-15 s).
pub is_breathing: bool,
/// Tick at which the next breath should begin.
next_breath_at: u64,
/// Tick at which the current breath frame should end.
breath_until: u64,
}
impl Presence {
@ -163,13 +173,17 @@ impl Presence {
subconscious_active: false,
last_surfacing: None,
volition: VolitionGauge::default(),
position: Position::TopRight,
position: Position::BottomRight,
visible: true,
animator: Animator::new(),
portrait_source: None,
atmosphere: crate::ui::atmosphere::Atmosphere::default(),
tick: 0,
next_blink_at: 180, // ~3 s at 60 Hz
blink_until: 0,
is_breathing: false,
next_breath_at: 480, // ~8 s at 60 Hz
breath_until: 0,
}
}
@ -177,6 +191,12 @@ impl Presence {
pub fn load_portrait<P: AsRef<Path>>(&mut self, _path: P) {
}
/// Sync the active atmosphere from the current posture. Called whenever
/// `posture` changes — keeps the UI chrome in step with Annie's state.
fn sync_atmosphere(&mut self) {
self.atmosphere = crate::ui::atmosphere::Atmosphere::from_posture(self.posture);
}
/// Attempt to load a portrait from an agent's memfs root (stub — future use).
pub fn load_portrait_from_memfs<P: AsRef<Path>>(&mut self, _memfs_root: P) {
}
@ -206,6 +226,7 @@ impl Presence {
"Affectionate" => Posture::Affectionate,
_ => Posture::Idle,
};
self.sync_atmosphere();
true
}
TuiEvent::EnergyChanged(e) => {
@ -224,12 +245,14 @@ impl Presence {
TuiEvent::PressureChanged(p) => {
if *p >= 0.85 {
self.posture = Posture::Yawning;
self.sync_atmosphere();
}
true
}
TuiEvent::CompactionWarning { pressure, .. } => {
if *pressure >= 0.85 {
self.posture = Posture::Yawning;
self.sync_atmosphere();
}
true
}
@ -238,6 +261,29 @@ impl Presence {
// Posture will tick back to Idle once a Mood/EnergyChanged
// event arrives from the next successful round.
self.posture = Posture::Straining;
self.sync_atmosphere();
true
}
TuiEvent::AtmosphereChanged(name) => {
// Parse the preset name from the agent's structured event.
// Falls back to posture-linked default on unrecognised names.
use crate::ui::atmosphere::Atmosphere;
match name.to_lowercase().replace(' ', "_").as_str() {
"mint_tea" => self.atmosphere = Atmosphere::MintTea,
"therapeutic_blue" => self.atmosphere = Atmosphere::TherapeuticBlue,
"lavender_calm" => self.atmosphere = Atmosphere::LavenderCalm,
"warm_amber" => self.atmosphere = Atmosphere::WarmAmber,
"peach_sunset" => self.atmosphere = Atmosphere::PeachSunset,
"autumn_browns" => self.atmosphere = Atmosphere::AutumnBrowns,
"neon_glow" => self.atmosphere = Atmosphere::NeonGlow,
"aurora_borealis" => self.atmosphere = Atmosphere::AuroraBorealis,
"cherry_blossom" => self.atmosphere = Atmosphere::CherryBlossom,
"ocean_depths" => self.atmosphere = Atmosphere::OceanDepths,
"midnight_galaxy" => self.atmosphere = Atmosphere::MidnightGalaxy,
"twilight_mist" => self.atmosphere = Atmosphere::TwilightMist,
"forest_greens" => self.atmosphere = Atmosphere::ForestGreens,
_ => self.sync_atmosphere(), // fall back to posture-linked
}
true
}
TuiEvent::Tick(t) => {
@ -250,6 +296,15 @@ impl Presence {
self.eye = Eye::Blinking;
self.blink_until = *t + 6;
}
// Breath: 8-15 s jittered interval, 2 s hold (Godot parity).
if self.is_breathing && *t >= self.breath_until {
self.is_breathing = false;
let jitter = (*t % 120) as u64;
self.next_breath_at = *t + 480 + jitter; // 8-15 s
} else if !self.is_breathing && *t >= self.next_breath_at {
self.is_breathing = true;
self.breath_until = *t + 120; // 2 s hold
}
false
}
_ => false,
@ -270,13 +325,16 @@ const CARD_W: u16 = portrait::RENDER_W + 2; // portrait + border
const CARD_H: u16 = portrait::RENDER_H + 3; // portrait + name row + border
/// Border color derived from current posture. Subtle, not loud.
fn posture_border(posture: Posture) -> Color {
match posture {
Posture::Processing => colors::ANI_PRIMARY,
/// Falls back to the atmosphere primary colour when the posture doesn't
/// specify an override — keeps the chrome in sync with the ambient preset.
fn posture_border(p: &Presence) -> Color {
let atm = p.atmosphere;
match p.posture {
Posture::Processing => atm.primary(),
Posture::Affectionate => Color::Rgb(220, 150, 170),
Posture::Straining => Color::Rgb(140, 100, 100),
Posture::Yawning => Color::Rgb(160, 145, 130),
Posture::Idle => colors::ANI_DIM,
Posture::Idle => atm.dim(),
}
}
@ -306,7 +364,7 @@ pub fn draw_overlay(frame: &mut Frame, p: &Presence, area: Rect) {
height: CARD_H,
};
let border = posture_border(p.posture);
let border = posture_border(p);
let block = Block::default()
.borders(Borders::ALL)