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:
parent
6b5a3c5959
commit
f32d949e11
8 changed files with 1457 additions and 520 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
660
src/ui/app.rs
660
src/ui/app.rs
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
411
src/ui/chat.rs
411
src/ui/chat.rs
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue