Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): per-agent portrait loading from agent memfs assets/

Tier 2 of the Presence visual stack lands as PNG/JPEG → palette grid:
the same half-block renderer keeps working, but the source pixels now
come from a per-agent portrait file when one is present. Blink, yawn,
strain, processing, and affection overlays still paint from the
hand-crafted Annie palette (rows 6, 7, 10, 11) so the seven animation
states keep working regardless of which portrait is loaded underneath.

How it loads:
- `<memfs_root>/assets/portrait.png` (preferred)
- `<memfs_root>/assets/portrait.jpg`
- `<memfs_root>/assets/portrait.jpeg`

`assets/` is deliberately outside `system/`, which is the auto-pinned
context territory built by `src/core/prompt.rs`. Portrait bytes never
land in the agent's context window.

If no portrait exists, Presence falls back to the hand-crafted Annie
palette grid silently — no error, no warning. Same renderer, same
behavior; the visual identity just stays at Tier 1.

What's new:
- `image = "0.25"` (default-features off; only `png` and `jpeg` features).
- `portrait::PortraitSource` — pixel grid loaded from `from_path`. Uses
  `image::open` + `resize_exact(Lanczos3)` to downsample to the existing
  PORTRAIT_W × PORTRAIT_H grid.
- `portrait::pixel_color` — single resolution path: source-when-loaded
  for non-overlay pixels, hand-crafted palette for overlay rows.
- `Presence::load_portrait_from_memfs(root)` — looks for the three
  candidate filenames; silently no-ops when absent.
- App refreshes the portrait when the dashboard refresh walks the
  agent's memory repo (`local_repo.root()`).

Note on architecture: this is "Tier 2" via downsampled palette rather
than via terminal image protocols (kitty/sixel). It works in EVERY
terminal, costs no dependency on capability detection, and stays
consistent with the half-block aesthetic the rest of the TUI uses.
True image-protocol rendering can land later as a separate enhancement;
the data flow (per-agent PNG in agent memfs assets/) is already correct
for that future path.

Build clean.
This commit is contained in:
Fimeg 2026-05-12 12:24:23 -04:00
commit 543ae50103
4 changed files with 103 additions and 8 deletions

View file

@ -53,6 +53,7 @@ crossterm = "0.27" # Terminal control
ratatui = { version = "0.26", features = ["crossterm"] } # TUI framework with crossterm backend
unicode-width = "0.1"
colored = "2" # Color gradients and effects
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Per-agent portraits (assets/portrait.{png,jpg})
# Logging/tracing
tracing = "0.1"

View file

@ -771,6 +771,10 @@ impl App {
}
// 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());
} else {
self.agent_status.recent_activity = vec![
format!("[{}] connected via {}", short_now(), mode),

View file

@ -34,6 +34,8 @@
//! - `c` collar cyan accent
//! - ` ` (space) skin (V-neck opening)
use std::path::Path;
use ratatui::{
buffer::Buffer,
layout::Rect,
@ -42,6 +44,51 @@ use ratatui::{
use crate::ui::presence::{Eye, Posture, Presence};
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
/// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
/// renderer pulls non-overlay pixels from here instead of the hand-coded
/// palette grid, while still painting eye/mouth/brow rows from the
/// state-aware overlays so the seven animation states keep working.
#[derive(Debug, Clone)]
pub struct PortraitSource {
pub pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
}
impl PortraitSource {
/// Sample the pixel at `(x, y)` from the source grid. Returns `None` if
/// the indices are out of range (caller falls back to the palette grid).
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
let idx = y * PORTRAIT_W as usize + x;
self.pixels.get(idx).copied()
}
/// Decode an image file (PNG or JPEG), resize to portrait grid dims,
/// and produce a colored pixel array. Returns `None` on any I/O or
/// decode error — the caller falls back to the hand-crafted Annie.
pub fn from_path(path: &Path) -> Option<Self> {
let img = image::open(path).ok()?;
let resized = img.resize_exact(
PORTRAIT_W as u32,
PORTRAIT_H as u32,
image::imageops::FilterType::Lanczos3,
);
let rgb = resized.to_rgb8();
let pixels = rgb
.pixels()
.map(|p| Color::Rgb(p[0], p[1], p[2]))
.collect();
Some(Self { pixels })
}
}
/// Rows reserved for state-aware overlays. Pixels in these rows always come
/// from the hand-crafted palette grid (and color_for) so that blinking, gaze
/// shifts, mouth changes, and brow furrows keep working regardless of which
/// portrait source the user loaded.
const OVERLAY_ROWS: &[usize] = &[6, 7, 10, 11];
pub const PORTRAIT_W: u16 = 18;
pub const PORTRAIT_H: u16 = 18;
@ -196,6 +243,23 @@ fn color_for(key: char, posture: Posture, breath: f32) -> Option<Color> {
// ── Render ──────────────────────────────────────────────────────
/// Resolve a single pixel's color, preferring the PortraitSource (if any)
/// for non-overlay rows and falling back to the palette grid elsewhere.
fn pixel_color(px: usize, py: usize, p: &Presence, breath: f32) -> Option<Color> {
let overlay = OVERLAY_ROWS.contains(&py);
// Posture overlays still come from BASE for the affected rows even when a
// PortraitSource is loaded — that's how blink / yawn / strain stay visible.
if !overlay {
if let Some(source) = p.portrait_source.as_ref() {
if let Some(c) = source.at(px, py) {
return Some(c);
}
}
}
let key = pixel_at(px, py, p);
color_for(key, p.posture, breath)
}
/// Render the portrait scaled-up by `scale` (1 = native half-block density).
/// Each grid pixel becomes a `scale × scale` square. Use this for the
/// presence-mode fullscreen view. Cells outside `area` are skipped.
@ -217,8 +281,7 @@ pub fn render_scaled(buf: &mut Buffer, area: Rect, p: &Presence, scale: u16) {
for py in 0..PORTRAIT_H {
for px in 0..PORTRAIT_W {
let key = pixel_at(px as usize, py as usize, p);
let col = color_for(key, p.posture, breath);
let col = pixel_color(px as usize, py as usize, p, breath);
if col.is_none() { continue; }
let col = col.unwrap();
@ -257,11 +320,8 @@ pub fn render(buf: &mut Buffer, area: Rect, p: &Presence) {
let py_top = (cy * 2) as usize;
let py_bot = py_top + 1;
let top_key = pixel_at(px, py_top, p);
let bot_key = pixel_at(px, py_bot, p);
let top_col = color_for(top_key, p.posture, breath);
let bot_col = color_for(bot_key, p.posture, breath);
let top_col = pixel_color(px, py_top, p, breath);
let bot_col = pixel_color(px, py_bot, p, breath);
if top_col.is_none() && bot_col.is_none() {
continue;

View file

@ -58,9 +58,11 @@ use ratatui::{
Frame,
};
use std::path::{Path, PathBuf};
use crate::ui::animation::{Animator, colors};
use crate::ui::component::TuiEvent;
use crate::ui::portrait;
use crate::ui::portrait::{self, PortraitSource};
// ── State ───────────────────────────────────────────────────────
@ -137,6 +139,10 @@ pub struct Presence {
pub position: Position,
pub visible: bool,
pub animator: Animator,
/// Optional per-agent portrait loaded from `assets/portrait.{png,jpg}`
/// in the agent's memfs. When `None`, the renderer falls back to the
/// hand-crafted Annie palette grid (Tier 1).
pub portrait_source: Option<PortraitSource>,
/// Most recent tick observed. Drives blink/breath timing.
tick: u64,
/// Tick at which the next blink should begin.
@ -159,12 +165,36 @@ impl Presence {
position: Position::TopRight,
visible: true,
animator: Animator::new(),
portrait_source: None,
tick: 0,
next_blink_at: 180, // ~3 s at 60 Hz
blink_until: 0,
}
}
/// Try to load a portrait from a path. Silently no-ops on failure.
pub fn load_portrait<P: AsRef<Path>>(&mut self, path: P) {
if let Some(src) = PortraitSource::from_path(path.as_ref()) {
self.portrait_source = Some(src);
}
}
/// Attempt to load a portrait from an agent's memfs root. Looks at:
/// `<memfs_root>/assets/portrait.png` then `assets/portrait.jpg`.
///
/// Crucially, `assets/` is OUTSIDE `system/` — it does NOT get pinned
/// into the agent's context window by `core::prompt::build`. See
/// `memory/feedback_system_folder_pinned.md`.
pub fn load_portrait_from_memfs<P: AsRef<Path>>(&mut self, memfs_root: P) {
for stem in &["portrait.png", "portrait.jpg", "portrait.jpeg"] {
let candidate: PathBuf = memfs_root.as_ref().join("assets").join(stem);
if candidate.exists() {
self.load_portrait(&candidate);
return;
}
}
}
pub fn set_position(&mut self, p: Position) {
self.position = p;
}