mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
23212d6b40
commit
aedc767c66
11 changed files with 109 additions and 40 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue