diff --git a/docs/skins/example-skin.yaml b/docs/skins/example-skin.yaml index b81ae00f8d..fb0be89da6 100644 --- a/docs/skins/example-skin.yaml +++ b/docs/skins/example-skin.yaml @@ -6,6 +6,11 @@ # All fields are optional — missing values inherit from the default skin. # Activate with: /skin or display.skin: 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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6e9249ae74..c8ae0be85c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 {} diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 6a48bc1be8..9e1db99463 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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)) }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 02057e807e..53269a1751 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 => { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 4b619bed5a..11c1107736 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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) } }, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 4e55a53ba8..1000adbb68 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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 ( + + {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}… + + ) +} + 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({ {'─ '} - {status} + {busy ? : {status}} │ {model} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index e8ae95b1e7..39056bfc31 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -138,6 +138,7 @@ const ComposerPane = memo(function ComposerPane({ {ui.statusBar && ( $ ) : ( - + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} )} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 46f6b667fe..859ff9bee7 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -106,7 +106,12 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {cwd} - {sid && Session: {sid}} + {sid && ( + + Session: + {sid} + + )} )} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index cd98dc9d47..31c58896b8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -5,6 +5,8 @@ export interface GatewaySkin { banner_logo?: string branding?: Record colors?: Record + help_header?: string + tool_prefix?: string } export interface GatewayCompletionItem { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index ddd722e538..88bc3c3908 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -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, branding: Record, 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, diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 2276254578..a296a63f7b 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -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