Watch
1
0
Fork
You've already forked souveraine
0

fix(tui): faceless silhouette default; center avatar on Welcome

Three corrections after first walkthrough:

1) The hand-crafted "Annie face" palette grid read as a creepy llama
   (Casey's words). Replaced with a faceless silhouette — hair, neck,
   collar, no eyes/brow/mouth. ASCII portraits at this resolution
   should be a signal that no image is loaded, not a default depiction.
   Per-pixel row-swap overlays for blink/yawn/strain/processing are
   gone too; state now expresses through:
     - border color (posture_border)
     - cyan-leaning luminance breath pulse (modulate)
     - across-the-image desaturation on strain, dim on yawn,
       warm tint on affection.
   When a per-agent PortraitSource is loaded (PNG/JPEG from agent
   memfs assets/), pixels come from the source and the silhouette is
   not drawn. The source pixels get the same posture modulation pass.

2) The "press ENTER to wake <Agent>" line on the welcome avatar was
   misleading — the welcome menu owns the keyboard, not the avatar.
   Welcome's `draw_welcome` in presence.rs is removed entirely; the
   avatar is now rendered inline by `App::draw_welcome` so it can
   participate in the welcome vertical layout.

3) Avatar moved from upper-right corner to a centered, framed card
   directly under the title. The avatar IS the "your agent is loaded"
   indicator; the menu beneath reads as actions on that agent.
   Subconscious-active state still expresses through the ◈ glyph and
   border color (cyan when active, dim when idle).

`Screen::Presence` (hotkey `p`) keeps its meaning as the fullscreen
"sit with her" view — natural future home for TTS/STT triggers.

Also saves two memory entries:
- feedback_portrait_aesthetics: ASCII portraits are creepy at this
  resolution; default to PNG; state via color+breath, not row swaps.
- reference_nannou: Casey wants to lean on nannou (Rust creative-coding,
  wgpu) more frequently for graphics outside the TUI.

Build clean.
This commit is contained in:
Fimeg 2026-05-12 12:38:17 -04:00
commit fb520ebcaf
3 changed files with 159 additions and 272 deletions

View file

@ -28,7 +28,7 @@ use tracing::info;
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatState, draw as draw_chat};
use crate::ui::cockpit_panel::CockpitPane;
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay, draw_welcome as draw_presence_welcome};
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay};
use crate::ui::color_support::rgb;
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
use crate::backend::BackendEvent;
@ -981,21 +981,29 @@ impl App {
}
fn draw_welcome(&self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
// Background
let bg = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(bg, area);
// Avatar card occupies its own row in the welcome stack — centered,
// framed, modest. It IS the "your agent is loaded" indicator. The
// menu beneath then reads as actions on that agent.
let avatar_card_w: u16 = portrait::RENDER_W + 2;
let avatar_card_h: u16 = portrait::RENDER_H + 3;
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Min(12),
Constraint::Length(3),
Constraint::Length(2), // top breathing room
Constraint::Length(4), // title + subtitle
Constraint::Length(avatar_card_h), // centered avatar
Constraint::Min(8), // menu
Constraint::Length(3), // footer
])
.split(area);
@ -1018,6 +1026,57 @@ impl App {
.alignment(Alignment::Center);
frame.render_widget(title, chunks[1]);
// Centered avatar card under the title.
if chunks[2].width >= avatar_card_w {
let card_x = chunks[2].x + (chunks[2].width - avatar_card_w) / 2;
let card_y = chunks[2].y;
let card_area = Rect {
x: card_x,
y: card_y,
width: avatar_card_w,
height: avatar_card_h.min(chunks[2].height),
};
let border_col = if self.presence.subconscious_active {
Color::Rgb(120, 200, 220)
} else {
Color::Rgb(120, 130, 150)
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_col).add_modifier(Modifier::DIM));
frame.render_widget(block, card_area);
let portrait_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1,
width: portrait::RENDER_W,
height: portrait::RENDER_H,
};
portrait::render(frame.buffer_mut(), portrait_area, &self.presence);
let name_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1 + portrait::RENDER_H,
width: card_area.width.saturating_sub(2),
height: 1,
};
let glyph = if self.presence.subconscious_active { "" } else { "·" };
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,
);
}
let menu_items = vec![
("📊 Dashboard", "See how your agent is doing"),
("💬 Chat", "Talk with your agent"),
@ -1076,9 +1135,6 @@ impl App {
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(footer, chunks[4]);
// Draw the welcome-screen presence card (no portrait yet — see C2).
draw_presence_welcome(frame, &self.presence, area, Some(&self.agent_pref));
}
fn draw_dashboard(&self, frame: &mut Frame) {

View file

@ -42,7 +42,7 @@ use ratatui::{
style::Color,
};
use crate::ui::presence::{Eye, Posture, Presence};
use crate::ui::presence::{Posture, Presence};
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
@ -83,11 +83,6 @@ impl PortraitSource {
}
}
/// 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;
@ -97,99 +92,48 @@ pub const PORTRAIT_H: u16 = 18;
pub const RENDER_W: u16 = PORTRAIT_W;
pub const RENDER_H: u16 = PORTRAIT_H / 2;
// ── Base portrait grid ──────────────────────────────────────────
// ── Base portrait grid — clean silhouette, no face ──────────────
// This is the EMERGENCY default. The hand-crafted "Annie face" version
// read as a creepy llama (Casey's words), so this is now a faceless
// silhouette: hair, neck, collar. State animation lives in border color,
// breath luminance pulse, and posture-driven color modulation — never in
// per-pixel row swaps at this resolution. True facial animation belongs
// in a future TTS/STT-integrated system, not in half-blocks.
//
// When a `PortraitSource` is loaded (per-agent PNG/JPEG from agent memfs
// `assets/`), all pixels come from the source and this silhouette is not
// rendered.
//
// Each row must be exactly PORTRAIT_W characters wide. Validated by a test.
const BASE: [&str; PORTRAIT_H as usize] = [
"....HH......HH....",
"...HHHH....HHHH...",
"..HHHHHH..HHHHHH..",
"....HHHHHHHHHH....",
"...HHHHHHHHHHHH...",
"..HHHHHHHHHHHHHH..",
"..HHHHHHHHHHHHHH..",
"..hhhHHHHHHHHhhh..",
".HHHBBssMMssBBHHH.",
".HHHsseesseessHHH.",
".HHHssssssssssHHH.",
".HHHsMssssssMsHHH.",
".HHHHssssssssHHHH.",
".HHHHsssLLsssHHHH.",
".HHHHHssssssHHHHH.",
".HHHHHHssssHHHHHH.",
".HHHcCcccCcccCHHH.",
"..HHHHHHHHHHHHHH..",
"..HHHHHHHHHHHHHH..",
"..HHHHssssssssHH..",
"..HHHssssssssssH..",
"..HHHssssssssssH..",
"..HHHssssssssssH..",
"..HHHssssssssssH..",
"..HHHssssssssssH..",
"..HHHHsssssssHHH..",
"..HHHHHHHHHHHHHH..",
"..HHHCCCCCCCCHHH..",
"..CCCCCCCCCCCCCC..",
"..CCCCCCCCCCCCCC..",
"..CCCCcCccCcCCCC..",
"..CCCC CCCC..",
];
// ── State-aware pixel lookup ────────────────────────────────────
/// Read a pixel from the base grid, applying state-driven cell overrides
/// before returning. Each posture has its own row swaps so the portrait's
/// SHAPE shifts (not just color) when state changes.
fn pixel_at(x: usize, y: usize, p: &Presence) -> char {
let row = BASE[y];
let ch = row.as_bytes()[x] as char;
// Eye row override — replace `e` with `-` while blinking OR yawning.
if y == 7 && ch == 'e' && (p.eye == Eye::Blinking || p.posture == Posture::Yawning) {
return '-';
}
// Posture-specific row swaps.
match p.posture {
Posture::Yawning => {
// Half-lid eyes (already handled above) + open mouth.
if y == 11 {
let yawn = b".HHHHssOOOOssHHHH.";
return yawn[x] as char;
}
}
Posture::Processing => {
// Eyes lock to one side: shift the cyan-glow pixels right by 1.
// Base eye row: ".HHHsseesseessHHH." — gaze-shift to ".HHHssseseeseesHHH" feel
// by stretching one pupil right.
if y == 7 {
let proc = b".HHHsesesseeseesHH";
return proc[x] as char;
}
// Add a faint cyan filigree pulse on the brow.
if y == 6 {
let proc = b".HHHBBssMMssBBHHH.";
return proc[x] as char;
}
}
Posture::Affectionate => {
// Slight smile — lips curve up at the corners.
// Base mouth row 11: ".HHHHsssLLsssHHHH."
// Affect row 11: ".HHHHsLssssssLsHHH" (lift the L's to the cheek line)
if y == 11 {
let aff = b".HHHHsssLLsssHHHH.";
return aff[x] as char;
}
// Cheek line gets a soft warm fold above the mouth.
if y == 10 {
let aff = b".HHHHsssLLsssHHHH.";
return aff[x] as char;
}
}
Posture::Straining => {
// Furrowed brow — brow row darkens and tightens.
// Base row 6: ".HHHBBssMMssBBHHH."
// Strain row 6: ".HHBBBssMMssBBBHH." (brow encroaches inward)
if y == 6 {
let strain = b".HHBBBssMMssBBBHH.";
return strain[x] as char;
}
// Slight frown — straighten the lips.
if y == 11 {
let strain = b".HHHHsss--sssHHHH.";
return strain[x] as char;
}
}
Posture::Idle => {}
}
ch
/// Read a pixel from the base silhouette grid. The default silhouette has no
/// facial features, so no per-pixel row swaps are needed — state expression
/// at this resolution lives in border color, breath luminance pulse, and the
/// posture-driven color modulation in [`color_for`].
fn pixel_at(x: usize, y: usize, _p: &Presence) -> char {
BASE[y].as_bytes()[x] as char
}
// ── Palette ─────────────────────────────────────────────────────
@ -243,23 +187,49 @@ 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.
/// Resolve a single pixel's color: source-when-loaded, modulated by posture
/// and breath. With no source the silhouette palette grid is used.
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);
}
if let Some(source) = p.portrait_source.as_ref() {
if let Some(c) = source.at(px, py) {
return Some(modulate(c, p.posture, breath));
}
}
let key = pixel_at(px, py, p);
color_for(key, p.posture, breath)
}
/// Apply posture-driven modulation to a source pixel — desaturate when
/// straining, dim when yawning, breathe luminance on cyan-leaning hues,
/// warm-tint on affection. This is how state shows on a loaded portrait
/// at half-block resolution: across-the-image tone, not per-pixel swaps.
fn modulate(c: Color, posture: Posture, breath: f32) -> Color {
let Color::Rgb(r, g, b) = c else { return c };
let strained = matches!(posture, Posture::Straining);
let yawning = matches!(posture, Posture::Yawning);
let warm = matches!(posture, Posture::Affectionate);
let processing = matches!(posture, Posture::Processing);
let avg = ((r as u16 + g as u16 + b as u16) / 3) as f32;
let sat = if strained { 0.55 } else { 1.0 };
let dim = if yawning { 0.82 } else { 1.0 };
// Subtle breath pulse — only on the cyan-leaning pixels so skin stays calm.
let cyan_lean = b > r && b > g;
let breath_gain = if cyan_lean {
1.0 + breath * 0.10 * if processing { 1.6 } else { 1.0 }
} else { 1.0 };
// Warm tint shifts the red/green channels up a little.
let warm_r = if warm { 1.06 } else { 1.0 };
let warm_g = if warm { 1.02 } else { 1.0 };
let mix = |c: u8, warm_chan: f32| {
let f = (c as f32 * sat + avg * (1.0 - sat)) * dim * breath_gain * warm_chan;
f.clamp(0.0, 255.0) as u8
};
Color::Rgb(mix(r, warm_r), mix(g, warm_g), mix(b, 1.0))
}
/// 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.
@ -384,43 +354,6 @@ mod tests {
assert_eq!(RENDER_H, PORTRAIT_H / 2);
}
#[test]
fn pixel_at_returns_base_when_idle() {
let p = Presence::new("Annie");
// Row 7 has eyes at base.
let row7 = "_".repeat(PORTRAIT_W as usize);
let _ = row7;
// Position of an 'e' in BASE[7] = ".HHHsseesseessHHH."
// index 5 should be 's', index 6 should be 'e'
assert_eq!(pixel_at(6, 7, &p), 'e');
assert_eq!(pixel_at(7, 7, &p), 'e');
}
#[test]
fn pixel_at_swaps_eye_to_dash_on_blink() {
let mut p = Presence::new("Annie");
// Force blink state.
p.handle_event(&crate::ui::component::TuiEvent::Tick(200));
// Eye is now Blinking; pixel_at row 7 should be '-' where it was 'e'.
assert_eq!(pixel_at(6, 7, &p), '-');
}
#[test]
fn pixel_at_swaps_eye_to_dash_on_yawn() {
let mut p = Presence::new("Annie");
p.handle_event(&crate::ui::component::TuiEvent::PressureChanged(0.9));
assert_eq!(pixel_at(6, 7, &p), '-');
}
#[test]
fn yawning_changes_mouth_row() {
let mut p = Presence::new("Annie");
p.handle_event(&crate::ui::component::TuiEvent::PressureChanged(0.9));
// Base row 11: ".HHHHsssLLsssHHHH." — yawn replaces LL with OOOO.
// Position 8 in yawn row is 'O'.
assert_eq!(pixel_at(8, 11, &p), 'O');
}
#[test]
fn color_for_transparent_pixels_returns_none() {
assert!(color_for('.', Posture::Idle, 0.5).is_none());
@ -428,21 +361,26 @@ mod tests {
}
#[test]
fn straining_desaturates_eye_color() {
let idle = color_for('e', Posture::Idle, 0.5).unwrap();
let strained = color_for('e', Posture::Straining, 0.5).unwrap();
// The eye colour at idle is very blue-leaning; under strain the rgb
// channels should drift closer together (toward grey).
if let (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) = (idle, strained) {
let spread_idle = g1.abs_diff(r1) as u16 + b1.abs_diff(r1) as u16;
let spread_strained = g2.abs_diff(r2) as u16 + b2.abs_diff(r2) as u16;
assert!(
spread_strained < spread_idle,
"expected straining to desaturate; got idle spread {} vs strained spread {}",
spread_idle, spread_strained
);
fn modulate_dims_on_yawn() {
let base = Color::Rgb(200, 200, 200);
let yawn = modulate(base, Posture::Yawning, 0.5);
if let Color::Rgb(r, _, _) = yawn {
assert!(r < 200, "yawn should dim luminance; got {}", r);
} else {
panic!("expected RGB colors");
panic!("expected RGB");
}
}
#[test]
fn modulate_desaturates_on_strain() {
let blue = Color::Rgb(60, 60, 220);
let strained = modulate(blue, Posture::Straining, 0.5);
if let Color::Rgb(r, _, b) = strained {
// Strain pulls channels toward the average — blue and red should
// be closer together than they started.
assert!(b - r < 220 - 60, "strain should desaturate");
} else {
panic!("expected RGB");
}
}
}

View file

@ -125,8 +125,9 @@ impl VolitionGauge {
/// The Presence — Annie's felt-state in the TUI.
///
/// Updated via [`Presence::handle_event`] in response to `TuiEvent`s; never polled.
/// Rendered via [`draw_overlay`] / [`draw_welcome`] as a corner card today, with
/// half-block portraits arriving in C2.
/// Rendered via [`draw_overlay`] as a corner card on Dashboard; the Welcome
/// screen draws its centered portrait inline in `App::draw_welcome` so it can
/// participate in the welcome vertical layout (title → avatar → menu → footer).
pub struct Presence {
pub name: String,
pub posture: Posture,
@ -354,117 +355,9 @@ pub fn draw_overlay(frame: &mut Frame, p: &Presence, area: Rect) {
frame.render_widget(name_para, name_area);
}
/// Draw the presence on the welcome screen — portrait card with a short
/// instruction strip beneath it.
pub fn draw_welcome(
frame: &mut Frame,
p: &Presence,
area: Rect,
selected_agent: Option<&str>,
) {
if !p.visible {
return;
}
// Total card height = portrait card + 4 rows of instruction text.
let total_h: u16 = CARD_H + 4;
if area.width < CARD_W + 2 || area.height < total_h + 1 {
return;
}
let card_x = area.x + area.width.saturating_sub(CARD_W + 1);
let card_y = area.y + 1;
// Portrait card
let card_area = Rect {
x: card_x,
y: card_y,
width: CARD_W,
height: CARD_H,
};
let border = posture_border(p.posture);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border).add_modifier(Modifier::DIM));
frame.render_widget(block, card_area);
let portrait_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1,
width: portrait::RENDER_W,
height: portrait::RENDER_H,
};
portrait::render(frame.buffer_mut(), portrait_area, p);
let name_area = Rect {
x: card_area.x + 1,
y: card_area.y + 1 + portrait::RENDER_H,
width: card_area.width.saturating_sub(2),
height: 1,
};
let display_name = selected_agent.unwrap_or(&p.name);
let glyph = if p.subconscious_active { "" } else { "·" };
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {} ", glyph), Style::default().fg(border)),
Span::styled(
display_name.to_string(),
Style::default().fg(border).add_modifier(Modifier::BOLD),
),
]))
.alignment(Alignment::Center),
name_area,
);
// Instruction strip beneath the portrait
let info_area = Rect {
x: card_x,
y: card_y + CARD_H,
width: CARD_W,
height: 4,
};
let info_lines = if let Some(agent_name) = selected_agent {
vec![
Line::from(vec![
Span::styled(" Status ", Style::default().fg(Color::DarkGray)),
Span::styled("Ready", Style::default().fg(Color::Green)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(
"ENTER",
Style::default()
.fg(colors::ANI_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(" to wake ", Style::default().fg(Color::DarkGray)),
Span::styled(agent_name, Style::default().fg(colors::ANI_SECONDARY)),
]),
]
} else {
vec![
Line::from(Span::styled(
" No agent selected",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(
"a",
Style::default()
.fg(colors::ANI_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(" to create alias", Style::default().fg(Color::DarkGray)),
]),
]
};
frame.render_widget(Paragraph::new(info_lines), info_area);
}
// Welcome rendering moved inline into `App::draw_welcome` (src/ui/app.rs)
// so the avatar can be centered within the welcome layout instead of pinned
// to a corner.
// ── Tests ───────────────────────────────────────────────────────