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:
parent
f32d949e11
commit
d5ccfbba2b
10 changed files with 1225 additions and 114 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
445
src/ui/app.rs
445
src/ui/app.rs
|
|
@ -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(),
|
||||
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,
|
||||
&self.presence,
|
||||
WELCOME_SCALE,
|
||||
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))),
|
||||
// ── 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(
|
||||
self.presence.name.clone(),
|
||||
format!(" {} ", p.name),
|
||||
Style::default()
|
||||
.fg(Color::Rgb(220, 215, 215))
|
||||
.fg(Color::White)
|
||||
.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,
|
||||
);
|
||||
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(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)))
|
||||
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);
|
||||
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);
|
||||
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
178
src/ui/atmosphere.rs
Normal 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
|
||||
}
|
||||
}
|
||||
303
src/ui/chat.rs
303
src/ui/chat.rs
|
|
@ -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 => {}
|
||||
|
|
|
|||
|
|
@ -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
240
src/ui/expressions.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue