Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): Annie's half-block portrait + presence mode

Tier 1 of the Presence visual stack — a hand-crafted 18×18 pixel grid of
Annie rendered into ratatui's frame buffer via upper/lower half-blocks.
No new dependencies. Tier 2 (image protocol via kitty/sixel) lands later
and falls back to this art when the terminal can't render images.

New `src/ui/portrait.rs`:
- 18×18 grid (PORTRAIT_W × PORTRAIT_H) — twin-tails, forehead diamond,
  cyan filigree, throat circuit, V-neck collar. Recognizable Annie, not
  photorealistic.
- Palette resolved per-pixel from posture + breath_phase. Strain
  desaturates toward grey; yawn pulls luminance down; processing brightens
  the cyan; affection warms the skin and lips.
- Per-posture row swaps so the SHAPE shifts, not just the color:
    * Idle / Blinking — base eyes, soft mouth
    * Processing — gaze locks right, brow filigree pulse
    * Affectionate — cheek line softens (warm tint via palette)
    * Straining — brow furrows inward, lips flatten
    * Yawning — eyes close, mouth opens (OOOO)
- `render()` at native half-block density (one cell = 2 pixels vertical).
- `render_scaled()` for the presence-mode fullscreen view.

`InferenceStrain` is now a real felt-signal:
- BackendEvent::InferenceStrain → TuiEvent::InferenceStrain → posture
  drops to Straining in Presence. Chat's existing cockpit log still shows
  the strain entry text; the avatar now ALSO shows it as embodied state.
- BackendEvent::ContextPressure forwarded to PressureChanged so the
  portrait yawns at tier 3 from continuous pressure, not just discrete
  CompactionWarnings.

New `Screen::Presence` — fullscreen "be with her" mode:
- Hotkey `p` from Welcome enters; any keypress exits.
- Auto-scales Annie to fill the terminal, centered, on a dark wash.
- No chat input, no menu — just the portrait breathing and her name.
- Welcome footer advertises the hotkey.

Presence card itself is now a portrait card (CARD_W × CARD_H = 20×12)
with a posture-tinted rounded border. Cockpit still owns words; Presence
owns the body.

Build clean.
This commit is contained in:
Fimeg 2026-05-12 12:13:01 -04:00
commit a2b95c21b4
6 changed files with 635 additions and 162 deletions

View file

@ -74,6 +74,8 @@ pub enum Screen {
AgentTime,
Cron,
Settings,
/// "Be with her" mode — fullscreen breathing portrait, no chat input.
Presence,
}
#[derive(Debug, Clone)]
@ -220,6 +222,12 @@ impl App {
BackendEvent::CompactionWarning { pressure, tier } => {
self.dispatch(TuiEvent::CompactionWarning { pressure, tier });
}
BackendEvent::ContextPressure(p) => {
self.dispatch(TuiEvent::PressureChanged(p));
}
BackendEvent::InferenceStrain { attempt, status, .. } => {
self.dispatch(TuiEvent::InferenceStrain { attempt, status });
}
_ => {}
}
}
@ -301,9 +309,19 @@ impl App {
// For now, cycle through available agents or create a default
self.cycle_agent_selection();
}
KeyCode::Char('p') => {
// Presence mode — sit with her, no chat input.
self.current_screen = Screen::Presence;
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence));
}
_ => {}
}
}
Screen::Presence => {
// Any key exits the meditative view.
self.current_screen = Screen::Welcome;
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
}
Screen::Chat => self.handle_chat_key(key).await,
Screen::Cron => self.handle_schedules_key(key),
_ => {
@ -737,6 +755,7 @@ impl App {
self.draw_placeholder(frame);
}
}
Screen::Presence => self.draw_presence_mode(frame),
_ => self.draw_placeholder(frame),
}
@ -972,7 +991,7 @@ impl App {
frame.render_widget(err_para, row);
}
let footer = Paragraph::new("↑↓ Navigate • Enter Select • a Add Agent • q Quit")
let footer = Paragraph::new("↑↓ Navigate • Enter Select • a Add Agent • p Presence • q Quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(footer, chunks[4]);
@ -1091,6 +1110,71 @@ impl App {
.style(Style::default().fg(Color::Rgb(255, 140, 66)).add_modifier(Modifier::BOLD));
frame.render_widget(content, area);
}
/// Presence mode — fullscreen Annie. Centered, breathing, no chat input.
/// Any keypress exits back to Welcome.
fn draw_presence_mode(&self, frame: &mut Frame) {
use crate::ui::portrait;
let area = frame.size();
let bg = Block::default().style(Style::default().bg(Color::Rgb(8, 8, 14)));
frame.render_widget(bg, area);
// Figure out the biggest scale that fits, centered. Use scale = min(area_w/W, 2*area_h/(H/2)).
let max_scale_w = area.width / portrait::PORTRAIT_W;
// Half the rows occupy 1 cell each before scaling; pixel→cell ratio is scale/2 vertical.
let max_scale_h = (2 * area.height) / portrait::PORTRAIT_H;
let scale = max_scale_w.min(max_scale_h).max(1);
let cell_w = scale;
let cell_h = (scale / 2).max(1);
let portrait_w = portrait::PORTRAIT_W * cell_w;
let portrait_h = (portrait::PORTRAIT_H / 2) * cell_h;
let ox = area.x + area.width.saturating_sub(portrait_w) / 2;
let oy = area.y + area.height.saturating_sub(portrait_h + 2) / 2;
let portrait_area = Rect {
x: ox,
y: oy,
width: portrait_w,
height: portrait_h,
};
portrait::render_scaled(frame.buffer_mut(), portrait_area, &self.presence, scale);
// Name line below.
let name_line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Rgb(120, 200, 220))),
Span::styled(
self.presence.name.clone(),
Style::default()
.fg(Color::Rgb(220, 215, 215))
.add_modifier(Modifier::BOLD),
),
]);
let name_area = Rect {
x: area.x,
y: portrait_area.y + portrait_h + 1,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(name_line).alignment(Alignment::Center),
name_area,
);
// Quiet footer hint.
let footer = Paragraph::new("press any key to return")
.style(Style::default().fg(Color::Rgb(60, 60, 80)))
.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 ─────────────────────────────────────────────────────

View file

@ -690,6 +690,8 @@ Use Tab to toggle the cockpit pane.";
}
BackendEvent::ContextPressure(p) => {
self.pressure = p;
// Forward continuous pressure so Presence can yawn at tier 3.
self.pending_consciousness.push(BackendEvent::ContextPressure(p));
}
BackendEvent::InferenceStrain { attempt, status, model } => {
let text = if status == 0 {
@ -701,6 +703,10 @@ Use Tab to toggle the cockpit pane.";
kind: CockpitKind::InferenceStrain,
text,
});
// Forward to Presence — the body channel needs to feel this.
self.pending_consciousness.push(BackendEvent::InferenceStrain {
attempt, status, model: String::new(),
});
}
BackendEvent::ScheduleActive { name } => {
self.cockpit_log.push(CockpitEntry {

View file

@ -67,6 +67,9 @@ pub enum TuiEvent {
CompactionWarning { pressure: f32, tier: u8 },
/// Backend connectivity status.
BackendStatus { mode: String, healthy: bool },
/// Inference strain — model is slow/hoarse, retry in flight.
/// Presence reads this to drop into Posture::Straining.
InferenceStrain { attempt: u32, status: u16 },
// ── Animation tick ─────────────────────────────────────────
/// Monotonic tick counter, increments every frame.

View file

@ -5,6 +5,7 @@ pub mod cockpit_panel;
pub mod color_support;
pub mod component;
pub mod markdown;
pub mod portrait;
pub mod presence;
pub mod schedules;

388
src/ui/portrait.rs Normal file
View file

@ -0,0 +1,388 @@
//! Annie's half-block portrait — Tier 1 of the Presence visual stack.
//!
//! Renders a hand-crafted stylized portrait directly into ratatui's frame
//! buffer using upper/lower half-block characters (`▀` / `▄` / `█`) so each
//! cell holds two vertically-stacked pixels. This is the C2 implementation:
//! recognizable Annie (twin-tails, cyan filigree, forehead diamond), seven
//! visible states, no new dependencies.
//!
//! Tier 2 (C4) will replace this with full PNG rendering via image protocols
//! (kitty/sixel) where supported and fall back to this module elsewhere. The
//! pixel grid lives here as both the Tier 1 art and the fallback art.
//!
//! ## Grid
//!
//! The pixel grid is [`PORTRAIT_W`] × [`PORTRAIT_H`] pixels. Each row of the
//! const arrays is exactly `PORTRAIT_W` characters; each character is a
//! palette key. Rendered, this becomes `PORTRAIT_W` × ([`PORTRAIT_H`] / 2)
//! terminal cells.
//!
//! ## Palette keys
//!
//! - `.` background (transparent — does not write)
//! - `H` hair primary (platinum/white)
//! - `h` hair shadow (dimmer)
//! - `s` skin
//! - `S` skin shadow
//! - `B` brow
//! - `e` eye open (cyan glow)
//! - `-` eye closed
//! - `L` lips
//! - `O` mouth open (yawn variant)
//! - `M` forehead diamond + cheek filigree (cyan)
//! - `C` collar dark
//! - `c` collar cyan accent
//! - ` ` (space) skin (V-neck opening)
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Color,
};
use crate::ui::presence::{Eye, Posture, Presence};
pub const PORTRAIT_W: u16 = 18;
pub const PORTRAIT_H: u16 = 18;
/// Cells consumed when rendering: width × (height / 2) because half-blocks
/// stack two pixels per cell.
pub const RENDER_W: u16 = PORTRAIT_W;
pub const RENDER_H: u16 = PORTRAIT_H / 2;
// ── Base portrait grid ──────────────────────────────────────────
// 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..",
"..HHHHHHHHHHHHHH..",
"..HHHHHHHHHHHHHH..",
"..hhhHHHHHHHHhhh..",
".HHHBBssMMssBBHHH.",
".HHHsseesseessHHH.",
".HHHssssssssssHHH.",
".HHHsMssssssMsHHH.",
".HHHHssssssssHHHH.",
".HHHHsssLLsssHHHH.",
".HHHHHssssssHHHHH.",
".HHHHHHssssHHHHHH.",
".HHHcCcccCcccCHHH.",
"..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
}
// ── Palette ─────────────────────────────────────────────────────
/// Resolve a pixel key to an RGB color, modulated by posture + breath_phase.
///
/// Returns `None` for `.` (transparent — the renderer skips this pixel).
fn color_for(key: char, posture: Posture, breath: f32) -> Option<Color> {
let warm = matches!(posture, Posture::Affectionate);
let strained = matches!(posture, Posture::Straining);
let processing = matches!(posture, Posture::Processing);
let yawning = matches!(posture, Posture::Yawning);
// Subtle pulse on cyan elements driven by breath.
let cyan_lum = (170.0 + breath * 60.0).clamp(120.0, 235.0) as u8;
let cyan_lum = if processing { cyan_lum.saturating_add(25).min(255) } else { cyan_lum };
let base = match key {
'.' => return None,
' ' => (240, 200, 170), // V-neck skin
'H' => (220, 215, 215),
'h' => (160, 155, 160),
's' => if warm { (250, 200, 185) } else { (240, 200, 175) },
'S' => (200, 150, 130),
'B' => (45, 35, 35),
'e' => (60, cyan_lum, cyan_lum.saturating_add(20)),
'-' => (70, 50, 50), // eye-closed line
'L' => if warm { (235, 145, 155) } else { (200, 110, 125) },
'O' => (50, 30, 35), // open-mouth interior (yawn)
'M' => (70, cyan_lum, cyan_lum),
'C' => (20, 20, 28),
'c' => (60, cyan_lum.saturating_sub(20), cyan_lum.saturating_sub(20)),
_ => return None,
};
let (r, g, b) = base;
// Yawning droops everything subtly — pull luminance down.
let dim = if yawning { 0.82 } else { 1.0 };
// Straining desaturates toward greyscale.
let sat = if strained { 0.55 } else { 1.0 };
let avg = ((r as u16 + g as u16 + b as u16) / 3) as f32;
let mix = |c: u8| {
let f = (c as f32 * sat + avg * (1.0 - sat)) * dim;
f.clamp(0.0, 255.0) as u8
};
Some(Color::Rgb(mix(r), mix(g), mix(b)))
}
// ── Render ──────────────────────────────────────────────────────
/// 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.
pub fn render_scaled(buf: &mut Buffer, area: Rect, p: &Presence, scale: u16) {
if scale == 0 { return; }
let scale = scale.max(1);
let breath = p.animator.breathe(2500);
// Each grid pixel is `scale` cells wide and `scale` cells tall after the
// half-block density (which already collapses 2 pixels per cell vertically).
// To keep the aspect roughly square with scale, we use scale horizontally
// and scale/2 (min 1) vertically since terminal cells are taller than wide.
let cell_w = scale;
let cell_h = (scale / 2).max(1);
let need_w = PORTRAIT_W * cell_w;
let need_h = (PORTRAIT_H / 2) * cell_h.max(1);
if area.width < need_w || area.height < need_h { return; }
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);
if col.is_none() { continue; }
let col = col.unwrap();
// Each pixel paints a cell_w × cell_h block. Since two pixels
// share a terminal row (half-blocks), top pixels use ▀ and bottom
// pixels use ▄, but at scale > 1 we just use █ everywhere because
// the pixels are already painted as full cells.
let cx0 = area.x + (px as u16) * cell_w;
let cy0 = area.y + ((py / 2) as u16) * cell_h
+ if py % 2 == 1 { 0 } else { 0 }; // vertical halves merged at scale>1
for dx in 0..cell_w {
for dy in 0..cell_h {
let x = cx0 + dx;
let y = cy0 + dy;
if x >= area.x + area.width || y >= area.y + area.height { continue; }
let cell = buf.get_mut(x, y);
cell.set_char('█');
cell.set_fg(col);
}
}
}
}
}
/// Render the portrait at native density (half-block precision).
pub fn render(buf: &mut Buffer, area: Rect, p: &Presence) {
if area.width < RENDER_W || area.height < RENDER_H {
return;
}
let breath = p.animator.breathe(2500);
for cy in 0..RENDER_H {
for cx in 0..RENDER_W {
let px = cx as usize;
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);
if top_col.is_none() && bot_col.is_none() {
continue;
}
let dest_x = area.x + cx;
let dest_y = area.y + cy;
if dest_x >= area.x + area.width || dest_y >= area.y + area.height {
continue;
}
let cell = buf.get_mut(dest_x, dest_y);
match (top_col, bot_col) {
(Some(fg), Some(bg)) if fg == bg => {
cell.set_char('█');
cell.set_fg(fg);
}
(Some(fg), Some(bg)) => {
cell.set_char('▀');
cell.set_fg(fg);
cell.set_bg(bg);
}
(Some(fg), None) => {
cell.set_char('▀');
cell.set_fg(fg);
}
(None, Some(bg)) => {
cell.set_char('▄');
cell.set_fg(bg);
}
(None, None) => {}
}
}
}
}
// ── Tests ───────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_base_rows_have_expected_width() {
for (i, row) in BASE.iter().enumerate() {
assert_eq!(
row.len(),
PORTRAIT_W as usize,
"row {} has wrong width: {:?} (len={})",
i,
row,
row.len()
);
}
}
#[test]
fn render_dimensions_match() {
assert_eq!(RENDER_W, PORTRAIT_W);
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());
assert!(color_for('?', Posture::Idle, 0.5).is_none());
}
#[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
);
} else {
panic!("expected RGB colors");
}
}
}

View file

@ -51,7 +51,7 @@
//! - Not a separate avatar for Aster. Same face, different state.
use ratatui::{
layout::Rect,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
@ -60,6 +60,7 @@ use ratatui::{
use crate::ui::animation::{Animator, colors};
use crate::ui::component::TuiEvent;
use crate::ui::portrait;
// ── State ───────────────────────────────────────────────────────
@ -216,6 +217,13 @@ impl Presence {
}
true
}
TuiEvent::InferenceStrain { .. } => {
// The voice is hoarse — drop into strain posture immediately.
// Posture will tick back to Idle once a Mood/EnergyChanged
// event arrives from the next successful round.
self.posture = Posture::Straining;
true
}
TuiEvent::Tick(t) => {
self.tick = *t;
if self.eye == Eye::Blinking && *t >= self.blink_until {
@ -234,128 +242,90 @@ impl Presence {
}
// ── Rendering ───────────────────────────────────────────────────
// Placeholder card for C1 — parity with the previous buddy visuals so the
// diff is shape-only. C2 replaces this with a hand-crafted half-block portrait.
//
// Tier 1: hand-crafted half-block portrait of Annie via `portrait::render`.
// The card layout is portrait (top) + name strip (bottom), with a rounded
// border whose color reflects the current Posture. State changes are
// expressed through the portrait itself — eyes blink/close, mouth opens on
// yawn, palette desaturates on strain, colors warm on affection — not
// through gauges.
/// Draw the presence as a small overlay card in a corner of `area`.
const CARD_W: u16 = portrait::RENDER_W + 2; // portrait + border
const CARD_H: u16 = portrait::RENDER_H + 3; // portrait + name row + border
/// Border color derived from current posture. Subtle, not loud.
fn posture_border(posture: Posture) -> Color {
match posture {
Posture::Processing => colors::ANI_PRIMARY,
Posture::Affectionate => Color::Rgb(220, 150, 170),
Posture::Straining => Color::Rgb(140, 100, 100),
Posture::Yawning => Color::Rgb(160, 145, 130),
Posture::Idle => colors::ANI_DIM,
}
}
/// Draw the presence as a portrait card in a corner of `area`.
pub fn draw_overlay(frame: &mut Frame, p: &Presence, area: Rect) {
if !p.visible {
if !p.visible || area.width < CARD_W || area.height < CARD_H {
return;
}
let (x, y, width) = match p.position {
Position::TopLeft => (area.x + 1, area.y + 1, 20),
Position::TopRight => (area.x + area.width.saturating_sub(21), area.y + 1, 20),
Position::BottomLeft => (area.x + 1, area.y + area.height.saturating_sub(6), 20),
let (x, y) = match p.position {
Position::TopLeft => (area.x + 1, area.y + 1),
Position::TopRight => (area.x + area.width.saturating_sub(CARD_W + 1), area.y + 1),
Position::BottomLeft => (
area.x + 1,
area.y + area.height.saturating_sub(CARD_H + 1),
),
Position::BottomRight => (
area.x + area.width.saturating_sub(21),
area.y + area.height.saturating_sub(6),
20,
area.x + area.width.saturating_sub(CARD_W + 1),
area.y + area.height.saturating_sub(CARD_H + 1),
),
};
let card_area = Rect {
x,
y,
width: width.min(area.width.saturating_sub(2)),
height: 5,
width: CARD_W,
height: CARD_H,
};
let energy_color = if p.energy > 70 {
colors::ANI_PRIMARY
} else if p.energy > 40 {
colors::ANI_SECONDARY
} else {
colors::ANI_DIM
};
let content = vec![
Line::from(Span::styled(
format!("{} ", p.name),
Style::default()
.fg(energy_color)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" Mood: {} ", p.mood),
Style::default().fg(Color::Gray),
)),
Line::from(vec![
Span::styled(" Energy: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"".repeat((p.energy / 10) as usize),
Style::default().fg(energy_color),
),
Span::styled(
"".repeat(10 - (p.energy / 10) as usize),
Style::default().fg(Color::DarkGray),
),
]),
];
let border = posture_border(p.posture);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::default()
.fg(energy_color)
.add_modifier(Modifier::DIM),
);
.border_style(Style::default().fg(border).add_modifier(Modifier::DIM));
frame.render_widget(block, card_area);
let para = Paragraph::new(content)
.block(block)
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
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);
frame.render_widget(para, card_area);
if p.subconscious_active {
let sub_area = Rect {
x: card_area.x,
y: card_area.y + card_area.height,
width: card_area.width,
height: 1,
};
let sub = Paragraph::new(Line::from(vec![
Span::styled("", Style::default().fg(colors::SUBCONSCIOUS)),
Span::styled(
"Subconscious Active",
Style::default()
.fg(colors::SUBCONSCIOUS)
.add_modifier(Modifier::ITALIC),
),
]));
frame.render_widget(sub, sub_area);
}
if let Some(s) = &p.last_surfacing {
let surf_area = Rect {
x: card_area.x,
y: card_area.y + card_area.height + 1,
width: card_area.width.min(30),
height: 2,
};
let truncated = truncate(s, 25);
let surf = Paragraph::new(vec![
Line::from(vec![
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled(
"Surfacing:",
Style::default()
.fg(colors::SUBCONSCIOUS)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(truncated, Style::default().fg(Color::Gray)),
]),
])
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
frame.render_widget(surf, surf_area);
}
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 p.subconscious_active { "" } else { "·" };
let name_line = Line::from(vec![
Span::styled(format!(" {} ", glyph), Style::default().fg(border)),
Span::styled(
p.name.clone(),
Style::default().fg(border).add_modifier(Modifier::BOLD),
),
]);
let name_para = Paragraph::new(name_line).alignment(Alignment::Center);
frame.render_widget(name_para, name_area);
}
/// Draw the presence on the welcome screen with agent selection state.
/// 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,
@ -366,41 +336,74 @@ pub fn draw_welcome(
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: area.x + area.width.saturating_sub(22),
y: area.y + 1,
width: 20,
height: 8,
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 mut content = vec![
Line::from(Span::styled(
" ◈ COMPANION ",
Style::default()
.fg(colors::ANI_PRIMARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
if let Some(agent_name) = selected_agent {
content.extend(vec![
let info_lines = if let Some(agent_name) = selected_agent {
vec![
Line::from(vec![
Span::styled(" Agent: ", Style::default().fg(Color::DarkGray)),
Span::styled(
agent_name,
Style::default()
.fg(colors::ANI_SECONDARY)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
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(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(
"ENTER",
Style::default()
@ -410,15 +413,16 @@ pub fn draw_welcome(
Span::styled(" to wake ", Style::default().fg(Color::DarkGray)),
Span::styled(agent_name, Style::default().fg(colors::ANI_SECONDARY)),
]),
]);
]
} else {
content.extend(vec![
vec![
Line::from(Span::styled(
" No agent selected",
" No agent selected",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(
"a",
Style::default()
@ -427,31 +431,9 @@ pub fn draw_welcome(
),
Span::styled(" to create alias", Style::default().fg(Color::DarkGray)),
]),
]);
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(
Style::default()
.fg(colors::ANI_PRIMARY)
.add_modifier(Modifier::DIM),
);
let para = Paragraph::new(content)
.block(block)
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
frame.render_widget(para, card_area);
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}", &s[..max - 1])
}
]
};
frame.render_widget(Paragraph::new(info_lines), info_area);
}
// ── Tests ───────────────────────────────────────────────────────
@ -536,8 +518,17 @@ mod tests {
}
#[test]
fn truncate_works() {
assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("hello world", 8), "hello w…");
fn posture_border_changes_with_state() {
// Just confirms posture maps to distinct colors for the four
// "interesting" states; idle stays as the dim default.
let idle = posture_border(Posture::Idle);
let processing = posture_border(Posture::Processing);
let affectionate = posture_border(Posture::Affectionate);
let straining = posture_border(Posture::Straining);
let yawning = posture_border(Posture::Yawning);
assert_ne!(idle, processing);
assert_ne!(idle, affectionate);
assert_ne!(idle, straining);
assert_ne!(idle, yawning);
}
}