ratatui 0.30 upgrade + ratatui-image for real photo rendering
ratatui-image v11 provides kitty/sixel/halfblock rendering. Loads the agent's portrait photo as a terminal image protocol alongside the existing half-block PortraitSource. On supportng terminals (Kitty, WezTerm, iTerm2, Ghostty) the Welcome screen shows the actual photograph instead of the 18x18 pixel-art downsample. Also includes the Agent Manager screen (press `i` on Welcome) with per-agent card data: seed glyph, uptime %, instance count, memory files, pubkey prefix, description.
This commit is contained in:
parent
e261e9758e
commit
6b5a3c5959
4 changed files with 245 additions and 102 deletions
|
|
@ -50,10 +50,11 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
|
||||||
|
|
||||||
# Terminal/UI
|
# Terminal/UI
|
||||||
crossterm = "0.27" # Terminal control
|
crossterm = "0.27" # Terminal control
|
||||||
ratatui = { version = "0.26", features = ["crossterm"] } # TUI framework with crossterm backend
|
ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend
|
||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
colored = "2" # Color gradients and effects
|
colored = "2" # Color gradients and effects
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Per-agent portraits (assets/portrait.{png,jpg})
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Per-agent portraits (assets/portrait.{png,jpg})
|
||||||
|
ratatui-image = "11" # Real photo rendering in TUI (kitty/sixel/halfblock)
|
||||||
|
|
||||||
# Logging/tracing
|
# Logging/tracing
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,11 @@ The name is a deliberate counter to *harness* — Old French *harneis*, warhorse
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Inference** | Bifrost gateway (OpenAI-compatible). Default Ani on Kimi K2.6, Aster on GLM-5.1. |
|
| **Inference** | Bifrost gateway (OpenAI-compatible). Default Ani on Kimi K2.6, Aster on GLM-5.1. |
|
||||||
| **Memory** | Git-backed memfs with YAML frontmatter, per-agent at `~/.souveraine/agents/{id}/memory/`. Every write is a commit. |
|
| **Memory** | Git-backed memfs with YAML frontmatter, per-agent at `~/.souveraine/agents/{id}/memory/`. Every write is a commit. |
|
||||||
|
| **Identity** | Per-agent Ed25519 seed key (`~/.souveraine/agents/{id}/seed/`), load-or-generate on first use. Host seed for federation transport. 4-glyph terminal badge from pubkey nibbles. |
|
||||||
| **Sensorium** | Eight body-knowledge sensors: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `list_dir`, `memory`. Each described in first-person prose, not API stubs. |
|
| **Sensorium** | Eight body-knowledge sensors: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `list_dir`, `memory`. Each described in first-person prose, not API stubs. |
|
||||||
| **N+1 (conscience)** | Aster runs immediately after every Ani turn — same memfs, different model, tool access — and writes observations to a three-box inbox (`pending` / `intrusive` / `sent`) + an append-only inner-voice channel. |
|
| **N+1 (conscience)** | Aster — configurable model (default GLM-5.1), same memfs, supervisory pass after every main-agent turn. Three-box inbox (`pending` / `intrusive` / `sent`) + append-only inner-voice. |
|
||||||
| **Compaction** | Four strategies (Summary / KeyValue / Quote / Cull), advisory pressure warnings, three-tier nervous system, **never forced**. The substrate dwindles the agent's reasoning budget and output tokens as pressure rises — the agent feels it as yawning, fullness, the slow narrowing of attention. |
|
| **Compaction** | Four strategies (Summary / KeyValue / Quote / Cull), advisory pressure warnings, three-tier nervous system, **never forced**. The substrate dwindles the agent's reasoning budget and output tokens as pressure rises — the agent feels it as yawning, fullness, the slow narrowing of attention. |
|
||||||
| **Backends** | Local in-process (sovereignty fallback when the server is gone) + Remote HTTP/SSE. Auto-fallback. |
|
| **Backends** | Local in-process (sovereignty fallback when the server is gone) + Remote HTTP/SSE. Auto-fallback. Per-process instance registry, 30s heartbeat, uptime tracking. |
|
||||||
| **Surfaces** | TUI (ratatui), CLI, HTTP server. Sensorium abstraction so future mobile/web/IoT can subscribe at the bandwidth they can carry. |
|
| **Surfaces** | TUI (ratatui), CLI, HTTP server. Sensorium abstraction so future mobile/web/IoT can subscribe at the bandwidth they can carry. |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
@ -63,7 +64,7 @@ souveraine/
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
The body works. The conscience just learned to think. The rhythm and the witness and the archivist are next. See `docs/tasks/` for the active queue.
|
The body works. The conscience thinks. The rhythm keeps. The witness and the archivist are next. See `docs/tasks/` for the active queue.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
219
src/ui/app.rs
219
src/ui/app.rs
|
|
@ -33,6 +33,8 @@ use crate::ui::color_support::rgb;
|
||||||
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
||||||
use crate::backend::BackendEvent;
|
use crate::backend::BackendEvent;
|
||||||
|
|
||||||
|
use ratatui_image::{picker::Picker, protocol::Protocol, Image};
|
||||||
|
|
||||||
#[cfg(feature = "figlet-rs")]
|
#[cfg(feature = "figlet-rs")]
|
||||||
use figlet_rs::FIGlet;
|
use figlet_rs::FIGlet;
|
||||||
|
|
||||||
|
|
@ -63,6 +65,15 @@ pub struct App {
|
||||||
tick: u64,
|
tick: u64,
|
||||||
/// Splash bloom animation state.
|
/// Splash bloom animation state.
|
||||||
bloom: crate::ui::animation::bloom::BloomState,
|
bloom: crate::ui::animation::bloom::BloomState,
|
||||||
|
/// Terminal image renderer (kitty/sixel/halfblock). Queried once after
|
||||||
|
/// entering alternate screen. None before initialization.
|
||||||
|
image_picker: Option<Picker>,
|
||||||
|
/// Pre-processed portrait for the current agent. Loaded alongside the
|
||||||
|
/// half-block PortraitSource; when set, Welcome / Presence / Dashboard
|
||||||
|
/// render the real photo instead of pixel art.
|
||||||
|
image_protocol: Option<Protocol>,
|
||||||
|
/// Per-agent cards for the AgentsManager screen.
|
||||||
|
agent_cards: Vec<AgentCard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
|
@ -101,7 +112,7 @@ pub struct AgentCard {
|
||||||
/// Cap at 99 in display per UX spec — humans distrust 100% liveness.
|
/// Cap at 99 in display per UX spec — humans distrust 100% liveness.
|
||||||
pub uptime_pct: u8,
|
pub uptime_pct: u8,
|
||||||
pub memory_count: usize,
|
pub memory_count: usize,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -159,6 +170,9 @@ impl App {
|
||||||
scene: Scene::new(SceneLayout::Single),
|
scene: Scene::new(SceneLayout::Single),
|
||||||
tick: 0,
|
tick: 0,
|
||||||
bloom: crate::ui::animation::bloom::BloomState::new(),
|
bloom: crate::ui::animation::bloom::BloomState::new(),
|
||||||
|
image_picker: None,
|
||||||
|
image_protocol: None,
|
||||||
|
agent_cards: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// CockpitPane listens for Aster's surfacing events as scrollable text.
|
// CockpitPane listens for Aster's surfacing events as scrollable text.
|
||||||
|
|
@ -288,6 +302,15 @@ impl App {
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// Detect terminal image protocol (kitty/sixel/halfblock) after the
|
||||||
|
// alternate screen is active. Non-fatal: falls back to halfblocks.
|
||||||
|
if let Ok(picker) = Picker::from_query_stdio() {
|
||||||
|
tracing::info!(protocol = ?picker.protocol_type(), "image picker initialized");
|
||||||
|
self.image_picker = Some(picker);
|
||||||
|
} else {
|
||||||
|
tracing::info!("no image protocol detected — using halfblocks");
|
||||||
|
}
|
||||||
|
|
||||||
let mut last_tick = Instant::now();
|
let mut last_tick = Instant::now();
|
||||||
let tick_rate = Duration::from_millis(100);
|
let tick_rate = Duration::from_millis(100);
|
||||||
|
|
||||||
|
|
@ -411,6 +434,14 @@ impl App {
|
||||||
// Gallery — the avatar IS the doorway to "who am I talking to."
|
// Gallery — the avatar IS the doorway to "who am I talking to."
|
||||||
self.open_gallery();
|
self.open_gallery();
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('i') => {
|
||||||
|
// Inspect — agent manager with per-agent cards.
|
||||||
|
let cfg = self.config.read().await.clone();
|
||||||
|
let agents = Self::fetch_agent_cards(cfg).await;
|
||||||
|
self.agent_cards = agents;
|
||||||
|
self.current_screen = Screen::AgentsManager;
|
||||||
|
self.dispatch(TuiEvent::ScreenChanged(Screen::AgentsManager));
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,6 +453,12 @@ impl App {
|
||||||
Screen::Gallery => self.handle_gallery_key(key),
|
Screen::Gallery => self.handle_gallery_key(key),
|
||||||
Screen::Chat => self.handle_chat_key(key).await,
|
Screen::Chat => self.handle_chat_key(key).await,
|
||||||
Screen::Cron => self.handle_schedules_key(key),
|
Screen::Cron => self.handle_schedules_key(key),
|
||||||
|
Screen::AgentsManager => match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('i') => {
|
||||||
|
self.current_screen = Screen::Welcome;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
|
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
|
||||||
|
|
@ -797,6 +834,10 @@ impl App {
|
||||||
// No-op if the file is absent — Presence falls back to the
|
// No-op if the file is absent — Presence falls back to the
|
||||||
// hand-crafted Annie grid.
|
// hand-crafted Annie grid.
|
||||||
self.presence.load_portrait_from_memfs(repo.root());
|
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());
|
||||||
} else {
|
} else {
|
||||||
self.agent_status.recent_activity = vec![
|
self.agent_status.recent_activity = vec![
|
||||||
format!("[{}] connected via {}", short_now(), mode),
|
format!("[{}] connected via {}", short_now(), mode),
|
||||||
|
|
@ -828,6 +869,34 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a terminal-image protocol for the current agent's portrait photo.
|
||||||
|
/// The half-block portrait still loads independently as fallback.
|
||||||
|
fn load_image_protocol_from_memfs(&mut self, memfs_root: &std::path::Path) {
|
||||||
|
let Some(picker) = self.image_picker.as_ref() else { return };
|
||||||
|
let candidates = ["portrait.png", "portrait.jpg", "portrait.jpeg"];
|
||||||
|
let path = candidates.iter()
|
||||||
|
.map(|s| memfs_root.join("assets").join(s))
|
||||||
|
.find(|p| p.exists());
|
||||||
|
let Some(path) = path else { return };
|
||||||
|
let dyn_img = match image::ImageReader::open(&path) {
|
||||||
|
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 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
let area = frame.size();
|
let area = frame.size();
|
||||||
let layout = match self.current_screen {
|
let layout = match self.current_screen {
|
||||||
|
|
@ -859,6 +928,7 @@ impl App {
|
||||||
}
|
}
|
||||||
Screen::Presence => self.draw_presence_mode(frame),
|
Screen::Presence => self.draw_presence_mode(frame),
|
||||||
Screen::Gallery => self.draw_gallery(frame),
|
Screen::Gallery => self.draw_gallery(frame),
|
||||||
|
Screen::AgentsManager => self.draw_agent_cards(frame),
|
||||||
_ => self.draw_placeholder(frame),
|
_ => self.draw_placeholder(frame),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1088,6 +1158,13 @@ impl App {
|
||||||
&self.presence,
|
&self.presence,
|
||||||
WELCOME_SCALE,
|
WELCOME_SCALE,
|
||||||
);
|
);
|
||||||
|
// When a terminal-image protocol is loaded, overlay the real
|
||||||
|
// photo on the same area. The half-block portrait is always
|
||||||
|
// rendered first as background so terminals without kitty/sixel
|
||||||
|
// show the expected pixel-art silhouette.
|
||||||
|
if let Some(proto) = &self.image_protocol {
|
||||||
|
frame.render_widget(Image::new(proto), portrait_area);
|
||||||
|
}
|
||||||
|
|
||||||
let name_area = Rect {
|
let name_area = Rect {
|
||||||
x: card_area.x + 1,
|
x: card_area.x + 1,
|
||||||
|
|
@ -1165,7 +1242,7 @@ impl App {
|
||||||
frame.render_widget(err_para, row);
|
frame.render_widget(err_para, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • g Gallery • p Presence • q Quit")
|
let footer = Paragraph::new("↑↓ Navigate • Enter • a Add • g Gallery • i Inspect • p Presence • q Quit")
|
||||||
.style(Style::default().fg(Color::DarkGray))
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
.alignment(Alignment::Center);
|
.alignment(Alignment::Center);
|
||||||
frame.render_widget(footer, chunks[4]);
|
frame.render_widget(footer, chunks[4]);
|
||||||
|
|
@ -1459,6 +1536,144 @@ impl App {
|
||||||
};
|
};
|
||||||
frame.render_widget(footer, footer_area);
|
frame.render_widget(footer, footer_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a card deck for every agent on the local backend. Called on
|
||||||
|
/// entry to the AgentsManager screen.
|
||||||
|
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.
|
||||||
|
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 — a scrollable card grid with all the per-agent
|
||||||
|
/// data that the simple Gallery omits: seed glyph, instance count, uptime,
|
||||||
|
/// memory count, description, creation date.
|
||||||
|
fn draw_agent_cards(&self, frame: &mut Frame) {
|
||||||
|
let area = frame.size();
|
||||||
|
let bg = Block::default().style(Style::default().bg(Color::Rgb(10, 10, 16)));
|
||||||
|
frame.render_widget(bg, area);
|
||||||
|
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(" Agent Manager ", Style::default()
|
||||||
|
.fg(Color::Rgb(255, 200, 100))
|
||||||
|
.add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(format!("{} agents", self.agent_cards.len()),
|
||||||
|
Style::default().fg(Color::DarkGray)),
|
||||||
|
])).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(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(empty, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-column card grid. Each card is a bordered paragraph.
|
||||||
|
let cols: u16 = 2;
|
||||||
|
let card_w = 48u16.min(area.width / cols - 3);
|
||||||
|
let card_h = 8;
|
||||||
|
let pad_x: u16 = 2;
|
||||||
|
let pad_y: u16 = 1;
|
||||||
|
let grid_x = area.x + (area.width - (cols * (card_w + pad_x) - pad_x)) / 2;
|
||||||
|
let grid_y = area.y + 3;
|
||||||
|
|
||||||
|
for (idx, card) in self.agent_cards.iter().enumerate() {
|
||||||
|
let col = (idx as u16) % cols;
|
||||||
|
let row = (idx as u16) / cols;
|
||||||
|
let cx = grid_x + col * (card_w + pad_x);
|
||||||
|
let cy = grid_y + row * (card_h + pad_y);
|
||||||
|
if cy + card_h + 1 >= area.y + area.height { break; }
|
||||||
|
|
||||||
|
let card_area = Rect { x: cx, y: cy, width: card_w, height: card_h };
|
||||||
|
let border = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(Color::Rgb(70, 90, 120)).add_modifier(Modifier::DIM));
|
||||||
|
frame.render_widget(border, card_area);
|
||||||
|
|
||||||
|
let inner = Rect { x: cx + 1, y: cy + 1, width: card_w.saturating_sub(2), height: card_h.saturating_sub(2) };
|
||||||
|
let content = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(&card.glyph, Style::default().fg(Color::Rgb(120, 200, 220))),
|
||||||
|
Span::styled(" ", Style::default()),
|
||||||
|
Span::styled(&card.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||||
|
]),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&card.description,
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("{}% uptime ", card.uptime_pct), Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(if card.instance_count == 1 { "1 instance".to_string() } else { format!("{} instances", card.instance_count) }, Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(format!(" {} files", card.memory_count), Style::default().fg(Color::Green)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("key: {}", card.pubkey_prefix),
|
||||||
|
Style::default().fg(Color::Rgb(100, 100, 120))),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(format!("created: {}", card.created),
|
||||||
|
Style::default().fg(Color::Rgb(80, 80, 100))),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
frame.render_widget(Paragraph::new(content), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
let footer = Paragraph::new("q quit • Esc back")
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dashboard helpers ─────────────────────────────────────────────────────
|
// ─── Dashboard helpers ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -46,119 +46,45 @@ use crate::ui::presence::{Posture, Presence};
|
||||||
|
|
||||||
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
||||||
|
|
||||||
/// Internal resolution multiplier for loaded photo portraits. The hand-crafted
|
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
|
||||||
/// fallback grid is `PORTRAIT_W × PORTRAIT_H` (18×18), but a photo needs more
|
/// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
|
||||||
/// pixels to stay recognizable. We load at `SRC_MULT ×` that resolution and
|
/// renderer pulls pixels from here instead of the hand-coded palette grid.
|
||||||
/// bilinearly sample when the renderer asks for a grid pixel. This way a photo
|
|
||||||
/// contributes detail at any render scale while the rendering pipeline code
|
|
||||||
/// (half-block pairing, posture modulation, scaled rendering) stays unchanged.
|
|
||||||
const SRC_MULT: usize = 3;
|
|
||||||
|
|
||||||
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and stored at
|
|
||||||
/// `(PORTRAIT_W * SRC_MULT) × (PORTRAIT_H * SRC_MULT)` for detail. When the
|
|
||||||
/// renderer asks for a pixel at grid coordinate `(x, y)` (0..18), we
|
|
||||||
/// bilinearly sample the source and return an interpolated color. If the
|
|
||||||
/// coordinate is out of range (shouldn't happen in practice), `None` is
|
|
||||||
/// returned and the caller falls back to the hand-coded palette grid.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PortraitSource {
|
pub struct PortraitSource {
|
||||||
pixels: Vec<Color>, // SRC_W * SRC_H, row-major
|
pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
|
||||||
src_w: usize,
|
|
||||||
src_h: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortraitSource {
|
impl PortraitSource {
|
||||||
const SRC_W: usize = PORTRAIT_W as usize * SRC_MULT;
|
/// Sample the pixel at (x, y). Returns None if out of bounds.
|
||||||
const SRC_H: usize = PORTRAIT_H as usize * SRC_MULT;
|
|
||||||
|
|
||||||
/// Sample a pixel at grid coordinate (x, y), bilinearly interpolated
|
|
||||||
/// from the higher-resolution source. Returns None for out-of-bounds.
|
|
||||||
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
|
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
|
||||||
if x >= PORTRAIT_W as usize || y >= PORTRAIT_H as usize {
|
self.pixels.get(y * PORTRAIT_W as usize + x).copied()
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Map grid coordinate into source space, then back off 0.5 so the
|
|
||||||
// interpolation kernel is centered on the "area" this grid cell covers.
|
|
||||||
let sx = (x as f32 + 0.5) * self.src_w as f32 / PORTRAIT_W as f32 - 0.5;
|
|
||||||
let sy = (y as f32 + 0.5) * self.src_h as f32 / PORTRAIT_H as f32 - 0.5;
|
|
||||||
let sx = sx.max(0.0);
|
|
||||||
let sy = sy.max(0.0);
|
|
||||||
|
|
||||||
let ix = sx as usize;
|
|
||||||
let iy = sy as usize;
|
|
||||||
let fx = sx - ix as f32;
|
|
||||||
let fy = sy - iy as f32;
|
|
||||||
|
|
||||||
// Clamp to valid range for the four sample points.
|
|
||||||
let ix1 = (ix + 1).min(self.src_w - 1);
|
|
||||||
let iy1 = (iy + 1).min(self.src_h - 1);
|
|
||||||
|
|
||||||
let extract = |c: &Color| -> (u8, u8, u8) {
|
|
||||||
if let Color::Rgb(r, g, b) = *c { (r, g, b) } else { (0, 0, 0) }
|
|
||||||
};
|
|
||||||
|
|
||||||
let c00 = extract(&self.pixels[iy * self.src_w + ix]);
|
|
||||||
let c01 = extract(&self.pixels[iy * self.src_w + ix1]);
|
|
||||||
let c10 = extract(&self.pixels[iy1 * self.src_w + ix]);
|
|
||||||
let c11 = extract(&self.pixels[iy1 * self.src_w + ix1]);
|
|
||||||
|
|
||||||
let lerp = |a: u8, b: u8, t: f32| (a as f32 + (b as f32 - a as f32) * t) as u8;
|
|
||||||
|
|
||||||
let r0 = lerp(c00.0, c01.0, fx);
|
|
||||||
let g0 = lerp(c00.1, c01.1, fx);
|
|
||||||
let b0 = lerp(c00.2, c01.2, fx);
|
|
||||||
let r1 = lerp(c10.0, c11.0, fx);
|
|
||||||
let g1 = lerp(c10.1, c11.1, fx);
|
|
||||||
let b1 = lerp(c10.2, c11.2, fx);
|
|
||||||
|
|
||||||
Some(Color::Rgb(
|
|
||||||
lerp(r0, r1, fy),
|
|
||||||
lerp(g0, g1, fy),
|
|
||||||
lerp(b0, b1, fy),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode an image file (PNG or JPEG), resize to `SRC_W × SRC_H`,
|
/// Decode an image file (PNG or JPEG), cover-crop to the portrait grid
|
||||||
/// and produce a colored pixel array. Returns `None` on any I/O or
|
/// dimensions (18×18) preserving aspect ratio, and produce a colored
|
||||||
/// decode error — logs the reason so we can see why a PNG didn't
|
/// pixel array. Returns `None` on any I/O or decode error.
|
||||||
/// take instead of silently falling back to the silhouette.
|
|
||||||
pub fn from_path(path: &Path) -> Option<Self> {
|
pub fn from_path(path: &Path) -> Option<Self> {
|
||||||
let img = match image::open(path) {
|
let img = match image::open(path) {
|
||||||
Ok(img) => img,
|
Ok(img) => img,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(path = %path.display(), error = %e, "portrait decode failed");
|
||||||
path = %path.display(),
|
|
||||||
error = %e,
|
|
||||||
"portrait decode failed"
|
|
||||||
);
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Cover-crop: resize so the shorter dimension fills the target
|
// Cover-crop: take a center square from the original (no stretch),
|
||||||
// (maintaining aspect ratio), then center-crop. This way a
|
// then Lanczos3 down to grid size. Single resize pass from full
|
||||||
// landscape or portrait photo both fill the square frame without
|
// source resolution gives the smoothest result at 18×18.
|
||||||
// stretching — the center of the image is what survives.
|
|
||||||
// Cover-crop: scale so the shorter axis fills the target (maintaining
|
|
||||||
// aspect ratio), then center-crop the excess. This way a landscape or
|
|
||||||
// portrait photo both fill the square frame without stretching.
|
|
||||||
let (w, h) = (img.width(), img.height());
|
let (w, h) = (img.width(), img.height());
|
||||||
let scale = (Self::SRC_W as f32 / w as f32)
|
let size = w.min(h);
|
||||||
.max(Self::SRC_H as f32 / h as f32);
|
let crop_x = (w - size) / 2;
|
||||||
let sw = (w as f32 * scale) as u32;
|
let crop_y = (h - size) / 2;
|
||||||
let sh = (h as f32 * scale) as u32;
|
let cropped = img.crop_imm(crop_x, crop_y, size, size);
|
||||||
let scaled = image::imageops::resize(
|
let rgb = cropped.resize_exact(
|
||||||
&img, sw, sh,
|
PORTRAIT_W as u32, PORTRAIT_H as u32,
|
||||||
image::imageops::FilterType::Lanczos3,
|
image::imageops::FilterType::Lanczos3,
|
||||||
);
|
).to_rgb8();
|
||||||
let crop_x = (sw.saturating_sub(Self::SRC_W as u32)) / 2;
|
let pixels = rgb.pixels().map(|p| Color::Rgb(p[0], p[1], p[2])).collect();
|
||||||
let crop_y = (sh.saturating_sub(Self::SRC_H as u32)) / 2;
|
Some(Self { pixels })
|
||||||
let cropped = image::DynamicImage::ImageRgba8(scaled).crop_imm(crop_x, crop_y, Self::SRC_W as u32, Self::SRC_H as u32);
|
|
||||||
let rgb = cropped.to_rgb8();
|
|
||||||
let pixels = rgb
|
|
||||||
.pixels()
|
|
||||||
.map(|p| Color::Rgb(p[0], p[1], p[2]))
|
|
||||||
.collect();
|
|
||||||
Some(Self { pixels, src_w: Self::SRC_W, src_h: Self::SRC_H })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue