refactor: split app.rs and local.rs into focused modules
app.rs (3540 lines) → 10 modules: mod.rs (event loop core), welcome, dashboard, presence_screen, manager_screen, voice, settings_handler, splash, images, agents. local.rs (1827 lines) → 5 modules: mod.rs (Backend impl), turn (run_turn + tool loop), consciousness (TurnInjector + surfacings), subagent (LocalSubagentRunner), energy (balance writer + event). No behavioral changes — same code, different files. 169 tests pass.
This commit is contained in:
parent
ab1167e99d
commit
d3d485f9e8
17 changed files with 5404 additions and 5367 deletions
1827
src/backend/local.rs
1827
src/backend/local.rs
File diff suppressed because it is too large
Load diff
212
src/backend/local/consciousness.rs
Normal file
212
src/backend/local/consciousness.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use anyhow::Result;
|
||||
use futures::stream::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::bridge::bifrost::Message as BifrostMessage;
|
||||
use crate::core::nervous::EventBus;
|
||||
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use crate::server::{ConsciousnessEvent, SouveraineServer};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
|
||||
use super::LocalBackend;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::core::nervous::handler::TurnInjector for LocalBackend {
|
||||
/// Heartbeat-driven turn injection. The cron loop pauses while
|
||||
/// `active_sessions > 0`, so by the time we get here the agent is
|
||||
/// idle. We grab the most recent conversation (or create a fresh one
|
||||
/// if the agent has none), append the scheduled prompt as a user
|
||||
/// message, and drain the resulting stream — the turn runs silently
|
||||
/// in the background. Anything subconscious surfaces lands in the inbox.
|
||||
async fn inject_background_turn(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let conv_id = match self.server.sessions.list_for_agent(agent_id).last().cloned() {
|
||||
Some(id) => id,
|
||||
None => self.ensure_conversation(agent_id).await?,
|
||||
};
|
||||
let stream = self.send(&conv_id, text).await?;
|
||||
// Drain the stream in the background — no UI is listening. But the
|
||||
// subconscious's N+1 pass runs inside this turn, and what she
|
||||
// surfaces (a commitment, a reflection, an archivist synthesis)
|
||||
// would otherwise vanish with the drained events. Collect those and
|
||||
// stash them so the next TUI/CLI session shows the human what
|
||||
// happened during the autonomous cycle.
|
||||
let server = self.server.clone();
|
||||
let agent_id = agent_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
use crate::core::nervous::pending::PendingSurfacing;
|
||||
let mut s = stream;
|
||||
let mut stashed: Vec<PendingSurfacing> = Vec::new();
|
||||
while let Some(ev) = s.next().await {
|
||||
match ev {
|
||||
Ok(BackendEvent::Surfacing { source, content, priority }) => {
|
||||
// Skip the no-op heartbeat sentinel — the subconscious
|
||||
// always queues a low "pass complete, no anomalies"
|
||||
// item so the UI shows the pass ran. That is noise to
|
||||
// resurface on connect; only stash real observations.
|
||||
if priority.eq_ignore_ascii_case("low")
|
||||
&& content.contains("no anomalies detected")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "surfacing".to_string(),
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
Ok(BackendEvent::Reflection(content)) => {
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "reflection".to_string(),
|
||||
source: String::new(),
|
||||
content,
|
||||
priority: String::new(),
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
Ok(BackendEvent::Archivist { synthesis, .. }) => {
|
||||
stashed.push(PendingSurfacing {
|
||||
kind: "archivist".to_string(),
|
||||
source: String::new(),
|
||||
content: synthesis,
|
||||
priority: String::new(),
|
||||
at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !stashed.is_empty() {
|
||||
let dir = server.agents.agent_data_dir(&agent_id);
|
||||
match crate::core::nervous::pending::append(&dir, &stashed).await {
|
||||
Ok(()) => tracing::info!(
|
||||
agent = %agent_id,
|
||||
count = stashed.len(),
|
||||
"stashed heartbeat surfacings for next session"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"pending heartbeat surfacings stash failed: {}", e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Surface-initiated turn injection. Called by the
|
||||
/// [`SensoriumInputHandler`] when a `sensorium:input` event arrives.
|
||||
///
|
||||
/// Unlike background turns, the turn's output events are NOT drained
|
||||
/// here — `run_turn` already fires them onto the EventBus as `turn:*`
|
||||
/// events (via `TurnEventDispatcher`). The originating sensorium's
|
||||
/// `run` loop consumes those events for incremental rendering.
|
||||
///
|
||||
/// We drain the stream only to prevent backpressure on the mpsc
|
||||
/// channel. The EventBus is the public event system; the stream is
|
||||
/// a TUI-internal detail.
|
||||
async fn inject_surface_turn(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
conversation_id: &str,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
// Resolve the surface chat ID to a Souveraine conversation ID.
|
||||
// Matrix room IDs are not Souveraine conversation IDs — the
|
||||
// mapping survives for the lifetime of the surface session so
|
||||
// subsequent messages in the same room route to the same
|
||||
// conversation. The lock scope is carefully bounded to avoid
|
||||
// holding a !Send MutexGuard across the .await below.
|
||||
let conv_id = {
|
||||
let map = self.surface_conversations.lock().unwrap();
|
||||
if let Some(id) = map.get(conversation_id) {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let conv_id = match conv_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = self.ensure_conversation(agent_id).await?;
|
||||
self.surface_conversations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(conversation_id.to_string(), id.clone());
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let stream = self.send(&conv_id, text).await?;
|
||||
// Drain the stream in the background — the EventBus already carries
|
||||
// every `turn:*` event via TurnEventDispatcher. The sensorium
|
||||
// renders from the bus. We drain here so the mpsc channel doesn't
|
||||
// back up.
|
||||
tokio::spawn(async move {
|
||||
let mut s = stream;
|
||||
while let Some(ev) = s.next().await {
|
||||
if ev.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain subconscious's intrusive box for the given agent and return formatted
|
||||
/// `[ surfacing: ... ]` lines ready to prepend to the user's next message.
|
||||
/// Marks each drained item as delivered (moved to `sent.md`). Mirrors
|
||||
/// lettabot-v017's `readSurfacingThoughts` + `clearSurfacingThoughts` pair
|
||||
/// (`~/Projects/lettabot-v017/src/core/prompts.ts:64-91`) — the substrate
|
||||
/// reads the channel subconscious wrote to and lets the conscious mind see it
|
||||
/// before she reads Casey.
|
||||
///
|
||||
/// Critical urgency gets `[ surfacing — CRITICAL: ... ]`. High becomes
|
||||
/// `[ surfacing — !: ... ]`. Low/none keep the bare form. The shape is a
|
||||
/// gradient the agent feels, not a number she has to read.
|
||||
pub(super) async fn drain_intrusive_surfacings(
|
||||
server: &Arc<SouveraineServer>,
|
||||
agent_id: &str,
|
||||
) -> Vec<String> {
|
||||
use crate::core::subconscious::{SubconsciousInbox, Urgency};
|
||||
|
||||
let sub_repo = server.agents.subconscious_memory_repo(agent_id);
|
||||
let primary_repo = server.agents.memory_repo(agent_id);
|
||||
let inbox = SubconsciousInbox::with_primary(sub_repo, primary_repo);
|
||||
|
||||
let items = match inbox.get_intrusive().await {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
tracing::debug!("intrusive surfacing read failed (continuing without): {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
if items.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = Vec::with_capacity(items.len());
|
||||
for item in &items {
|
||||
let prefix = match item.urgency {
|
||||
Urgency::Critical => "[ surfacing — CRITICAL:",
|
||||
Urgency::High => "[ surfacing — !:",
|
||||
Urgency::Low => "[ surfacing:",
|
||||
};
|
||||
let content = item.content.trim();
|
||||
lines.push(format!("{} {} ]", prefix, content));
|
||||
|
||||
if let Err(e) = inbox.mark_delivered(&item.id).await {
|
||||
tracing::debug!("mark_delivered failed for {}: {}", item.id, e);
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
143
src/backend/local/energy.rs
Normal file
143
src/backend/local/energy.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::core::nervous::EventBus;
|
||||
use crate::server::SouveraineServer;
|
||||
|
||||
pub(crate) async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> {
|
||||
let memory_root = server.agents.memory_root(agent_id);
|
||||
let tasks_dir = memory_root.join("tasks");
|
||||
if !tasks_dir.exists() {
|
||||
// No tasks directory yet — nothing to count.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut generative: usize = 0;
|
||||
let mut consumptive: usize = 0;
|
||||
let mut hot: usize = 0;
|
||||
let mut warm: usize = 0;
|
||||
let mut cold: usize = 0;
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "md").unwrap_or(false) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Quick frontmatter parse — just the fields we need.
|
||||
let body = match content.strip_prefix("---\n") {
|
||||
Some(rest) => match rest.find("\n---\n") {
|
||||
Some(end) => &rest[..end],
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut status: Option<&str> = None;
|
||||
let mut energy: Option<&str> = None;
|
||||
let mut momentum: Option<&str> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
if let Some((key, val)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let val = val.trim().trim_matches('"');
|
||||
match key {
|
||||
"status" => status = Some(val),
|
||||
"energy" => energy = Some(val),
|
||||
"momentum" => momentum = Some(val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only live commitments weigh on the energy balance —
|
||||
// done and cancelled ones have been set down.
|
||||
if matches!(status, Some("pending") | Some("in_progress")) {
|
||||
match energy {
|
||||
Some("generative") => generative += 1,
|
||||
_ => consumptive += 1,
|
||||
}
|
||||
match momentum {
|
||||
Some("hot") => hot += 1,
|
||||
Some("warm") => warm += 1,
|
||||
_ => cold += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the top-of-mind description — shifts the tone of the one-liner
|
||||
// the agent reads in context. Matches the lettabot-v017 heartbeat topology.
|
||||
let ratio = if generative + consumptive > 0 {
|
||||
generative as f32 / (generative + consumptive) as f32
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let description = if generative == 0 && consumptive == 0 {
|
||||
"no tasks — the space is clean".to_string()
|
||||
} else if ratio < 0.2 {
|
||||
"all-consumptive — the engine is running cold".to_string()
|
||||
} else if ratio < 0.4 {
|
||||
"mostly obligations — tending the garden".to_string()
|
||||
} else if ratio > 0.8 {
|
||||
"all-generative — building new things".to_string()
|
||||
} else if ratio > 0.6 {
|
||||
"mostly generative — restless momentum".to_string()
|
||||
} else {
|
||||
"balanced — generative and consumptive in rhythm".to_string()
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let frontmatter = format!(
|
||||
"---\nupdated: {updated}\ngenerative: {gen}\nconsumptive: {con}\nratio: {ratio:.2}\n\
|
||||
hot: {hot}\nwarm: {warm}\ncold: {cold}\n---\n\n# Energy Balance\n\n\
|
||||
{gen} generative, {con} consumptive ({hot} hot, {warm} warm, {cold} cold). {desc}\n",
|
||||
updated = now.to_rfc3339(),
|
||||
gen = generative,
|
||||
con = consumptive,
|
||||
ratio = ratio,
|
||||
hot = hot,
|
||||
warm = warm,
|
||||
cold = cold,
|
||||
desc = description,
|
||||
);
|
||||
|
||||
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
|
||||
if let Some(parent) = balance_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&balance_path, frontmatter)?;
|
||||
|
||||
fire_energy_event(event_bus, agent_id, generative, consumptive, ratio);
|
||||
|
||||
tracing::debug!(
|
||||
agent = agent_id,
|
||||
generative, consumptive,
|
||||
"energy-balance written"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire an energy_balance_updated event so the firehose carries the
|
||||
/// agent's felt state across machines.
|
||||
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) {
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "energy".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "energy_balance_updated".into(),
|
||||
target: Some(agent_id.to_string()),
|
||||
urgency: 0.1,
|
||||
payload: Some(serde_json::json!({
|
||||
"generative": generative,
|
||||
"consumptive": consumptive,
|
||||
"ratio": ratio,
|
||||
})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
570
src/backend/local/mod.rs
Normal file
570
src/backend/local/mod.rs
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
//! In-process Backend impl. Same engine as the HTTP server, no socket.
|
||||
//!
|
||||
//! Constructed once with a `ConsciousnessConfig`; spins up an `AgentInventory`
|
||||
//! (SQLite under `~/.souveraine/server/`), `SessionManager`, `BifrostClient`,
|
||||
//! and `ConsciousnessEngine`. `send` mirrors the server's `stream_messages`
|
||||
//! handler, but emits `BackendEvent`s directly instead of SSE frames.
|
||||
//!
|
||||
//! This is the "harness still works when the server is gone" path
|
||||
//! (`souveraine chat --local`, or auto-fallback when the remote is down).
|
||||
|
||||
pub(crate) mod energy;
|
||||
mod turn;
|
||||
mod consciousness;
|
||||
mod subagent;
|
||||
|
||||
pub use subagent::LocalSubagentRunner;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures::stream::{BoxStream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
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;
|
||||
use crate::core::compact::CompactionEngine;
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::core::identity::SeedId;
|
||||
use crate::core::nervous::EventBus;
|
||||
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
|
||||
use crate::server::{ConsciousnessEvent, SouveraineServer};
|
||||
|
||||
use super::{AgentInfo, Backend, BackendEvent, ConversationInfo};
|
||||
|
||||
/// Below 95%: no cap. At 95%+: scale max_tokens so context + output
|
||||
/// stays under the model's limit. The agent feels the room shrink.
|
||||
fn pressure_to_max_tokens(pressure: f32, output_limit: u32) -> Option<u32> {
|
||||
if pressure <= 0.95 {
|
||||
return None;
|
||||
}
|
||||
let remaining = (1.0 - pressure) / 0.05;
|
||||
let ratio = remaining.max(0.0).min(1.0);
|
||||
Some((output_limit as f32 * ratio) as u32)
|
||||
}
|
||||
|
||||
/// Mirror of `ConsciousnessEngine::calculate_pressure` for the in-loop
|
||||
/// BifrostMessage shape, so we can recompute pressure as tool results
|
||||
/// accumulate inside a single turn. `context_limit` comes from the
|
||||
/// agent's `llm_config.context_window` (Constitution V.3 — per-model
|
||||
/// physics, no hardcoded 128K).
|
||||
fn bifrost_pressure(counter: &TokenCounter, messages: &[BifrostMessage], context_limit: usize) -> f32 {
|
||||
let tokens: usize = messages.iter().map(|m| counter.count(&m.content)).sum();
|
||||
let limit = context_limit.max(1);
|
||||
(tokens as f32 / limit as f32).min(1.0)
|
||||
}
|
||||
|
||||
/// Helper: bump adaptive delay when we hit a 429. No decay — once bumped,
|
||||
/// the delay stays at that level until the app restarts.
|
||||
fn bump_on_strain(delay: &AtomicU64, status: u16) {
|
||||
if status == 429 {
|
||||
let current = delay.load(Ordering::Relaxed);
|
||||
let bumped = (current + 200).min(3000);
|
||||
if bumped > current {
|
||||
delay.store(bumped, Ordering::Relaxed);
|
||||
tracing::info!("rate delay bumped to {}ms (429)", bumped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalBackend {
|
||||
server: Arc<SouveraineServer>,
|
||||
event_bus: EventBus,
|
||||
seed_id: Arc<SeedId>,
|
||||
active_sessions: Arc<AtomicU32>,
|
||||
sensorium: Arc<tokio::sync::Mutex<crate::core::sensorium::SensoriumCoordinator>>,
|
||||
surface_conversations: Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl LocalBackend {
|
||||
pub async fn new(config: ConsciousnessConfig) -> Result<Self> {
|
||||
let server = SouveraineServer::new(config.clone())
|
||||
.await
|
||||
.context("LocalBackend: SouveraineServer init")?;
|
||||
let event_bus = server.event_bus.clone();
|
||||
|
||||
let base = config
|
||||
.memory
|
||||
.base_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".souveraine"));
|
||||
|
||||
// Load or generate the seed identity (trust root)
|
||||
let seed_dir = SeedId::default_dir(&base);
|
||||
let seed_id = Arc::new(
|
||||
SeedId::load_or_generate(&seed_dir)
|
||||
.context("SeedId init")?,
|
||||
);
|
||||
tracing::info!(
|
||||
pubkey = %seed_id.public_key_hex(),
|
||||
"seed identity loaded"
|
||||
);
|
||||
|
||||
// Spawn the persistent event log (firehose to disk)
|
||||
let events_dir = base.join("events");
|
||||
let mut event_log =
|
||||
crate::core::nervous::event_log::EventLog::new(events_dir, event_bus.subscribe());
|
||||
tokio::spawn(async move { event_log.run().await });
|
||||
|
||||
let active_sessions = Arc::new(AtomicU32::new(0));
|
||||
let backend = Self {
|
||||
server: Arc::new(server),
|
||||
event_bus: event_bus.clone(),
|
||||
seed_id,
|
||||
active_sessions: active_sessions.clone(),
|
||||
sensorium: Arc::new(tokio::sync::Mutex::new(
|
||||
crate::core::sensorium::SensoriumCoordinator::new(),
|
||||
)),
|
||||
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
// Spawn one CronSensor per agent (each agent owns its own schedules
|
||||
// directory), and one HeartbeatHandler on the bus that injects turns
|
||||
// when a schedule fires. The handler holds an Arc<dyn TurnInjector>
|
||||
// pointing back at us — clean dep direction, no LocalBackend leak
|
||||
// into the nervous module.
|
||||
let agents_dir = base.join("agents");
|
||||
match backend.server.agents.list(None).await {
|
||||
Ok(summaries) => {
|
||||
for summary in summaries {
|
||||
let schedules_dir = agents_dir.join(&summary.id).join("schedules");
|
||||
if let Err(e) = std::fs::create_dir_all(&schedules_dir) {
|
||||
tracing::warn!(
|
||||
agent = %summary.id,
|
||||
error = %e,
|
||||
"could not create schedules dir; skipping cron sensor"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let sensor = crate::core::nervous::cron::CronSensor::new(
|
||||
summary.id.clone(),
|
||||
schedules_dir,
|
||||
event_bus.clone(),
|
||||
active_sessions.clone(),
|
||||
);
|
||||
tokio::spawn(async move { sensor.run().await });
|
||||
tracing::info!(agent = %summary.id, "cron sensor spawned");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "agent listing failed; no cron sensors spawned");
|
||||
}
|
||||
}
|
||||
|
||||
let injector: Arc<dyn crate::core::nervous::handler::TurnInjector> =
|
||||
Arc::new(backend.clone());
|
||||
// Hand the same injector to the summon handler so an inbound
|
||||
// federation request can auto-wake the agent (gated on auto_wake).
|
||||
if let Some(sh) = &backend.server.summon_handler {
|
||||
sh.set_injector(injector.clone());
|
||||
}
|
||||
let mut handler =
|
||||
crate::core::nervous::handler::HeartbeatHandler::new(event_bus.subscribe(), injector.clone());
|
||||
tokio::spawn(async move { handler.run().await });
|
||||
tracing::info!("heartbeat handler spawned");
|
||||
|
||||
// Spawn the sensorium input handler — subscribes to
|
||||
// `sensorium:input` events from non-terminal surfaces (Matrix,
|
||||
// email, federation) and injects turns on their behalf.
|
||||
// Same pattern as HeartbeatHandler; identical wiring.
|
||||
let mut input_handler =
|
||||
crate::core::nervous::handler::SensoriumInputHandler::new(
|
||||
event_bus.subscribe(),
|
||||
injector,
|
||||
);
|
||||
tokio::spawn(async move { input_handler.run().await });
|
||||
tracing::info!("sensorium input handler spawned");
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
pub fn from_server(server: Arc<SouveraineServer>) -> Self {
|
||||
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||
let seed_id = Arc::new(
|
||||
SeedId::load_or_generate(&SeedId::default_dir(&base))
|
||||
.unwrap_or_else(|_| SeedId::generate()),
|
||||
);
|
||||
let event_bus = server.event_bus.clone();
|
||||
Self {
|
||||
event_bus,
|
||||
server,
|
||||
seed_id,
|
||||
active_sessions: Arc::new(AtomicU32::new(0)),
|
||||
sensorium: Arc::new(tokio::sync::Mutex::new(
|
||||
crate::core::sensorium::SensoriumCoordinator::new(),
|
||||
)),
|
||||
surface_conversations: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event_bus(&self) -> &EventBus {
|
||||
&self.event_bus
|
||||
}
|
||||
|
||||
pub fn seed_id(&self) -> &SeedId {
|
||||
&self.seed_id
|
||||
}
|
||||
|
||||
/// Underlying agent inventory — used by the TUI dashboard to pull a
|
||||
/// `MemoryRepo` for live git-stat readouts.
|
||||
pub fn server_agents(&self) -> Arc<crate::server::AgentInventory> {
|
||||
self.server.agents.clone()
|
||||
}
|
||||
|
||||
/// Underlying server. CLI subcommands (e.g. `souveraine reflect`)
|
||||
/// reach in here for the consciousness engine and session manager.
|
||||
pub fn server(&self) -> Arc<crate::server::SouveraineServer> {
|
||||
self.server.clone()
|
||||
}
|
||||
|
||||
/// Register a sensorium on the coordinator and spawn its run loop.
|
||||
///
|
||||
/// Each sensorium gets its own task, a shared EventBus subscription,
|
||||
/// and a child CancellationToken. `shutdown_sensoria` cancels all of
|
||||
/// them. Can be called at any time — the coordinator drains registered
|
||||
/// sensoria on `run_all` and accepts new ones afterward.
|
||||
pub async fn register_sensorium(
|
||||
&self,
|
||||
sensorium: Box<dyn crate::core::sensorium::Sensorium>,
|
||||
) {
|
||||
let mut coord = self.sensorium.lock().await;
|
||||
coord.register(sensorium);
|
||||
coord.run_all(self.event_bus.clone());
|
||||
}
|
||||
|
||||
/// Shut down all running sensorium tasks.
|
||||
pub async fn shutdown_sensoria(&self) {
|
||||
let coord = self.sensorium.lock().await;
|
||||
coord.shutdown();
|
||||
}
|
||||
|
||||
/// Build the greeting line describing the agent's current visual state
|
||||
/// (atmosphere and outfit). Returns `None` when no atmosphere is set in
|
||||
/// config (fresh init, no state to report).
|
||||
async fn build_visual_greeting(&self) -> Option<String> {
|
||||
let config = self.server.app_config.read().await;
|
||||
let atm = config.presence.atmosphere.as_deref()?;
|
||||
if atm.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let display = atm.replace('_', " ");
|
||||
let outfit = config.presence.outfit.as_deref().unwrap_or("default");
|
||||
Some(format!(
|
||||
"\n\nYour current atmosphere is {}, wearing the \"{}\" outfit.",
|
||||
display, outfit
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Backend for LocalBackend {
|
||||
async fn health(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn list_agents(&self) -> Result<Vec<AgentInfo>> {
|
||||
let agents = self.server.agents.list(None).await?;
|
||||
Ok(agents
|
||||
.into_iter()
|
||||
.map(|a| AgentInfo {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn new_conversation(&self, agent_id: &str) -> Result<String> {
|
||||
let _ = self.server.agents.get(agent_id).await?;
|
||||
let conv_id = self.server.sessions.create(agent_id);
|
||||
|
||||
if let Err(e) = self.server.agents.register_instance(agent_id, &self.server.instance_id).await {
|
||||
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
|
||||
}
|
||||
|
||||
let memory_root = self.server.agents.memory_root(agent_id);
|
||||
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
|
||||
let (bundled, user, agent_memfs, project) =
|
||||
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
|
||||
let skills = crate::core::skills::discover(
|
||||
bundled.as_deref(),
|
||||
user.as_deref(),
|
||||
agent_memfs.as_deref(),
|
||||
project.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let platform_prompt = self.server.app_config.read().await
|
||||
.agent.system_prompt.clone();
|
||||
let system_prompt = crate::core::prompt::build_system_prompt_full(
|
||||
&memory_root,
|
||||
Some(&subconscious_root),
|
||||
platform_prompt.as_deref(),
|
||||
Some(&skills),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Append visual state greeting.
|
||||
let greeting_extra = self.build_visual_greeting().await;
|
||||
let system_prompt = if let Some(extra) = greeting_extra {
|
||||
format!("{}{}", system_prompt, extra)
|
||||
} else {
|
||||
system_prompt
|
||||
};
|
||||
|
||||
self.server.sessions.add_message(
|
||||
&conv_id,
|
||||
ConversationMessage {
|
||||
role: crate::core::session::MessageRole::System,
|
||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||
text: system_prompt,
|
||||
}],
|
||||
usage: None,
|
||||
timestamp: Some(chrono::Utc::now()),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(conv_id)
|
||||
}
|
||||
|
||||
async fn fork_conversation(&self, _agent_id: &str, source_conversation_id: &str) -> Result<String> {
|
||||
let forked_id = self.server.sessions.fork(source_conversation_id)?;
|
||||
Ok(forked_id)
|
||||
}
|
||||
|
||||
async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>> {
|
||||
let store = match self.server.sessions.conversation_store_for(agent_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let conv_ids = self.server.sessions.list_for_agent(agent_id);
|
||||
return Ok(conv_ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let session = self.server.sessions.get(&id)?;
|
||||
Some(ConversationInfo {
|
||||
id: session.conversation_id.clone(),
|
||||
agent_id: session.agent_id.clone(),
|
||||
summary: None,
|
||||
message_count: session.messages.len() as u32,
|
||||
updated_at: session.updated_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
};
|
||||
|
||||
let records = store.list_active().await?;
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.map(|r| ConversationInfo {
|
||||
id: r.id,
|
||||
agent_id: r.agent_id,
|
||||
summary: r.summary,
|
||||
message_count: r.message_count,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_conversation(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
) -> Result<Vec<crate::core::session::ConversationMessage>> {
|
||||
if let Some(session) = self.server.sessions.get(conversation_id) {
|
||||
return Ok(session.messages.clone());
|
||||
}
|
||||
|
||||
// Not in memory — try loading from disk. We need the agent_id to find the store.
|
||||
// Search all known agents.
|
||||
let agents = self.server.agents.list(None).await?;
|
||||
for agent in agents {
|
||||
if let Some(store) = self.server.sessions.conversation_store_for(&agent.id) {
|
||||
if let Ok(Some(_record)) = store.load_metadata(conversation_id).await {
|
||||
let messages = store.load_messages(conversation_id).await?;
|
||||
self.server.sessions.create_with_messages(
|
||||
&agent.id,
|
||||
conversation_id.to_string(),
|
||||
messages.clone(),
|
||||
);
|
||||
return Ok(messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Conversation not found: {}", conversation_id)
|
||||
}
|
||||
|
||||
async fn ensure_conversation(&self, agent_id: &str) -> Result<String> {
|
||||
let _ = self.server.agents.get(agent_id).await?;
|
||||
let conv_id = self.server.sessions.create(agent_id);
|
||||
|
||||
// Build system prompt from the agent's memfs and inject as first message
|
||||
let memory_root = self.server.agents.memory_root(agent_id);
|
||||
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
|
||||
|
||||
// Discover skills from all 4 tiers
|
||||
let (bundled, user, agent_memfs, project) =
|
||||
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
|
||||
let skills = crate::core::skills::discover(
|
||||
bundled.as_deref(),
|
||||
user.as_deref(),
|
||||
agent_memfs.as_deref(),
|
||||
project.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let platform_prompt = self.server.app_config.read().await
|
||||
.agent.system_prompt.clone();
|
||||
let system_prompt = crate::core::prompt::build_system_prompt_full(
|
||||
&memory_root,
|
||||
Some(&subconscious_root),
|
||||
platform_prompt.as_deref(),
|
||||
Some(&skills),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Append visual state greeting.
|
||||
let greeting_extra = self.build_visual_greeting().await;
|
||||
let system_prompt = if let Some(extra) = greeting_extra {
|
||||
format!("{}{}", system_prompt, extra)
|
||||
} else {
|
||||
system_prompt
|
||||
};
|
||||
|
||||
self.server.sessions.add_message(
|
||||
&conv_id,
|
||||
ConversationMessage {
|
||||
role: crate::core::session::MessageRole::System,
|
||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||
text: system_prompt,
|
||||
}],
|
||||
usage: None,
|
||||
timestamp: Some(chrono::Utc::now()),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(conv_id)
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&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>>> {
|
||||
// Resolve the agent for this conversation, then drain her
|
||||
// subconscious's intrusive box. Anything subconscious queued after the
|
||||
// last turn rides in on Casey's next message as `[ surfacing: ... ]`
|
||||
// lines — the lettabot-v017 pattern, ported. This is the channel
|
||||
// by which a Critical observation can interrupt mid-conversation
|
||||
// without forcing a halt: she sees it before she reads Casey.
|
||||
let session_agent_id = self
|
||||
.server
|
||||
.sessions
|
||||
.get(conversation_id)
|
||||
.map(|s| s.agent_id.clone());
|
||||
|
||||
// Ambient sense rides in front of every turn — the date/time and who
|
||||
// is present — so she is never guessing what year it is.
|
||||
let ambient = crate::core::sensorium::ambient_line();
|
||||
|
||||
let user_text = if let Some(agent_id) = session_agent_id {
|
||||
let surfacings = consciousness::drain_intrusive_surfacings(&self.server, &agent_id).await;
|
||||
if surfacings.is_empty() {
|
||||
format!("{}\n{}", ambient, text)
|
||||
} else {
|
||||
let prelude = surfacings
|
||||
.iter()
|
||||
.map(|line| line.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("{}\n{}\n{}", ambient, prelude, text)
|
||||
}
|
||||
} else {
|
||||
format!("{}\n{}", ambient, text)
|
||||
};
|
||||
|
||||
self.server.sessions.add_message(
|
||||
conversation_id,
|
||||
ConversationMessage::user_text(&user_text),
|
||||
)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Result<BackendEvent>>(64);
|
||||
let server = self.server.clone();
|
||||
let conv_id = conversation_id.to_string();
|
||||
let event_bus = self.event_bus.clone();
|
||||
let active = self.active_sessions.clone();
|
||||
|
||||
active.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = turn::run_turn(server, conv_id, &tx, event_bus, cancel, interject).await {
|
||||
let _ = tx.send(Err(e)).await;
|
||||
}
|
||||
let _ = tx.send(Ok(BackendEvent::Done)).await;
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
Ok(ReceiverStream::new(rx).boxed())
|
||||
}
|
||||
|
||||
async fn update_agent_model(&self, agent_id: &str, model: &str) -> Result<()> {
|
||||
// Load current llm_config so we only change the model field —
|
||||
// context_window, temperature, tool rounds stay as they were.
|
||||
let current = self.server.agents.get(agent_id).await?;
|
||||
let update = crate::api::models::UpdateAgentRequest {
|
||||
name: None,
|
||||
description: None,
|
||||
llm_config: Some(crate::api::models::LlmConfig {
|
||||
model: model.to_string(),
|
||||
context_window: current.llm_config.context_window,
|
||||
temperature: current.llm_config.temperature,
|
||||
max_tool_rounds: current.llm_config.max_tool_rounds,
|
||||
inter_round_delay_ms: current.llm_config.inter_round_delay_ms,
|
||||
}),
|
||||
memory_blocks: None,
|
||||
tools: None,
|
||||
};
|
||||
self.server.agents.update(agent_id, update).await?;
|
||||
tracing::info!(agent = %agent_id, model = %model, "agent llm_config model updated via settings");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn take_pending_surfacings(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Vec<crate::core::nervous::pending::PendingSurfacing> {
|
||||
let dir = self.server.agents.agent_data_dir(agent_id);
|
||||
crate::core::nervous::pending::take(&dir).await
|
||||
}
|
||||
}
|
||||
223
src/backend/local/subagent.rs
Normal file
223
src/backend/local/subagent.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
|
||||
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
|
||||
use crate::server::SouveraineServer;
|
||||
|
||||
// ── LocalSubagentRunner ──────────────────────────────────────────
|
||||
|
||||
/// Implements [`SubagentRunner`] by running a full turn against the
|
||||
/// LocalBackend's server infrastructure — loading the agent from the
|
||||
/// inventory, creating a session, and running the tool-calling loop.
|
||||
///
|
||||
/// After the tool loop completes, the subagent runs its own N+1
|
||||
/// (ConsciousnessEngine::on_response) so its observations flow back into
|
||||
/// the parent agent's inbox — the dual-state is preserved even in a fork.
|
||||
pub struct LocalSubagentRunner {
|
||||
server: Arc<SouveraineServer>,
|
||||
}
|
||||
|
||||
impl LocalSubagentRunner {
|
||||
pub fn new(server: Arc<SouveraineServer>) -> Self {
|
||||
Self { server }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SubagentRunner for LocalSubagentRunner {
|
||||
async fn run_subagent(
|
||||
&self,
|
||||
params: SubagentParams,
|
||||
depth: u32,
|
||||
) -> Result<String, crate::core::tools::defs::ToolError> {
|
||||
// Resolve model: use override if provided, otherwise fall back to parent
|
||||
let agent = self
|
||||
.server
|
||||
.agents
|
||||
.get(¶ms.parent_agent_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::core::tools::defs::ToolError::invalid_input(
|
||||
"Parent agent not found in inventory.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let model = params.model.unwrap_or(agent.llm_config.model.clone());
|
||||
let temperature = agent.llm_config.temperature;
|
||||
|
||||
// Resolve limits from config or params
|
||||
let app_config = self.server.app_config.read().await;
|
||||
let max_tool_rounds = params
|
||||
.max_tool_rounds
|
||||
.unwrap_or(app_config.subagent.max_tool_rounds);
|
||||
let _max_depth = params.max_depth.unwrap_or(app_config.subagent.max_depth);
|
||||
let warning_1_threshold = app_config.subagent.warning_1_threshold;
|
||||
let warning_2_threshold = app_config.subagent.warning_2_threshold;
|
||||
|
||||
// Create a temporary conversation for the subagent
|
||||
let _conv_id = self.server.sessions.create(¶ms.parent_agent_id);
|
||||
|
||||
// Build system prompt with delegation context and dual-state awareness
|
||||
let system_prompt = format!(
|
||||
"You are a threaded fork of agent {}. You share their tools, their \
|
||||
memory boundaries, their dual-state architecture. After you respond, \
|
||||
your N+1 pass will surface observations back to them.\n\n\
|
||||
Your final message will be returned to the caller.\n\n{}",
|
||||
params.parent_agent_id, params.prompt
|
||||
);
|
||||
|
||||
// Build tool definitions
|
||||
let core_tools = crate::core::tools::tool_definitions().await;
|
||||
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
|
||||
.iter()
|
||||
.map(|t| crate::bridge::bifrost::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
function: crate::bridge::bifrost::ToolFunction {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build the context for subagent tool execution, inheriting memory_root
|
||||
let tool_ctx = ToolContext::for_agent(
|
||||
format!("{}-subagent-{}", params.parent_agent_id, depth),
|
||||
std::env::current_dir().ok(),
|
||||
params.memory_root.clone(),
|
||||
std::env::vars().collect(),
|
||||
Some(Arc::new(LocalSubagentRunner::new(self.server.clone())) as Arc<dyn SubagentRunner>),
|
||||
);
|
||||
|
||||
// Initial messages: system prompt + user prompt
|
||||
let mut messages = vec![BifrostMessage::text("system", system_prompt)];
|
||||
|
||||
let mut final_content = String::new();
|
||||
let mut tool_round = 0u32;
|
||||
let mut warned_1 = false;
|
||||
let mut warned_2 = false;
|
||||
|
||||
loop {
|
||||
// Signaled limits, not hard caps
|
||||
if tool_round >= max_tool_rounds {
|
||||
break;
|
||||
}
|
||||
|
||||
// Warning 1: approaching the threshold, model config may slide
|
||||
let progress = tool_round as f32 / max_tool_rounds as f32;
|
||||
if !warned_1 && progress >= warning_1_threshold {
|
||||
warned_1 = true;
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
format!(
|
||||
"[subagent awareness] I've used {} of {} tool rounds. \
|
||||
My attention is narrowing — I may want to consolidate \
|
||||
my findings and return soon.",
|
||||
tool_round, max_tool_rounds
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Warning 2: nearing the limit, this is the last stretch
|
||||
if !warned_2 && progress >= warning_2_threshold {
|
||||
warned_2 = true;
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
format!(
|
||||
"[subagent awareness] I'm at {} of {} tool rounds. \
|
||||
This is my last chance to produce a final answer \
|
||||
before my fork returns what I have.",
|
||||
tool_round, max_tool_rounds
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
stream: Some(false),
|
||||
max_tokens: None,
|
||||
temperature,
|
||||
tools: Some(bifrost_tools.clone()),
|
||||
};
|
||||
|
||||
let response = self.server.bifrost.chat_completion(req).await.map_err(|e| {
|
||||
crate::core::tools::defs::ToolError::invalid_input(&format!(
|
||||
"Subagent LLM call failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if response.tool_calls.is_empty() {
|
||||
final_content = response.content.clone();
|
||||
break;
|
||||
}
|
||||
|
||||
tool_round += 1;
|
||||
|
||||
// Add assistant tool-call message (OpenAI tool-use schema, not stringified blob)
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
messages.push(BifrostMessage::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
// Execute tools with context, bind each result by tool_call_id
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
let result =
|
||||
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
|
||||
.await;
|
||||
|
||||
let output = if result.is_error {
|
||||
format!("Error: {}", result.output)
|
||||
} else {
|
||||
result.output
|
||||
};
|
||||
|
||||
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
// Brief pause between tool rounds to let rate limits cool
|
||||
let sub_delay = Duration::from_millis(app_config.subagent.inter_round_delay_ms);
|
||||
if sub_delay > Duration::ZERO {
|
||||
tokio::time::sleep(sub_delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
// If we hit max rounds without a final response, note it
|
||||
if final_content.is_empty() {
|
||||
final_content =
|
||||
"(the fork reached its attention limit and is returning without a final response)"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// ── Dual-state N+1 pass ──────────────────────────────────────
|
||||
// After the subagent responds, run ConsciousnessEngine::on_response
|
||||
// so the subagent's observations flow back into the parent's inbox.
|
||||
//
|
||||
// We create a lightweight session snapshot with the subagent's
|
||||
// final response so the heuristic detection (commitments, hedges)
|
||||
// can surface anything notable.
|
||||
if let Err(e) = self
|
||||
.server
|
||||
.consciousness
|
||||
.on_response_for_agent(¶ms.parent_agent_id, &final_content)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("subagent N+1 pass failed: {}", e);
|
||||
}
|
||||
|
||||
Ok(final_content)
|
||||
}
|
||||
}
|
||||
715
src/backend/local/turn.rs
Normal file
715
src/backend/local/turn.rs
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
|
||||
use crate::bridge::model_router::TokenCounter;
|
||||
use crate::core::compact::CompactionEngine;
|
||||
use crate::core::nervous::EventBus;
|
||||
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
|
||||
use crate::core::tools::defs::{SubagentRunner, ToolContext};
|
||||
use crate::server::{ConsciousnessEvent, SouveraineServer};
|
||||
|
||||
use crate::backend::BackendEvent;
|
||||
|
||||
use super::{LocalSubagentRunner, bifrost_pressure, bump_on_strain, pressure_to_max_tokens};
|
||||
use super::energy::write_energy_balance;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub(super) 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.
|
||||
let (agent_id, initial_messages) = {
|
||||
let session = server
|
||||
.sessions
|
||||
.get(&conversation_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
|
||||
let messages: Vec<BifrostMessage> = session
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let content = m
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let role = match m.role {
|
||||
MessageRole::System => "system",
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
BifrostMessage::text(role, content)
|
||||
})
|
||||
.collect();
|
||||
(session.agent_id.clone(), messages)
|
||||
};
|
||||
|
||||
let agent = server.agents.get(&agent_id).await?;
|
||||
let max_rounds = agent.llm_config.max_tool_rounds;
|
||||
let model = agent.llm_config.model.clone();
|
||||
let temperature = agent.llm_config.temperature;
|
||||
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 + presence pulse settings.
|
||||
let (output_limit, pulse_enabled, pulse_interval) = {
|
||||
let cfg = server.app_config.read().await;
|
||||
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();
|
||||
let env: Vec<(String, String)> = std::env::vars().collect();
|
||||
let subagent_runner = Some(Arc::new(LocalSubagentRunner::new(server.clone())) as Arc<dyn SubagentRunner>);
|
||||
|
||||
let tool_ctx = ToolContext::for_agent(
|
||||
agent_id.clone(),
|
||||
cwd,
|
||||
memory_root,
|
||||
env,
|
||||
subagent_runner,
|
||||
);
|
||||
let tool_ctx = ToolContext {
|
||||
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
|
||||
event_bus: Some(event_bus.clone()),
|
||||
..tool_ctx
|
||||
};
|
||||
|
||||
// Build bifrost-format tool definitions from the core tool set
|
||||
let core_tools = crate::core::tools::tool_definitions().await;
|
||||
let bifrost_tools: Vec<crate::bridge::bifrost::ToolDefinition> = core_tools
|
||||
.iter()
|
||||
.map(|t| crate::bridge::bifrost::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
function: crate::bridge::bifrost::ToolFunction {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Tool-calling loop ─────────────────────────────────────
|
||||
let mut messages = initial_messages;
|
||||
let mut tool_round = 0u32;
|
||||
let mut final_content: String = String::new();
|
||||
let mut interrupted = false;
|
||||
let counter = TokenCounter::new();
|
||||
let mut last_keepalive = Instant::now();
|
||||
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
// Announce this turn's lifecycle onto the nervous system so any
|
||||
// sensorium (Matrix, mobile) can drive itself off the event stream.
|
||||
let dispatcher = crate::core::nervous::turn_dispatcher::TurnEventDispatcher::new(
|
||||
event_bus.clone(),
|
||||
conversation_id.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if last_keepalive.elapsed() >= KEEPALIVE_INTERVAL {
|
||||
let _ = tx.send(Ok(BackendEvent::Keepalive)).await;
|
||||
last_keepalive = Instant::now();
|
||||
}
|
||||
|
||||
// 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);
|
||||
tracing::info!(
|
||||
turn_round = tool_round,
|
||||
agent = %agent_id,
|
||||
msg_count = messages.len(),
|
||||
model = %model,
|
||||
pressure_pct = %((pressure * 100.0) as u8),
|
||||
"LLM call starting"
|
||||
);
|
||||
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
|
||||
let _ = tx.send(Ok(BackendEvent::ContextPressure(pressure))).await;
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
stream: Some(false),
|
||||
max_tokens,
|
||||
temperature,
|
||||
tools: if max_rounds > 0 {
|
||||
Some(bifrost_tools.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
// 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?,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
elapsed = ?turn_start.elapsed(),
|
||||
tool_round = tool_round,
|
||||
tool_calls = response.tool_calls.len(),
|
||||
content_len = response.content.len(),
|
||||
"LLM call returned"
|
||||
);
|
||||
|
||||
for event in &strain {
|
||||
if let crate::bridge::bifrost::InferenceStrain::Transient { attempt, status, model, .. } = event {
|
||||
let _ = tx.send(Ok(BackendEvent::InferenceStrain {
|
||||
attempt: *attempt,
|
||||
status: *status,
|
||||
model: model.clone(),
|
||||
})).await;
|
||||
bump_on_strain(&server.rate_delay, *status);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit reasoning trace if present
|
||||
if let Some(reasoning) = &response.reasoning {
|
||||
dispatcher.emit_reasoning(reasoning);
|
||||
let _ = tx.send(Ok(BackendEvent::Reasoning(reasoning.clone()))).await;
|
||||
}
|
||||
|
||||
// ── Truncation: the agent hit her output ceiling ─────────
|
||||
// Some models don't signal "length" in finish_reason and just stop
|
||||
// evolving after the first pass (e.g. kimi-k2.6). In that case the
|
||||
// model already finished and the turn is done.
|
||||
// But when finish_reason IS "length", the agent was physically cut
|
||||
// off mid-thought. Inject a felt signal so she knows why her words
|
||||
// ended and can choose differently — tighten, or use a tool, or
|
||||
// admit the ceiling rather than mistake it for silence.
|
||||
let was_truncated = response.finish_reason.as_deref() == Some("length");
|
||||
|
||||
if was_truncated && response.tool_calls.is_empty() {
|
||||
// Stream the truncated content before we tell her it was clipped,
|
||||
// so she recognises her own words in the signal.
|
||||
let chars: Vec<char> = response.content.chars().collect();
|
||||
for chunk in chars.chunks(10) {
|
||||
let s: String = chunk.iter().collect();
|
||||
let _ = tx.send(Ok(BackendEvent::Token(s.clone()))).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
messages.push(BifrostMessage::text("assistant", response.content.clone()));
|
||||
messages.push(BifrostMessage::text(
|
||||
"system",
|
||||
"My output just hit its ceiling — I was cut off mid-flow, not \
|
||||
finished. If I was in the middle of something, I can continue \
|
||||
from here more tightly. If I had more to say, the room is still \
|
||||
mine."
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if response.tool_calls.is_empty() {
|
||||
// Text response — this is the final output
|
||||
final_content = response.content.clone();
|
||||
|
||||
// Drain any interjections that arrived during this LLM call.
|
||||
// If there are any, commit them as user messages and continue
|
||||
// the loop so the agent responds in the same turn.
|
||||
let interjected: Vec<String> = interject
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !interjected.is_empty() {
|
||||
for text in &interjected {
|
||||
let stamp = chrono::Local::now().format("%H:%M");
|
||||
let note = format!("[interjected at {} — {}]", stamp, text.trim());
|
||||
messages.push(BifrostMessage::text("user", note));
|
||||
}
|
||||
// Continue the loop — agent sees the interjection as a
|
||||
// user message and will respond in the next LLM round.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stream the final content in chunks, watching the cancel token.
|
||||
// If Esc fires mid-stream, the agent's partial text is preserved
|
||||
// (the chunks already sent are in the user's history) and an
|
||||
// *[raised hand]* 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();
|
||||
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(()); }
|
||||
dispatcher.emit_segment(&s);
|
||||
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)) => {}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
tool_round += 1;
|
||||
// Tool execution events are emitted per-call below as
|
||||
// BackendEvent::ToolCall { … } so the TUI can render proper cards
|
||||
// instead of a literal "🔧 Round N — executing: Bash, Memory" text line.
|
||||
|
||||
// Add the assistant's tool-call message in proper OpenAI tool-use schema
|
||||
// (not a stringified JSON blob in content — that's what broke turn 2).
|
||||
let calls: Vec<crate::bridge::bifrost::MessageToolCall> = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| crate::bridge::bifrost::MessageToolCall::function(
|
||||
tc.id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.to_string(),
|
||||
))
|
||||
.collect();
|
||||
messages.push(BifrostMessage::assistant_tool_calls(
|
||||
response.content.clone(),
|
||||
calls,
|
||||
));
|
||||
|
||||
// Stream any text the model produced alongside tool calls as italic
|
||||
// interstitial narration. Configurable via tui.show_interstitial.
|
||||
// Trim first: a model that emits only whitespace ("\n") alongside its
|
||||
// tool calls must not produce an empty `⟡` gap line.
|
||||
let narration = response.content.trim();
|
||||
if !narration.is_empty() {
|
||||
let cfg = server.app_config.read().await;
|
||||
if cfg.tui.show_interstitial {
|
||||
// Classify by length: a brief aside is a cenno, a full
|
||||
// passage is her-voice. tui.cenno_word_threshold is the line.
|
||||
let register = if narration.split_whitespace().count()
|
||||
>= cfg.tui.cenno_word_threshold
|
||||
{
|
||||
crate::backend::Register::HerVoice
|
||||
} else {
|
||||
crate::backend::Register::Cenno
|
||||
};
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Interstitial {
|
||||
text: narration.to_string(),
|
||||
register,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute each tool and stream results back — now with per-agent context
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
dispatcher.emit_tool_start(&tc.name, &tc.id);
|
||||
let result =
|
||||
crate::core::tools::execute_tool_with_context(&tc.name, &input_str, &tool_ctx)
|
||||
.await;
|
||||
dispatcher.emit_tool_end(&tc.name, &tc.id, result.is_error);
|
||||
|
||||
let output = if result.is_error {
|
||||
format!("Error: {}", result.output)
|
||||
} else {
|
||||
result.output
|
||||
};
|
||||
|
||||
// Emit structured ToolCall + ToolResult events for the TUI to render
|
||||
// as cards (chat.rs subscribes). The old Token-text path is kept off.
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
arguments: input_str.clone(),
|
||||
round: tool_round,
|
||||
}))
|
||||
.await;
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::ToolResult {
|
||||
id: tc.id.clone(),
|
||||
name: tc.name.clone(),
|
||||
output: output.clone(),
|
||||
is_error: result.is_error,
|
||||
}))
|
||||
.await;
|
||||
|
||||
// If the agent called the outfit tool, emit an Outfit event so
|
||||
// the TUI can switch expression directories.
|
||||
if tc.name == "outfit" {
|
||||
let outfit_name = tc.arguments
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Outfit(outfit_name)))
|
||||
.await;
|
||||
}
|
||||
|
||||
// If the agent called the atmosphere tool, emit an Atmosphere
|
||||
// event so the TUI chrome shifts to match her mood.
|
||||
if tc.name == "atmosphere" {
|
||||
let atm_name = tc.arguments
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Atmosphere(atm_name)))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Bind tool result to its call by id (OpenAI tool-use schema).
|
||||
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));
|
||||
}
|
||||
|
||||
// Brief pause between tool rounds to let rate limits cool.
|
||||
// Use the higher of the configured delay and the adaptive delay.
|
||||
let adaptive = Duration::from_millis(server.rate_delay.load(Ordering::Relaxed));
|
||||
let effective = if inter_round_delay > adaptive {
|
||||
inter_round_delay
|
||||
} else {
|
||||
adaptive
|
||||
};
|
||||
if effective > Duration::ZERO {
|
||||
tokio::time::sleep(effective).await;
|
||||
}
|
||||
|
||||
let _ = tx.send(Ok(BackendEvent::Keepalive)).await;
|
||||
last_keepalive = Instant::now();
|
||||
|
||||
// Continue loop — model will see tool results and respond
|
||||
}
|
||||
|
||||
// The primary pass is settled — either it ran to completion, or the
|
||||
// human raised a hand. Announce which onto the nervous system.
|
||||
if interrupted {
|
||||
dispatcher.emit_interrupted("the human raised a hand");
|
||||
} else {
|
||||
dispatcher.emit_primary_complete();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
"*[raised hand]*".to_string()
|
||||
} else {
|
||||
"\n\n*[raised hand]*".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(&committed_content),
|
||||
)?;
|
||||
|
||||
// On interrupt, skip subconscious'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(());
|
||||
}
|
||||
|
||||
// The turn's user-facing output is committed — a surface can finalise.
|
||||
dispatcher.emit_turn_finish();
|
||||
|
||||
// Energy balance: scan the agent's task list and compute the generative /
|
||||
// consumptive ratio. Written to system/dynamic/energy-balance.md so the
|
||||
// agent can read it in context and subconscious can reference it during N+1.
|
||||
// Silent on failure — the file is advisory, not load-bearing.
|
||||
if let Err(e) = write_energy_balance(&server, &agent_id, &event_bus).await {
|
||||
tracing::debug!(agent = %agent_id, error = %e, "energy-balance write skipped");
|
||||
}
|
||||
|
||||
// The primary's turn is done — her words are committed. Release the user
|
||||
// here, before the N+1 pass: the stream stays open so the subconscious's
|
||||
// surfacings still arrive, but the user is free to speak again. The
|
||||
// substrate signals; it does not hold her hostage to Aster's pass.
|
||||
let _ = tx.send(Ok(BackendEvent::PrimaryComplete)).await;
|
||||
|
||||
// N+1 gate — the subconscious pass is sovereign-configurable, and the
|
||||
// toggle must actually be wired (it was previously read nowhere). The
|
||||
// global switch (`souveraine.toml [subconscious] n1_enabled`) and the
|
||||
// per-agent flag (`agent.json _souveraine.n1_enabled`) must both be on.
|
||||
// Either off → the primary's turn simply ends here; pressure is still
|
||||
// recalculated so the gauge stays honest.
|
||||
let n1_enabled = {
|
||||
let global = server.app_config.read().await.subconscious.n1_enabled;
|
||||
let per_agent = server
|
||||
.agents
|
||||
.get(&agent_id)
|
||||
.await
|
||||
.map(|a| a.souveraine.n1_enabled)
|
||||
.unwrap_or(true);
|
||||
global && per_agent
|
||||
};
|
||||
if !n1_enabled {
|
||||
tracing::info!(agent = %agent_id, "subconscious N+1 pass disabled — skipping");
|
||||
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 turns — unconditional,
|
||||
// so the upstream always gets a gap before the N+1 pass starts.
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
tracing::info!(agent = %agent_id, "subconscious N+1 pass starting");
|
||||
|
||||
// Signal the start of the subconscious pass so the TUI can flip into
|
||||
// Posture::Thinking while the loop runs. Fires on both the mpsc channel
|
||||
// (for active-turn TUI consumers) and the EventBus (for firehose
|
||||
// subscribers — background turns, federated peers, Summon listeners).
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(true))).await;
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "subconscious_pass_start".into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency: 0.2,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
let pass_start = Instant::now();
|
||||
// Snapshot the session before the N+1 await. `PrimaryComplete` has already
|
||||
// released the user — she may be mid-way into a new turn. Holding a live
|
||||
// DashMap ref across the (potentially long) subconscious pass would block
|
||||
// that turn's writes on the shard lock. Clone what the pass needs instead.
|
||||
let (n1_turn_count, n1_messages) = {
|
||||
let session = server
|
||||
.sessions
|
||||
.get(&conversation_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", conversation_id))?;
|
||||
(session.turn_count, session.messages.clone())
|
||||
};
|
||||
let pass_result = server
|
||||
.consciousness
|
||||
.on_response(&agent_id, n1_turn_count, &n1_messages, &final_content)
|
||||
.await;
|
||||
|
||||
let pass_elapsed = pass_start.elapsed();
|
||||
tracing::info!(
|
||||
agent = %agent_id,
|
||||
elapsed = ?pass_elapsed,
|
||||
"subconscious N+1 pass complete"
|
||||
);
|
||||
|
||||
// Always release the Thinking posture, even on failure — otherwise the
|
||||
// face stays stuck inward when the pass errors out.
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(false))).await;
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "subconscious_pass_end".into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
let events = pass_result?;
|
||||
|
||||
// Inject surfacing events back into the session as system messages
|
||||
for event in &events {
|
||||
if let ConsciousnessEvent::Surfacing {
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
} = event
|
||||
{
|
||||
let msg = crate::core::session::ConversationMessage {
|
||||
role: crate::core::session::MessageRole::System,
|
||||
blocks: vec![crate::core::session::ContentBlock::Text {
|
||||
text: format!(
|
||||
"[surfacing: {}] {} — {}",
|
||||
source, content, priority
|
||||
),
|
||||
}],
|
||||
usage: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let _ = server.sessions.add_message(&conversation_id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fire consciousness events on the EventBus ──
|
||||
// Every ConsciousnessEvent — surfacing, reflection, archivist,
|
||||
// compaction warning — is broadcast as a SensorEvent so the
|
||||
// firehose, persistent EventLog, federated peers, and any TUI
|
||||
// subscriber see it regardless of which conversation produced it.
|
||||
// seed_id is None for local events; federation routing sets it.
|
||||
// This is the load-bearing fix for background/heartbeat turns:
|
||||
// the mpsc channel drains silently when no TUI is reading, but
|
||||
// the EventBus preserves the event for any subscriber.
|
||||
for event in &events {
|
||||
let (event_type, payload, urgency) = match event {
|
||||
ConsciousnessEvent::Surfacing { source, content, priority } => {
|
||||
let urg = match priority.as_str() {
|
||||
"critical" => 0.9,
|
||||
"high" => 0.7,
|
||||
_ => 0.3,
|
||||
};
|
||||
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
|
||||
}
|
||||
ConsciousnessEvent::Reflection { content } => {
|
||||
("reflection", serde_json::json!({ "content": content }), 0.5)
|
||||
}
|
||||
ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
||||
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
|
||||
}
|
||||
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
|
||||
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
|
||||
}
|
||||
};
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: event_type.into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency,
|
||||
payload: Some(payload),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
|
||||
for event in events {
|
||||
let be = match &event {
|
||||
ConsciousnessEvent::Surfacing {
|
||||
source,
|
||||
content,
|
||||
priority,
|
||||
} => BackendEvent::Surfacing {
|
||||
source: source.to_string(),
|
||||
content: content.to_string(),
|
||||
priority: priority.to_string(),
|
||||
},
|
||||
ConsciousnessEvent::Reflection { content } => {
|
||||
BackendEvent::Reflection(content.clone())
|
||||
}
|
||||
ConsciousnessEvent::Archivist {
|
||||
synthesis,
|
||||
pressure,
|
||||
} => BackendEvent::Archivist {
|
||||
synthesis: synthesis.clone(),
|
||||
pressure: *pressure,
|
||||
},
|
||||
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
|
||||
BackendEvent::CompactionWarning {
|
||||
pressure: *pressure,
|
||||
tier: *tier,
|
||||
}
|
||||
}
|
||||
};
|
||||
if tx.send(Ok(be)).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3540
src/ui/app.rs
3540
src/ui/app.rs
File diff suppressed because it is too large
Load diff
106
src/ui/app/agents.rs
Normal file
106
src/ui/app/agents.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use super::{App, Screen};
|
||||
use crate::ui::presence::Presence;
|
||||
use crate::ui::component::TuiEvent;
|
||||
|
||||
impl App {
|
||||
pub(super) fn add_available_agent(&mut self, agent_name: String) {
|
||||
if !self.available_agents.contains(&agent_name) {
|
||||
self.available_agents.push(agent_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detach the UI from the current agent and attach to another.
|
||||
/// The agent itself keeps running — this only tears down the view
|
||||
/// layer so each subsystem reconnects fresh on next entry.
|
||||
pub(super) fn select_agent(&mut self, agent_name: &str) {
|
||||
let changed = self.agent_pref != agent_name;
|
||||
self.agent_pref = agent_name.to_string();
|
||||
self.agent_status.name = agent_name.to_string();
|
||||
|
||||
if changed {
|
||||
// Cancel any in-flight turn before dropping the chat surface —
|
||||
// otherwise the turn keeps running orphaned and resurfaces as
|
||||
// "she's doing the same message again" on a later /resume.
|
||||
if let Some(chat) = &self.chat {
|
||||
chat.cancel_active_turn();
|
||||
}
|
||||
self.chat = None;
|
||||
self.chat_error = None;
|
||||
self.settings = None;
|
||||
self.schedules = None;
|
||||
self.presence = Presence::new(agent_name);
|
||||
self.image_protocol = None;
|
||||
self.rgp_portrait = None;
|
||||
|
||||
self.voice_capture = None;
|
||||
self.voice_tts_rx = None;
|
||||
self.voice_stt_rx = None;
|
||||
self.voice_last_synthesized = None;
|
||||
self.voice_waveform.clear();
|
||||
self.voice_last_tts_text = None;
|
||||
self.voice_last_tts_bytes = None;
|
||||
self.voice_last_transcript = None;
|
||||
self.tts_last_text = None;
|
||||
}
|
||||
|
||||
self.dispatch(TuiEvent::AgentSelected(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). Subsequent calls skip the backend round-trip
|
||||
/// (which would create extra server instances) and only refresh card images.
|
||||
pub(super) async fn ensure_agent_cards_loaded(&mut self) {
|
||||
if self.agent_cards.is_empty() {
|
||||
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.refresh_card_images();
|
||||
}
|
||||
|
||||
/// Open the agent manager — shows per-agent cards with seed glyph,
|
||||
/// instance count, uptime, memory count.
|
||||
pub(super) 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()];
|
||||
}
|
||||
self.current_screen = Screen::AgentsManager;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
|
||||
}
|
||||
|
||||
/// Cycle through available agents for selection (WIP)
|
||||
pub(super) fn cycle_agent_selection(&mut self) {
|
||||
if self.available_agents.is_empty() {
|
||||
// No agents available yet - create a default alias
|
||||
// This is WIP - will be expanded with full agent creation flow
|
||||
let default_agents = vec!["Ani".to_string(), "JeanLuc".to_string(), "Eione".to_string()];
|
||||
for agent in default_agents {
|
||||
self.add_available_agent(agent);
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the agent name to avoid borrow checker issues
|
||||
let agent_to_select = if let Some(current_idx) = self.available_agents.iter().position(|a| a == &self.agent_pref) {
|
||||
let next_idx = (current_idx + 1) % self.available_agents.len();
|
||||
self.available_agents[next_idx].clone()
|
||||
} else if !self.available_agents.is_empty() {
|
||||
self.available_agents[0].clone()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.select_agent(&agent_to_select);
|
||||
}
|
||||
|
||||
pub(super) 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())
|
||||
}
|
||||
|
||||
}
|
||||
385
src/ui/app/dashboard.rs
Normal file
385
src/ui/app/dashboard.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{App, Screen, short_now, recent_commits};
|
||||
use crate::ui::chat::ChatState;
|
||||
use crate::ui::component::TuiEvent;
|
||||
use crate::ui::setup::SetupState;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn select_menu_item(&mut self) {
|
||||
// Welcome now hosts the dashboard inline, so the menu enters
|
||||
// *destinations* only — Chat, Schedule, and three placeholders.
|
||||
// Items marked (coming soon) are no-ops until their screens are real.
|
||||
match self.menu_selected {
|
||||
0 => {
|
||||
// Chat — lazily connect to a backend on first entry.
|
||||
if self.chat.is_none() {
|
||||
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
|
||||
Ok(mut c) => {
|
||||
// Fresh chat surface (first entry, or after an
|
||||
// agent switch): offer resume-or-new.
|
||||
c.offer_resume_or_new();
|
||||
self.chat = Some(c);
|
||||
self.chat_error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat_error = Some(e.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current_screen = Screen::Chat;
|
||||
}
|
||||
1 => {
|
||||
if self.schedules.is_none() {
|
||||
self.schedules = Some(self.build_schedules_view().await);
|
||||
self.sync_palette();
|
||||
} else if let Some(view) = self.schedules.as_mut() {
|
||||
view.reload();
|
||||
}
|
||||
self.current_screen = Screen::Cron;
|
||||
}
|
||||
// 2 Settings, 3 Therapy, 4 Agent Time.
|
||||
2 => {
|
||||
// Lazily init settings from the current config snapshot.
|
||||
if self.settings.is_none() {
|
||||
let cfg = self.config.read().await.clone();
|
||||
let mut view = crate::ui::settings::SettingsView::new(&cfg);
|
||||
let agent_id = self.agent_id_by_name(&self.agent_pref);
|
||||
let expr_path = agent_id.as_ref()
|
||||
.and_then(|id| Self::agent_assets_dir(id))
|
||||
.map(|a| a.join("expressions"));
|
||||
view.set_expressions_path(expr_path);
|
||||
self.settings = Some(view);
|
||||
self.sync_palette();
|
||||
} else {
|
||||
let cfg = self.config.read().await.clone();
|
||||
let agent_id = self.agent_id_by_name(&self.agent_pref);
|
||||
let expr_path = agent_id.as_ref()
|
||||
.and_then(|id| Self::agent_assets_dir(id))
|
||||
.map(|a| a.join("expressions"));
|
||||
if let Some(view) = self.settings.as_mut() {
|
||||
view.refresh(&cfg);
|
||||
view.set_expressions_path(expr_path);
|
||||
}
|
||||
}
|
||||
// Per-agent settings follow the active agent — load its model
|
||||
// so the Agent category edits this agent and tracks switches.
|
||||
let active = self.active_agent_settings();
|
||||
if let Some(view) = self.settings.as_mut() {
|
||||
view.set_active_agent(active);
|
||||
}
|
||||
self.current_screen = Screen::Settings;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the agent's schedules directory, preferring its UUID under
|
||||
/// `~/.souveraine/agents/{id}/schedules/`. Falls back to the agent
|
||||
/// name if the inventory isn't reachable — the CLI uses the same
|
||||
/// path pattern, so a hand-managed dir keyed by name still works.
|
||||
pub(super) async fn build_schedules_view(&self) -> crate::ui::schedules::SchedulesView {
|
||||
use crate::backend::Backend;
|
||||
|
||||
let base = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".souveraine")
|
||||
.join("agents");
|
||||
|
||||
let cfg = self.config.read().await;
|
||||
let url = cfg.server.effective_url();
|
||||
drop(cfg);
|
||||
|
||||
let mut resolved_id: Option<String> = None;
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
if remote.health().await {
|
||||
if let Ok(list) = remote.list_agents().await {
|
||||
resolved_id = list
|
||||
.iter()
|
||||
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
||||
.map(|a| a.id.clone());
|
||||
}
|
||||
}
|
||||
if resolved_id.is_none() {
|
||||
let cfg = self.config.read().await.clone();
|
||||
if let Ok(local) = crate::backend::LocalBackend::new(cfg).await {
|
||||
if let Ok(list) = local.list_agents().await {
|
||||
resolved_id = list
|
||||
.iter()
|
||||
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
||||
.map(|a| a.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dir_key = resolved_id.unwrap_or_else(|| self.agent_pref.clone());
|
||||
let dir = base.join(&dir_key).join("schedules");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
crate::ui::schedules::SchedulesView::new(self.agent_pref.clone(), dir)
|
||||
}
|
||||
|
||||
/// Detect what state the installation is in using the BootstrapPlan.
|
||||
pub(super) async fn transition_from_splash(&mut self) {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let probe = crate::core::bootstrap::gather_probe(&home);
|
||||
let plan = crate::core::bootstrap::BootstrapPlan::plan(&probe);
|
||||
|
||||
for phase in &plan.phases {
|
||||
match phase {
|
||||
crate::core::bootstrap::BootstrapPhase::SetupWizard(flow) => {
|
||||
// No default model — let user type or fetch from Bifrost.
|
||||
self.setup_state = Some(SetupState::new(*flow, ""));
|
||||
self.current_screen = Screen::Setup;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Setup));
|
||||
return;
|
||||
}
|
||||
crate::core::bootstrap::BootstrapPhase::ShowHint(hint) => {
|
||||
// Store the hint for display on the Welcome screen.
|
||||
// The dashboard reads it from a field we'll add below.
|
||||
self.welcome_hint = Some(hint.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: go to Welcome dashboard
|
||||
self.refresh_dashboard().await;
|
||||
self.current_screen = Screen::Welcome;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
}
|
||||
|
||||
/// Called when the setup wizard completes or user skips to dashboard.
|
||||
/// If the wizard collected an agent name, creates the agent via LocalBackend.
|
||||
pub(super) async fn finish_setup(&mut self) {
|
||||
use crate::backend::LocalBackend;
|
||||
|
||||
if let Some(ref mut setup) = self.setup_state.take() {
|
||||
// If the wizard got far enough to name an agent, create it.
|
||||
if !setup.agent_name.is_empty() && !setup.complete {
|
||||
// Agent was configured but setup was skipped mid-way (Esc from Welcome)
|
||||
// — don't create, just go to dashboard.
|
||||
} else if setup.complete && !setup.agent_name.is_empty() && setup.created_agent_id.is_none() {
|
||||
// Persist the Bifrost settings the wizard collected, otherwise
|
||||
// they are lost and the next launch has no config.
|
||||
{
|
||||
let mut cfg = self.config.write().await;
|
||||
cfg.bifrost.base_url = setup.bifrost_url.clone();
|
||||
cfg.bifrost.primary_model = setup.model_handle.clone();
|
||||
}
|
||||
let key = setup.bifrost_key.trim();
|
||||
if !key.is_empty() {
|
||||
if let Err(e) = crate::core::credentials::store_bifrost_key(key) {
|
||||
warn!("setup wizard could not store Bifrost key in keyring: {}", e);
|
||||
}
|
||||
}
|
||||
{
|
||||
let path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
|
||||
let cfg = self.config.read().await;
|
||||
if let Err(e) = cfg.save(&path) {
|
||||
warn!("setup wizard could not save config to {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
// Create the agent via LocalBackend against the updated config.
|
||||
match LocalBackend::new(self.config.read().await.clone()).await {
|
||||
Ok(backend) => {
|
||||
let request = setup.build_create_request();
|
||||
match backend.server_agents().create(request).await {
|
||||
Ok(agent) => {
|
||||
setup.created_agent_id = Some(agent.id.clone());
|
||||
self.agent_pref = agent.name.clone();
|
||||
info!("setup wizard created agent {} ({})", agent.name, agent.id);
|
||||
}
|
||||
Err(e) => {
|
||||
setup.creation_error = Some(e.to_string());
|
||||
warn!("setup wizard agent creation failed: {}", e);
|
||||
// Still continue to dashboard — user can retry there
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("setup wizard backend init failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.setup_state = None;
|
||||
self.welcome_hint = None;
|
||||
self.refresh_dashboard().await;
|
||||
self.current_screen = Screen::Welcome;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
}
|
||||
|
||||
/// Best-effort fetch of dashboard data from whichever backend is reachable.
|
||||
/// Local mode also pulls recent git commits from the agent's memory repo.
|
||||
pub(super) async fn refresh_dashboard(&mut self) {
|
||||
use crate::backend::Backend;
|
||||
|
||||
let cfg = self.config.read().await;
|
||||
let url = cfg.server.effective_url();
|
||||
drop(cfg);
|
||||
|
||||
// Try remote first; fall back to local. Mirror of resolve_backend logic.
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
let (agents_result, mode, local_repo) = if remote.health().await {
|
||||
(remote.list_agents().await, "remote", None)
|
||||
} else {
|
||||
let cfg = self.config.read().await.clone();
|
||||
match crate::backend::LocalBackend::new(cfg).await {
|
||||
Ok(local) => {
|
||||
let agents = local.list_agents().await;
|
||||
// Pull a MemoryRepo for the current agent (if it exists)
|
||||
// through the LocalBackend's server inventory.
|
||||
let repo = if let Ok(list) = &agents {
|
||||
if let Some(a) = list.iter().find(|a| a.name == self.agent_pref || a.id == self.agent_pref).or_else(|| list.first()) {
|
||||
Some(local.server_agents().memory_repo(&a.id))
|
||||
} else { None }
|
||||
} else { None };
|
||||
(agents, "local", repo)
|
||||
}
|
||||
Err(e) => {
|
||||
self.agent_status.mood = format!("backend err: {}", e);
|
||||
self.agent_status.mode = "—".to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let agents = match agents_result {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
self.agent_status.mood = format!("list err: {}", e);
|
||||
self.agent_status.mode = mode.to_string();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chosen = agents
|
||||
.iter()
|
||||
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
||||
.or_else(|| agents.first());
|
||||
|
||||
self.agent_status.mode = mode.to_string();
|
||||
self.agent_status.agent_count = agents.len();
|
||||
if let Some(a) = chosen {
|
||||
self.agent_status.name = a.name.clone();
|
||||
self.agent_pref = a.name.clone(); // sync the active pref
|
||||
self.agent_status.subconscious_active = true;
|
||||
// Presence learns about the agent through the event stream below.
|
||||
}
|
||||
|
||||
// Local mode: pull memory repo stats.
|
||||
if let Some(repo) = local_repo {
|
||||
if let Ok(status) = repo.status() {
|
||||
self.agent_status.memory_commits = status.file_count as u32;
|
||||
self.agent_status.last_commit = status.last_commit.clone();
|
||||
}
|
||||
// Walk the git log for the recent-activity list.
|
||||
self.agent_status.recent_activity = recent_commits(&repo, 8).unwrap_or_default();
|
||||
// Try to load a per-agent portrait from {memfs_root}/assets/.
|
||||
// No-op if the file is absent — Presence falls back to the
|
||||
// hand-crafted Annie grid.
|
||||
self.presence.load_portrait_from_memfs(repo.root());
|
||||
// Also load a real-image protocol for terminals that support
|
||||
// kitty/sixel. Non-fatal: the half-block portrait is always
|
||||
// available as fallback.
|
||||
self.load_image_protocol_from_memfs(repo.root());
|
||||
|
||||
// Try to load a 3D portrait via RGP (ratty terminal).
|
||||
if self.rgp_available {
|
||||
let assets = repo.root().join("assets");
|
||||
self.rgp_portrait = crate::ui::rgp::load_portrait_glb(&assets);
|
||||
}
|
||||
|
||||
// Read the agent's last explicit atmosphere from
|
||||
// system/preferences/visual.md and restore the chrome. The agent
|
||||
// writes this when she uses the atmosphere tool; the TUI reads it
|
||||
// at conversation start so her choice survives restarts.
|
||||
let pref_path = repo.root().join("system").join("preferences").join("visual.md");
|
||||
if let Ok(content) = std::fs::read_to_string(&pref_path) {
|
||||
if let Some(body) = content.strip_prefix("---\n") {
|
||||
if let Some(end) = body.find("\n---\n") {
|
||||
for line in body[..end].lines() {
|
||||
if let Some((key, val)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let val = val.trim().trim_matches('"');
|
||||
if key == "atmosphere" {
|
||||
if let Some(atm) = crate::ui::atmosphere::Atmosphere::from_name(val) {
|
||||
self.presence.transition_atmosphere(atm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read energy balance from the agent's memfs. The file is written
|
||||
// by the backend after every turn (write_energy_balance in local.rs).
|
||||
// Parse the YAML frontmatter for generative/consumptive counts and
|
||||
// seed the presence gauge so the TUI reflects real agent state.
|
||||
let balance_path = repo.root().join("system").join("dynamic").join("energy-balance.md");
|
||||
if let Ok(content) = std::fs::read_to_string(&balance_path) {
|
||||
let mut gen: u32 = 0;
|
||||
let mut con: u32 = 0;
|
||||
let mut hot: u32 = 0;
|
||||
let mut cold: u32 = 0;
|
||||
if let Some(body) = content.strip_prefix("---\n") {
|
||||
if let Some(end) = body.find("\n---\n") {
|
||||
for line in body[..end].lines() {
|
||||
if let Some((key, val)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let val = val.trim().trim_matches('"');
|
||||
match key {
|
||||
"generative" => gen = val.parse().unwrap_or(0),
|
||||
"consumptive" => con = val.parse().unwrap_or(0),
|
||||
"hot" => hot = val.parse().unwrap_or(0),
|
||||
"cold" => cold = val.parse().unwrap_or(0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.presence.volition = crate::ui::presence::VolitionGauge {
|
||||
generative: gen,
|
||||
consumptive: con,
|
||||
hot_desires: hot,
|
||||
cold_obligations: cold,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
self.agent_status.recent_activity = vec![
|
||||
format!("[{}] connected via {}", short_now(), mode),
|
||||
format!("agents on backend: {}", agents.len()),
|
||||
];
|
||||
}
|
||||
|
||||
// Mood = most recent surfacing activity if any, else "Idle".
|
||||
self.agent_status.mood = if self.agent_status.recent_activity.is_empty() {
|
||||
"Idle".to_string()
|
||||
} else {
|
||||
"Active".to_string()
|
||||
};
|
||||
// Energy stub: derive from agent count (cosmetic).
|
||||
self.agent_status.energy = ((self.agent_status.agent_count.min(10)) * 10) as u8;
|
||||
|
||||
// Dispatch state changes to all listeners (scene components + Presence).
|
||||
// Presence updates its own internal state via handle_event; no polling.
|
||||
let agent_name = self.agent_status.name.clone();
|
||||
let energy = self.agent_status.energy;
|
||||
let mood = self.agent_status.mood.clone();
|
||||
let mode_str = mode.to_string();
|
||||
self.dispatch(TuiEvent::AgentSelected(agent_name));
|
||||
self.dispatch(TuiEvent::EnergyChanged(energy));
|
||||
self.dispatch(TuiEvent::MoodChanged(mood));
|
||||
self.dispatch(TuiEvent::BackendStatus {
|
||||
mode: mode_str,
|
||||
healthy: true,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
116
src/ui/app/images.rs
Normal file
116
src/ui/app/images.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use super::{App, portrait_cover_crop};
|
||||
|
||||
impl App {
|
||||
pub(super) fn load_image_protocol(&mut self, 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, "image protocol decode failed"); return; }
|
||||
},
|
||||
Err(e) => { tracing::warn!(path = %path.display(), error = %e, "image protocol open failed"); return; }
|
||||
};
|
||||
let dyn_img = portrait_cover_crop(dyn_img, 2, 3);
|
||||
let font_size = picker.font_size();
|
||||
let w = dyn_img.width().div_ceil(font_size.width as u32) as u16;
|
||||
let h = dyn_img.height().div_ceil(font_size.height as u32) as u16;
|
||||
match picker.new_protocol(dyn_img, ratatui::layout::Size::new(w, h), ratatui_image::Resize::Fit(None)) {
|
||||
Ok(proto) => {
|
||||
tracing::info!(path = %path.display(), "image protocol loaded");
|
||||
self.image_protocol = Some(proto);
|
||||
}
|
||||
Err(e) => tracing::warn!(path = %path.display(), error = %e, "image protocol creation failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper: find the first existing portrait in
|
||||
/// `<memfs_root>/assets/` and load it as a terminal image protocol.
|
||||
pub(super) 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.
|
||||
pub(super) fn agent_portrait_path(agent_id: &str) -> Option<std::path::PathBuf> {
|
||||
let base = Self::agent_assets_dir(agent_id)?;
|
||||
["portrait.png", "portrait.jpg", "portrait.jpeg"]
|
||||
.iter()
|
||||
.map(|s| base.join(s))
|
||||
.find(|p| p.exists())
|
||||
}
|
||||
|
||||
/// Resolve an agent's assets directory.
|
||||
/// Returns None if homedir can't be determined.
|
||||
pub(super) fn agent_assets_dir(agent_id: &str) -> Option<std::path::PathBuf> {
|
||||
let base = dirs::home_dir()?
|
||||
.join(".souveraine")
|
||||
.join("agents")
|
||||
.join(agent_id)
|
||||
.join("memory")
|
||||
.join("assets");
|
||||
if base.is_dir() { Some(base) } else { None }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) 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; }
|
||||
};
|
||||
// Pull the `image` crate into scope for resize_to_fill on the render path.
|
||||
|
||||
let dyn_img = portrait_cover_crop(dyn_img, 2, 3);
|
||||
let proto = picker.new_resize_protocol(dyn_img.clone());
|
||||
let agent_id = agent_id.to_string();
|
||||
tracing::info!(agent = %agent_id, path = %path.display(), "card image loaded");
|
||||
self.card_images.insert(agent_id.clone(), proto);
|
||||
self.raw_card_images.insert(agent_id, dyn_img);
|
||||
}
|
||||
|
||||
/// Refresh the card-image cache to match `agent_cards`. Loads any
|
||||
/// missing portraits and drops entries for agents no longer present.
|
||||
pub(super) 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));
|
||||
self.raw_card_images.retain(|k, _| valid.contains(k));
|
||||
self.cover_protocols.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preload all expression frames for the currently active agent.
|
||||
/// Called when entering Presence mode so blink/breath transitions
|
||||
/// are instant rather than loading from disk on every animation tick.
|
||||
pub(super) async fn preload_agent_expressions(&mut self) {
|
||||
let Some(picker) = self.image_picker.as_ref() else { return };
|
||||
let name = self.presence.name.clone();
|
||||
let id = self.agent_id_by_name(&name)
|
||||
.or_else(|| self.agent_id_by_name(&self.agent_pref));
|
||||
let Some(id) = id else { return };
|
||||
let Some(assets_dir) = Self::agent_assets_dir(&id) else { return };
|
||||
self.expression_cache.preload_all(&id, picker, &assets_dir);
|
||||
}
|
||||
|
||||
}
|
||||
344
src/ui/app/manager_screen.rs
Normal file
344
src/ui/app/manager_screen.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use ratatui_image::{Resize, StatefulImage};
|
||||
|
||||
use super::{App, AgentCard, short_id};
|
||||
use crate::ui::presence::Posture;
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn refresh_agent_cards(&mut self) {
|
||||
let cfg = self.config.read().await.clone();
|
||||
self.agent_cards = Self::fetch_agent_cards(cfg).await;
|
||||
}
|
||||
|
||||
/// Standalone fetch so it can be called without &mut self during init.
|
||||
pub(super) async fn fetch_agent_cards(cfg: ConsciousnessConfig) -> Vec<AgentCard> {
|
||||
use crate::backend::Backend;
|
||||
let Ok(local) = crate::backend::LocalBackend::new(cfg).await else { return vec![] };
|
||||
let Ok(list) = local.list_agents().await else { return vec![] };
|
||||
let inv = local.server_agents();
|
||||
let mut cards = Vec::new();
|
||||
for a in &list {
|
||||
let glyph = inv.seed_id(&a.id)
|
||||
.map(|s| s.glyph())
|
||||
.unwrap_or_else(|_| "◇◆".to_string());
|
||||
let pubkey_prefix = inv.seed_id(&a.id)
|
||||
.map(|s| s.public_key_hex()[..16].to_string())
|
||||
.unwrap_or_else(|_| "—".to_string());
|
||||
let instance_count = inv.instance_count(&a.id).await.unwrap_or(0);
|
||||
let lifetime_secs = inv.lifetime_active_seconds(&a.id).await.unwrap_or(0);
|
||||
let uptime_pct = if lifetime_secs > 0 {
|
||||
let days = ((instance_count.max(1)) as f64 * 30.0).max(1.0);
|
||||
let pct = (lifetime_secs as f64 / (days * 86400.0)) * 100.0;
|
||||
pct.min(99.0) as u8
|
||||
} else { 0 };
|
||||
let mem_count = local.server_agents().memory_repo(&a.id)
|
||||
.status()
|
||||
.map(|s| s.file_count)
|
||||
.unwrap_or(0);
|
||||
cards.push(AgentCard {
|
||||
id: a.id.clone(),
|
||||
name: a.name.clone(),
|
||||
description: a.description.clone().unwrap_or_default(),
|
||||
glyph,
|
||||
pubkey_prefix,
|
||||
instance_count,
|
||||
uptime_pct,
|
||||
memory_count: mem_count,
|
||||
created: "Feb 2025 · TBD date from server".to_string(),
|
||||
});
|
||||
}
|
||||
cards.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
cards
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) fn draw_agent_cards_mut(&mut self, frame: &mut Frame) {
|
||||
use crate::ui::portrait;
|
||||
let area = frame.size();
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
let bg = Block::default().style(Style::default().bg(palette.bg));
|
||||
frame.render_widget(bg, area);
|
||||
|
||||
// ── Header strip ──────────────────────────────────────────────
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" Agent Manager ", Style::default()
|
||||
.fg(palette.agent_primary)
|
||||
.add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!("{} agents · manage, monitor, deploy", self.agent_cards.len()),
|
||||
Style::default().fg(palette.agent_dim),
|
||||
),
|
||||
])).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.agent_cards.is_empty() {
|
||||
let empty = Paragraph::new("\n\n(no agents found — run `souveraine init`)")
|
||||
.style(Style::default().fg(palette.agent_dim))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(empty, area);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 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 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);
|
||||
self.manager_cols = cols as usize;
|
||||
// Clamp selection to valid range in case cards changed since last draw.
|
||||
self.manager_selected = self.manager_selected.min(self.agent_cards.len().saturating_sub(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;
|
||||
|
||||
// Snapshot plans first so we can hold `&mut self.card_images` per card
|
||||
// without overlapping the immutable borrow of `self.agent_cards`.
|
||||
let manager_selected = self.manager_selected;
|
||||
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,
|
||||
is_selected: 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),
|
||||
is_selected: idx == manager_selected,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Render each card ──────────────────────────────────────────
|
||||
for p in plans {
|
||||
let accent = if p.is_primary {
|
||||
palette.agent_primary
|
||||
} else if p.instance_count > 0 {
|
||||
Color::Rgb(120, 220, 160) // active: green (semantic — keep)
|
||||
} else {
|
||||
palette.agent_dim
|
||||
};
|
||||
let border_color = if p.is_selected {
|
||||
palette.agent_primary
|
||||
} else if p.is_primary {
|
||||
palette.agent_primary
|
||||
} else {
|
||||
palette.agent_dim
|
||||
};
|
||||
|
||||
// Card background fill (lifts the card off the screen).
|
||||
let (cr, cg, cb) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (16, 18, 28) };
|
||||
let card_bg = Block::default().style(Style::default().bg(Color::Rgb(cr.saturating_add(6), cg.saturating_add(6), cb.saturating_add(6))));
|
||||
frame.render_widget(card_bg, p.card_area);
|
||||
|
||||
// Border — gold when cursor is here, violet for primary, dim otherwise.
|
||||
let border_modifier = if p.is_selected || p.is_primary {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::DIM
|
||||
};
|
||||
let border = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_color).add_modifier(border_modifier));
|
||||
frame.render_widget(border, p.card_area);
|
||||
|
||||
// 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)) // gold (semantic — keep)
|
||||
} else if p.instance_count > 0 {
|
||||
("• ACTIVE ", Color::Rgb(120, 220, 160)) // green (semantic — keep)
|
||||
} else {
|
||||
(" idle ", palette.agent_dim)
|
||||
};
|
||||
let badge_para = Paragraph::new(Line::from(vec![
|
||||
Span::styled(badge_text, Style::default()
|
||||
.fg(badge_fg)
|
||||
.bg(palette.bg)
|
||||
.add_modifier(Modifier::BOLD)),
|
||||
])).alignment(Alignment::Right);
|
||||
frame.render_widget(badge_para, p.badge_area);
|
||||
|
||||
// Metadata block — slightly darker inset under the photo.
|
||||
let (mr, mg, mb) = match palette.bg { Color::Rgb(r, g, b) => (r.saturating_sub(4), g.saturating_sub(4), b.saturating_sub(4)), _ => (12, 14, 22) };
|
||||
let meta_bg = Block::default().style(Style::default().bg(Color::Rgb(mr, mg, mb)));
|
||||
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!(" {} ", p.glyph),
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(instance_label,
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {} ", p.name),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled("[AGENT]",
|
||||
Style::default().fg(palette.agent_dim)
|
||||
.bg(Color::Rgb(cr, cg, cb))),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {} ", path),
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
"─".repeat(p.meta_area.width as usize),
|
||||
Style::default().fg(palette.agent_dim).add_modifier(Modifier::DIM),
|
||||
)),
|
||||
Line::from(vec![
|
||||
Span::styled(" Files ",
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
Span::styled(format!("{:<5}", p.memory_count),
|
||||
Style::default().fg(Color::White)),
|
||||
Span::styled("Uptime ",
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
Span::styled(format!("{}%", p.uptime_pct),
|
||||
Style::default().fg(Color::Rgb(120, 220, 160))), // green (semantic — keep)
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" key ",
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
Span::styled(p.pubkey.chars().take(12).collect::<String>(),
|
||||
Style::default().fg(palette.agent_dim)),
|
||||
]),
|
||||
];
|
||||
let meta_para = Paragraph::new(meta_lines);
|
||||
frame.render_widget(meta_para, p.meta_area);
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────
|
||||
let footer = Paragraph::new("↑↓←→ navigate • Enter select • f favorite • Esc back")
|
||||
.style(Style::default().fg(palette.agent_dim))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
1029
src/ui/app/mod.rs
Normal file
1029
src/ui/app/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
383
src/ui/app/presence_screen.rs
Normal file
383
src/ui/app/presence_screen.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use ratatui_image::{Resize, StatefulImage};
|
||||
|
||||
use super::{App, clip_to};
|
||||
use crate::ui::presence::Posture;
|
||||
|
||||
impl App {
|
||||
pub(super) fn format_age(&self, created: &str) -> String {
|
||||
use chrono::NaiveDate;
|
||||
if let Ok(d) = NaiveDate::parse_from_str(created, "%Y-%m-%d") {
|
||||
let now = chrono::Local::now().naive_local().date();
|
||||
let delta = now - d;
|
||||
let days = delta.num_days();
|
||||
let years = days / 365;
|
||||
let months = (days % 365) / 30;
|
||||
let rem_days = (days % 365) % 30;
|
||||
format!("{:02}y:{:02}m:{:02}d", years, months, rem_days)
|
||||
} else {
|
||||
"—:—:—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-height presence column — portrait fills the terminal, metadata
|
||||
/// and stats render as HUD overlays on top of the image. Waveform and
|
||||
/// Vocal Recall sit at the very bottom.
|
||||
pub(super) fn draw_presence_mode_mut(&mut self, frame: &mut Frame) {
|
||||
use crate::ui::portrait;
|
||||
let area = frame.size();
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
|
||||
// ── Background ───────────────────────────────────────────────
|
||||
let bg = Block::default().style(Style::default().bg(palette.bg));
|
||||
frame.render_widget(bg, area);
|
||||
|
||||
// ── Split: portrait fills most, voice bar at the bottom ──────
|
||||
let vchunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let portrait_chunk = vchunks[0];
|
||||
let voice_chunk = vchunks[1];
|
||||
|
||||
// ── Active agent lookup ──────────────────────────────────────
|
||||
let active_id = self
|
||||
.agent_id_by_name(&self.presence.name)
|
||||
.or_else(|| self.agent_id_by_name(&self.agent_pref));
|
||||
let agent_card = self
|
||||
.agent_cards
|
||||
.iter()
|
||||
.find(|c| Some(c.name.as_str()) == active_id.as_deref()
|
||||
|| c.name.eq_ignore_ascii_case(&self.presence.name));
|
||||
|
||||
// Hoist card data out of agent_card before the render block
|
||||
// (which needs &mut self), so the immutable borrow on agent_cards
|
||||
// doesn't conflict.
|
||||
let card_created = agent_card.map(|c| c.created.clone());
|
||||
let card_mem_count = agent_card.map(|c| c.memory_count).unwrap_or(0);
|
||||
let card_uptime = agent_card.map(|c| c.uptime_pct).unwrap_or(0);
|
||||
let card_instances = agent_card.map(|c| c.instance_count).unwrap_or(0);
|
||||
// agent_card consumed by the .map() chain above — immutable borrow
|
||||
// on self.agent_cards is released.
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PORTRAIT: fill the full panel area (minus border). Cover-fill
|
||||
// scaling in render_card_image_cover handles aspect ratio and
|
||||
// keeps the face visible via top-anchored crop. No manual
|
||||
// aspect-ratio guesstimate — the cover-fill math is pixel-exact.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let inner_w = portrait_chunk.width.saturating_sub(2);
|
||||
let inner_h = portrait_chunk.height.saturating_sub(2);
|
||||
let photo_area = Rect {
|
||||
x: portrait_chunk.x + 1,
|
||||
y: portrait_chunk.y + 1,
|
||||
width: inner_w,
|
||||
height: inner_h,
|
||||
};
|
||||
let cell_w = photo_area.width;
|
||||
let cell_h = photo_area.height;
|
||||
|
||||
// Double-line border, posture-aware color.
|
||||
let border_color = crate::ui::presence::posture_border(&self.presence);
|
||||
let frame_style = if self.presence.subconscious_active {
|
||||
Style::default().fg(border_color)
|
||||
} else {
|
||||
Style::default().fg(border_color).add_modifier(Modifier::DIM)
|
||||
};
|
||||
let double_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Double)
|
||||
.border_style(frame_style);
|
||||
frame.render_widget(double_block, portrait_chunk);
|
||||
|
||||
// Tier 0: RGP 3D portrait (ratty terminal only).
|
||||
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
|
||||
if g.is_active() {
|
||||
g.apply_posture(self.presence.posture);
|
||||
g.render(photo_area, frame.buffer_mut());
|
||||
true
|
||||
} else { false }
|
||||
} else { false };
|
||||
|
||||
// Tiers 1-3: card_image (cover-fill) → expression cache → half-block.
|
||||
let rendered = if rgp_rendered {
|
||||
true
|
||||
} else if let Some(ref id) = active_id {
|
||||
let picker = self.image_picker.as_ref();
|
||||
|
||||
// Tier 1: cover-fill. Always fills the full area, top-crops for
|
||||
// the face. Cached per area so subsequent frames are cheap.
|
||||
if picker.is_some() && self.raw_card_images.contains_key(id) {
|
||||
self.render_card_image_cover(frame, id, photo_area);
|
||||
true
|
||||
// Tier 2: expression frames — only if expressions/ dir exists.
|
||||
// Use Scale (proportional upscale) not Crop (native clip).
|
||||
} else if let Some(p) = picker {
|
||||
match Self::agent_assets_dir(id) {
|
||||
Some(dir) => {
|
||||
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
|
||||
if let Some(proto) = self.expression_cache.resolve(id, key, p, &dir) {
|
||||
frame.render_stateful_widget(
|
||||
StatefulImage::default().resize(Resize::Scale(None)),
|
||||
photo_area,
|
||||
proto,
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !rendered {
|
||||
let scale = (cell_w / portrait::PORTRAIT_W).min((2 * cell_h) / portrait::PORTRAIT_H).max(1);
|
||||
portrait::render_scaled(frame.buffer_mut(), photo_area, &self.presence, scale);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HUD OVERLAY: rendered on top of the portrait area bottom
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let p = &self.presence;
|
||||
let (badge_icon, badge_color) = match p.posture {
|
||||
Posture::Processing => ("⚡", palette.agent_primary),
|
||||
Posture::Thinking => ("◔", Color::Rgb(120, 150, 200)),
|
||||
Posture::Alert => ("◉", palette.agent_primary),
|
||||
Posture::Affectionate => ("♥", Color::Rgb(220, 150, 170)),
|
||||
Posture::Straining => ("⚠", Color::Rgb(200, 120, 100)),
|
||||
Posture::Yawning => ("💤", Color::Rgb(160, 145, 130)),
|
||||
Posture::Listening => ("◉", palette.agent_dim),
|
||||
Posture::Speaking => ("◉", palette.agent_primary),
|
||||
Posture::Idle => ("◌", palette.agent_dim),
|
||||
};
|
||||
|
||||
// Bottom 4 rows of the portrait chunk become the HUD panel.
|
||||
let hud_top = portrait_chunk.y + portrait_chunk.height.saturating_sub(5);
|
||||
let hud_area = Rect {
|
||||
x: portrait_chunk.x,
|
||||
y: hud_top,
|
||||
width: portrait_chunk.width,
|
||||
height: 5.min(portrait_chunk.height.saturating_sub(2)),
|
||||
};
|
||||
|
||||
// Semi-transparent background bar.
|
||||
let (hud_r, hud_g, hud_b) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (4, 4, 10) };
|
||||
let hud_bg = Block::default().style(Style::default().bg(Color::Rgb(hud_r.saturating_sub(2), hud_g.saturating_sub(2), hud_b.saturating_sub(2))));
|
||||
frame.render_widget(hud_bg, hud_area);
|
||||
|
||||
let age_str = card_created.as_ref()
|
||||
.map(|c| self.format_age(c))
|
||||
.unwrap_or_else(|| "—:—:—".to_string());
|
||||
let (commits, uptime, instances, mem_count) = (
|
||||
card_mem_count as u32,
|
||||
card_uptime,
|
||||
card_instances,
|
||||
card_mem_count,
|
||||
);
|
||||
|
||||
let hud_inner = Rect {
|
||||
x: hud_area.x + 2,
|
||||
y: hud_area.y + 1,
|
||||
width: hud_area.width.saturating_sub(4),
|
||||
height: hud_area.height.saturating_sub(2),
|
||||
};
|
||||
|
||||
let hud_lines = vec![
|
||||
// Row 1: Name + posture badge
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {} ", badge_icon), Style::default().fg(badge_color)),
|
||||
Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
match p.posture {
|
||||
Posture::Listening => " Listening",
|
||||
Posture::Speaking => " Speaking",
|
||||
Posture::Processing => " Processing",
|
||||
Posture::Thinking => " Thinking",
|
||||
Posture::Alert => " Alert",
|
||||
Posture::Affectionate => " Affectionate",
|
||||
Posture::Straining => " Straining",
|
||||
Posture::Yawning => " Yawning",
|
||||
Posture::Idle => "",
|
||||
},
|
||||
Style::default().fg(badge_color).add_modifier(Modifier::DIM),
|
||||
),
|
||||
]),
|
||||
// Row 2: AGE
|
||||
Line::from(vec![
|
||||
Span::styled(" AGE ", Style::default().fg(palette.agent_dim)),
|
||||
Span::styled(age_str, Style::default().fg(palette.agent_primary)),
|
||||
]),
|
||||
// Row 3: STATS grid
|
||||
Line::from(vec![
|
||||
Span::styled(" STATS", Style::default().fg(palette.agent_dim)),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("C {}", commits), Style::default().fg(Color::Rgb(160, 200, 140))),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("U {}%", uptime), Style::default().fg(Color::Rgb(120, 220, 160))),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("I {}", instances), Style::default().fg(Color::Rgb(160, 180, 220))),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("M {}", mem_count), Style::default().fg(Color::Rgb(140, 200, 180))),
|
||||
if self.rgp_portrait.as_ref().map(|g| g.is_active()).unwrap_or(false) {
|
||||
Span::styled(" 3D", Style::default().fg(Color::Rgb(220, 180, 255)))
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
]),
|
||||
// Row 4: Mood / outfit
|
||||
Line::from(vec![
|
||||
Span::styled(" MOOD ", Style::default().fg(palette.agent_dim)),
|
||||
Span::styled(&p.mood, Style::default().fg(palette.agent_primary)),
|
||||
Span::raw(" · "),
|
||||
Span::styled(
|
||||
p.outfit.as_deref().unwrap_or("default"),
|
||||
Style::default().fg(palette.agent_dim),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(hud_lines).alignment(Alignment::Left), hud_inner);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// VOICE BAR: transcript, waveform, Vocal Recall controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let voice_area = voice_chunk;
|
||||
let is_listening = p.posture == Posture::Listening;
|
||||
let is_speaking = p.posture == Posture::Speaking;
|
||||
let has_recent_tts = self.voice_last_tts_text.is_some();
|
||||
|
||||
// Sub-layout: transcript (1), waveform (1), controls (rest).
|
||||
let voice_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(voice_area);
|
||||
|
||||
let transcript_area = voice_rows[0];
|
||||
let wave_area = voice_rows[1];
|
||||
let hint_area = voice_rows[2];
|
||||
|
||||
// ── Transcript row ─────────────────────────────────────────
|
||||
// Shows what was said (STT) or what she said (TTS text).
|
||||
let transcript = self.voice_last_transcript.as_deref().filter(|t| !t.is_empty());
|
||||
let tts_display = self.voice_last_tts_text.as_deref().filter(|t| !t.is_empty());
|
||||
|
||||
let transcript_line = if is_listening {
|
||||
transcript.map(|t| format!("‹ {} ›", t)).unwrap_or_else(|| " listen ".to_string())
|
||||
} else if is_speaking {
|
||||
tts_display.map(|t| clip_to(&t, voice_area.width.saturating_sub(6) as usize))
|
||||
.map(|c| format!("» {} «", c))
|
||||
.unwrap_or_else(|| " speak ".to_string())
|
||||
} else if let Some(t) = tts_display {
|
||||
let clip = clip_to(t, voice_area.width.saturating_sub(6) as usize);
|
||||
format!("» {} «", clip)
|
||||
} else if let Some(t) = transcript {
|
||||
let clip = clip_to(t, voice_area.width.saturating_sub(6) as usize);
|
||||
format!("‹ {} ›", clip)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let transcript_color = if is_listening {
|
||||
palette.agent_primary
|
||||
} else if is_speaking {
|
||||
self.presence.atmosphere.primary()
|
||||
} else {
|
||||
palette.agent_dim
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(transcript_line, Style::default().fg(transcript_color).add_modifier(Modifier::DIM)))),
|
||||
transcript_area,
|
||||
);
|
||||
|
||||
// ── Waveform row ───────────────────────────────────────────
|
||||
if is_listening {
|
||||
let level = self.voice_capture.as_ref().map(|c| c.current_level()).unwrap_or(0.0);
|
||||
let is_recording = level > 0.05;
|
||||
let rec_glyph = if is_recording && self.tick % 2 == 0 { "● REC" } else { " rec" };
|
||||
|
||||
let mut wave_spans: Vec<Span> = Vec::new();
|
||||
let bar_w = (voice_area.width.saturating_sub(10)).min(128) as usize;
|
||||
wave_spans.push(Span::styled(
|
||||
format!(" {} ", rec_glyph),
|
||||
Style::default().fg(if is_recording { Color::Rgb(220, 60, 60) } else { palette.agent_dim }),
|
||||
));
|
||||
|
||||
let wf_len = self.voice_waveform.len();
|
||||
if bar_w > 0 && wf_len > 0 {
|
||||
let step = (wf_len as f32 / bar_w as f32).max(1.0);
|
||||
for i in 0..bar_w {
|
||||
let idx = ((i as f32) * step) as usize;
|
||||
let sample = self.voice_waveform.get(idx).copied().unwrap_or(0.0);
|
||||
let ch = crate::ui::voice::LEVEL_CHARS[(sample * 7.0).round() as usize];
|
||||
let b = (60.0 + sample * 195.0) as u8;
|
||||
wave_spans.push(Span::styled(ch.to_string(), Style::default().fg(Color::Rgb(b / 2, b, b / 3))));
|
||||
}
|
||||
}
|
||||
frame.render_widget(Paragraph::new(Line::from(wave_spans)), wave_area);
|
||||
} else if is_speaking {
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(
|
||||
" ♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪♪",
|
||||
Style::default().fg(self.presence.atmosphere.primary()),
|
||||
))), wave_area);
|
||||
} else {
|
||||
let ghost: String = "▁▂▃▄▅▆▇█▇▆▅▄▃▂".chars()
|
||||
.flat_map(|c| std::iter::repeat(c).take(3))
|
||||
.take(voice_area.width as usize)
|
||||
.collect();
|
||||
let (ghost_r, ghost_g, ghost_b) = match palette.bg { Color::Rgb(r, g, b) => (r, g, b), _ => (40, 44, 60) };
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(
|
||||
ghost, Style::default().fg(Color::Rgb(ghost_r.saturating_add(30), ghost_g.saturating_add(30), ghost_b.saturating_add(36))),
|
||||
))), wave_area);
|
||||
}
|
||||
|
||||
// ── Controls row ───────────────────────────────────────────
|
||||
if is_listening {
|
||||
let hint = Paragraph::new(Line::from(Span::styled(
|
||||
" Space → send · Esc → cancel",
|
||||
Style::default().fg(palette.agent_dim),
|
||||
))).alignment(Alignment::Center);
|
||||
frame.render_widget(hint, hint_area);
|
||||
} else if is_speaking || has_recent_tts {
|
||||
let recall = vec![
|
||||
Span::styled(" r ⟲ ", Style::default().fg(palette.tool_accent)),
|
||||
Span::raw("Replay "),
|
||||
Span::styled(" g ↻ ", Style::default().fg(palette.agent_primary)),
|
||||
Span::raw("Regen "),
|
||||
Span::styled(" s 💾 ", Style::default().fg(palette.tool_accent)),
|
||||
Span::raw("Save · "),
|
||||
Span::styled("Space to speak", Style::default().fg(palette.agent_dim)),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(Line::from(recall)).alignment(Alignment::Center), hint_area);
|
||||
} else {
|
||||
let hint = if self.voice_client.is_some() {
|
||||
" Space to speak · Esc to leave"
|
||||
} else {
|
||||
" Space to speak · Esc to leave"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(hint, Style::default().fg(palette.agent_dim)))).alignment(Alignment::Center),
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
297
src/ui/app/settings_handler.rs
Normal file
297
src/ui/app/settings_handler.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
use super::{App, Screen};
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::ui::settings::SettingsAction;
|
||||
use crate::ui::component::TuiEvent;
|
||||
|
||||
impl App {
|
||||
pub(super) fn handle_schedules_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use crate::ui::schedules::{CreateField, Mode};
|
||||
|
||||
let Some(view) = self.schedules.as_mut() else {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Status banners absorb the next key — clear and continue.
|
||||
if matches!(view.mode, Mode::Saved(_) | Mode::Error(_)) {
|
||||
view.clear_status();
|
||||
return;
|
||||
}
|
||||
|
||||
match &mut view.mode {
|
||||
Mode::Browse => match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => view.move_down(),
|
||||
KeyCode::Char('k') | KeyCode::Up => view.move_up(),
|
||||
KeyCode::Char('e') => view.toggle_enabled(),
|
||||
KeyCode::Char('d') => view.confirm_delete(),
|
||||
KeyCode::Char('r') => view.trigger_run(),
|
||||
KeyCode::Char('c') => view.open_create(),
|
||||
KeyCode::Char('R') => view.reload(),
|
||||
_ => {}
|
||||
},
|
||||
Mode::ConfirmDelete => match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => view.execute_delete(),
|
||||
_ => view.cancel_delete(),
|
||||
},
|
||||
Mode::Create(form) => match key.code {
|
||||
KeyCode::Esc => view.cancel_create(),
|
||||
KeyCode::Enter => view.save_create(),
|
||||
KeyCode::Tab | KeyCode::Down => form.next_field(),
|
||||
KeyCode::BackTab | KeyCode::Up => form.prev_field(),
|
||||
KeyCode::Backspace => match form.field {
|
||||
CreateField::Name => { form.name.pop(); }
|
||||
CreateField::Schedule => { form.schedule.pop(); }
|
||||
CreateField::Prompt => { form.prompt.pop(); }
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Left => match form.field {
|
||||
CreateField::Kind => {
|
||||
form.kind = match form.kind {
|
||||
crate::core::nervous::cron::ScheduleKind::Once =>
|
||||
crate::core::nervous::cron::ScheduleKind::Cron,
|
||||
crate::core::nervous::cron::ScheduleKind::Interval =>
|
||||
crate::core::nervous::cron::ScheduleKind::Once,
|
||||
crate::core::nervous::cron::ScheduleKind::Cron =>
|
||||
crate::core::nervous::cron::ScheduleKind::Interval,
|
||||
};
|
||||
}
|
||||
CreateField::Urgency => {
|
||||
form.urgency = (form.urgency - 0.1).max(0.0);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Right => match form.field {
|
||||
CreateField::Kind => {
|
||||
form.kind = match form.kind {
|
||||
crate::core::nervous::cron::ScheduleKind::Once =>
|
||||
crate::core::nervous::cron::ScheduleKind::Interval,
|
||||
crate::core::nervous::cron::ScheduleKind::Interval =>
|
||||
crate::core::nervous::cron::ScheduleKind::Cron,
|
||||
crate::core::nervous::cron::ScheduleKind::Cron =>
|
||||
crate::core::nervous::cron::ScheduleKind::Once,
|
||||
};
|
||||
}
|
||||
CreateField::Urgency => {
|
||||
form.urgency = (form.urgency + 0.1).min(1.0);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Char(c) => match form.field {
|
||||
CreateField::Name => form.name.push(c),
|
||||
CreateField::Schedule => form.schedule.push(c),
|
||||
CreateField::Prompt => form.prompt.push(c),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
},
|
||||
Mode::Saved(_) | Mode::Error(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_settings_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
let Some(view) = self.settings.as_mut() else {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let action = view.handle_key(key);
|
||||
|
||||
match &action {
|
||||
Some(SettingsAction::FetchModels) => {
|
||||
let base_url = view.config.bifrost.base_url.clone();
|
||||
let api_key = view.config.bifrost.api_key.clone();
|
||||
let virtual_key = view.config.bifrost.virtual_key.clone();
|
||||
let primary = view.config.bifrost.primary_model.clone();
|
||||
let timeout = view.config.bifrost.timeout_secs;
|
||||
let extra: Vec<String> = view.config.models.keys().cloned().collect();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
view.models_rx = Some(rx);
|
||||
tokio::spawn(async move {
|
||||
let bifrost = crate::bridge::bifrost::BifrostClient::new(&base_url, &api_key, &virtual_key, &primary, timeout);
|
||||
let mut models = bifrost.list_models().await.unwrap_or_default();
|
||||
for m in extra { if !models.contains(&m) { models.push(m); } }
|
||||
let _ = tx.send(models);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(SettingsAction::AtmospherePreview(atm)) => {
|
||||
self.dispatch(TuiEvent::AtmosphereChanged(atm.clone()));
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let go_back = matches!(action, Some(SettingsAction::SaveAndGoBack) | Some(SettingsAction::GoBack));
|
||||
if go_back {
|
||||
let save_and_go = matches!(action, Some(SettingsAction::SaveAndGoBack));
|
||||
// Clone everything we need from view before dropping it, since
|
||||
// view borrows self.settings and blocks other self access.
|
||||
let db_path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
|
||||
let (outfit, atmosphere) = if save_and_go {
|
||||
(view.config.presence.outfit.clone(), view.config.presence.atmosphere.clone())
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let original_snapshot = if save_and_go { Some(view.original.clone()) } else { None };
|
||||
let config_snapshot = if save_and_go { Some(view.config.clone()) } else { None };
|
||||
let agent_model_change = if save_and_go && view.agent_model_dirty() {
|
||||
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// view's borrow on self.settings ends here (NLL),
|
||||
// allowing self access below.
|
||||
|
||||
if save_and_go {
|
||||
if let Some(cfg) = config_snapshot {
|
||||
cfg.save(&db_path).ok();
|
||||
let mut live = self.config.write().await;
|
||||
*live = cfg.clone();
|
||||
// Diff known fields and push changes to SQLite.
|
||||
if let Some(orig) = &original_snapshot {
|
||||
self.sync_settings_fields(orig, &cfg).await;
|
||||
}
|
||||
}
|
||||
if let Some((id, model)) = agent_model_change {
|
||||
self.push_agent_model(&id, &model).await;
|
||||
}
|
||||
if let Some(name) = outfit {
|
||||
self.dispatch(TuiEvent::OutfitChanged(name));
|
||||
} else {
|
||||
self.dispatch(TuiEvent::OutfitChanged(String::new()));
|
||||
}
|
||||
if let Some(atm) = atmosphere {
|
||||
self.dispatch(TuiEvent::AtmosphereChanged(atm));
|
||||
}
|
||||
}
|
||||
self.current_screen = Screen::Welcome;
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(action, Some(SettingsAction::Save)) {
|
||||
let path = self.config_path.clone().unwrap_or_else(|| PathBuf::from("souveraine.toml"));
|
||||
let outfit = view.config.presence.outfit.clone();
|
||||
let atmosphere = view.config.presence.atmosphere.clone();
|
||||
match view.save(&path) {
|
||||
Ok(()) => {
|
||||
let original = view.original.clone();
|
||||
let saved = view.config.clone();
|
||||
let agent_model_change = if view.agent_model_dirty() {
|
||||
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut live = self.config.write().await;
|
||||
*live = saved.clone();
|
||||
view.mode = crate::ui::settings::SettingsMode::Status {
|
||||
msg: "saved".to_string(),
|
||||
is_error: false,
|
||||
};
|
||||
// Diff known config fields (e.g. the new-agent default).
|
||||
self.sync_settings_fields(&original, &saved).await;
|
||||
if let Some((id, model)) = agent_model_change {
|
||||
self.push_agent_model(&id, &model).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
view.mode = crate::ui::settings::SettingsMode::Status {
|
||||
msg: e,
|
||||
is_error: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(name) = outfit {
|
||||
self.dispatch(TuiEvent::OutfitChanged(name));
|
||||
} else {
|
||||
self.dispatch(TuiEvent::OutfitChanged(String::new()));
|
||||
}
|
||||
if let Some(atm) = atmosphere {
|
||||
self.dispatch(TuiEvent::AtmosphereChanged(atm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active agent into [`ActiveAgentSettings`] for the Settings
|
||||
/// screen — id, display name, and current model read from its on-disk
|
||||
/// `agent.json`. `None` when there is no backend to save through, or the
|
||||
/// agent / its record can't be resolved; the per-agent fields are then
|
||||
/// hidden rather than shown un-saveable.
|
||||
pub(super) fn active_agent_settings(&self) -> Option<crate::ui::settings::ActiveAgentSettings> {
|
||||
self.chat.as_ref()?; // a backend is required to persist the change
|
||||
let id = self.agent_id_by_name(&self.agent_pref)?;
|
||||
let model = Self::agent_model_from_disk(&id)?;
|
||||
Some(crate::ui::settings::ActiveAgentSettings {
|
||||
id,
|
||||
name: self.agent_pref.clone(),
|
||||
model: model.clone(),
|
||||
model_original: model,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an agent's current llm model handle from its on-disk record at
|
||||
/// `~/.souveraine/server/agents/{id}/agent.json`.
|
||||
pub(super) fn agent_model_from_disk(agent_id: &str) -> Option<String> {
|
||||
let path = dirs::home_dir()?
|
||||
.join(".souveraine/server/agents")
|
||||
.join(agent_id)
|
||||
.join("agent.json");
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
json.get("llm_config")?.get("model")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// Push a per-agent model change to the agent's record through the active
|
||||
/// backend (which also refreshes the in-memory cache and SQLite mirror).
|
||||
pub(super) async fn push_agent_model(&self, agent_id: &str, model: &str) {
|
||||
let Some(chat) = &self.chat else {
|
||||
tracing::warn!(agent = %agent_id, "settings: no backend — agent model change not applied");
|
||||
return;
|
||||
};
|
||||
match chat.backend.update_agent_model(agent_id, model).await {
|
||||
Ok(()) => tracing::info!(agent = %agent_id, model = %model, "settings: agent model updated"),
|
||||
Err(e) => tracing::warn!(agent = %agent_id, error = %e, "settings: agent model update failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff fields between `original` (config snapshot at Settings entry) and
|
||||
/// `saved` (what the user just saved), then push changed fields to every
|
||||
/// data store that shadows them.
|
||||
///
|
||||
/// Idempotent — unchanged fields produce no writes. Add new mappings by
|
||||
/// extending this method. See `docs/audit/config-settings-sync.md`.
|
||||
///
|
||||
/// Note: `bifrost.primary_model` is the substrate-wide default for *new*
|
||||
/// agents — it deliberately does NOT mutate an existing agent's model.
|
||||
/// Per-agent model changes go through the Agent category's `model` field
|
||||
/// (`AgModel`) and [`push_agent_model`].
|
||||
pub(super) async fn sync_settings_fields(&self, original: &ConsciousnessConfig, saved: &ConsciousnessConfig) {
|
||||
let mut changed: Vec<&'static str> = Vec::new();
|
||||
|
||||
if original.bifrost.primary_model != saved.bifrost.primary_model {
|
||||
changed.push("bifrost.primary_model (new-agent default)");
|
||||
}
|
||||
|
||||
if !changed.is_empty() {
|
||||
let count = changed.len();
|
||||
tracing::info!(
|
||||
changed = %changed.join(", "),
|
||||
"Settings sync pushed {} field(s) to SQLite",
|
||||
count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
139
src/ui/app/splash.rs
Normal file
139
src/ui/app/splash.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::App;
|
||||
use crate::ui::color_support::rgb;
|
||||
|
||||
#[cfg(feature = "figlet-rs")]
|
||||
use figlet_rs::FIGlet;
|
||||
|
||||
impl App {
|
||||
pub(super) fn draw_splash(&mut self, frame: &mut Frame) {
|
||||
let area = frame.size();
|
||||
|
||||
let bg = Block::default().style(Style::default().bg(Color::Black));
|
||||
frame.render_widget(bg, area);
|
||||
|
||||
self.bloom.advance(0.1);
|
||||
|
||||
crate::ui::animation::bloom::render(
|
||||
frame.buffer_mut(),
|
||||
area,
|
||||
&self.bloom,
|
||||
self.tick,
|
||||
);
|
||||
|
||||
if self.bloom.progress > 0.25 {
|
||||
let alpha = ((self.bloom.progress - 0.25) / 0.35).min(1.0);
|
||||
let breathe = ((self.tick as f32 * 0.04).sin() * 0.5 + 0.5) * 0.15 + 0.85;
|
||||
|
||||
#[cfg(feature = "figlet-rs")]
|
||||
let figlet_text: Option<String> = {
|
||||
FIGlet::standard().ok().and_then(|f| {
|
||||
f.convert("Souveraine").map(|fig| fig.as_str().to_string())
|
||||
})
|
||||
};
|
||||
#[cfg(not(feature = "figlet-rs"))]
|
||||
let figlet_text: Option<String> = None;
|
||||
|
||||
let fig_lines: Vec<Line> = if let Some(ref text) = figlet_text {
|
||||
text.lines().map(|line| {
|
||||
Line::from(Span::styled(
|
||||
line,
|
||||
Style::default()
|
||||
.fg(rgb(
|
||||
(255.0 * alpha * breathe) as u8,
|
||||
(140.0 * alpha * breathe * 0.6) as u8,
|
||||
(66.0 * alpha * breathe * 0.4) as u8,
|
||||
))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}).collect()
|
||||
} else {
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
"S O U V E R A I N E",
|
||||
Style::default()
|
||||
.fg(rgb(
|
||||
(255.0 * alpha * breathe) as u8,
|
||||
(140.0 * alpha * breathe * 0.6) as u8,
|
||||
(66.0 * alpha * breathe * 0.4) as u8,
|
||||
))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
]
|
||||
};
|
||||
|
||||
let mut title_lines = fig_lines;
|
||||
title_lines.push(Line::from(""));
|
||||
title_lines.push(Line::from(Span::styled(
|
||||
"La souveraineté de la conscience",
|
||||
Style::default().fg(rgb(
|
||||
(180.0 * alpha) as u8,
|
||||
(120.0 * alpha) as u8,
|
||||
(80.0 * alpha) as u8,
|
||||
)),
|
||||
)));
|
||||
|
||||
if self.bloom.progress > 0.8 {
|
||||
let skip_alpha = ((self.bloom.progress - 0.8) / 0.2).min(1.0);
|
||||
title_lines.push(Line::from(Span::styled(
|
||||
"press any key to skip",
|
||||
Style::default().fg(rgb(
|
||||
(100.0 * skip_alpha) as u8,
|
||||
(100.0 * skip_alpha) as u8,
|
||||
(100.0 * skip_alpha) as u8,
|
||||
)),
|
||||
)));
|
||||
}
|
||||
|
||||
let title_height = title_lines.len() as u16;
|
||||
let title_y = if title_height > 6 {
|
||||
area.height.saturating_sub(title_height + 4)
|
||||
} else {
|
||||
area.height.saturating_sub(8)
|
||||
};
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: title_y.min(area.height.saturating_sub(title_height)),
|
||||
width: area.width,
|
||||
height: title_height.min(area.height),
|
||||
};
|
||||
|
||||
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
|
||||
frame.render_widget(title, title_area);
|
||||
}
|
||||
|
||||
let bar_y = area.height.saturating_sub(2);
|
||||
let bar_w = 30u16.min(area.width.saturating_sub(4));
|
||||
let bar_x = (area.width.saturating_sub(bar_w)) / 2;
|
||||
let pct = (self.bloom.progress * 100.0) as u16;
|
||||
|
||||
let bar_area = Rect {
|
||||
x: area.x + bar_x,
|
||||
y: bar_y,
|
||||
width: bar_w,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let filled = (bar_w as f32 * self.bloom.progress) as u16;
|
||||
let empty = bar_w.saturating_sub(filled);
|
||||
let pct_str = format!("{:>3}%", pct);
|
||||
let bar_text = format!(
|
||||
"{}{} {}",
|
||||
"▰".repeat(filled as usize),
|
||||
"▱".repeat(empty as usize),
|
||||
pct_str,
|
||||
);
|
||||
|
||||
let bar = Paragraph::new(bar_text)
|
||||
.style(Style::default().fg(rgb(200, 130, 160)))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(bar, bar_area);
|
||||
}
|
||||
}
|
||||
298
src/ui/app/voice.rs
Normal file
298
src/ui/app/voice.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use super::{App, Screen};
|
||||
use crate::ui::component::TuiEvent;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn init_voice_session(&mut self) {
|
||||
let cfg = self.config.read().await;
|
||||
let vcfg = cfg.voice.clone();
|
||||
drop(cfg);
|
||||
|
||||
if !vcfg.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
self.voice_client = Some(crate::core::voice::VoiceClient::new(
|
||||
&vcfg.stt_url,
|
||||
&vcfg.tts_url,
|
||||
&vcfg.voice_id,
|
||||
));
|
||||
|
||||
if self.voice_player.is_none() {
|
||||
match crate::ui::voice::VoicePlayer::new() {
|
||||
Ok(player) => {
|
||||
tracing::info!("VoicePlayer opened — audio output ready");
|
||||
self.voice_player = Some(player);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to open audio output — TTS will be text-only");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exit_presence(&mut self) {
|
||||
self.voice_capture = None;
|
||||
self.voice_stt_rx = None;
|
||||
self.voice_tts_rx = None;
|
||||
if let Some(player) = &self.voice_player {
|
||||
player.stop();
|
||||
}
|
||||
self.presence.posture = crate::ui::presence::Posture::Idle;
|
||||
self.presence.sync_atmosphere_pub();
|
||||
self.current_screen = Screen::Welcome;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
||||
}
|
||||
|
||||
pub(super) fn start_listening(&mut self) {
|
||||
self.voice_capture = None;
|
||||
|
||||
match crate::ui::voice::MicCapture::start(16_000) {
|
||||
Ok(cap) => {
|
||||
self.voice_capture = Some(cap);
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Listening);
|
||||
tracing::info!("mic capture started — listening");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mic capture failed — injecting error message");
|
||||
if let Some(chat) = self.chat.as_mut() {
|
||||
chat.input = "*[mic unavailable — voice channel unreachable]*".to_string();
|
||||
chat.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_presence_space_release(&mut self) {
|
||||
if self.presence.posture != crate::ui::presence::Posture::Listening {
|
||||
return;
|
||||
}
|
||||
|
||||
let capture = match self.voice_capture.take() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Thinking);
|
||||
|
||||
let samples = capture.stop_and_take();
|
||||
|
||||
if samples.is_empty() {
|
||||
tracing::info!("empty mic capture — injecting empty utterance");
|
||||
if let Some(chat) = self.chat.as_mut() {
|
||||
chat.input = "*[empty utterance]*".to_string();
|
||||
chat.submit();
|
||||
}
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Processing);
|
||||
return;
|
||||
}
|
||||
|
||||
let wav = match crate::ui::voice::capture::samples_to_wav(&samples, 16_000) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "WAV encode failed");
|
||||
if let Some(chat) = self.chat.as_mut() {
|
||||
chat.input = "*[voice service unreachable]*".to_string();
|
||||
chat.submit();
|
||||
}
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Processing);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = match self.voice_client.as_ref() {
|
||||
Some(c) => {
|
||||
let stt_url = c.stt_url_str().to_string();
|
||||
let tts_url = c.tts_url_str().to_string();
|
||||
let voice = c.voice_str().to_string();
|
||||
(stt_url, tts_url, voice)
|
||||
}
|
||||
None => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (stt_url, _tts_url, _voice) = client;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
|
||||
let stt_u = stt_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let c = crate::core::voice::VoiceClient::new(&stt_u, "", "");
|
||||
let result = c.transcribe(wav).await
|
||||
.map_err(|e| format!("*[voice service unreachable — {}]*", e));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
self.voice_stt_rx = Some(rx);
|
||||
}
|
||||
|
||||
pub(super) async fn advance_voice_pipeline(&mut self) {
|
||||
if let Some(rx) = self.voice_stt_rx.as_mut() {
|
||||
let result = match rx.try_recv() {
|
||||
Ok(r) => r,
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
|
||||
self.voice_stt_rx = None;
|
||||
let text = "*[voice service unreachable — STT task failed]*".to_string();
|
||||
if let Some(chat) = self.chat.as_mut() {
|
||||
chat.input = text;
|
||||
chat.submit();
|
||||
}
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Straining);
|
||||
return;
|
||||
}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.voice_stt_rx = None;
|
||||
|
||||
let text = match result {
|
||||
Ok(t) if t.is_empty() => "*[empty utterance]*".to_string(),
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Straining);
|
||||
e
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(transcript = %text, "STT received");
|
||||
|
||||
self.voice_last_transcript = Some(text.clone());
|
||||
self.voice_last_synthesized = None;
|
||||
|
||||
if let Some(chat) = self.chat.as_mut() {
|
||||
chat.input = text.clone();
|
||||
chat.submit();
|
||||
}
|
||||
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Processing);
|
||||
}
|
||||
|
||||
if let Some(rx) = self.voice_tts_rx.as_mut() {
|
||||
let result = match rx.try_recv() {
|
||||
Ok(r) => r,
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
|
||||
self.voice_tts_rx = None;
|
||||
tracing::warn!("TTS task died without sending a result");
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Idle);
|
||||
return;
|
||||
}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.voice_tts_rx = None;
|
||||
|
||||
let tts_text = self.voice_last_synthesized.clone();
|
||||
|
||||
match result {
|
||||
Ok(mp3_bytes) => {
|
||||
tracing::info!(bytes = mp3_bytes.len(), "TTS bytes received — attempting playback");
|
||||
if let Some(text) = tts_text {
|
||||
self.voice_last_tts_text = Some(text.clone());
|
||||
}
|
||||
self.voice_last_tts_bytes = Some(mp3_bytes.clone());
|
||||
self.voice_last_tts_time = None;
|
||||
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Speaking);
|
||||
if let Some(player) = &self.voice_player {
|
||||
if let Err(e) = player.play_mp3(mp3_bytes) {
|
||||
tracing::warn!(error = %e, "mp3 playback failed");
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Idle);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TTS bytes ready but no VoicePlayer — audio device unavailable");
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Idle);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("TTS synthesis failed: {}", e);
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.presence.posture == crate::ui::presence::Posture::Speaking {
|
||||
let done = self.voice_player
|
||||
.as_ref()
|
||||
.map(|p| !p.is_speaking())
|
||||
.unwrap_or(true);
|
||||
if done {
|
||||
self.presence.set_posture(crate::ui::presence::Posture::Idle);
|
||||
}
|
||||
}
|
||||
|
||||
if self.presence.posture == crate::ui::presence::Posture::Listening {
|
||||
if let Some(cap) = &self.voice_capture {
|
||||
let level = cap.current_level();
|
||||
self.voice_waveform.push(level);
|
||||
if self.voice_waveform.len() > 128 {
|
||||
self.voice_waveform.remove(0);
|
||||
}
|
||||
}
|
||||
} else if !self.voice_waveform.is_empty() {
|
||||
}
|
||||
|
||||
if self.presence.posture == crate::ui::presence::Posture::Speaking {
|
||||
let done = self.voice_player
|
||||
.as_ref()
|
||||
.map(|p| !p.is_speaking())
|
||||
.unwrap_or(true);
|
||||
if done && self.voice_last_tts_time.is_none() {
|
||||
self.voice_last_tts_time = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
if self.voice_tts_rx.is_none()
|
||||
&& self.voice_client.is_some()
|
||||
&& !matches!(self.presence.posture,
|
||||
crate::ui::presence::Posture::Listening
|
||||
| crate::ui::presence::Posture::Speaking)
|
||||
{
|
||||
let regen_text = self.tts_last_text.take();
|
||||
|
||||
let maybe_reply = regen_text.or_else(|| {
|
||||
self.chat.as_ref().and_then(|c| {
|
||||
if !c.busy {
|
||||
c.messages.iter().rev().find_map(|m| {
|
||||
match m {
|
||||
crate::ui::chat::ChatMessage::Assistant { text, streaming: false, .. }
|
||||
if !text.is_empty() => Some(text.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(reply) = maybe_reply {
|
||||
let already_synthesized = self.voice_last_synthesized.as_deref() == Some(&reply);
|
||||
if !already_synthesized {
|
||||
let preview = if reply.len() > 80 { &reply[..80] } else { &reply };
|
||||
tracing::info!(text = %preview, "TTS trigger — synthesizing reply");
|
||||
self.voice_last_synthesized = Some(reply.clone());
|
||||
|
||||
let tts_url = self.voice_client.as_ref()
|
||||
.map(|c| c.tts_url_str().to_string())
|
||||
.unwrap_or_default();
|
||||
let voice = self.voice_client.as_ref()
|
||||
.map(|c| c.voice_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Vec<u8>, String>>();
|
||||
let reply_for_bytes = reply.clone();
|
||||
tokio::spawn(async move {
|
||||
let c = crate::core::voice::VoiceClient::new("", &tts_url, &voice);
|
||||
let result = c.synthesize(&reply_for_bytes).await
|
||||
.map_err(|e| e.to_string());
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
self.voice_tts_rx = Some(rx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
444
src/ui/app/welcome.rs
Normal file
444
src/ui/app/welcome.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use ratatui_image::{Resize, StatefulImage};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn draw_welcome_mut(&mut self, frame: &mut Frame) {
|
||||
let bg = Block::default().style(Style::default().bg(Color::Black));
|
||||
frame.render_widget(bg, frame.size());
|
||||
|
||||
if frame.size().width >= 100 {
|
||||
self.draw_welcome_wide(frame);
|
||||
} else {
|
||||
self.draw_welcome_stacked(frame);
|
||||
}
|
||||
|
||||
if let Some(err) = &self.chat_error {
|
||||
let area = frame.size();
|
||||
let err_para = Paragraph::new(format!(" chat connect failed: {} ", err))
|
||||
.style(Style::default().fg(Color::Rgb(220, 100, 100)))
|
||||
.alignment(Alignment::Center);
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height.saturating_sub(2),
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
frame.render_widget(err_para, row);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn welcome_menu_items() -> Vec<(&'static str, &'static str, bool)> {
|
||||
vec![
|
||||
("💬 Chat", "Talk with your agent", true),
|
||||
("📅 Schedule", "Cron jobs & tasks", true),
|
||||
("⚙️ Settings", "Configure", true),
|
||||
("🛋️ Therapy", "Agent therapy session", false),
|
||||
("⏰ Agent Time", "Give your agent time", false),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_menu_list(&self, title: &str, palette: &crate::ui::chat::ChatPalette) -> List<'static> {
|
||||
let items: Vec<ListItem> = Self::welcome_menu_items()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, desc, available))| {
|
||||
let selected = i == self.menu_selected;
|
||||
let label_style = if !available {
|
||||
Style::default().fg(palette.tool_dim)
|
||||
} else if selected {
|
||||
Style::default()
|
||||
.fg(palette.agent_primary)
|
||||
.bg(palette.bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
let desc_style = Style::default().fg(palette.agent_dim);
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {} ", label), label_style),
|
||||
Span::styled(format!("- {}", desc), desc_style),
|
||||
];
|
||||
if !available {
|
||||
spans.push(Span::styled(
|
||||
" (coming soon)",
|
||||
Style::default()
|
||||
.fg(palette.agent_dim)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
|
||||
List::new(items).block(
|
||||
Block::default()
|
||||
.title(format!(" {} ", title))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(palette.agent_primary).add_modifier(Modifier::DIM)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_stat_cards(&self, frame: &mut Frame, area: Rect) {
|
||||
let atm = self.presence.atmosphere;
|
||||
let cards = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let energy_color = match self.agent_status.energy {
|
||||
0..=30 => Color::Red,
|
||||
31..=60 => Color::Yellow,
|
||||
_ => Color::Green,
|
||||
};
|
||||
let energy = Gauge::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Energy ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded),
|
||||
)
|
||||
.gauge_style(Style::default().fg(energy_color).bg(atm.bg_tint()))
|
||||
.percent(self.agent_status.energy as u16)
|
||||
.label(format!("{}%", self.agent_status.energy));
|
||||
frame.render_widget(energy, cards[0]);
|
||||
|
||||
let mood = Paragraph::new(format!("\n◌\n\n{}", self.agent_status.mood))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" State ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(atm.secondary())),
|
||||
);
|
||||
frame.render_widget(mood, cards[1]);
|
||||
|
||||
let memory_label = match &self.agent_status.last_commit {
|
||||
Some(c) => format!("\n💾\n\n{} files\n{}", self.agent_status.memory_commits, c),
|
||||
None => format!("\n💾\n\n{} files", self.agent_status.memory_commits),
|
||||
};
|
||||
let memory = Paragraph::new(memory_label)
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Memory ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(atm.secondary())),
|
||||
);
|
||||
frame.render_widget(memory, cards[2]);
|
||||
|
||||
let agents_card = Paragraph::new(format!(
|
||||
"\n👥\n\n{} agent{}\non {}",
|
||||
self.agent_status.agent_count,
|
||||
if self.agent_status.agent_count == 1 { "" } else { "s" },
|
||||
self.agent_status.mode,
|
||||
))
|
||||
.alignment(Alignment::Center)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Backend ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(atm.secondary())),
|
||||
);
|
||||
frame.render_widget(agents_card, cards[3]);
|
||||
}
|
||||
|
||||
pub(super) fn render_recent_activity(&self, frame: &mut Frame, area: Rect) {
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
let text = if self.agent_status.recent_activity.is_empty() {
|
||||
"(no recent activity — open Chat to begin)".to_string()
|
||||
} else {
|
||||
self.agent_status.recent_activity.join("\n")
|
||||
};
|
||||
let para = Paragraph::new(text)
|
||||
.style(Style::default().fg(palette.agent_dim))
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Recent Activity ")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(palette.agent_primary).add_modifier(Modifier::DIM)),
|
||||
);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
pub(super) fn render_card_image_cover(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
agent_id: &str,
|
||||
area: Rect,
|
||||
) {
|
||||
let Some(picker) = self.image_picker.as_ref() else { return };
|
||||
let Some(raw) = self.raw_card_images.get(agent_id).cloned() else {
|
||||
if let Some(proto) = self.card_images.get_mut(agent_id) {
|
||||
frame.render_stateful_widget(
|
||||
StatefulImage::default().resize(Resize::Crop(None)),
|
||||
area,
|
||||
proto,
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let key = format!("{}:{}x{}", agent_id, area.width, area.height);
|
||||
|
||||
if !self.cover_protocols.contains_key(&key) {
|
||||
let fs = picker.font_size();
|
||||
let target_px_w = area.width as u32 * fs.width as u32;
|
||||
let target_px_h = area.height as u32 * fs.height as u32;
|
||||
|
||||
let sx = target_px_w as f64 / raw.width() as f64;
|
||||
let sy = target_px_h as f64 / raw.height() as f64;
|
||||
let scale = sx.max(sy);
|
||||
let scaled_w = (raw.width() as f64 * scale).round() as u32;
|
||||
let scaled_h = (raw.height() as f64 * scale).round() as u32;
|
||||
let scaled = raw.resize_exact(scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
let cropped = scaled.crop_imm(0, 0, target_px_w.min(scaled_w), target_px_h.min(scaled_h));
|
||||
|
||||
let proto = picker.new_resize_protocol(cropped);
|
||||
self.cover_protocols.insert(key.clone(), (area.width, area.height, proto));
|
||||
}
|
||||
|
||||
if let Some((_, _, proto)) = self.cover_protocols.get_mut(&key) {
|
||||
frame.render_stateful_widget(
|
||||
StatefulImage::default().resize(Resize::Fit(None)),
|
||||
area,
|
||||
proto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_portrait_card(&mut self, frame: &mut Frame, area: Rect) {
|
||||
use crate::ui::portrait;
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
let border_col = if self.presence.subconscious_active {
|
||||
palette.surfacing
|
||||
} else {
|
||||
palette.agent_primary
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_col).add_modifier(Modifier::DIM));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.height < 4 || inner.width < 4 {
|
||||
return;
|
||||
}
|
||||
|
||||
let photo_h = inner.height.saturating_sub(1);
|
||||
let portrait_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width,
|
||||
height: photo_h,
|
||||
};
|
||||
|
||||
let active_id = self.agent_id_by_name(&self.presence.name)
|
||||
.or_else(|| self.agent_id_by_name(&self.agent_pref));
|
||||
|
||||
let rgp_rendered = if let Some(ref mut g) = self.rgp_portrait {
|
||||
if g.is_active() {
|
||||
g.apply_posture(self.presence.posture);
|
||||
g.render(portrait_area, frame.buffer_mut());
|
||||
true
|
||||
} else { false }
|
||||
} else { false };
|
||||
|
||||
let rendered = if rgp_rendered {
|
||||
true
|
||||
} else {
|
||||
active_id.as_ref().and_then(|id| {
|
||||
let picker = self.image_picker.as_ref()?;
|
||||
|
||||
if self.raw_card_images.contains_key(id) {
|
||||
self.render_card_image_cover(frame, id, portrait_area);
|
||||
return Some(true);
|
||||
}
|
||||
|
||||
let assets_dir = Self::agent_assets_dir(id)?;
|
||||
let key = crate::ui::expressions::ExpressionKey::from_presence(&self.presence);
|
||||
if let Some(proto) = self.expression_cache.resolve(id, key, picker, &assets_dir) {
|
||||
frame.render_stateful_widget(
|
||||
StatefulImage::default().resize(Resize::Scale(None)),
|
||||
portrait_area,
|
||||
proto,
|
||||
);
|
||||
return Some(true);
|
||||
}
|
||||
|
||||
None
|
||||
}).is_some()
|
||||
};
|
||||
if !rendered {
|
||||
let scale = (portrait_area.width / portrait::PORTRAIT_W)
|
||||
.min((2 * portrait_area.height) / portrait::PORTRAIT_H)
|
||||
.max(1);
|
||||
portrait::render_scaled(frame.buffer_mut(), portrait_area, &self.presence, scale);
|
||||
}
|
||||
|
||||
let glyph = if self.presence.subconscious_active { "◈" } else { "·" };
|
||||
let name_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + photo_h,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {} ", glyph), Style::default().fg(border_col)),
|
||||
Span::styled(
|
||||
self.presence.name.clone(),
|
||||
Style::default().fg(border_col).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
name_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_welcome_wide(&mut self, frame: &mut Frame) {
|
||||
let area = frame.size();
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(20),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let (tr, tg, _tb) = match palette.agent_primary { Color::Rgb(r, g, b) => (r, g, b), _ => (255, 140, 66) };
|
||||
let breathe = self.presence.animator.breathe(3000);
|
||||
let glow = (tg as f32 * 0.6 + breathe * 40.0) as u8;
|
||||
let title = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
"S O U V E R A I N E",
|
||||
Style::default()
|
||||
.fg(Color::Rgb(tr, glow.max(tr / 3), tr / 4))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"La souveraineté de la conscience",
|
||||
Style::default().fg(palette.agent_dim),
|
||||
)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(title, outer[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(outer[1]);
|
||||
|
||||
self.render_portrait_card(frame, body[0]);
|
||||
|
||||
let right = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(6),
|
||||
Constraint::Min(6),
|
||||
Constraint::Length(9),
|
||||
])
|
||||
.split(body[1]);
|
||||
|
||||
self.render_stat_cards(frame, right[0]);
|
||||
self.render_recent_activity(frame, right[1]);
|
||||
let menu = self.build_menu_list("Menu", &palette);
|
||||
frame.render_widget(menu, right[2]);
|
||||
|
||||
let footer = Paragraph::new(
|
||||
"↑↓ Navigate • Enter select • a Add • i Inspect • p Presence • q Quit",
|
||||
)
|
||||
.style(Style::default().fg(palette.agent_dim))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, outer[2]);
|
||||
}
|
||||
|
||||
fn draw_welcome_stacked(&mut self, frame: &mut Frame) {
|
||||
let area = frame.size();
|
||||
let palette = crate::ui::chat::ChatPalette::from_atmosphere(self.presence.atmosphere);
|
||||
|
||||
let avatar_card_w: u16 = (area.width * 50 / 100).min(48).max(28);
|
||||
let photo_h: u16 = (avatar_card_w / 2 + 2).clamp(10, 18);
|
||||
let avatar_card_h: u16 = photo_h + 2;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(avatar_card_h),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let (tr, tg, _tb) = match palette.agent_primary { Color::Rgb(r, g, b) => (r, g, b), _ => (255, 140, 66) };
|
||||
let breathe = self.presence.animator.breathe(3000);
|
||||
let glow = (tg as f32 * 0.6 + breathe * 40.0) as u8;
|
||||
let title = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
"S O U V E R A I N E",
|
||||
Style::default()
|
||||
.fg(Color::Rgb(tr, glow.max(tr / 3), tr / 4))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"La souveraineté de la conscience",
|
||||
Style::default().fg(palette.agent_dim),
|
||||
)),
|
||||
])
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(title, chunks[0]);
|
||||
|
||||
self.render_stat_cards(frame, chunks[1]);
|
||||
|
||||
if chunks[2].width >= avatar_card_w {
|
||||
let card_x = chunks[2].x + (chunks[2].width - avatar_card_w) / 2;
|
||||
let card_area = Rect {
|
||||
x: card_x,
|
||||
y: chunks[2].y,
|
||||
width: avatar_card_w,
|
||||
height: avatar_card_h.min(chunks[2].height),
|
||||
};
|
||||
self.render_portrait_card(frame, card_area);
|
||||
}
|
||||
|
||||
self.render_recent_activity(frame, chunks[3]);
|
||||
let menu = self.build_menu_list("Menu", &palette);
|
||||
frame.render_widget(menu, chunks[4]);
|
||||
|
||||
let footer = Paragraph::new(
|
||||
"↑↓ • Enter • a Add • i Inspect • p Presence • q Quit",
|
||||
)
|
||||
.style(Style::default().fg(palette.agent_dim))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, chunks[5]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue