feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel

The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.

Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).

Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
This commit is contained in:
Brooklyn Nicholson 2026-04-16 20:14:25 -05:00
parent 23212d6b40
commit aedc767c66
11 changed files with 109 additions and 40 deletions

View file

@ -6,6 +6,11 @@
# All fields are optional — missing values inherit from the default skin.
# Activate with: /skin <name> or display.skin: <name> in config.yaml
#
# Keys are marked:
# (both) — applies to both the classic CLI and the TUI
# (classic) — classic CLI only (see hermes --tui in user-guide/tui.md)
# (tui) — TUI only
#
# See hermes_cli/skin_engine.py for the full schema reference.
# ============================================================================
@ -14,43 +19,47 @@ name: example
description: An example custom skin — copy and modify this template
# ── Colors ──────────────────────────────────────────────────────────────────
# Hex color values for Rich markup. These control the CLI's visual palette.
# Hex color values. These control the visual palette.
colors:
# Banner panel (the startup welcome box)
# Banner panel (the startup welcome box) — (both)
banner_border: "#CD7F32" # Panel border
banner_title: "#FFD700" # Panel title text
banner_accent: "#FFBF00" # Section headers (Available Tools, Skills, etc.)
banner_dim: "#B8860B" # Dim/muted text (separators, model info)
banner_text: "#FFF8DC" # Body text (tool names, skill names)
# UI elements
ui_accent: "#FFBF00" # General accent color
# UI elements — (both)
ui_accent: "#FFBF00" # General accent (falls back to banner_accent)
ui_label: "#4dd0e1" # Labels
ui_ok: "#4caf50" # Success indicators
ui_error: "#ef5350" # Error indicators
ui_warn: "#ffa726" # Warning indicators
# Input area
prompt: "#FFF8DC" # Prompt text color
input_rule: "#CD7F32" # Horizontal rule around input
prompt: "#FFF8DC" # Prompt text / `` glyph color (both)
input_rule: "#CD7F32" # Horizontal rule above input (classic)
# Response box
response_border: "#FFD700" # Response box border (ANSI color)
# Response box — (classic)
response_border: "#FFD700" # Response box border
# Session display
session_label: "#DAA520" # Session label
session_border: "#8B8682" # Session ID dim color
# Session display — (both)
session_label: "#DAA520" # "Session: " label
session_border: "#8B8682" # Session ID text
# TUI surfaces
status_bar_bg: "#1a1a2e" # Status / usage bar background
voice_status_bg: "#1a1a2e" # Voice-mode badge background
completion_menu_bg: "#1a1a2e" # Completion list background
completion_menu_current_bg: "#333355" # Active completion row background
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# TUI / CLI surfaces — (classic: status bar, voice badge, completion meta)
status_bar_bg: "#1a1a2e" # Status / usage bar background (classic)
voice_status_bg: "#1a1a2e" # Voice-mode badge background (classic)
completion_menu_bg: "#1a1a2e" # Completion list background (both)
completion_menu_current_bg: "#333355" # Active completion row background (both)
completion_menu_meta_bg: "#1a1a2e" # Completion meta column bg (classic)
completion_menu_meta_current_bg: "#333355" # Active meta bg (classic)
# Drag-to-select background — (tui)
selection_bg: "#3a3a55" # Uniform selection highlight in the TUI
# ── Spinner ─────────────────────────────────────────────────────────────────
# Customize the animated spinner shown during API calls and tool execution.
# (classic) — the TUI uses its own animated indicators; spinner config here
# is only read by the classic prompt_toolkit CLI.
spinner:
# Faces shown while waiting for the API response
waiting_faces:
@ -78,17 +87,17 @@ spinner:
# - ["⟪▲", "▲⟫"]
# ── Branding ────────────────────────────────────────────────────────────────
# Text strings used throughout the CLI interface.
# Text strings used throughout the interface.
branding:
agent_name: "Hermes Agent" # Banner title, about display
welcome: "Welcome! Type your message or /help for commands."
goodbye: "Goodbye! ⚕" # Exit message
response_label: " ⚕ Hermes " # Response box header label
prompt_symbol: " " # Input prompt symbol
help_header: "(^_^)? Available Commands" # /help header text
agent_name: "Hermes Agent" # (both) Banner title, about display
welcome: "Welcome! Type your message or /help for commands." # (both)
goodbye: "Goodbye! ⚕" # (both) Exit message
response_label: " ⚕ Hermes " # (classic) Response box header label
prompt_symbol: " " # (both) Input prompt glyph
help_header: "(^_^)? Available Commands" # (both) /help overlay title
# ── Tool Output ─────────────────────────────────────────────────────────────
# Character used as the prefix for tool output lines.
# Character used as the prefix for tool output lines. (both)
# Default is "┊" (thin dotted vertical line). Some alternatives:
# "╎" (light triple dash vertical)
# "▏" (left one-eighth block)

View file

@ -316,6 +316,8 @@ def resolve_skin() -> dict:
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
"tool_prefix": skin.tool_prefix,
"help_header": (skin.branding or {}).get("help_header", ""),
}
except Exception:
return {}

View file

@ -102,7 +102,7 @@ describe('createSlashHandler', () => {
const h = createSlashHandler(ctx)
expect(h('/zzz')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array))
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
})
@ -119,7 +119,7 @@ describe('createSlashHandler', () => {
})
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array))
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
})

View file

@ -16,7 +16,16 @@ const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
const applySkin = (s: GatewaySkin) =>
patchUiState({ theme: fromSkin(s.colors ?? {}, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '') })
patchUiState({
theme: fromSkin(
s.colors ?? {},
s.branding ?? {},
s.banner_logo ?? '',
s.banner_hero ?? '',
s.tool_prefix ?? '',
s.help_header ?? ''
)
})
const dropBgTask = (taskId: string) =>
patchUiState(state => {

View file

@ -55,7 +55,7 @@ export const coreCommands: SlashCommand[] = [
})
sections.push({ rows: HOTKEYS, title: 'Hotkeys' })
ctx.transcript.panel('Commands', sections)
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
}
},

View file

@ -1,12 +1,32 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
const FACE_TICK_MS = 2500
function FaceTicker({ color }: { color: string }) {
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
useEffect(() => {
const id = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
return () => clearInterval(id)
}, [])
return (
<Text color={color}>
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}
</Text>
)
}
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.dim
@ -73,6 +93,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
export function StatusRule({
cwdLabel,
cols,
busy,
status,
statusColor,
model,
@ -84,6 +105,7 @@ export function StatusRule({
}: {
cwdLabel: string
cols: number
busy: boolean
status: string
statusColor: string
model: string
@ -111,7 +133,7 @@ export function StatusRule({
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end">
{'─ '}
<Text color={statusColor}>{status}</Text>
{busy ? <FaceTicker color={statusColor} /> : <Text color={statusColor}>{status}</Text>}
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (

View file

@ -138,6 +138,7 @@ const ComposerPane = memo(function ComposerPane({
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
@ -181,7 +182,7 @@ const ComposerPane = memo(function ComposerPane({
{sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text>
) : (
<Text bold color={ui.theme.color.gold}>
<Text bold color={ui.theme.color.prompt}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
)}

View file

@ -106,7 +106,12 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Text color={t.color.dim} wrap="truncate-end">
{cwd}
</Text>
{sid && <Text color={t.color.dim}>Session: {sid}</Text>}
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
<Box flexDirection="column" width={w}>

View file

@ -5,6 +5,8 @@ export interface GatewaySkin {
banner_logo?: string
branding?: Record<string, string>
colors?: Record<string, string>
help_header?: string
tool_prefix?: string
}
export interface GatewayCompletionItem {

View file

@ -12,6 +12,10 @@ export interface ThemeColors {
error: string
warn: string
prompt: string
sessionLabel: string
sessionBorder: string
statusBg: string
statusFg: string
statusGood: string
@ -35,6 +39,7 @@ export interface ThemeBrand {
welcome: string
goodbye: string
tool: string
helpHeader: string
}
export interface Theme {
@ -88,6 +93,10 @@ export const DEFAULT_THEME: Theme = {
error: '#ef5350',
warn: '#ffa726',
prompt: '#FFF8DC',
sessionLabel: '#B8860B',
sessionBorder: '#B8860B',
statusBg: '#1a1a2e',
statusFg: '#C0C0C0',
statusGood: '#8FBC8F',
@ -109,7 +118,8 @@ export const DEFAULT_THEME: Theme = {
prompt: '',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊'
tool: '┊',
helpHeader: '(^_^)? Commands'
},
bannerLogo: '',
@ -122,20 +132,24 @@ export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
bannerLogo = '',
bannerHero = ''
bannerHero = '',
toolPrefix = '',
helpHeader = ''
): Theme {
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
const dim = c('banner_dim') ?? d.color.dim
return {
color: {
gold: c('banner_title') ?? d.color.gold,
amber: c('banner_accent') ?? d.color.amber,
amber,
bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk,
dim: c('banner_dim') ?? d.color.dim,
dim,
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
@ -144,6 +158,10 @@ export function fromSkin(
error: c('ui_error') ?? d.color.error,
warn: c('ui_warn') ?? d.color.warn,
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
sessionLabel: c('session_label') ?? dim,
sessionBorder: c('session_border') ?? dim,
statusBg: d.color.statusBg,
statusFg: d.color.statusFg,
statusGood: c('ui_ok') ?? d.color.statusGood,
@ -165,7 +183,8 @@ export function fromSkin(
prompt: branding.prompt_symbol ?? d.brand.prompt,
welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye,
tool: d.brand.tool
tool: toolPrefix || d.brand.tool,
helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
},
bannerLogo,

View file

@ -48,7 +48,7 @@ The classic CLI remains available as the default. Anything documented in [CLI In
- **Alternate-screen rendering** — differential updates mean no flicker when streaming, no scrollback clutter after you quit.
- **Composer affordances** — inline paste-collapse for long snippets, image paste from the clipboard (`Alt+V`), bracketed-paste safety.
Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, `/theme daylight`, and the UI repaints live.
Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, and the UI repaints live. Skin keys are marked `(both)`, `(classic)`, or `(tui)` in [`example-skin.yaml`](https://github.com/NousResearch/hermes-agent/blob/main/docs/skins/example-skin.yaml) so you can see at a glance what applies where — the TUI honors the banner palette, UI colors, prompt glyph/color, session display, completion menu, selection bg, `tool_prefix`, and `help_header`.
## Requirements