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:
parent
7a1912719a
commit
543ae50103
4 changed files with 103 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue