feat(tui): HealthPane — live substrate vitals panel
A second sidebar component beneath the cockpit. Shows context pressure (tiered bar), backend connectivity, N+1/N+25/N+100 cadence, last compaction warning, inference strain (504/429/error tallies — the 504s Casey kept seeing), energy and mood, plus session uptime in the title. Pure accumulation from existing TuiEvents — no new backend wiring.
This commit is contained in:
parent
27d86fe4ca
commit
73ab402b19
3 changed files with 348 additions and 0 deletions
|
|
@ -30,6 +30,7 @@ use tracing::{info, warn};
|
|||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::ui::chat::{ChatState, draw as draw_chat};
|
||||
use crate::ui::cockpit_panel::CockpitPane;
|
||||
use crate::ui::health_panel::HealthPane;
|
||||
use crate::ui::presence::{Posture, Presence, draw_overlay as draw_presence_overlay};
|
||||
use crate::ui::color_support::rgb;
|
||||
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
|
|
@ -270,6 +271,9 @@ impl App {
|
|||
// Presence (Annie's body channel) lives outside the Scene because it's
|
||||
// an overlay, not a zoned component — App feeds it events via `dispatch`.
|
||||
app.scene.add(CockpitPane::new());
|
||||
// HealthPane sits below the cockpit in the sidebar — substrate vitals
|
||||
// (pressure, backend, N+1 cadence, 504 strain) beneath her voice.
|
||||
app.scene.add(HealthPane::new());
|
||||
|
||||
app
|
||||
}
|
||||
|
|
|
|||
342
src/ui/health_panel.rs
Normal file
342
src/ui/health_panel.rs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
//! HealthPane — a live vitals readout for the agent's substrate.
|
||||
//!
|
||||
//! Where the CockpitPane shows what the subconscious *says*, the HealthPane
|
||||
//! shows how the substrate *is*: context pressure, backend connectivity, the
|
||||
//! cadence of the N+1/N+25/N+100 passes, inference strain (the 504s Casey
|
||||
//! kept seeing), and the agent's own energy and mood.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭─ Health · 02:14 up ─────╮
|
||||
//! │ context ███████░░░ 73% │
|
||||
//! │ backend ● local │
|
||||
//! │ N+1 48 · 12 surfaced│
|
||||
//! │ N+25 refl 14:35 │
|
||||
//! │ N+100 arch 13:02 │
|
||||
//! │ compact urgent · 14:40 │
|
||||
//! │ strain 504×3 · 429×1 │
|
||||
//! │ energy 72 · focused │
|
||||
//! ╰─────────────────────────╯
|
||||
//! ```
|
||||
//!
|
||||
//! Every value comes from a `TuiEvent` the TUI already emits — the pane is
|
||||
//! pure accumulation, no new backend wiring.
|
||||
|
||||
use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
layout::Rect,
|
||||
Frame,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::chat::ChatPalette;
|
||||
use super::component::{Component, TuiEvent};
|
||||
|
||||
pub struct HealthPane {
|
||||
/// When the pane came up — drives the uptime readout.
|
||||
started: Instant,
|
||||
|
||||
/// Latest context pressure (0.0–1.0).
|
||||
pressure: f32,
|
||||
/// Last compaction warning: (tier, "HH:MM").
|
||||
last_compaction: Option<(u8, String)>,
|
||||
|
||||
/// Whether an N+1 subconscious pass is in flight right now.
|
||||
n1_active: bool,
|
||||
/// Completed N+1 passes this session.
|
||||
n1_passes: u32,
|
||||
/// Surfacings promoted to the cockpit this session.
|
||||
surfacings: u32,
|
||||
/// Last N+25 reflection time.
|
||||
last_reflection: Option<String>,
|
||||
/// Last N+100 archivist synthesis time.
|
||||
last_archivist: Option<String>,
|
||||
|
||||
/// Inference strain tallies — 504s are the timeout Casey watches for.
|
||||
strain_504: u32,
|
||||
strain_429: u32,
|
||||
strain_other: u32,
|
||||
|
||||
/// Backend connectivity.
|
||||
backend_mode: String,
|
||||
backend_healthy: bool,
|
||||
|
||||
/// Agent felt-state.
|
||||
energy: u8,
|
||||
mood: String,
|
||||
|
||||
palette: ChatPalette,
|
||||
}
|
||||
|
||||
impl HealthPane {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
started: Instant::now(),
|
||||
pressure: 0.0,
|
||||
last_compaction: None,
|
||||
n1_active: false,
|
||||
n1_passes: 0,
|
||||
surfacings: 0,
|
||||
last_reflection: None,
|
||||
last_archivist: None,
|
||||
strain_504: 0,
|
||||
strain_429: 0,
|
||||
strain_other: 0,
|
||||
backend_mode: "—".to_string(),
|
||||
backend_healthy: false,
|
||||
energy: 0,
|
||||
mood: "—".to_string(),
|
||||
palette: ChatPalette::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_now() -> String {
|
||||
chrono::Local::now().format("%H:%M").to_string()
|
||||
}
|
||||
|
||||
fn set_palette_from_name(&mut self, name: &str) {
|
||||
use super::atmosphere::Atmosphere;
|
||||
let atm = match name.to_lowercase().replace(' ', "_").as_str() {
|
||||
"mint_tea" => Atmosphere::MintTea,
|
||||
"therapeutic_blue" => Atmosphere::TherapeuticBlue,
|
||||
"lavender_calm" => Atmosphere::LavenderCalm,
|
||||
"warm_amber" => Atmosphere::WarmAmber,
|
||||
"peach_sunset" => Atmosphere::PeachSunset,
|
||||
"autumn_browns" => Atmosphere::AutumnBrowns,
|
||||
"neon_glow" => Atmosphere::NeonGlow,
|
||||
"aurora_borealis" => Atmosphere::AuroraBorealis,
|
||||
"cherry_blossom" => Atmosphere::CherryBlossom,
|
||||
"ocean_depths" => Atmosphere::OceanDepths,
|
||||
"midnight_galaxy" => Atmosphere::MidnightGalaxy,
|
||||
"twilight_mist" => Atmosphere::TwilightMist,
|
||||
"forest_greens" => Atmosphere::ForestGreens,
|
||||
_ => return,
|
||||
};
|
||||
self.palette = ChatPalette::from_atmosphere(atm);
|
||||
}
|
||||
|
||||
/// Colour for the current pressure, tiered like the compaction warnings.
|
||||
fn pressure_color(&self) -> Color {
|
||||
if self.pressure >= 0.95 {
|
||||
Color::Rgb(220, 100, 100) // critical
|
||||
} else if self.pressure >= 0.90 {
|
||||
Color::Rgb(230, 150, 80) // urgent
|
||||
} else if self.pressure >= 0.80 {
|
||||
Color::Rgb(220, 200, 110) // warn
|
||||
} else {
|
||||
Color::Rgb(120, 200, 120) // easy
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10-cell block bar for a 0.0–1.0 ratio.
|
||||
fn bar(ratio: f32, cells: usize) -> String {
|
||||
let ratio = ratio.clamp(0.0, 1.0);
|
||||
let filled = (ratio * cells as f32).round() as usize;
|
||||
let filled = filled.min(cells);
|
||||
let mut s = String::with_capacity(cells);
|
||||
for i in 0..cells {
|
||||
s.push(if i < filled { '█' } else { '░' });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// One `label value` row with a dim label and a coloured value.
|
||||
fn row(&self, label: &str, value: Vec<Span<'static>>) -> Line<'static> {
|
||||
let mut spans = vec![Span::styled(
|
||||
format!(" {:<8}", label),
|
||||
Style::default().fg(self.palette.agent_dim),
|
||||
)];
|
||||
spans.extend(value);
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn build_lines(&self) -> Vec<Line<'static>> {
|
||||
let p = &self.palette;
|
||||
let dim = Style::default().fg(p.agent_dim);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// ── context pressure ──────────────────────────────────────────
|
||||
let pc = self.pressure_color();
|
||||
let pct = (self.pressure * 100.0).round() as u16;
|
||||
lines.push(self.row("context", vec![
|
||||
Span::styled(Self::bar(self.pressure, 10), Style::default().fg(pc)),
|
||||
Span::styled(format!(" {pct:>3}%"), Style::default().fg(pc).add_modifier(Modifier::BOLD)),
|
||||
]));
|
||||
|
||||
// ── backend ───────────────────────────────────────────────────
|
||||
let (dot, dot_c) = if self.backend_healthy {
|
||||
("●", Color::Rgb(120, 200, 120))
|
||||
} else {
|
||||
("○", Color::Rgb(200, 120, 100))
|
||||
};
|
||||
lines.push(self.row("backend", vec![
|
||||
Span::styled(format!("{dot} "), Style::default().fg(dot_c)),
|
||||
Span::styled(self.backend_mode.clone(), Style::default().fg(p.agent_primary)),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// ── N+1 subconscious ──────────────────────────────────────────
|
||||
let n1_mark = if self.n1_active { "◌ active" } else { "idle" };
|
||||
lines.push(self.row("N+1", vec![
|
||||
Span::styled(
|
||||
format!("{} pass · {}", self.n1_passes, n1_mark),
|
||||
Style::default().fg(if self.n1_active { p.surfacing } else { p.agent_primary }),
|
||||
),
|
||||
]));
|
||||
lines.push(self.row("◈ surf", vec![
|
||||
Span::styled(format!("{} surfaced", self.surfacings), Style::default().fg(p.surfacing)),
|
||||
]));
|
||||
|
||||
// ── N+25 reflection ───────────────────────────────────────────
|
||||
lines.push(self.row("◎ N+25", vec![match &self.last_reflection {
|
||||
Some(t) => Span::styled(t.clone(), Style::default().fg(p.reflection)),
|
||||
None => Span::styled("—", dim),
|
||||
}]));
|
||||
|
||||
// ── N+100 archivist ───────────────────────────────────────────
|
||||
lines.push(self.row("◉ N+100", vec![match &self.last_archivist {
|
||||
Some(t) => Span::styled(t.clone(), Style::default().fg(p.archivist)),
|
||||
None => Span::styled("—", dim),
|
||||
}]));
|
||||
|
||||
// ── last compaction warning ───────────────────────────────────
|
||||
lines.push(self.row("⚠ compact", vec![match &self.last_compaction {
|
||||
Some((tier, t)) => {
|
||||
let label = match tier {
|
||||
3 => "critical",
|
||||
2 => "urgent",
|
||||
_ => "warn",
|
||||
};
|
||||
Span::styled(format!("{label} · {t}"), Style::default().fg(p.compaction))
|
||||
}
|
||||
None => Span::styled("none", dim),
|
||||
}]));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// ── inference strain ──────────────────────────────────────────
|
||||
let strain_spans: Vec<Span> = if self.strain_504 + self.strain_429 + self.strain_other == 0 {
|
||||
vec![Span::styled("clear", Style::default().fg(Color::Rgb(120, 200, 120)))]
|
||||
} else {
|
||||
let mut v = Vec::new();
|
||||
if self.strain_504 > 0 {
|
||||
v.push(Span::styled(
|
||||
format!("504×{}", self.strain_504),
|
||||
Style::default().fg(Color::Rgb(220, 100, 100)).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
if self.strain_429 > 0 {
|
||||
if !v.is_empty() { v.push(Span::styled(" · ", dim)); }
|
||||
v.push(Span::styled(format!("429×{}", self.strain_429), Style::default().fg(p.compaction)));
|
||||
}
|
||||
if self.strain_other > 0 {
|
||||
if !v.is_empty() { v.push(Span::styled(" · ", dim)); }
|
||||
v.push(Span::styled(format!("err×{}", self.strain_other), Style::default().fg(p.compaction)));
|
||||
}
|
||||
v
|
||||
};
|
||||
lines.push(self.row("strain", strain_spans));
|
||||
|
||||
// ── energy + mood ─────────────────────────────────────────────
|
||||
lines.push(self.row("energy", vec![
|
||||
Span::styled(Self::bar(self.energy as f32 / 100.0, 5), Style::default().fg(p.agent_primary)),
|
||||
Span::styled(format!(" {}", self.energy), Style::default().fg(p.agent_primary)),
|
||||
]));
|
||||
lines.push(self.row("mood", vec![
|
||||
Span::styled(self.mood.clone(), Style::default().fg(p.agent_primary).add_modifier(Modifier::ITALIC)),
|
||||
]));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn uptime(&self) -> String {
|
||||
let secs = self.started.elapsed().as_secs();
|
||||
let (h, m) = (secs / 3600, (secs % 3600) / 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02} up")
|
||||
} else {
|
||||
format!("{m}m up")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for HealthPane {
|
||||
fn name(&self) -> &str {
|
||||
"health"
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: &TuiEvent) -> bool {
|
||||
match event {
|
||||
TuiEvent::PressureChanged(p) => { self.pressure = *p; true }
|
||||
TuiEvent::CompactionWarning { pressure, tier } => {
|
||||
self.pressure = *pressure;
|
||||
self.last_compaction = Some((*tier, Self::timestamp_now()));
|
||||
true
|
||||
}
|
||||
TuiEvent::Archivist { pressure, .. } => {
|
||||
self.pressure = *pressure;
|
||||
self.last_archivist = Some(Self::timestamp_now());
|
||||
true
|
||||
}
|
||||
TuiEvent::Reflection { .. } => {
|
||||
self.last_reflection = Some(Self::timestamp_now());
|
||||
true
|
||||
}
|
||||
TuiEvent::Surfacing { .. } => { self.surfacings += 1; true }
|
||||
TuiEvent::SubconsciousPass(active) => {
|
||||
if *active {
|
||||
self.n1_active = true;
|
||||
} else if self.n1_active {
|
||||
self.n1_active = false;
|
||||
self.n1_passes += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
TuiEvent::InferenceStrain { status, .. } => {
|
||||
match *status {
|
||||
504 => self.strain_504 += 1,
|
||||
429 => self.strain_429 += 1,
|
||||
_ => self.strain_other += 1,
|
||||
}
|
||||
true
|
||||
}
|
||||
TuiEvent::BackendStatus { mode, healthy } => {
|
||||
self.backend_mode = mode.clone();
|
||||
self.backend_healthy = *healthy;
|
||||
true
|
||||
}
|
||||
TuiEvent::EnergyChanged(e) => { self.energy = *e; true }
|
||||
TuiEvent::MoodChanged(m) => { self.mood = m.clone(); true }
|
||||
TuiEvent::AtmosphereChanged(name) => { self.set_palette_from_name(name); true }
|
||||
// Redraw once a minute so the uptime clock advances.
|
||||
TuiEvent::Tick(t) => *t % 60 == 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, frame: &mut Frame) {
|
||||
if area.width < 12 || area.height < 4 {
|
||||
return;
|
||||
}
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(self.palette.agent_primary).add_modifier(Modifier::DIM))
|
||||
.title(Span::styled(
|
||||
format!(" Health · {} ", self.uptime()),
|
||||
Style::default().fg(self.palette.agent_primary).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.width < 6 || inner.height < 1 {
|
||||
return;
|
||||
}
|
||||
let lines = self.build_lines();
|
||||
// Clip to the available height — vitals are most-important-first.
|
||||
let visible: Vec<Line> = lines.into_iter().take(inner.height as usize).collect();
|
||||
frame.render_widget(Paragraph::new(visible), inner);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ pub mod chat;
|
|||
pub mod cockpit_panel;
|
||||
pub mod color_support;
|
||||
pub mod component;
|
||||
pub mod health_panel;
|
||||
pub mod markdown;
|
||||
pub mod expressions;
|
||||
pub mod portrait;
|
||||
|
|
@ -17,5 +18,6 @@ pub mod voice;
|
|||
|
||||
pub use app::App;
|
||||
pub use cockpit_panel::CockpitPane;
|
||||
pub use health_panel::HealthPane;
|
||||
pub use component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
pub use presence::{Position, Posture, Presence};
|
||||
|
|
|
|||
Loading…
Reference in a new issue