Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions docs/familiars.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,23 +247,43 @@ Or check the [Coven documentation](https://opencoven.ai/docs) for installation i

---

## Familiar glyphs in the TUI
## Familiar cards in the TUI

Each familiar has a dedicated pixel-art glyph rendered in the welcome panel. The active familiar (set via `settings.json` → `"familiar"`) determines which glyph is shown. The glyph animates — it blinks, shifts pose when loading, and walks left/right across the panel.
Every familiar — built-in or user-defined — renders as a **static themed card** in three places:

Built-in glyphs:
1. The **welcome panel** (top-left of the home screen): glyph, name, access tier dot, and on wider terminals the role and an accent rule.
2. The **F2 switcher popup**: one row per familiar, each painted in that familiar's accent palette with a coloured tier dot.
3. The **`/agents` detail view**: the card appears above the persona preview when you select a familiar-sourced agent.

| ID | Concept |
|---|---|
| `kitty` | Cat head — ears, whiskers, square eyes (default) |
| `nova` | 4-point star with orbiting sparks |
| `cody` | Robot face — antenna, bracket eyes |
| `charm` | Heart with sparkle dots |
| `sage` | Wizard hat + star + open book |
| `astra` | Crescent moon + compass star + orbit |
| `echo` | Round ghost + mirror eyes + echo dots |
The glyph itself does **not** animate. The only motion is a quarter-block eye spinner that kicks in when the assistant has gone quiet for ~3 seconds, so you still get a "thinking" signal without a walking mascot pulling attention from the work area.

Cards adapt to available room:

- **Compact** (narrow terminals): glyph only, no border.
- **Standard** (default): glyph + name + tier dot inside a rounded border.
- **Large** (wide terminals): adds the role line and an accent rule under the glyph.

### Built-in glyphs

| ID | Concept | Accent |
|---|---|---|
| `kitty` | Cat head — ears, whiskers, square eyes (default) | violet |
| `nova` | Crowned sorceress with star sparkles | gold |
| `cody` | Robot face — antenna, bracket eyes, code body | cyan |
| `charm` | Heart with sparkle dots | pink |
| `sage` | Wizard hat + star + open book | emerald |
| `astra` | Crescent moon + compass star + orbit | indigo |
| `echo` | Round ghost + bracket eyes + echo dots | teal |

To change the displayed glyph, set `familiar` in your settings:
### User-defined familiars

Any familiar declared in `~/.coven/familiars.toml` automatically gets a procedurally-generated card. The accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked deterministically from the familiar's `id`, so the same familiar looks the same across sessions and machines without storing extra config. The familiar's `emoji` is rendered inside the frame.

If you want a hand-crafted image instead of the procedural sigil, drop a PNG/JPG/WebP at `~/.coven/assets/familiars/<id>.<ext>`. When the terminal supports Kitty or Sixel inline graphics, that image takes precedence over the card.

### Changing the displayed glyph

Set `familiar` in your settings:

```json
{
Expand All @@ -277,6 +297,8 @@ Or run:
coven-code config set familiar nova
```

You can also press **F2** at any time to open the switcher popup and pick a familiar interactively.

---

## See also
Expand Down
17 changes: 17 additions & 0 deletions src-rust/crates/tui/src/agents_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use std::time::{Duration, Instant};

use claurst_core::coven_shared;

use crate::familiar_card::{self, CardSize};
use crate::familiar_theme;
use crate::overlays::{
begin_modal_buf, modal_header_line_area, render_modal_title_buf, COVEN_CODE_ACCENT,
COVEN_CODE_MUTED, COVEN_CODE_PANEL_BG, COVEN_CODE_TEXT,
Expand Down Expand Up @@ -1013,6 +1015,21 @@ fn render_agent_detail(def: &AgentDefinition, area: Rect, buf: &mut Buffer) {
let mut lines = Vec::new();
let is_familiar = def.source.starts_with("coven:familiar");

// For familiar-sourced agents, render the themed card at the top of the
// detail panel so the user sees the same visual identity they pick from
// F2 or the welcome screen. We resolve from the daemon's familiar list
// so user-defined entries get a procedural card instead of a fallback.
if is_familiar {
if let Some(id) = def.source.strip_prefix("coven:familiar:") {
let daemon = coven_shared::load_familiars().unwrap_or_default();
let theme = familiar_theme::resolve(id, &daemon);
for line in familiar_card::render_card(&theme, CardSize::Standard, None) {
Comment on lines +1022 to +1026
lines.push(line);
}
lines.push(Line::default());
}
}

// Source badge — colour-coded for familiar vs user.
let source_style = if is_familiar {
Style::default().fg(Color::Rgb(139, 92, 246)).add_modifier(Modifier::BOLD)
Expand Down
114 changes: 22 additions & 92 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,21 +772,10 @@ pub struct App {

/// Instant the session started (used for elapsed-time in the status bar).
pub session_start: std::time::Instant,
/// Current Rune pose for rendering (updated each frame).
/// Current familiar pose for rendering. `Static` when idle; `Loading {
/// frame }` while streaming has stalled long enough to surface a spinner.
/// The glyph itself never walks or blinks.
pub rustle_current_pose: crate::rustle::RustlePose,
/// Temporary Rune pose override (e.g. look-down on Tab). Reverts to
/// default after this instant passes.
pub rustle_pose_until: Option<std::time::Instant>,
/// The temporary pose to show until `rustle_pose_until`.
pub rustle_temp_pose: Option<crate::rustle::RustlePose>,
/// Frame counter at which the next random eye-shift should fire.
pub rustle_next_blink: u64,
/// Horizontal walk position of the mascot in the welcome panel (0-based column offset).
pub rustle_walk_x: i32,
/// Walk direction: +1 = right, -1 = left.
pub rustle_walk_dir: i32,
/// Maximum walk offset (in columns) — set each render frame based on available width.
pub rustle_walk_max: Cell<i32>,
/// Instant the current turn's streaming began (reset each time streaming starts).
pub turn_start: Option<std::time::Instant>,
/// Elapsed time string for the last completed turn, e.g. "2m 5s".
Expand Down Expand Up @@ -1291,16 +1280,7 @@ impl App {
new_messages_while_scrolled: 0,
token_warning_threshold_shown: 0,
session_start: std::time::Instant::now(),
rustle_current_pose: crate::rustle::RustlePose::Default,
rustle_pose_until: None,
rustle_temp_pose: None,
rustle_next_blink: 200 + (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as u64 % 300),
rustle_walk_x: 0,
rustle_walk_dir: 1,
rustle_walk_max: Cell::new(0),
rustle_current_pose: crate::rustle::RustlePose::Static,
turn_start: None,
last_turn_elapsed: None,
last_turn_verb: None,
Expand Down Expand Up @@ -1878,77 +1858,27 @@ impl App {
None
}

/// and the loading spinner on stalls/errors.
/// Call once per frame before rendering.
/// Update the familiar pose for this render frame.
///
/// The glyph itself is static — this just toggles between `Static` and
/// `Loading { frame }` so the eye-spinner kicks in when the assistant has
/// gone quiet for 3+ seconds. Call once per frame before rendering.
pub fn tick_rustle_pose(&mut self) {
// Loading spinner: shown when streaming has stalled (no data for 3s+).
if self.is_streaming {
if let Some(start) = self.stall_start {
if start.elapsed() > std::time::Duration::from_secs(3) {
self.rustle_current_pose = crate::rustle::RustlePose::Loading {
frame: self.frame_count,
};
return;
}
}
}

// Check if a temporary pose is active.
if let Some(until) = self.rustle_pose_until {
if std::time::Instant::now() < until {
self.rustle_current_pose = self.rustle_temp_pose.clone()
.unwrap_or(crate::rustle::RustlePose::Default);
return;
}
// Expired — clear it.
self.rustle_pose_until = None;
self.rustle_temp_pose = None;
}

// Random eye-shift: every ~200-500 frames, briefly look right.
if self.frame_count >= self.rustle_next_blink {
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight);
self.rustle_pose_until = Some(
std::time::Instant::now() + std::time::Duration::from_millis(800)
);
// Schedule next blink 200-500 frames from now (random-ish).
let jitter = (self.frame_count.wrapping_mul(7) % 300) + 200;
self.rustle_next_blink = self.frame_count + jitter;
self.rustle_current_pose = crate::rustle::RustlePose::LookRight;
return;
}

self.rustle_current_pose = crate::rustle::RustlePose::Default;

// Advance walk position every 8 frames (slow pace).
if self.frame_count % 8 == 0 {
self.rustle_walk_x += self.rustle_walk_dir;
let walk_max = self.rustle_walk_max.get();
if self.rustle_walk_x >= walk_max {
self.rustle_walk_x = walk_max;
self.rustle_walk_dir = -1;
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookLeft);
self.rustle_pose_until = Some(
std::time::Instant::now() + std::time::Duration::from_millis(300)
);
} else if self.rustle_walk_x <= 0 {
self.rustle_walk_x = 0;
self.rustle_walk_dir = 1;
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight);
self.rustle_pose_until = Some(
std::time::Instant::now() + std::time::Duration::from_millis(300)
);
}
}
let stalled = self.is_streaming
&& self
.stall_start
.map(|s| s.elapsed() > std::time::Duration::from_secs(3))
.unwrap_or(false);
self.rustle_current_pose = if stalled {
crate::rustle::RustlePose::Loading { frame: self.frame_count }
} else {
crate::rustle::RustlePose::Static
};
}

/// Trigger Rune looking down briefly (called on Tab / mode switch).
pub fn rustle_look_down(&mut self) {
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookDown);
self.rustle_pose_until = Some(
std::time::Instant::now() + std::time::Duration::from_secs(1)
);
}
/// No-op retained for callsites left over from the animated era (Tab /
/// mode-switch handlers). The static glyph has no look-down pose.
pub fn rustle_look_down(&mut self) {}

/// Cycle to the next agent mode: build → plan → explore → build.
/// Sets `agent_mode_changed` so the main loop can update the query config
Expand Down
Loading