Skip to content

Commit b339e1d

Browse files
BunsDevclaude
andauthored
feat(tui): static themed familiar cards with procedural sigils (#26)
Replace the walking/blinking violet mascot with a static themed card so every familiar — built-in or user-defined in ~/.coven/familiars.toml — gets first-class visual identity in the welcome panel, F2 switcher, and /agents detail view. - familiar_theme: per-familiar palette + archetype. Built-ins get hand- tuned colours (kitty=violet, nova=gold, cody=cyan, charm=pink, sage=emerald, astra=indigo, echo=teal). User-defined entries get a deterministic palette + sigil archetype hashed from their id so they stay stable across sessions and machines. - familiar_card: composes Compact/Standard/Large cards plus a mini-row for the F2 switcher; four procedural sigil frames (crystal, hex, rune, seal) host the emoji for any user-defined familiar. - rustle: collapses RustlePose to Static | Loading{frame} and threads the palette through every glyph builder. The only motion that survives is the eye-spinner during stalled streaming. - app: drops rustle_walk_*, rustle_temp_pose, rustle_pose_until, rustle_next_blink; tick_rustle_pose is now a 4-line static/loading toggle, and rustle_look_down is a no-op for the Tab callsite. - render: welcome panel + F2 switcher route through familiar_card; the hardcoded built-in emoji table is gone. - agents_view: familiar-sourced agents show a Standard card above the persona preview. - docs/familiars.md: rewrites the glyph section to describe the new static themed cards and procedural sigils. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41de009 commit b339e1d

8 files changed

Lines changed: 996 additions & 427 deletions

File tree

docs/familiars.md

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -247,23 +247,43 @@ Or check the [Coven documentation](https://opencoven.ai/docs) for installation i
247247

248248
---
249249

250-
## Familiar glyphs in the TUI
250+
## Familiar cards in the TUI
251251

252-
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.
252+
Every familiar — built-in or user-defined — renders as a **static themed card** in three places:
253253

254-
Built-in glyphs:
254+
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.
255+
2. The **F2 switcher popup**: one row per familiar, each painted in that familiar's accent palette with a coloured tier dot.
256+
3. The **`/agents` detail view**: the card appears above the persona preview when you select a familiar-sourced agent.
255257

256-
| ID | Concept |
257-
|---|---|
258-
| `kitty` | Cat head — ears, whiskers, square eyes (default) |
259-
| `nova` | 4-point star with orbiting sparks |
260-
| `cody` | Robot face — antenna, bracket eyes |
261-
| `charm` | Heart with sparkle dots |
262-
| `sage` | Wizard hat + star + open book |
263-
| `astra` | Crescent moon + compass star + orbit |
264-
| `echo` | Round ghost + mirror eyes + echo dots |
258+
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.
259+
260+
Cards adapt to available room:
261+
262+
- **Compact** (narrow terminals): glyph only, no border.
263+
- **Standard** (default): glyph + name + tier dot inside a rounded border.
264+
- **Large** (wide terminals): adds the role line and an accent rule under the glyph.
265+
266+
### Built-in glyphs
267+
268+
| ID | Concept | Accent |
269+
|---|---|---|
270+
| `kitty` | Cat head — ears, whiskers, square eyes (default) | violet |
271+
| `nova` | Crowned sorceress with star sparkles | gold |
272+
| `cody` | Robot face — antenna, bracket eyes, code body | cyan |
273+
| `charm` | Heart with sparkle dots | pink |
274+
| `sage` | Wizard hat + star + open book | emerald |
275+
| `astra` | Crescent moon + compass star + orbit | indigo |
276+
| `echo` | Round ghost + bracket eyes + echo dots | teal |
265277

266-
To change the displayed glyph, set `familiar` in your settings:
278+
### User-defined familiars
279+
280+
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.
281+
282+
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.
283+
284+
### Changing the displayed glyph
285+
286+
Set `familiar` in your settings:
267287

268288
```json
269289
{
@@ -277,6 +297,8 @@ Or run:
277297
coven-code config set familiar nova
278298
```
279299

300+
You can also press **F2** at any time to open the switcher popup and pick a familiar interactively.
301+
280302
---
281303

282304
## See also

src-rust/crates/tui/src/agents_view.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use std::time::{Duration, Instant};
1515

1616
use claurst_core::coven_shared;
1717

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

1018+
// For familiar-sourced agents, render the themed card at the top of the
1019+
// detail panel so the user sees the same visual identity they pick from
1020+
// F2 or the welcome screen. We resolve from the daemon's familiar list
1021+
// so user-defined entries get a procedural card instead of a fallback.
1022+
if is_familiar {
1023+
if let Some(id) = def.source.strip_prefix("coven:familiar:") {
1024+
let daemon = coven_shared::load_familiars().unwrap_or_default();
1025+
let theme = familiar_theme::resolve(id, &daemon);
1026+
for line in familiar_card::render_card(&theme, CardSize::Standard, None) {
1027+
lines.push(line);
1028+
}
1029+
lines.push(Line::default());
1030+
}
1031+
}
1032+
10161033
// Source badge — colour-coded for familiar vs user.
10171034
let source_style = if is_familiar {
10181035
Style::default().fg(Color::Rgb(139, 92, 246)).add_modifier(Modifier::BOLD)

src-rust/crates/tui/src/app.rs

Lines changed: 22 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -772,21 +772,10 @@ pub struct App {
772772

773773
/// Instant the session started (used for elapsed-time in the status bar).
774774
pub session_start: std::time::Instant,
775-
/// Current Rune pose for rendering (updated each frame).
775+
/// Current familiar pose for rendering. `Static` when idle; `Loading {
776+
/// frame }` while streaming has stalled long enough to surface a spinner.
777+
/// The glyph itself never walks or blinks.
776778
pub rustle_current_pose: crate::rustle::RustlePose,
777-
/// Temporary Rune pose override (e.g. look-down on Tab). Reverts to
778-
/// default after this instant passes.
779-
pub rustle_pose_until: Option<std::time::Instant>,
780-
/// The temporary pose to show until `rustle_pose_until`.
781-
pub rustle_temp_pose: Option<crate::rustle::RustlePose>,
782-
/// Frame counter at which the next random eye-shift should fire.
783-
pub rustle_next_blink: u64,
784-
/// Horizontal walk position of the mascot in the welcome panel (0-based column offset).
785-
pub rustle_walk_x: i32,
786-
/// Walk direction: +1 = right, -1 = left.
787-
pub rustle_walk_dir: i32,
788-
/// Maximum walk offset (in columns) — set each render frame based on available width.
789-
pub rustle_walk_max: Cell<i32>,
790779
/// Instant the current turn's streaming began (reset each time streaming starts).
791780
pub turn_start: Option<std::time::Instant>,
792781
/// Elapsed time string for the last completed turn, e.g. "2m 5s".
@@ -1291,16 +1280,7 @@ impl App {
12911280
new_messages_while_scrolled: 0,
12921281
token_warning_threshold_shown: 0,
12931282
session_start: std::time::Instant::now(),
1294-
rustle_current_pose: crate::rustle::RustlePose::Default,
1295-
rustle_pose_until: None,
1296-
rustle_temp_pose: None,
1297-
rustle_next_blink: 200 + (std::time::SystemTime::now()
1298-
.duration_since(std::time::UNIX_EPOCH)
1299-
.unwrap_or_default()
1300-
.subsec_nanos() as u64 % 300),
1301-
rustle_walk_x: 0,
1302-
rustle_walk_dir: 1,
1303-
rustle_walk_max: Cell::new(0),
1283+
rustle_current_pose: crate::rustle::RustlePose::Static,
13041284
turn_start: None,
13051285
last_turn_elapsed: None,
13061286
last_turn_verb: None,
@@ -1878,77 +1858,27 @@ impl App {
18781858
None
18791859
}
18801860

1881-
/// and the loading spinner on stalls/errors.
1882-
/// Call once per frame before rendering.
1861+
/// Update the familiar pose for this render frame.
1862+
///
1863+
/// The glyph itself is static — this just toggles between `Static` and
1864+
/// `Loading { frame }` so the eye-spinner kicks in when the assistant has
1865+
/// gone quiet for 3+ seconds. Call once per frame before rendering.
18831866
pub fn tick_rustle_pose(&mut self) {
1884-
// Loading spinner: shown when streaming has stalled (no data for 3s+).
1885-
if self.is_streaming {
1886-
if let Some(start) = self.stall_start {
1887-
if start.elapsed() > std::time::Duration::from_secs(3) {
1888-
self.rustle_current_pose = crate::rustle::RustlePose::Loading {
1889-
frame: self.frame_count,
1890-
};
1891-
return;
1892-
}
1893-
}
1894-
}
1895-
1896-
// Check if a temporary pose is active.
1897-
if let Some(until) = self.rustle_pose_until {
1898-
if std::time::Instant::now() < until {
1899-
self.rustle_current_pose = self.rustle_temp_pose.clone()
1900-
.unwrap_or(crate::rustle::RustlePose::Default);
1901-
return;
1902-
}
1903-
// Expired — clear it.
1904-
self.rustle_pose_until = None;
1905-
self.rustle_temp_pose = None;
1906-
}
1907-
1908-
// Random eye-shift: every ~200-500 frames, briefly look right.
1909-
if self.frame_count >= self.rustle_next_blink {
1910-
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight);
1911-
self.rustle_pose_until = Some(
1912-
std::time::Instant::now() + std::time::Duration::from_millis(800)
1913-
);
1914-
// Schedule next blink 200-500 frames from now (random-ish).
1915-
let jitter = (self.frame_count.wrapping_mul(7) % 300) + 200;
1916-
self.rustle_next_blink = self.frame_count + jitter;
1917-
self.rustle_current_pose = crate::rustle::RustlePose::LookRight;
1918-
return;
1919-
}
1920-
1921-
self.rustle_current_pose = crate::rustle::RustlePose::Default;
1922-
1923-
// Advance walk position every 8 frames (slow pace).
1924-
if self.frame_count % 8 == 0 {
1925-
self.rustle_walk_x += self.rustle_walk_dir;
1926-
let walk_max = self.rustle_walk_max.get();
1927-
if self.rustle_walk_x >= walk_max {
1928-
self.rustle_walk_x = walk_max;
1929-
self.rustle_walk_dir = -1;
1930-
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookLeft);
1931-
self.rustle_pose_until = Some(
1932-
std::time::Instant::now() + std::time::Duration::from_millis(300)
1933-
);
1934-
} else if self.rustle_walk_x <= 0 {
1935-
self.rustle_walk_x = 0;
1936-
self.rustle_walk_dir = 1;
1937-
self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight);
1938-
self.rustle_pose_until = Some(
1939-
std::time::Instant::now() + std::time::Duration::from_millis(300)
1940-
);
1941-
}
1942-
}
1867+
let stalled = self.is_streaming
1868+
&& self
1869+
.stall_start
1870+
.map(|s| s.elapsed() > std::time::Duration::from_secs(3))
1871+
.unwrap_or(false);
1872+
self.rustle_current_pose = if stalled {
1873+
crate::rustle::RustlePose::Loading { frame: self.frame_count }
1874+
} else {
1875+
crate::rustle::RustlePose::Static
1876+
};
19431877
}
19441878

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

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

0 commit comments

Comments
 (0)