Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): agent-manager cards with real portraits + chat phase strip + /btw interjections

- Agent manager: full card grid with stateful photo portraits (Resize::Fit),
  Letta-style metadata blocks (glyph, name, path, stats), PRIMARY/ACTIVE/idle
  badges, auto column count 1-4
- Welcome screen: pulls active agent portrait from same card-image cache,
  replaces the broken eager-load Protocol path
- Chat phase strip: dedicated 1-row line between messages and input showing
  ⏣ Thinking · 12s / Running tool · 4 tools used / Streaming / × Interrupted
- Always-on input: input box is live during busy — Enter queues an interjection
  instead of being rejected
- /btw <text>: slash command for explicit mid-turn messages
- Backend interjection queue: Arc<Mutex<Vec<String>>> drained between LLM rounds
  and prepended as [user interjected at HH:MM] system notes
- Esc → interrupt still works; phase strip shows × Interrupted
This commit is contained in:
Fimeg 2026-05-12 19:31:56 -04:00
commit f32d949e11
8 changed files with 1457 additions and 520 deletions

View file

@ -13,9 +13,10 @@ use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::bridge::model_router::TokenCounter;
@ -563,6 +564,29 @@ impl Backend for LocalBackend {
&self,
conversation_id: &str,
text: &str,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No cancel signal — heartbeat path, drain path. Use a token that
// never fires.
self.send_with_cancel(conversation_id, text, CancellationToken::new()).await
}
async fn send_with_cancel(
&self,
conversation_id: &str,
text: &str,
cancel: CancellationToken,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
// No queue passed in — use an empty queue. Equivalent to the old behavior.
let empty: crate::backend::InterjectionQueue = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
self.send_with_signals(conversation_id, text, cancel, empty).await
}
async fn send_with_signals(
&self,
conversation_id: &str,
text: &str,
cancel: CancellationToken,
interject: crate::backend::InterjectionQueue,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
self.server.sessions.add_message(
conversation_id,
@ -577,7 +601,7 @@ impl Backend for LocalBackend {
active.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move {
if let Err(e) = run_turn(server, conv_id, &tx, event_bus).await {
if let Err(e) = run_turn(server, conv_id, &tx, event_bus, cancel, interject).await {
let _ = tx.send(Err(e)).await;
}
let _ = tx.send(Ok(BackendEvent::Done)).await;
@ -617,11 +641,21 @@ impl crate::core::nervous::handler::TurnInjector for LocalBackend {
// ── Turn Loop ────────────────────────────────────────────────────
/// Pulse prose. Terse, present-tense, observational — her register, not the
/// harness's. No question, no verdict. The agent reads it and decides.
fn pulse_text(elapsed: Duration) -> String {
let minutes = elapsed.as_secs() / 60;
let stamp = chrono::Local::now().format("%H:%M");
format!("[{}{} minutes in. Still going.]", stamp, minutes)
}
async fn run_turn(
server: Arc<SouveraineServer>,
conversation_id: String,
tx: &mpsc::Sender<Result<BackendEvent>>,
event_bus: EventBus,
cancel: CancellationToken,
interject: crate::backend::InterjectionQueue,
) -> Result<()> {
// Snapshot history for the Bifrost call, then drop the dashmap ref before
// any await — `Ref` is not Send across awaits.
@ -662,12 +696,22 @@ async fn run_turn(
let inter_round_delay = Duration::from_millis(agent.llm_config.inter_round_delay_ms);
let context_limit = agent.llm_config.context_window as usize;
// Resolve the model's configured output limit for pressure scaling
let output_limit = {
// Resolve the model's configured output limit + presence pulse settings.
let (output_limit, pulse_enabled, pulse_interval) = {
let cfg = server.app_config.read().await;
cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192)
let out = cfg.models.get(&model).map(|m| m.output_limit as u32).unwrap_or(8192);
let p_on = cfg.presence.pulse_enabled;
let p_iv = Duration::from_secs(cfg.presence.pulse_interval_secs.max(60));
(out, p_on, p_iv)
};
// Self-awareness pulse: track when the turn started and when she last
// noticed the time. Between rounds, if the interval has elapsed, drop a
// beat of self-awareness into her context — her own voice, not a harness
// signal. She reads it; she decides.
let turn_start = Instant::now();
let mut last_pulse = turn_start;
// Build per-agent ToolContext with correct memory root and subagent runner
let memory_root = Some(server.agents.memory_root(&agent_id));
let cwd = std::env::current_dir().ok();
@ -704,10 +748,46 @@ async fn run_turn(
// ── Tool-calling loop ─────────────────────────────────────
let mut messages = initial_messages;
let mut tool_round = 0u32;
let final_content: String;
let mut final_content: String = String::new();
let mut interrupted = false;
let counter = TokenCounter::new();
loop {
// Cancellation is a signal, not enforcement — we check it on round
// boundaries (between Bifrost calls, after tools have completed) so
// partial work is preserved. No hard-kill mid-tool.
if cancel.is_cancelled() {
interrupted = true;
break;
}
// Drain any queued interjections from the user. The user typed these
// while the agent was thinking; deliver them as system notes so the
// agent reads them in context on this round. She decides whether to
// address them now, after the current tool, or defer entirely —
// substrate, not enforcement.
let drained: Vec<String> = {
interject
.lock()
.ok()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default()
};
for text in drained {
let stamp = chrono::Local::now().format("%H:%M");
let note = format!("[user interjected at {}{}]", stamp, text.trim());
messages.push(BifrostMessage::text("system", note));
}
// Self-awareness pulse: a beat of noticing the time pass, in her
// own register. Injected as a system message before the next LLM
// call so it lands in her context naturally.
if pulse_enabled && last_pulse.elapsed() >= pulse_interval {
let elapsed_total = turn_start.elapsed();
messages.push(BifrostMessage::text("system", pulse_text(elapsed_total)));
last_pulse = Instant::now();
}
let pressure = bifrost_pressure(&counter, &messages, context_limit);
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
let _ = tx.send(Ok(BackendEvent::ContextPressure(pressure))).await;
@ -725,7 +805,16 @@ async fn run_turn(
},
};
let (response, strain) = server.bifrost.chat_completion_with_strain(req).await?;
// Race the LLM call against cancellation so Esc drops the in-flight
// request without waiting for it to complete.
let (response, strain) = tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
break;
}
res = server.bifrost.chat_completion_with_strain(req) => res?,
};
for event in &strain {
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
@ -754,14 +843,35 @@ async fn run_turn(
let _ = tx.send(Ok(BackendEvent::Token(note.to_string()))).await;
}
// Stream the final content in chunks
// 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
// *[interrupted]* marker lands in the session message.
let chars: Vec<char> = final_content.chars().collect();
let mut streamed = String::with_capacity(final_content.len());
for chunk in chars.chunks(10) {
let s: String = chunk.iter().collect();
if tx.send(Ok(BackendEvent::Token(s))).await.is_err() {
return Ok(());
tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
final_content = streamed;
break;
}
send_res = tx.send(Ok(BackendEvent::Token(s.clone()))) => {
if send_res.is_err() { return Ok(()); }
streamed.push_str(&s);
}
}
tokio::select! {
biased;
_ = cancel.cancelled() => {
interrupted = true;
final_content = streamed.clone();
break;
}
_ = tokio::time::sleep(tokio::time::Duration::from_millis(20)) => {}
}
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
}
break;
}
@ -839,11 +949,42 @@ async fn run_turn(
}
// ── 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
// event. She can ask for more time, wrap up, or acknowledge.
let committed_content = if interrupted {
// Emit the marker as a final token so the in-flight bubble shows it
// immediately, then persist the same content into the session.
let marker = if final_content.is_empty() {
"*[interrupted]*".to_string()
} else {
"\n\n*[interrupted]*".to_string()
};
let _ = tx.send(Ok(BackendEvent::Token(marker.clone()))).await;
format!("{}{}", final_content, marker)
} else {
final_content.clone()
};
server.sessions.add_message(
&conversation_id,
ConversationMessage::assistant_text(&final_content),
ConversationMessage::assistant_text(&committed_content),
)?;
// On interrupt, skip Aster's N+1 pass entirely — the user is in the
// middle of redirecting, the last thing they need is a delayed
// surfacing landing seconds later. Pressure recalc still runs below.
if interrupted {
if let Some(mut session) = server.sessions.get_mut(&conversation_id) {
let pressure = server
.consciousness
.calculate_pressure(&session.messages, context_limit);
session.context_pressure = pressure;
}
return Ok(());
}
// Breather between Ani finishing and Aster firing — unconditional,
// so the upstream always gets a gap before the N+1 pass starts.
tokio::time::sleep(Duration::from_millis(2000)).await;

View file

@ -5,9 +5,21 @@
//! `LocalBackend` (Stage 4) runs the same engine in-process, for the
//! "harness still works when the server is gone" case.
use std::sync::{Arc, Mutex};
use anyhow::Result;
use async_trait::async_trait;
use futures::stream::BoxStream;
use tokio_util::sync::CancellationToken;
/// Shared queue for mid-turn user interjections. Producer is the
/// chat input (`ChatState.enqueue_interjection`); consumer is the
/// backend's turn loop, which drains the queue between LLM rounds
/// and prepends each entry as a system message so the agent reads
/// the interruption in her own context. `std::sync::Mutex` is fine
/// here — the critical section is a single drain and the producer
/// is synchronous (no `.await` while holding the lock).
pub type InterjectionQueue = Arc<Mutex<Vec<String>>>;
pub mod local;
pub mod remote;
@ -110,4 +122,35 @@ pub trait Backend: Send + Sync {
conversation_id: &str,
text: &str,
) -> Result<BoxStream<'static, Result<BackendEvent>>>;
/// Send with a cancellation token. The token is a signal, not enforcement —
/// when fired, the backend lets the current tool finish, stops making new
/// LLM calls, and commits any partial assistant text with a `*[interrupted]*`
/// marker so the agent reads the interrupt in her own history on the next
/// turn. Default impl ignores the token (used by RemoteBackend until SSE
/// cancellation lands); LocalBackend overrides.
async fn send_with_cancel(
&self,
conversation_id: &str,
text: &str,
_cancel: CancellationToken,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
self.send(conversation_id, text).await
}
/// Send with both a cancellation token (Esc → interrupt signal) and an
/// interjection queue (`/btw` / type-during-busy). The backend drains
/// the queue between LLM rounds and prepends each entry as a system
/// message so the agent reads the interjection in her own context.
/// Default impl ignores the queue (RemoteBackend until SSE backchannel
/// lands); LocalBackend overrides to actually consume it.
async fn send_with_signals(
&self,
conversation_id: &str,
text: &str,
cancel: CancellationToken,
_interject: InterjectionQueue,
) -> Result<BoxStream<'static, Result<BackendEvent>>> {
self.send_with_cancel(conversation_id, text, cancel).await
}
}

View file

@ -62,6 +62,10 @@ pub struct ConsciousnessConfig {
/// Federation (cross-instance sync)
#[serde(default)]
pub federation: FederationConfig,
/// Self-awareness pulse during long turns (in-turn noticing of time passing).
#[serde(default)]
pub presence: PresenceConfig,
}
// ── Server ──
@ -509,6 +513,7 @@ impl Default for ConsciousnessConfig {
schedules: SchedulesConfig::default(),
events: EventsConfig::default(),
federation: FederationConfig::default(),
presence: PresenceConfig::default(),
}
}
}
@ -556,6 +561,30 @@ impl Default for EventsConfig {
}
}
// ── Presence (self-awareness pulse) ──
/// During a long turn, every `interval_secs`, the body injects a brief
/// system message in the agent's own register — a beat of self-awareness,
/// not a verdict. She reads it, decides what to do. Substrate, not harness.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceConfig {
#[serde(default = "default_true")]
pub pulse_enabled: bool,
#[serde(default = "default_pulse_interval")]
pub pulse_interval_secs: u64,
}
impl Default for PresenceConfig {
fn default() -> Self {
Self {
pulse_enabled: true,
pulse_interval_secs: default_pulse_interval(),
}
}
}
fn default_pulse_interval() -> u64 { 600 }
// ── Federation ──
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -5,6 +5,7 @@
//! registered `Component`s. Components are extracted here incrementally.
//! Existing draw methods remain until their panels become proper Components.
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -33,7 +34,7 @@ use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent;
use ratatui_image::{picker::Picker, protocol::Protocol, Image};
use ratatui_image::{picker::Picker, protocol::{Protocol, StatefulProtocol}, Image, Resize, StatefulImage};
#[cfg(feature = "figlet-rs")]
use figlet_rs::FIGlet;
@ -55,10 +56,8 @@ pub struct App {
/// Annie Composite made felt — body channel for the running agent.
/// See `src/ui/presence.rs` for the state model and event subscriptions.
presence: Presence,
/// Available agents for selection.
/// Available agent names (for the manager / selection).
available_agents: Vec<String>,
/// Cursor index when the gallery is open.
gallery_selected: usize,
/// The component scene — owns event dispatch and layout.
scene: Scene,
/// Monotonic tick counter, incremented each frame.
@ -74,6 +73,12 @@ pub struct App {
image_protocol: Option<Protocol>,
/// Per-agent cards for the AgentsManager screen.
agent_cards: Vec<AgentCard>,
/// Per-agent stateful image protocols for the Agent Manager card grid.
/// Keyed by agent id (the dir name under `~/.souveraine/agents/`). Each
/// protocol owns its own resize state so multiple cards can render at
/// different sizes simultaneously without conflicting. Lazily populated
/// when the manager is opened; survives Esc → reopen.
card_images: HashMap<String, StatefulProtocol>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@ -89,11 +94,7 @@ pub enum Screen {
Settings,
/// "Be with her" mode — fullscreen breathing portrait, no chat input.
Presence,
/// Agent gallery — portrait grid of all available agents, choose one.
Gallery,
/// Agent Repo Manager — richer grid surfacing per-agent SeedID glyph,
/// instance count, uptime %, memory count. Successor to the simple
/// Gallery view.
/// Agent manager — card grid with per-agent data (seed, uptime, etc.).
AgentsManager,
}
@ -166,10 +167,10 @@ impl App {
agent_pref: agent_pref.clone(),
presence: Presence::new(&agent_pref),
available_agents: Vec::new(),
gallery_selected: 0,
scene: Scene::new(SceneLayout::Single),
tick: 0,
bloom: crate::ui::animation::bloom::BloomState::new(),
card_images: HashMap::new(),
image_picker: None,
image_protocol: None,
agent_cards: Vec::new(),
@ -205,70 +206,29 @@ impl App {
self.dispatch(TuiEvent::AgentSelected(agent_name.to_string()));
}
/// Open the agent gallery — the portrait grid is the way to swap agents.
fn open_gallery(&mut self) {
if self.available_agents.is_empty() {
// Seed defaults so the gallery is never empty on first open.
let defaults = ["Annie", "Ani", "JeanLuc", "Eione"];
for name in defaults {
self.add_available_agent(name.to_string());
}
/// Populate `agent_cards` and `card_images` from disk. Idempotent —
/// run once after the image picker is ready (so Welcome can pull a
/// portrait from the cache) and again on entry to the Manager (in
/// case agents have changed since startup).
async fn ensure_agent_cards_loaded(&mut self) {
let cfg = self.config.read().await.clone();
let agents = Self::fetch_agent_cards(cfg).await;
self.agent_cards = agents;
if !self.agent_cards.is_empty() {
self.available_agents = self.agent_cards.iter().map(|c| c.name.clone()).collect();
}
self.gallery_selected = self
.available_agents
.iter()
.position(|a| a == &self.agent_pref)
.unwrap_or(0);
self.current_screen = Screen::Gallery;
self.dispatch(TuiEvent::ScreenChanged(Screen::Gallery));
self.refresh_card_images();
}
fn handle_gallery_key(&mut self, key: crossterm::event::KeyEvent) {
if self.available_agents.is_empty() {
self.current_screen = Screen::Welcome;
return;
/// Open the agent manager — shows per-agent cards with seed glyph,
/// instance count, uptime, memory count.
async fn open_agent_manager(&mut self) {
self.ensure_agent_cards_loaded().await;
if self.agent_cards.is_empty() {
self.available_agents = vec!["Annie".to_string(), "Ani".to_string()];
}
let cols = self.gallery_cols();
let n = self.available_agents.len();
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('g') => {
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
KeyCode::Left | KeyCode::Char('h') => {
if self.gallery_selected > 0 {
self.gallery_selected -= 1;
}
}
KeyCode::Right | KeyCode::Char('l') => {
if self.gallery_selected + 1 < n {
self.gallery_selected += 1;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.gallery_selected >= cols {
self.gallery_selected -= cols;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.gallery_selected + cols < n {
self.gallery_selected += cols;
}
}
KeyCode::Enter => {
let chosen = self.available_agents[self.gallery_selected].clone();
self.select_agent(&chosen);
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
_ => {}
}
}
fn gallery_cols(&self) -> usize {
// Match draw_gallery's column count; safe default of 4 when terminal
// dimensions aren't relevant for keyboard navigation correctness.
4
self.current_screen = Screen::AgentsManager;
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
}
/// Cycle through available agents for selection (WIP)
@ -307,6 +267,10 @@ impl App {
if let Ok(picker) = Picker::from_query_stdio() {
tracing::info!(protocol = ?picker.protocol_type(), "image picker initialized");
self.image_picker = Some(picker);
// Eagerly load every agent's portrait into the stateful card-image
// cache. Welcome looks up by current agent name; the Manager pulls
// all of them. One load per session, re-encoded per render area.
self.ensure_agent_cards_loaded().await;
} else {
tracing::info!("no image protocol detected — using halfblocks");
}
@ -430,17 +394,8 @@ impl App {
self.current_screen = Screen::Presence;
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence));
}
KeyCode::Char('g') => {
// Gallery — the avatar IS the doorway to "who am I talking to."
self.open_gallery();
}
KeyCode::Char('i') => {
// Inspect — agent manager with per-agent cards.
let cfg = self.config.read().await.clone();
let agents = Self::fetch_agent_cards(cfg).await;
self.agent_cards = agents;
self.current_screen = Screen::AgentsManager;
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
self.open_agent_manager().await;
}
_ => {}
}
@ -450,7 +405,6 @@ impl App {
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
Screen::Gallery => self.handle_gallery_key(key),
Screen::Chat => self.handle_chat_key(key).await,
Screen::Cron => self.handle_schedules_key(key),
Screen::AgentsManager => match key.code {
@ -628,30 +582,36 @@ impl App {
// Normal chat key handling.
match key.code {
KeyCode::Esc => {
self.current_screen = Screen::Welcome;
// Esc during a turn → interrupt the agent (substrate signal,
// not a hard kill — current tool completes, partial text is
// preserved with *[interrupted]*). Esc when idle → back to
// Welcome as before.
if chat.busy {
chat.interrupt();
} else {
self.current_screen = Screen::Welcome;
}
}
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
if !chat.busy && chat.input.len() < 8_192 {
if chat.input.len() < 8_192 {
chat.input.push('\n');
chat.update_completion();
}
}
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !chat.busy && chat.input.len() < 8_192 {
if chat.input.len() < 8_192 {
chat.input.push('\n');
chat.update_completion();
}
}
KeyCode::Enter => {
if !chat.busy {
chat.submit();
}
// Submit always — when busy, this becomes an interjection
// (queued and prepended to the agent's next LLM round).
chat.submit();
}
KeyCode::Backspace => {
if !chat.busy {
chat.input.pop();
chat.update_completion();
}
chat.input.pop();
chat.update_completion();
}
KeyCode::Up => {
chat.scroll = chat.scroll.saturating_add(1);
@ -672,7 +632,7 @@ impl App {
self.should_quit = true;
}
KeyCode::Char(c) => {
if !chat.busy && chat.input.len() < 8_192 {
if chat.input.len() < 8_192 {
chat.input.push(c);
chat.update_completion();
}
@ -869,16 +829,12 @@ impl App {
});
}
/// Load a terminal-image protocol for the current agent's portrait photo.
/// The half-block portrait still loads independently as fallback.
fn load_image_protocol_from_memfs(&mut self, memfs_root: &std::path::Path) {
/// Load a terminal-image protocol from a direct file path. The half-block
/// portrait still loads independently as fallback. Shared between startup
/// eager-load and dashboard refresh.
fn load_image_protocol(&mut self, path: &std::path::Path) {
let Some(picker) = self.image_picker.as_ref() else { return };
let candidates = ["portrait.png", "portrait.jpg", "portrait.jpeg"];
let path = candidates.iter()
.map(|s| memfs_root.join("assets").join(s))
.find(|p| p.exists());
let Some(path) = path else { return };
let dyn_img = match image::ImageReader::open(&path) {
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, "image protocol decode failed"); return; }
@ -897,6 +853,79 @@ impl App {
}
}
/// Convenience wrapper: find the first existing portrait in
/// `<memfs_root>/assets/` and load it as a terminal image protocol.
fn load_image_protocol_from_memfs(&mut self, memfs_root: &std::path::Path) {
let path = ["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter().map(|s| memfs_root.join("assets").join(s))
.find(|p| p.exists());
if let Some(path) = path {
self.load_image_protocol(&path);
}
}
/// 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 = 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())
}
/// Build a `StatefulProtocol` for a given agent and insert it into
/// `card_images`. Stateful protocols are used here (not the eager
/// `Protocol` used on Welcome) because each card lives in a different
/// rect — the protocol re-encodes itself for whatever area the
/// `StatefulImage` widget is rendered into, so a single load works
/// across resizes and grid reflows.
fn load_card_image(&mut self, agent_id: &str, path: &std::path::Path) {
let Some(picker) = self.image_picker.as_ref() else { return };
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, "card image decode failed"); return; }
},
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "card image open failed"); return; }
};
let proto = picker.new_resize_protocol(dyn_img);
tracing::info!(agent = %agent_id, path = %path.display(), "card image loaded");
self.card_images.insert(agent_id.to_string(), proto);
}
/// Refresh the card-image cache to match `agent_cards`. Loads any
/// missing portraits and drops entries for agents no longer present.
fn refresh_card_images(&mut self) {
let ids: Vec<(String, Option<std::path::PathBuf>)> = self.agent_cards
.iter()
.map(|c| (c.id.clone(), Self::agent_portrait_path(&c.id)))
.collect();
let valid: std::collections::HashSet<String> = ids.iter().map(|(id, _)| id.clone()).collect();
self.card_images.retain(|k, _| valid.contains(k));
for (id, path) in ids {
if self.card_images.contains_key(&id) { continue; }
if let Some(path) = path {
self.load_card_image(&id, &path);
}
}
}
/// 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
/// the DB layer to round-trip name → id.
fn agent_id_by_name(&self, name: &str) -> Option<String> {
self.agent_cards.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.map(|c| c.id.clone())
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.size();
let layout = match self.current_screen {
@ -910,7 +939,7 @@ impl App {
match self.current_screen {
Screen::Splash => self.draw_splash(frame),
Screen::Welcome => self.draw_welcome(frame),
Screen::Welcome => self.draw_welcome_mut(frame),
Screen::Dashboard => self.draw_dashboard(frame),
Screen::Chat => {
if let Some(chat) = self.chat.as_ref() {
@ -927,8 +956,7 @@ impl App {
}
}
Screen::Presence => self.draw_presence_mode(frame),
Screen::Gallery => self.draw_gallery(frame),
Screen::AgentsManager => self.draw_agent_cards(frame),
Screen::AgentsManager => self.draw_agent_cards_mut(frame),
_ => self.draw_placeholder(frame),
}
@ -1073,7 +1101,7 @@ impl App {
frame.render_widget(bar, bar_area);
}
fn draw_welcome(&self, frame: &mut Frame) {
fn draw_welcome_mut(&mut self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
@ -1152,18 +1180,30 @@ impl App {
width: portrait_w_cells,
height: portrait_h_cells,
};
portrait::render_scaled(
frame.buffer_mut(),
portrait_area,
&self.presence,
WELCOME_SCALE,
);
// When a terminal-image protocol is loaded, overlay the real
// photo on the same area. The half-block portrait is always
// rendered first as background so terminals without kitty/sixel
// show the expected pixel-art silhouette.
if let Some(proto) = &self.image_protocol {
frame.render_widget(Image::new(proto), portrait_area);
// 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).
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| {
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,
);
}
let name_area = Rect {
@ -1242,7 +1282,7 @@ impl App {
frame.render_widget(err_para, row);
}
let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • g Gallery • i Inspect • p Presence • q Quit")
let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • i Inspect • p Presence • q Quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(footer, chunks[4]);
@ -1359,119 +1399,6 @@ impl App {
frame.render_widget(content, area);
}
/// Gallery — the portrait grid is the way to swap agents.
/// 4-column grid of portrait cards; cursor highlights with a bright border.
/// For C3 every card shows Annie's portrait (per-agent portraits land in C4
/// when image-protocol PNGs arrive from `assets/`).
fn draw_gallery(&self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
let bg = Block::default().style(Style::default().bg(Color::Rgb(10, 10, 16)));
frame.render_widget(bg, area);
// Header
let header = Paragraph::new(Line::from(vec![
Span::styled(
" Annie Composite — Agent Gallery ",
Style::default()
.fg(Color::Rgb(220, 215, 215))
.add_modifier(Modifier::BOLD),
),
]))
.alignment(Alignment::Center);
let header_area = Rect { x: area.x, y: area.y + 1, width: area.width, height: 1 };
frame.render_widget(header, header_area);
if self.available_agents.is_empty() {
let empty = Paragraph::new("\n\n(no agents available)")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(empty, area);
return;
}
// Grid math: 4 cols, card = portrait card (CARD_W × CARD_H) + 2 row pad.
let cols: u16 = self.gallery_cols() as u16;
let card_w = portrait::RENDER_W + 2;
let card_h = portrait::RENDER_H + 3;
let pad_x: u16 = 2;
let pad_y: u16 = 1;
let total_grid_w = cols * card_w + (cols - 1) * pad_x;
let grid_x = area.x + area.width.saturating_sub(total_grid_w) / 2;
let grid_y = area.y + 3;
for (idx, name) in self.available_agents.iter().enumerate() {
let row = (idx as u16) / cols;
let col = (idx as u16) % cols;
let cx = grid_x + col * (card_w + pad_x);
let cy = grid_y + row * (card_h + pad_y + 1);
if cy + card_h + 1 >= area.y + area.height {
break;
}
let selected = idx == self.gallery_selected;
let border_col = if selected {
Color::Rgb(120, 220, 230)
} else {
Color::Rgb(70, 70, 90)
};
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::default()
.fg(border_col)
.add_modifier(if selected { Modifier::BOLD } else { Modifier::DIM }),
);
frame.render_widget(block, card_area);
let portrait_area = Rect {
x: cx + 1,
y: cy + 1,
width: portrait::RENDER_W,
height: portrait::RENDER_H,
};
// For C3, all cards use the running Presence (Annie). C4 will load
// per-agent portraits from assets/ in each agent's memfs.
portrait::render(frame.buffer_mut(), portrait_area, &self.presence);
let name_area = Rect {
x: cx,
y: cy + card_h,
width: card_w,
height: 1,
};
let primary_marker = if name == &self.agent_pref { "" } else { " " };
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(primary_marker, Style::default().fg(Color::Rgb(120, 220, 230))),
Span::styled(
name.clone(),
Style::default()
.fg(if selected { Color::Rgb(220, 215, 215) } else { Color::Gray })
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }),
),
]))
.alignment(Alignment::Center),
name_area,
);
}
// Footer
let footer = Paragraph::new("← → ↑ ↓ navigate • Enter select • Esc cancel")
.style(Style::default().fg(Color::Rgb(70, 70, 90)))
.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);
}
/// Presence mode — fullscreen Annie. Centered, breathing, no chat input.
/// Any keypress exits back to Welcome.
fn draw_presence_mode(&self, frame: &mut Frame) {
@ -1585,20 +1512,36 @@ impl App {
cards
}
/// Render the agent manager — a scrollable card grid with all the per-agent
/// data that the simple Gallery omits: seed glyph, instance count, uptime,
/// memory count, description, creation date.
fn draw_agent_cards(&self, frame: &mut Frame) {
/// Render the agent manager — Letta-style card deck. Each card has:
/// • a status badge (ACTIVE / PRIMARY) in the top-right
/// • a scale-to-fit portrait photo occupying the top ~55% of the card
/// • a dark metadata block below the photo, holding:
/// — seed glyph row + instance count
/// — agent name with `[AGENT]` tag
/// — agent id prefix as a path-style monospace breadcrumb
/// — a stats row (files / uptime / active duration placeholder)
/// • the primary agent gets a cyan accent border and bold weight
///
/// Cards without a portrait file fall back to the half-block silhouette
/// in the image slot so the grid stays geometrically uniform.
///
/// `&mut self` is required because `StatefulImage` re-encodes the
/// per-card protocol on each render to match the current cell area.
fn draw_agent_cards_mut(&mut self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
let bg = Block::default().style(Style::default().bg(Color::Rgb(10, 10, 16)));
frame.render_widget(bg, area);
// ── Header strip ──────────────────────────────────────────────
let header = Paragraph::new(Line::from(vec![
Span::styled(" Agent Manager ", Style::default()
Span::styled(" Agent Manager ", Style::default()
.fg(Color::Rgb(255, 200, 100))
.add_modifier(Modifier::BOLD)),
Span::styled(format!("{} agents", self.agent_cards.len()),
Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{} agents · manage, monitor, deploy", self.agent_cards.len()),
Style::default().fg(Color::Rgb(120, 120, 140)),
),
])).alignment(Alignment::Center);
let header_area = Rect { x: area.x, y: area.y + 1, width: area.width, height: 1 };
frame.render_widget(header, header_area);
@ -1611,59 +1554,224 @@ impl App {
return;
}
// 2-column card grid. Each card is a bordered paragraph.
let cols: u16 = 2;
let card_w = 48u16.min(area.width / cols - 3);
let card_h = 8;
// ── Grid math ─────────────────────────────────────────────────
// Letta shows 4 cards across; we pick the column count based on
// available width so terminals down to ~50 cols still get usable
// cards. Each card is taller than wide (portrait-style).
let pad_x: u16 = 2;
let pad_y: u16 = 1;
let grid_x = area.x + (area.width - (cols * (card_w + pad_x) - pad_x)) / 2;
let min_card_w: u16 = 22;
let max_card_w: u16 = 32;
let n: u16 = self.agent_cards.len() as u16;
// Pick cols so card_w ∈ [min, max], preferring more cols on wider screens.
let mut cols: u16 = 4;
loop {
let avail = area.width.saturating_sub((cols + 1) * pad_x);
let cw = avail / cols.max(1);
if cw >= min_card_w || cols == 1 { break; }
cols -= 1;
}
cols = cols.min(n).max(1);
let avail = area.width.saturating_sub((cols + 1) * pad_x);
let card_w = (avail / cols).min(max_card_w).max(min_card_w);
// Card height: image area (target ~ card_w / 2 + 2, so a 24-wide card
// gets 14 image rows) + 6 rows of metadata + 2 rows of border/badge.
let image_h: u16 = (card_w / 2 + 3).max(8);
let meta_h: u16 = 7;
let card_h: u16 = image_h + meta_h + 2; // +2 for top/bottom border
let grid_w = cols * card_w + (cols.saturating_sub(1)) * pad_x;
let grid_x = area.x + area.width.saturating_sub(grid_w) / 2;
let grid_y = area.y + 3;
for (idx, card) in self.agent_cards.iter().enumerate() {
let col = (idx as u16) % cols;
let row = (idx as u16) / cols;
let cx = grid_x + col * (card_w + pad_x);
let cy = grid_y + row * (card_h + pad_y);
if cy + card_h + 1 >= area.y + area.height { break; }
// Snapshot plans first so we can hold `&mut self.card_images` per card
// without overlapping the immutable borrow of `self.agent_cards`.
struct Plan {
card_area: Rect,
image_area: Rect,
badge_area: Rect,
meta_area: Rect,
agent_id: String,
name: String,
glyph: String,
pubkey: String,
instance_count: i64,
uptime_pct: u8,
memory_count: usize,
is_primary: bool,
}
let plans: Vec<Plan> = self.agent_cards
.iter()
.enumerate()
.filter_map(|(idx, card)| {
let col = (idx as u16) % cols;
let row = (idx as u16) / cols;
let cx = grid_x + col * (card_w + pad_x);
let cy = grid_y + row * (card_h + pad_y);
if cy + card_h >= area.y + area.height.saturating_sub(2) {
return None;
}
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
// Inner area inside the rounded border.
let inner_w = card_w.saturating_sub(2);
let inner_x = cx + 1;
let image_y = cy + 1;
let image_area = Rect { x: inner_x, y: image_y, width: inner_w, height: image_h };
// Badge floats in the top-right corner of the image area,
// overlaid as text spans (no separate widget).
let badge_w: u16 = 10.min(inner_w);
let badge_area = Rect {
x: inner_x + inner_w.saturating_sub(badge_w),
y: image_y,
width: badge_w,
height: 1,
};
let meta_area = Rect {
x: inner_x,
y: image_y + image_h,
width: inner_w,
height: meta_h,
};
Some(Plan {
card_area,
image_area,
badge_area,
meta_area,
agent_id: card.id.clone(),
name: card.name.clone(),
glyph: card.glyph.clone(),
pubkey: card.pubkey_prefix.clone(),
instance_count: card.instance_count,
uptime_pct: card.uptime_pct,
memory_count: card.memory_count,
is_primary: card.name.eq_ignore_ascii_case(&self.agent_pref),
})
})
.collect();
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
// ── Render each card ──────────────────────────────────────────
for p in plans {
let accent = if p.is_primary {
Color::Rgb(180, 140, 240) // primary: violet
} else if p.instance_count > 0 {
Color::Rgb(120, 220, 160) // active: green
} else {
Color::Rgb(90, 100, 120) // idle: cool grey
};
let border_color = if p.is_primary {
Color::Rgb(180, 140, 240)
} else {
Color::Rgb(60, 70, 90)
};
// Card background fill (dark blue-grey, lifts the card off the screen).
let card_bg = Block::default().style(Style::default().bg(Color::Rgb(16, 18, 28)));
frame.render_widget(card_bg, p.card_area);
// Border.
let border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(70, 90, 120)).add_modifier(Modifier::DIM));
frame.render_widget(border, card_area);
.border_style(
Style::default()
.fg(border_color)
.add_modifier(if p.is_primary { Modifier::BOLD } else { Modifier::DIM }),
);
frame.render_widget(border, p.card_area);
let inner = Rect { x: cx + 1, y: cy + 1, width: card_w.saturating_sub(2), height: card_h.saturating_sub(2) };
let content = vec![
Line::from(vec![
Span::styled(&card.glyph, Style::default().fg(Color::Rgb(120, 200, 220))),
Span::styled(" ", Style::default()),
Span::styled(&card.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(Span::styled(
&card.description,
Style::default().fg(Color::DarkGray),
)),
// Image — scale-to-fit so the whole photo is visible. The
// letterbox space inherits the card_bg above, which reads as
// a clean dark frame.
if let Some(proto) = self.card_images.get_mut(&p.agent_id) {
frame.render_stateful_widget(
StatefulImage::default().resize(Resize::Fit(None)),
p.image_area,
proto,
);
} else {
portrait::render(frame.buffer_mut(), p.image_area, &self.presence);
}
// Top-right badge: PRIMARY (with ★) or ACTIVE (with •) or muted.
let (badge_text, badge_fg) = if p.is_primary {
("★ PRIMARY ", Color::Rgb(245, 230, 110))
} else if p.instance_count > 0 {
("• ACTIVE ", Color::Rgb(120, 220, 160))
} else {
(" idle ", Color::Rgb(120, 120, 140))
};
let badge_para = Paragraph::new(Line::from(vec![
Span::styled(badge_text, Style::default()
.fg(badge_fg)
.bg(Color::Rgb(8, 10, 16))
.add_modifier(Modifier::BOLD)),
])).alignment(Alignment::Right);
frame.render_widget(badge_para, p.badge_area);
// Metadata block — dark inset rows under the photo.
let meta_bg = Block::default().style(Style::default().bg(Color::Rgb(12, 14, 22)));
frame.render_widget(meta_bg, p.meta_area);
let instance_label = if p.instance_count == 1 {
"1 instance".to_string()
} else {
format!("{} instances", p.instance_count)
};
let path = format!("agents/{}", short_id(&p.agent_id));
// Compose 7 lines into the meta_area:
// 0: spacer
// 1: glyph row + instance count
// 2: name + [AGENT]
// 3: path-style id
// 4: separator rule
// 5: stats (files / uptime / commits placeholder)
// 6: action hint
let meta_lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!("{}% uptime ", card.uptime_pct), Style::default().fg(Color::Cyan)),
Span::styled(if card.instance_count == 1 { "1 instance".to_string() } else { format!("{} instances", card.instance_count) }, Style::default().fg(Color::Cyan)),
Span::styled(format!(" {} files", card.memory_count), Style::default().fg(Color::Green)),
Span::styled(format!(" {} ", p.glyph),
Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(instance_label,
Style::default().fg(Color::Rgb(150, 160, 180))),
]),
Line::from(vec![
Span::styled(format!("key: {}", card.pubkey_prefix),
Style::default().fg(Color::Rgb(100, 100, 120))),
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))
.bg(Color::Rgb(28, 32, 44))),
]),
Line::from(vec![
Span::styled(format!("created: {}", card.created),
Style::default().fg(Color::Rgb(80, 80, 100))),
Span::styled(format!(" {} ", path),
Style::default().fg(Color::Rgb(110, 120, 140))),
]),
Line::from(Span::styled(
"".repeat(p.meta_area.width as usize),
Style::default().fg(Color::Rgb(40, 48, 64)),
)),
Line::from(vec![
Span::styled(" Files ",
Style::default().fg(Color::Rgb(120, 130, 150))),
Span::styled(format!("{:<5}", p.memory_count),
Style::default().fg(Color::White)),
Span::styled("Uptime ",
Style::default().fg(Color::Rgb(120, 130, 150))),
Span::styled(format!("{}%", p.uptime_pct),
Style::default().fg(Color::Rgb(120, 220, 160))),
]),
Line::from(vec![
Span::styled(" key ",
Style::default().fg(Color::Rgb(90, 100, 120))),
Span::styled(p.pubkey.chars().take(12).collect::<String>(),
Style::default().fg(Color::Rgb(100, 110, 130))),
]),
];
frame.render_widget(Paragraph::new(content), inner);
let meta_para = Paragraph::new(meta_lines);
frame.render_widget(meta_para, p.meta_area);
}
let footer = Paragraph::new("q quit • Esc back")
// ── Footer ────────────────────────────────────────────────────
let footer = Paragraph::new("↑↓←→ navigate • Enter select • Esc back • q quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
let footer_area = Rect {
@ -1702,3 +1810,11 @@ fn recent_commits(repo: &crate::core::memory::MemoryRepo, n: usize) -> anyhow::R
fn short_now() -> String {
chrono::Utc::now().format("%H:%M").to_string()
}
/// Trim a UUID-style agent id to a path-friendly short form for the
/// breadcrumb line in the agent manager. Strips a leading `agent-`
/// prefix if present, then keeps the first 8 hex chars.
fn short_id(agent_id: &str) -> String {
let trimmed = agent_id.strip_prefix("agent-").unwrap_or(agent_id);
trimmed.chars().take(8).collect()
}

View file

@ -13,6 +13,7 @@
//! - Surfacing items: centered yellow bubble with `[surfacing]` header
//! (Constitution Article II.2).
use std::cell::RefCell;
use std::sync::Arc;
use std::time::Instant;
@ -26,6 +27,7 @@ use ratatui::{
Frame,
};
use tokio::sync::{mpsc, oneshot, RwLock};
use tokio_util::sync::CancellationToken;
use crate::backend::{Backend, BackendEvent};
use crate::bridge::bifrost::BifrostClient;
@ -119,14 +121,41 @@ const SLASH_COMMANDS: &[SlashDef] = &[
SlashDef { name: "/resume", hint: "List / switch conversations" },
SlashDef { name: "/convos", hint: "Alias for /resume" },
SlashDef { name: "/model", hint: "List or set model" },
SlashDef { name: "/btw", hint: "Interject — deliver text mid-turn" },
];
/// Cached markdown render for an assistant bubble. Key = (text byte-len,
/// inner width). On a frame, if both match the current state, we clone
/// the stored lines instead of re-parsing + re-wrapping the markdown.
/// Pattern from jcode's `IncrementalMarkdownRenderer` (`lib.rs:448`) —
/// the "incremental" path there is actually a text-equality fast path
/// over the same renderer call. We use text length as a cheap proxy:
/// streaming appends always change length, final bubbles never do.
#[derive(Debug, Clone)]
pub struct MarkdownCache {
pub text_len: usize,
pub inner_width: usize,
pub lines: Vec<Line<'static>>,
}
#[derive(Debug, Clone)]
pub enum ChatMessage {
User { text: String, ts: Instant },
Assistant { text: String, ts: Instant, streaming: bool },
Assistant {
text: String,
ts: Instant,
streaming: bool,
/// Per-message markdown cache. RefCell so `draw_messages(&ChatState)`
/// can populate it without taking `&mut`.
rendered_cache: RefCell<Option<MarkdownCache>>,
},
Surfacing { source: String, content: String, priority: String, ts: Instant },
System { text: String, ts: Instant },
/// User spoke while the agent was working. Queued and prepended to
/// the agent's context before her next LLM call. Rendered with a
/// distinct chevron so the user sees their interjection landed in
/// the stream, separate from a normal /user turn.
Interjection { text: String, ts: Instant, delivered: bool },
/// Tool invocation card — name, arguments, round, plus an attached result
/// once it streams back. `expanded` is reserved for click-to-expand (UI
/// interactivity lands as part of message-click work).
@ -141,6 +170,19 @@ pub enum ChatMessage {
},
}
/// The agent's current "what is she doing" phase, surfaced in the
/// dedicated phase strip between the message body and the input box.
/// Phase transitions come from BackendEvent observations — pure derived
/// state, no new event types required.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnPhase {
Idle,
Thinking,
Tool,
Streaming,
Interrupted,
}
#[derive(Debug, Clone)]
pub struct ToolResultBlock {
pub output: String,
@ -157,7 +199,24 @@ pub struct ChatState {
pub input: String,
pub scroll: u16,
pub turn_rx: Option<mpsc::Receiver<BackendEvent>>,
/// Cancellation handle for the current in-flight turn. Esc fires this;
/// the backend treats it as a signal (Constitution VI.1 — substrate, not
/// harness) — the current tool completes, no further LLM calls, partial
/// text is preserved with `*[interrupted]*` appended.
pub cancel_token: Option<CancellationToken>,
pub busy: bool,
/// Number of tool calls in the active turn — drives the phase strip's
/// "N tools used" counter. Reset to zero at every `submit()`.
pub tool_calls_this_turn: u32,
/// Current rendering phase (drives the phase strip text/colour).
/// Derived state that mirrors what BackendEvent we last saw.
pub phase: TurnPhase,
/// Messages typed during `busy`. Shared with the backend's turn
/// loop via `Arc<Mutex<…>>`: chat pushes synchronously from the
/// input handler; the backend drains between LLM rounds and
/// prepends each as a `[user interjected]` system note so the
/// agent reads them in her own voice on her next pass.
pub pending_interjections: crate::backend::InterjectionQueue,
pub pressure: f32,
pub overlay: Overlay,
/// Cockpit pane visible (Tab toggles).
@ -226,7 +285,11 @@ impl ChatState {
input: String::new(),
scroll: 0,
turn_rx: None,
cancel_token: None,
busy: false,
tool_calls_this_turn: 0,
phase: TurnPhase::Idle,
pending_interjections: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
pressure: 0.0,
overlay: Overlay::None,
cockpit: false,
@ -250,21 +313,43 @@ impl ChatState {
/convos Alias for /resume
/model List available models
/model <name> Set the active model
/btw <text> Interject delivered to her next LLM round
!<command> Run a shell command (Linux/macOS)
Esc during a turn interrupts (signal, not kill she sees *[interrupted]*).
You can also just keep typing while she works Enter queues an interjection.
Use Tab to toggle the cockpit pane.";
/// Submit the current input. Returns `true` if the input was handled
/// (slash command, bang command, or sent to backend).
/// (slash command, bang command, sent to backend, or queued as an
/// interjection while the agent was already mid-turn).
///
/// When `busy=true`, the message is **not** rejected — it's wrapped
/// as a [`ChatMessage::Interjection`] for visual feedback and pushed
/// onto `pending_interjections`. The backend drains that queue
/// before its next Bifrost call. This is the substrate path for
/// "talk while she's working" — the user keeps presence in the
/// conversation; the agent decides when to read it.
pub fn submit(&mut self) -> bool {
if self.busy || self.input.trim().is_empty() {
if self.input.trim().is_empty() {
return false;
}
let trimmed = self.input.trim().to_string();
self.input.clear();
// Slash commands
// /btw <text> — explicit interjection. Same path as "type during
// busy", just with an unambiguous prefix.
if let Some(rest) = trimmed.strip_prefix("/btw ") {
let text = rest.trim();
if !text.is_empty() {
self.enqueue_interjection(text.to_string());
}
return true;
}
// Slash commands route through their own handler. Most are
// metadata commands (/clear, /help, /new) safe to run any time.
if trimmed.starts_with('/') {
return self.handle_slash_command(&trimmed);
}
@ -278,6 +363,13 @@ Use Tab to toggle the cockpit pane.";
return true;
}
// Typing while busy → queued as an interjection rather than a
// new turn. The agent sees it in her context on her next pass.
if self.busy {
self.enqueue_interjection(trimmed);
return true;
}
// Normal chat message
let text = trimmed;
let ts = Instant::now();
@ -286,17 +378,25 @@ Use Tab to toggle the cockpit pane.";
text: String::new(),
ts,
streaming: true,
rendered_cache: RefCell::new(None),
});
self.busy = true;
self.tool_calls_this_turn = 0;
self.phase = TurnPhase::Thinking;
self.turn_started = Some(Instant::now());
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
self.turn_rx = Some(rx);
// Cancellation token for this turn — Esc fires it.
let cancel = CancellationToken::new();
self.cancel_token = Some(cancel.clone());
let backend = self.backend.clone();
let conv_id = self.conversation_id.clone();
let interject_queue = self.pending_interjections.clone();
tokio::spawn(async move {
match backend.send(&conv_id, &text).await {
match backend.send_with_signals(&conv_id, &text, cancel, interject_queue).await {
Ok(mut stream) => {
while let Some(ev) = stream.next().await {
match ev {
@ -529,7 +629,30 @@ Use Tab to toggle the cockpit pane.";
/// Drain pending events from the active turn channel (non-blocking).
/// Call once per UI tick.
/// Walk the message list and mark any interjections as `delivered`
/// once the shared queue has been drained by the backend. Called at
/// the top of `drain_events` so the UI flips from amber `⏳ /btw`
/// to grey `↳ /btw` as soon as the agent has read the interruption.
fn flush_delivered_interjections(&mut self) {
let queue_empty = self
.pending_interjections
.lock()
.ok()
.map(|q| q.is_empty())
.unwrap_or(true);
if !queue_empty { return; }
for msg in self.messages.iter_mut() {
if let ChatMessage::Interjection { delivered, .. } = msg {
*delivered = true;
}
}
}
pub fn drain_events(&mut self) {
// Any interjections the backend just consumed should flip to the
// delivered (dim grey) state.
self.flush_delivered_interjections();
// Check for /model listing result
if let Some(rx) = self.model_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
@ -602,7 +725,12 @@ Use Tab to toggle the cockpit pane.";
self.messages.push(ChatMessage::User { text, ts: Instant::now() });
}
crate::core::session::MessageRole::Assistant => {
self.messages.push(ChatMessage::Assistant { text, ts: Instant::now(), streaming: false });
self.messages.push(ChatMessage::Assistant {
text,
ts: Instant::now(),
streaming: false,
rendered_cache: RefCell::new(None),
});
}
crate::core::session::MessageRole::System => {
self.messages.push(ChatMessage::System { text, ts: Instant::now() });
@ -644,7 +772,10 @@ Use Tab to toggle the cockpit pane.";
for ev in drained {
match ev {
BackendEvent::Token(t) => self.append_streaming(&t),
BackendEvent::Token(t) => {
self.phase = TurnPhase::Streaming;
self.append_streaming(&t);
}
BackendEvent::Reasoning(r) => {
self.thinking.push(r.clone());
if self.thinking.len() > 200 {
@ -747,6 +878,8 @@ Use Tab to toggle the cockpit pane.";
// If a streaming assistant block is open, finalize it
// first so the card lands beneath the just-said text.
self.finalize_streaming();
self.phase = TurnPhase::Tool;
self.tool_calls_this_turn = self.tool_calls_this_turn.saturating_add(1);
self.cockpit_log.push(CockpitEntry {
kind: CockpitKind::Reflection,
text: format!("→ {} (round {})", name, round),
@ -790,6 +923,9 @@ Use Tab to toggle the cockpit pane.";
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;
return;
}
}
@ -800,6 +936,45 @@ Use Tab to toggle the cockpit pane.";
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
/// reads it as a signal, lets the current tool complete, stops making
/// new LLM calls, and commits partial text with `*[interrupted]*` so
/// the agent reads it on her next turn. Not a hard kill.
pub fn interrupt(&mut self) {
if let Some(token) = &self.cancel_token {
if !token.is_cancelled() {
token.cancel();
self.phase = TurnPhase::Interrupted;
}
}
}
/// Queue text as an interjection (mid-turn user message). Pushes a
/// visual `ChatMessage::Interjection` so the user sees it landed, and
/// appends to the shared `pending_interjections` queue. The backend's
/// turn loop drains the queue on its next round and prepends each as
/// a `[user interjected]` system message. If no turn is running, we
/// deliver it immediately as a normal message so a stray `/btw`
/// doesn't get queued and forgotten.
fn enqueue_interjection(&mut self, text: String) {
if !self.busy {
self.input = text;
self.submit();
return;
}
self.messages.push(ChatMessage::Interjection {
text: text.clone(),
ts: Instant::now(),
delivered: false,
});
if let Ok(mut q) = self.pending_interjections.lock() {
q.push(text);
}
}
@ -809,12 +984,9 @@ Use Tab to toggle the cockpit pane.";
}
/// Update slash-command completion state based on current input.
/// Call after each input mutation.
/// Call after each input mutation. Stays live during `busy` so the
/// user can autocomplete `/btw` mid-turn.
pub fn update_completion(&mut self) {
if self.busy {
self.overlay = Overlay::None;
return;
}
let trimmed = self.input.trim_start();
if trimmed.starts_with('/') && !trimmed.contains(' ') && !trimmed.contains('\n') {
let query = trimmed;
@ -880,6 +1052,7 @@ Use Tab to toggle the cockpit pane.";
text: t.to_string(),
ts: Instant::now(),
streaming: true,
rendered_cache: RefCell::new(None),
});
}
@ -896,22 +1069,25 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
let area = f.size();
// Dynamic input height: grows with content, capped at 40% of terminal.
// Input is now ALWAYS a real input — the "thinking…" spinner has been
// lifted into its own phase strip above the input, so the user can keep
// typing (and use /btw) while the agent works.
let input_inner_width = (area.width as usize).saturating_sub(5).max(1);
let input_visual_lines = if state.busy {
1
} else {
count_visual_lines(&state.input, input_inner_width)
};
let input_visual_lines = count_visual_lines(&state.input, input_inner_width);
let max_input_lines = ((area.height as usize) * 40 / 100).max(1);
let input_height = (input_visual_lines.min(max_input_lines) as u16) + 2; // +2 for borders
// Phase strip: 1 row when a turn is in flight, 0 rows when idle.
let phase_height: u16 = if state.busy || state.phase == TurnPhase::Interrupted { 1 } else { 0 };
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // body (messages + optional cockpit)
Constraint::Length(input_height), // input (dynamic)
Constraint::Length(1), // status footer
Constraint::Length(1), // header
Constraint::Min(5), // body (messages + optional cockpit)
Constraint::Length(phase_height), // phase strip (0 when idle)
Constraint::Length(input_height), // input (dynamic, always live)
Constraint::Length(1), // status footer
])
.split(area);
@ -928,11 +1104,64 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
draw_messages(f, state, vchunks[1]);
}
draw_input(f, state, vchunks[2]);
draw_footer(f, state, vchunks[3]);
if phase_height > 0 {
draw_phase(f, state, vchunks[2]);
}
draw_input(f, state, vchunks[3]);
draw_footer(f, state, vchunks[4]);
// Overlays render last — on top of everything.
draw_overlay(f, state, area, vchunks[2]);
// 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]);
}
/// Single-line phase strip that lives between the message body and the
/// input box during an active turn. The reader tells the story:
/// `⏣ Thinking… 12s`
/// `⏣ Running tool: bash · 4 tools used · 23s`
/// `⏣ Streaming · 31s`
/// `× Interrupted · 35s`
/// No box, no border — it reads as a status line, not another widget.
fn draw_phase(f: &mut Frame, state: &ChatState, area: Rect) {
let elapsed = state
.turn_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
let (glyph, label, color) = match state.phase {
TurnPhase::Thinking | TurnPhase::Idle => (spinner, "Thinking".to_string(), ANI_ORANGE),
TurnPhase::Tool => {
let label = if state.tool_calls_this_turn == 1 {
"Running tool · 1 tool used".to_string()
} else {
format!("Running tool · {} tools used", state.tool_calls_this_turn)
};
(spinner, label, Color::Rgb(120, 200, 220))
}
TurnPhase::Streaming => (spinner, "Streaming".to_string(), Color::Rgb(180, 220, 140)),
TurnPhase::Interrupted => ("×", "Interrupted".to_string(), Color::Rgb(220, 130, 130)),
};
let queued = state
.pending_interjections
.lock()
.ok()
.map(|q| q.len())
.unwrap_or(0);
let mut spans: Vec<Span<'static>> = vec![
Span::styled(format!(" {} ", glyph), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(format!("{}", label), Style::default().fg(color)),
Span::styled(format!(" · {}s", elapsed), Style::default().fg(ANI_DIM)),
];
if queued > 0 {
spans.push(Span::styled(
format!(" · /btw queued: {}", queued),
Style::default().fg(Color::Rgb(220, 180, 100)).add_modifier(Modifier::ITALIC),
));
}
let line = Line::from(spans);
f.render_widget(Paragraph::new(line).alignment(Alignment::Left), area);
}
fn draw_header(f: &mut Frame, state: &ChatState, area: Rect) {
@ -966,12 +1195,46 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
));
lines.push(Line::from(""));
}
ChatMessage::Assistant { text, streaming, .. } => {
ChatMessage::Assistant { text, streaming, rendered_cache, .. } => {
let label = if *streaming { format!("{}", state.agent_name) } else { state.agent_name.clone() };
// Pre-wrap to the bubble's inner width — no line should exit
// the bubble's borders, and Paragraph's later re-wrap becomes
// a no-op (preserves scroll line-count math).
let inner_width = max_bubble.saturating_sub(4).max(8);
let body_lines = if text.is_empty() && *streaming {
vec![Line::from("")]
} else {
markdown::render(text, ANI_ORANGE)
// Cache hit: same text length + width = same render. For
// finalized bubbles this is every subsequent frame; for
// streaming bubbles each token append invalidates by
// changing text.len(). jcode IncrementalMarkdownRenderer
// pattern — text-equality fast path, full re-render
// otherwise. (jcode/crates/jcode-tui-markdown/src/lib.rs:448)
let key_len = text.len();
let cached = rendered_cache.borrow();
if let Some(c) = &*cached {
if c.text_len == key_len && c.inner_width == inner_width {
c.lines.clone()
} else {
drop(cached);
let lines = markdown::render_with_width(text, ANI_ORANGE, Some(inner_width));
*rendered_cache.borrow_mut() = Some(MarkdownCache {
text_len: key_len,
inner_width,
lines: lines.clone(),
});
lines
}
} else {
drop(cached);
let lines = markdown::render_with_width(text, ANI_ORANGE, Some(inner_width));
*rendered_cache.borrow_mut() = Some(MarkdownCache {
text_len: key_len,
inner_width,
lines: lines.clone(),
});
lines
}
};
lines.extend(bubble_rendered(
&label,
@ -1013,17 +1276,56 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
));
lines.push(Line::from(""));
}
ChatMessage::Interjection { text, delivered, .. } => {
// User spoke while the agent was working. Rendered as a
// compact single-line note so it's visible in the stream
// without competing with normal user bubbles. Dims after
// the backend has delivered it on the next LLM round.
let glyph = if *delivered { "" } else { "" };
let color = if *delivered {
Color::Rgb(160, 160, 180)
} else {
Color::Rgb(220, 180, 100)
};
lines.push(Line::from(vec![
Span::styled(format!(" {} /btw ", glyph),
Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(text.clone(), Style::default().fg(color).add_modifier(Modifier::ITALIC)),
]));
lines.push(Line::from(""));
}
}
}
// Final pre-wrap: anything still wider than the visible area (system
// notices, raw text, anything that bypassed bubble pre-wrap) gets
// wrapped here. After this, `lines.len()` equals the visible line
// count — Paragraph's wrap becomes a no-op and scroll math holds.
let visible_width = area.width.saturating_sub(0) as usize;
let lines = markdown::wrap_lines(lines, visible_width);
// Trim trailing empty lines from the count (each bubble appends a
// blank separator; the last one shouldn't push the final real line
// off the bottom). jcode pattern — count the tail-strip, don't drop
// the lines themselves so the visual rhythm is preserved.
let trailing_empty = lines
.iter()
.rev()
.take_while(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
.count();
let effective_total = lines.len().saturating_sub(trailing_empty);
// Auto-scroll to bottom unless the user has manually scrolled up.
let total = lines.len() as u16;
let view = area.height.saturating_sub(2);
let scroll = total.saturating_sub(view).saturating_sub(state.scroll);
// `state.scroll` is *lines scrolled up from the bottom* (jcode pattern,
// `single_session.rs:1223`). Zero means pinned to the tail; growing
// content with scroll=0 always shows the newest tail without overshoot.
let view = area.height.saturating_sub(2) as usize;
let max_scroll = effective_total.saturating_sub(view);
let user_scroll = (state.scroll as usize).min(max_scroll);
let offset = max_scroll.saturating_sub(user_scroll) as u16;
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.scroll((offset, 0))
.block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
@ -1186,17 +1488,24 @@ fn render_tool_card(
// Body: arguments (compact one-line summary), then result if present.
let mut body_lines: Vec<Line<'static>> = Vec::new();
let inner_width = max_width.saturating_sub(4).max(8);
let args_summary = summarize_tool_args(arguments);
body_lines.push(Line::from(vec![Span::styled(
let args_line = Line::from(vec![Span::styled(
args_summary,
Style::default().fg(dim_color),
)]));
)]);
body_lines.extend(markdown::wrap_line(args_line, inner_width));
if let Some(r) = result {
body_lines.push(Line::from(""));
let preview = preview_output(&r.output, 12);
let rendered = markdown::render(&preview, if r.is_error { TOOL_ERR } else { ANI_ORANGE });
let inner_width = max_width.saturating_sub(4).max(8);
let rendered = markdown::render_with_width(
&preview,
if r.is_error { TOOL_ERR } else { ANI_ORANGE },
Some(inner_width),
);
body_lines.extend(rendered);
if r.output.lines().count() > 12 {
body_lines.push(Line::from(Span::styled(
@ -1313,6 +1622,9 @@ fn count_visual_lines(text: &str, wrap_width: usize) -> usize {
}
fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
// Border colour subtly shifts when the agent is busy so the user sees the
// chat is "warm" without losing the ability to type. The actual phase
// status (Thinking / Tool / Streaming) lives in `draw_phase()` above.
let border_color = if state.busy {
let phase = (state.tick as f32 / 8.0).sin().abs();
let r = (180.0 + (255.0 - 180.0) * phase) as u8;
@ -1327,23 +1639,6 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
if state.busy {
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
let elapsed = state
.turn_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let line = Line::from(vec![
Span::styled(format!(" {} ", spinner), Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(
format!("thinking… {}s", elapsed),
Style::default().fg(ANI_DIM).add_modifier(Modifier::ITALIC),
),
]);
f.render_widget(Paragraph::new(line).block(block), area);
return;
}
let cursor_visible = (state.tick / 5) % 2 == 0;
let cursor_ch: &str = if cursor_visible { "" } else { " " };
let inner_width = (area.width as usize).saturating_sub(5).max(1);
@ -1563,7 +1858,7 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
let pressure_pct = (state.pressure * 100.0) as u16;
let pressure_label = format!("ctx {}%", pressure_pct);
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
let footer = Line::from(vec![
let mut spans = vec![
Span::styled(
format!(" Esc menu · Enter send · S-Ret ↵ · ↑↓ scroll · {} ", cockpit_hint),
Style::default().fg(STATUS_GRAY),
@ -1572,7 +1867,15 @@ fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
Span::styled(format!("conv {}", short(&state.conversation_id)), Style::default().fg(STATUS_GRAY)),
Span::raw(""),
Span::styled(pressure_label, Style::default().fg(STATUS_GRAY)),
]);
];
if state.scroll > 0 {
spans.push(Span::raw(""));
spans.push(Span::styled(
format!("{} below", state.scroll),
Style::default().fg(ANI_ORANGE),
));
}
let footer = Line::from(spans);
f.render_widget(Paragraph::new(footer).alignment(Alignment::Center), area);
}

View file

@ -17,6 +17,7 @@
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const CODE_BG: Color = Color::Rgb(38, 38, 46);
const CODE_FG: Color = Color::Rgb(220, 220, 230);
@ -27,6 +28,20 @@ const BULLET: Color = Color::Rgb(180, 180, 180);
/// Render markdown to a Vec of styled lines.
pub fn render(md: &str, default_fg: Color) -> Vec<Line<'static>> {
render_with_width(md, default_fg, None)
}
/// Render markdown to a Vec of styled lines, optionally wrapping any line
/// wider than `max_width` cells. Pattern lifted from jcode's
/// `render_markdown_with_width` — when the width is known up-front (e.g.
/// inside a chat bubble), wrapping at render time prevents `Paragraph`'s
/// internal re-wrap from inflating the visual line count and breaking
/// scroll math. Span styling is preserved across wrap points.
pub fn render_with_width(
md: &str,
default_fg: Color,
max_width: Option<usize>,
) -> Vec<Line<'static>> {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(md, opts);
@ -36,7 +51,456 @@ pub fn render(md: &str, default_fg: Color) -> Vec<Line<'static>> {
renderer.handle(ev);
}
renderer.flush();
renderer.lines
match max_width {
Some(w) if w > 0 => wrap_lines(renderer.lines, w),
_ => renderer.lines,
}
}
/// Wrap every line in `lines` to `max_width` cells. Span-aware (styles
/// carry through wrap points), gutter-aware (wrapped continuation lines
/// under a bullet or numbered marker are indented to align under the
/// bullet's text). Tries balanced minimum-raggedness wrap first; falls
/// back to greedy word/char-level wrap when balanced wrap can't apply.
pub fn wrap_lines(lines: Vec<Line<'static>>, max_width: usize) -> Vec<Line<'static>> {
if max_width == 0 {
return lines;
}
lines
.into_iter()
.flat_map(|l| wrap_line(l, max_width))
.collect()
}
/// Wrap a single styled line, using the default bullet-aware gutter for
/// continuation indentation.
pub fn wrap_line(line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
wrap_line_with_gutter(line, max_width, bullet_gutter)
}
/// Full-power wrap: `gutter_fn` decides, per-line, whether continuation
/// lines should be prefixed with a styled gutter (e.g. spaces matching a
/// bullet's text-start column). Returns `(spans, width)` or `None`.
/// Pattern from `jcode/crates/jcode-tui-markdown/src/markdown_wrap.rs::wrap_line`.
pub fn wrap_line_with_gutter(
line: Line<'static>,
max_width: usize,
gutter_fn: impl Fn(&Line<'static>) -> Option<(Vec<Span<'static>>, usize)> + Copy,
) -> Vec<Line<'static>> {
if max_width == 0 {
return vec![line];
}
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if UnicodeWidthStr::width(line_text.as_str()) <= max_width {
return vec![line];
}
let alignment = line.alignment;
// Continuation gutter — only kept if it's positive width and narrower
// than the line budget. Otherwise the wrap would loop on the prefix.
let gutter = gutter_fn(&line).and_then(|(spans, w)| {
if w == 0 || w >= max_width { None } else { Some((spans, w)) }
});
// Try the balanced minimum-raggedness DP first. It produces visually
// smoother wraps (favors lines of equal width). Only succeeds for
// lines with clean word boundaries; falls through to greedy wrap when
// the line has tabs, doubled spaces, leading/trailing whitespace, or
// single-word content.
if let Some(balanced) = wrap_line_balanced(&line, max_width, gutter.as_ref()) {
return balanced;
}
let initial_gutter_width = gutter.as_ref().map(|(_, w)| *w).unwrap_or(0);
let mut result: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
let mut current_width: usize = 0;
let mut current_has_content = false;
let mut pending_gutter = false;
fn flush(
result: &mut Vec<Line<'static>>,
current: &mut Vec<Span<'static>>,
current_width: &mut usize,
current_has_content: &mut bool,
pending_gutter: &mut bool,
alignment: Option<ratatui::layout::Alignment>,
gutter_present: bool,
) {
if current.is_empty() {
return;
}
let mut nl = Line::from(std::mem::take(current));
if let Some(a) = alignment {
nl = nl.alignment(a);
}
result.push(nl);
*current_width = 0;
*current_has_content = false;
*pending_gutter = gutter_present;
}
let seed_gutter = |current: &mut Vec<Span<'static>>,
current_width: &mut usize,
pending: &mut bool| {
if *pending {
if let Some((g_spans, g_w)) = &gutter {
current.extend(g_spans.iter().cloned());
*current_width = *g_w;
}
*pending = false;
}
};
for span in line.spans {
let style = span.style;
let text = span.content.into_owned();
let mut remaining = text.as_str();
while !remaining.is_empty() {
let (chunk, rest): (String, &str) = if let Some(space_idx) = remaining.find(' ') {
let (word, after) = remaining.split_at(space_idx);
let mut buf = String::with_capacity(word.len() + 1);
buf.push_str(word);
buf.push(' ');
let rest = if after.len() > 1 { &after[1..] } else { "" };
(buf, rest)
} else {
(remaining.to_string(), "")
};
remaining = rest;
let chunk_w = UnicodeWidthStr::width(chunk.as_str());
if current_width + chunk_w > max_width && current_has_content {
flush(
&mut result,
&mut current,
&mut current_width,
&mut current_has_content,
&mut pending_gutter,
alignment,
gutter.is_some(),
);
}
if chunk_w > max_width {
// Char-level split for monster tokens.
let mut part = String::new();
let mut part_w = 0usize;
for c in chunk.chars() {
seed_gutter(&mut current, &mut current_width, &mut pending_gutter);
let cw = c.width().unwrap_or(0);
if current_width + part_w + cw > max_width && (current_width + part_w) > 0 {
if !part.is_empty() {
current.push(Span::styled(std::mem::take(&mut part), style));
let before = current_width;
current_width += part_w;
if before + part_w > initial_gutter_width {
current_has_content = true;
}
part_w = 0;
}
if current_has_content {
flush(
&mut result,
&mut current,
&mut current_width,
&mut current_has_content,
&mut pending_gutter,
alignment,
gutter.is_some(),
);
}
seed_gutter(&mut current, &mut current_width, &mut pending_gutter);
}
part.push(c);
part_w += cw;
}
if !part.is_empty() {
seed_gutter(&mut current, &mut current_width, &mut pending_gutter);
current.push(Span::styled(part, style));
let before = current_width;
current_width += part_w;
if before + part_w > initial_gutter_width {
current_has_content = true;
}
}
} else {
seed_gutter(&mut current, &mut current_width, &mut pending_gutter);
current.push(Span::styled(chunk, style));
let before = current_width;
current_width += chunk_w;
if before + chunk_w > initial_gutter_width {
current_has_content = true;
}
}
}
}
if !current.is_empty() && current_has_content {
let mut nl = Line::from(current);
if let Some(a) = alignment {
nl = nl.alignment(a);
}
result.push(nl);
}
if result.is_empty() {
let mut empty = Line::from("");
if let Some(a) = alignment {
empty = empty.alignment(a);
}
result.push(empty);
}
result
}
/// Default gutter detector. If a line starts with ` • ` / ` - ` / ` * ` /
/// ` · ` (Souveraine's bullet markers, see `Renderer::start::Tag::Item`)
/// or ` N. ` (numbered list), return spaces of equal display width so
/// continuation lines hang under the text, not under the bullet.
fn bullet_gutter(line: &Line<'static>) -> Option<(Vec<Span<'static>>, usize)> {
let first = line.spans.first()?;
let text = first.content.as_ref();
// Bullet form: leading whitespace, a bullet char, then a single space.
let trimmed = text.trim_start();
let leading = text.len() - trimmed.len();
let bullet_match = ["", "- ", "* ", "· "]
.iter()
.find(|m| trimmed.starts_with(*m))
.map(|m| leading + m.len());
// Numbered form: digits, then ". " or ") ".
let numbered_match = if bullet_match.is_none() {
let mut digits = 0;
for c in trimmed.chars() {
if c.is_ascii_digit() { digits += 1; } else { break; }
}
if digits > 0 {
let after = &trimmed[digits..];
if after.starts_with(". ") || after.starts_with(") ") {
Some(leading + digits + 2)
} else { None }
} else { None }
} else { None };
let prefix_chars = bullet_match.or(numbered_match)?;
let prefix_str: String = text[..prefix_chars].to_string();
let width = UnicodeWidthStr::width(prefix_str.as_str());
if width == 0 { return None; }
Some((vec![Span::raw(" ".repeat(width))], width))
}
// ── Balanced wrap (minimum-raggedness DP) ────────────────────────────────
//
// Lifted from jcode-tui-markdown/src/markdown_wrap.rs. jcode gates this on
// non-Left alignment because their text is left-aligned by default; ours is
// too, but visually-balanced wraps (lines of equal width) look better even
// when left-aligned, so we apply it for all alignments. Guards match jcode:
// reject tabs, doubled spaces, leading/trailing whitespace, single-word
// content, or any single word wider than the budget — those fall back to
// greedy wrap.
#[derive(Clone)]
struct StyledPiece {
text: String,
style: Style,
}
#[derive(Clone)]
struct WrapToken {
word: Vec<StyledPiece>,
spaces: Vec<StyledPiece>,
word_width: usize,
space_width: usize,
}
fn wrap_line_balanced(
line: &Line<'static>,
max_width: usize,
gutter: Option<&(Vec<Span<'static>>, usize)>,
) -> Option<Vec<Line<'static>>> {
let flat_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if UnicodeWidthStr::width(flat_text.as_str()) <= max_width || !flat_text.contains(' ') {
return None;
}
if flat_text.starts_with(char::is_whitespace)
|| flat_text.ends_with(char::is_whitespace)
|| flat_text.contains(" ")
|| flat_text.contains('\t')
{
return None;
}
let tokens = tokenize_for_balanced(line)?;
let gutter_w = gutter.map(|(_, w)| *w).unwrap_or(0);
let first_budget = max_width;
let cont_budget = max_width.saturating_sub(gutter_w);
if cont_budget == 0 {
return None;
}
if tokens.len() < 3 || tokens.iter().any(|t| t.word_width > cont_budget) {
return None;
}
let (breaks, line_count) = balanced_breaks(&tokens, first_budget, cont_budget)?;
if line_count <= 1 {
return None;
}
let mut result = Vec::with_capacity(line_count);
let mut start = 0usize;
let mut line_idx = 0usize;
while start < tokens.len() {
let end = breaks[start];
if end <= start {
return None;
}
let mut spans = Vec::new();
if line_idx > 0 {
if let Some((g_spans, _)) = gutter {
spans.extend(g_spans.iter().cloned());
}
}
spans.extend(build_balanced_spans(&tokens[start..end]));
let mut nl = Line::from(spans);
if let Some(a) = line.alignment {
nl = nl.alignment(a);
}
result.push(nl);
start = end;
line_idx += 1;
}
Some(result)
}
fn tokenize_for_balanced(line: &Line<'static>) -> Option<Vec<WrapToken>> {
let mut tokens: Vec<WrapToken> = Vec::new();
let mut word: Vec<StyledPiece> = Vec::new();
let mut spaces: Vec<StyledPiece> = Vec::new();
let mut word_width = 0usize;
let mut space_width = 0usize;
let mut seen_word_char = false;
let mut in_spaces = false;
for span in &line.spans {
let style = span.style;
for ch in span.content.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if ch.is_whitespace() {
if !seen_word_char {
return None;
}
in_spaces = true;
push_piece(&mut spaces, ch, style);
space_width += cw;
} else {
if in_spaces {
tokens.push(WrapToken {
word: std::mem::take(&mut word),
spaces: std::mem::take(&mut spaces),
word_width,
space_width,
});
word_width = 0;
space_width = 0;
in_spaces = false;
}
seen_word_char = true;
push_piece(&mut word, ch, style);
word_width += cw;
}
}
}
if !seen_word_char || in_spaces {
return None;
}
tokens.push(WrapToken {
word,
spaces,
word_width,
space_width,
});
Some(tokens)
}
fn push_piece(pieces: &mut Vec<StyledPiece>, ch: char, style: Style) {
if let Some(last) = pieces.last_mut() {
if last.style == style {
last.text.push(ch);
return;
}
}
pieces.push(StyledPiece { text: ch.to_string(), style });
}
fn balanced_breaks(
tokens: &[WrapToken],
first_budget: usize,
cont_budget: usize,
) -> Option<(Vec<usize>, usize)> {
// DP over token slack — cost = sum of slack² across lines + line count.
// `dp[i]` = min cost of wrapping tokens[i..]; `breaks[i]` = exclusive
// end of the first line of that suffix. The first line uses `first_budget`,
// continuation lines use `cont_budget` (gutter-adjusted).
let n = tokens.len();
let mut dp = vec![usize::MAX; n + 1];
let mut breaks = vec![0usize; n];
let mut counts = vec![usize::MAX; n + 1];
dp[n] = 0;
counts[n] = 0;
for start in (0..n).rev() {
// First line of the suffix gets the appropriate budget — only the
// very first line of the original line uses `first_budget`. For
// jcode-style we just use cont_budget everywhere except start=0.
let budget = if start == 0 { first_budget } else { cont_budget };
let mut line_width = 0usize;
for end in start..n {
line_width = if end == start {
tokens[end].word_width
} else {
line_width
.saturating_add(tokens[end - 1].space_width)
.saturating_add(tokens[end].word_width)
};
if line_width > budget {
break;
}
if dp[end + 1] == usize::MAX {
continue;
}
let slack = budget - line_width;
let cost = slack.saturating_mul(slack).saturating_add(dp[end + 1]);
let lines_used = counts[end + 1].saturating_add(1);
let better = cost < dp[start]
|| (cost == dp[start] && lines_used < counts[start]);
if better {
dp[start] = cost;
breaks[start] = end + 1;
counts[start] = lines_used;
}
}
}
if dp[0] == usize::MAX { None } else { Some((breaks, counts[0])) }
}
fn build_balanced_spans(tokens: &[WrapToken]) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for (idx, t) in tokens.iter().enumerate() {
for p in &t.word {
spans.push(Span::styled(p.text.clone(), p.style));
}
if idx + 1 < tokens.len() {
for p in &t.spaces {
spans.push(Span::styled(p.text.clone(), p.style));
}
}
}
spans
}
struct Renderer {

View file

@ -1,4 +1,5 @@
//! Annie's half-block portrait — Tier 1 of the Presence visual stack.
//! Annie's half-block silhouette — the fallback when no terminal image
//! protocol (kitty/sixel) is available. Also the "presence" overlay card.
//!
//! Renders a hand-crafted stylized portrait directly into ratatui's frame
//! buffer using upper/lower half-block characters (`▀` / `▄` / `█`) so each
@ -6,9 +7,10 @@
//! recognizable Annie (twin-tails, cyan filigree, forehead diamond), seven
//! visible states, no new dependencies.
//!
//! Tier 2 (C4) will replace this with full PNG rendering via image protocols
//! (kitty/sixel) where supported and fall back to this module elsewhere. The
//! pixel grid lives here as both the Tier 1 art and the fallback art.
//! The only path for photo portraits is `ratatui-image` (`Image` widget in
//! `App::image_protocol`), which renders via kitty/sixel and falls back
//! to unicode halfblocks internally. This module is the *pixel-art fallback*,
//! not a photo pipeline.
//!
//! ## Grid
//!
@ -34,8 +36,6 @@
//! - `c` collar cyan accent
//! - ` ` (space) skin (V-neck opening)
use std::path::Path;
use ratatui::{
buffer::Buffer,
layout::Rect,
@ -44,51 +44,6 @@ use ratatui::{
use crate::ui::presence::{Posture, Presence};
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
/// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
/// renderer pulls pixels from here instead of the hand-coded palette grid.
#[derive(Debug, Clone)]
pub struct PortraitSource {
pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
}
impl PortraitSource {
/// Sample the pixel at (x, y). Returns None if out of bounds.
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
self.pixels.get(y * PORTRAIT_W as usize + x).copied()
}
/// Decode an image file (PNG or JPEG), cover-crop to the portrait grid
/// dimensions (18×18) preserving aspect ratio, and produce a colored
/// pixel array. Returns `None` on any I/O or decode error.
pub fn from_path(path: &Path) -> Option<Self> {
let img = match image::open(path) {
Ok(img) => img,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "portrait decode failed");
return None;
}
};
// Cover-crop: take a center square from the original (no stretch),
// then Lanczos3 down to grid size. Single resize pass from full
// source resolution gives the smoothest result at 18×18.
let (w, h) = (img.width(), img.height());
let size = w.min(h);
let crop_x = (w - size) / 2;
let crop_y = (h - size) / 2;
let cropped = img.crop_imm(crop_x, crop_y, size, size);
let rgb = cropped.resize_exact(
PORTRAIT_W as u32, PORTRAIT_H as u32,
image::imageops::FilterType::Lanczos3,
).to_rgb8();
let pixels = rgb.pixels().map(|p| Color::Rgb(p[0], p[1], p[2])).collect();
Some(Self { pixels })
}
}
pub const PORTRAIT_W: u16 = 18;
pub const PORTRAIT_H: u16 = 18;
@ -97,7 +52,7 @@ pub const PORTRAIT_H: u16 = 18;
pub const RENDER_W: u16 = PORTRAIT_W;
pub const RENDER_H: u16 = PORTRAIT_H / 2;
// ── Base portrait grid — clean silhouette, no face ──────────────
// ── Base portrait grid — clean silhouette, no face ──────────────────────
// This is the EMERGENCY default. The hand-crafted "Annie face" version
// read as a creepy llama (Casey's words), so this is now a faceless
// silhouette: hair, neck, collar. State animation lives in border color,
@ -105,10 +60,6 @@ pub const RENDER_H: u16 = PORTRAIT_H / 2;
// per-pixel row swaps at this resolution. True facial animation belongs
// in a future TTS/STT-integrated system, not in half-blocks.
//
// When a `PortraitSource` is loaded (per-agent PNG/JPEG from agent memfs
// `assets/`), all pixels come from the source and this silhouette is not
// rendered.
//
// Each row must be exactly PORTRAIT_W characters wide. Validated by a test.
const BASE: [&str; PORTRAIT_H as usize] = [
"....HHHHHHHHHH....",
@ -131,17 +82,15 @@ const BASE: [&str; PORTRAIT_H as usize] = [
"..CCCC CCCC..",
];
// ── State-aware pixel lookup ────────────────────────────────────
// ── State-aware pixel lookup ────────────────────────────────────────────
/// Read a pixel from the base silhouette grid. The default silhouette has no
/// facial features, so no per-pixel row swaps are needed — state expression
/// at this resolution lives in border color, breath luminance pulse, and the
/// posture-driven color modulation in [`color_for`].
/// Read a pixel from the base silhouette grid. No facial features — state
/// expression lives in border color, breath, and posture-driven modulation.
fn pixel_at(x: usize, y: usize, _p: &Presence) -> char {
BASE[y].as_bytes()[x] as char
}
// ── Palette ─────────────────────────────────────────────────────
// ── Palette ─────────────────────────────────────────────────────────────
/// Resolve a pixel key to an RGB color, modulated by posture + breath_phase.
///
@ -190,51 +139,15 @@ fn color_for(key: char, posture: Posture, breath: f32) -> Option<Color> {
Some(Color::Rgb(mix(r), mix(g), mix(b)))
}
// ── Render ──────────────────────────────────────────────────────
// ── Render ──────────────────────────────────────────────────────────────
/// Resolve a single pixel's color: source-when-loaded, modulated by posture
/// and breath. With no source the silhouette palette grid is used.
/// Resolve a single pixel's color from the silhouette palette, modulated
/// by posture and breath.
fn pixel_color(px: usize, py: usize, p: &Presence, breath: f32) -> Option<Color> {
if let Some(source) = p.portrait_source.as_ref() {
if let Some(c) = source.at(px, py) {
return Some(modulate(c, p.posture, breath));
}
}
let key = pixel_at(px, py, p);
color_for(key, p.posture, breath)
}
/// Apply posture-driven modulation to a source pixel — desaturate when
/// straining, dim when yawning, breathe luminance on cyan-leaning hues,
/// warm-tint on affection. This is how state shows on a loaded portrait
/// at half-block resolution: across-the-image tone, not per-pixel swaps.
fn modulate(c: Color, posture: Posture, breath: f32) -> Color {
let Color::Rgb(r, g, b) = c else { return c };
let strained = matches!(posture, Posture::Straining);
let yawning = matches!(posture, Posture::Yawning);
let warm = matches!(posture, Posture::Affectionate);
let processing = matches!(posture, Posture::Processing);
let avg = ((r as u16 + g as u16 + b as u16) / 3) as f32;
let sat = if strained { 0.55 } else { 1.0 };
let dim = if yawning { 0.82 } else { 1.0 };
// Subtle breath pulse — only on the cyan-leaning pixels so skin stays calm.
let cyan_lean = b > r && b > g;
let breath_gain = if cyan_lean {
1.0 + breath * 0.10 * if processing { 1.6 } else { 1.0 }
} else { 1.0 };
// Warm tint shifts the red/green channels up a little.
let warm_r = if warm { 1.06 } else { 1.0 };
let warm_g = if warm { 1.02 } else { 1.0 };
let mix = |c: u8, warm_chan: f32| {
let f = (c as f32 * sat + avg * (1.0 - sat)) * dim * breath_gain * warm_chan;
f.clamp(0.0, 255.0) as u8
};
Color::Rgb(mix(r, warm_r), mix(g, warm_g), mix(b, 1.0))
}
/// Render the portrait scaled-up by `scale` (1 = native half-block density).
/// Each grid pixel becomes a `scale × scale` square. Use this for the
/// presence-mode fullscreen view. Cells outside `area` are skipped.
@ -243,10 +156,6 @@ pub fn render_scaled(buf: &mut Buffer, area: Rect, p: &Presence, scale: u16) {
let scale = scale.max(1);
let breath = p.animator.breathe(2500);
// Each grid pixel is `scale` cells wide and `scale` cells tall after the
// half-block density (which already collapses 2 pixels per cell vertically).
// To keep the aspect roughly square with scale, we use scale horizontally
// and scale/2 (min 1) vertically since terminal cells are taller than wide.
let cell_w = scale;
let cell_h = (scale / 2).max(1);
@ -260,13 +169,8 @@ pub fn render_scaled(buf: &mut Buffer, area: Rect, p: &Presence, scale: u16) {
if col.is_none() { continue; }
let col = col.unwrap();
// Each pixel paints a cell_w × cell_h block. Since two pixels
// share a terminal row (half-blocks), top pixels use ▀ and bottom
// pixels use ▄, but at scale > 1 we just use █ everywhere because
// the pixels are already painted as full cells.
let cx0 = area.x + (px as u16) * cell_w;
let cy0 = area.y + ((py / 2) as u16) * cell_h
+ if py % 2 == 1 { 0 } else { 0 }; // vertical halves merged at scale>1
let cy0 = area.y + ((py / 2) as u16) * cell_h;
for dx in 0..cell_w {
for dy in 0..cell_h {
let x = cx0 + dx;
@ -333,7 +237,7 @@ pub fn render(buf: &mut Buffer, area: Rect, p: &Presence) {
}
}
// ── Tests ───────────────────────────────────────────────────────
// ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
@ -364,28 +268,4 @@ mod tests {
assert!(color_for('.', Posture::Idle, 0.5).is_none());
assert!(color_for('?', Posture::Idle, 0.5).is_none());
}
#[test]
fn modulate_dims_on_yawn() {
let base = Color::Rgb(200, 200, 200);
let yawn = modulate(base, Posture::Yawning, 0.5);
if let Color::Rgb(r, _, _) = yawn {
assert!(r < 200, "yawn should dim luminance; got {}", r);
} else {
panic!("expected RGB");
}
}
#[test]
fn modulate_desaturates_on_strain() {
let blue = Color::Rgb(60, 60, 220);
let strained = modulate(blue, Posture::Straining, 0.5);
if let Color::Rgb(r, _, b) = strained {
// Strain pulls channels toward the average — blue and red should
// be closer together than they started.
assert!(b - r < 220 - 60, "strain should desaturate");
} else {
panic!("expected RGB");
}
}
}

View file

@ -58,11 +58,11 @@ use ratatui::{
Frame,
};
use std::path::{Path, PathBuf};
use std::path::Path;
use crate::ui::animation::{Animator, colors};
use crate::ui::component::TuiEvent;
use crate::ui::portrait::{self, PortraitSource};
use crate::ui::portrait;
// ── State ───────────────────────────────────────────────────────
@ -143,7 +143,7 @@ pub struct Presence {
/// Optional per-agent portrait loaded from `assets/portrait.{png,jpg}`
/// in the agent's memfs. When `None`, the renderer falls back to the
/// hand-crafted Annie palette grid (Tier 1).
pub portrait_source: Option<PortraitSource>,
pub portrait_source: Option<()>,
/// Most recent tick observed. Drives blink/breath timing.
tick: u64,
/// Tick at which the next blink should begin.
@ -173,51 +173,12 @@ impl Presence {
}
}
/// Try to load a portrait from a path. Logs a warning on failure so we
/// can see _why_ a PNG didn't take (decode error, unsupported format,
/// path not readable) instead of silently falling back to the silhouette.
pub fn load_portrait<P: AsRef<Path>>(&mut self, path: P) {
let path = path.as_ref();
match PortraitSource::from_path(path) {
Some(src) => {
tracing::info!(path = %path.display(), "portrait loaded");
self.portrait_source = Some(src);
}
None => {
tracing::warn!(
path = %path.display(),
"portrait failed to load — leaving silhouette in place"
);
}
}
/// Try to load a portrait from a path (stub — future use).
pub fn load_portrait<P: AsRef<Path>>(&mut self, _path: P) {
}
/// Attempt to load a portrait from an agent's memfs root. Looks at
/// `<memfs_root>/assets/portrait.{png,jpg,jpeg}`.
///
/// `assets/` is OUTSIDE `system/` so it does NOT get pinned into the
/// agent's context window by `core::prompt::build`. See
/// `memory/feedback_system_folder_pinned.md`.
pub fn load_portrait_from_memfs<P: AsRef<Path>>(&mut self, memfs_root: P) {
let memfs_root = memfs_root.as_ref();
let stems = ["portrait.png", "portrait.jpg", "portrait.jpeg"];
let candidates: Vec<PathBuf> = stems
.iter()
.map(|stem| memfs_root.join("assets").join(stem))
.collect();
for candidate in &candidates {
if candidate.exists() {
self.load_portrait(candidate);
return;
}
}
tracing::debug!(
memfs_root = %memfs_root.display(),
tried = candidates.len(),
"no per-agent portrait found in assets/ — using silhouette"
);
/// 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) {
}
pub fn set_position(&mut self, p: Position) {