mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
* feat(tui): pluggable busy-indicator styles (kaomoji/emoji/unicode/ascii) The status-bar `FaceTicker` rotated through wide-and-variable kaomoji glyphs (`(。•́︿•̀。)`, `( ͡° ͜ʖ ͡°)`, …) every 2.5s. Real display widths range from ~5 to ~16 columns, so the rest of the bar (cwd, ctx %, voice, bg counter) shifted on every cycle. Padding the verb alone (#17116) helped but didn't address the dominant jitter source — the glyph itself. Add four indicator styles, configurable + hot-swappable: * `kaomoji` (default — preserves the existing vibe; verb is now pad-stable so the only width churn left is the kaomoji itself). * `emoji` — single 2-col emoji frame (`⚕ 🌀 🤔 ✨ 🍵 🔮`). * `unicode` — `unicode-animations` braille spinner (1-col, smooth). * `ascii` — `| / - \` (1-col, max compat). Wires: * `display.tui_status_indicator` in `DEFAULT_CONFIG` (default `kaomoji`). * New JSON-RPC `config.set/get indicator` keys, narrow allow-list. * `applyDisplay` reads the field and patches `UiState.indicatorStyle`, so the existing `mtime` poll picks up `~/.hermes/config.yaml` edits within ~5s without a TUI restart. * `/indicator [style]` slash command (alias `/indicator-style`, subcommand completion `kaomoji|emoji|unicode|ascii`). Bare form shows the current style; setter fires `config.set` and optimistically `patchUiState({ indicatorStyle })` so the live TUI swaps immediately, matching the `/skin` UX. * `CommandDef("indicator", ..., subcommands=...)` so classic CLI autocomplete + TUI `complete.slash` both surface it. * `FaceTicker` decouples spinner cadence from verb cadence — the glyph runs at the spinner's authored interval (or `FACE_TICK_MS` for kaomoji), the verb stays on the original 2.5s cycle, and both re-arm cleanly when style changes. Tests: * `normalizeIndicatorStyle` rejects unknown / non-string input. * `applyDisplay → tui_status_indicator` covers fan-out + fallback. * `/indicator <style>` hot-swaps `UiState.indicatorStyle` after a successful `config.set`. * `/indicator sparkle` rejects with the usage hint and never hits the gateway. * Slash-parity matrix gets `'/indicator'` → `config.get`. Validation: cd ui-tui && npm run type-check — clean; npm test --run — 398/398. scripts/run_tests.sh tests/test_tui_gateway_server.py tests/hermes_cli/test_commands.py — 220/220. * chore(tui): drop /indicator-style alias to declutter autocomplete * fix(tui): drop verb-width pad — /indicator handles glyph jitter directly * fix(tui): unicode indicator style hides the verb (cleanest option) * refactor(tui): single source of truth for INDICATOR_STYLES; cleaner error format Round 1 Copilot review on PR #17150: - Exported `INDICATOR_STYLES` const tuple from `interfaces.ts`; `IndicatorStyle` union type is derived from it. `useConfigSync` builds its validation Set from the tuple, and `session.ts` uses it for both the usage hint and the runtime allow-list — adding/removing a style now touches one line. - Backend `config.set indicator` error message: switched `sorted(allowed)` list repr to `pick one of ascii|emoji|kaomoji|unicode` (matches the TUI usage hint), and reports the normalized `raw` instead of the original `value`. Backend allowed tuple now has a comment pointing back at `INDICATOR_STYLES` so the two stay aligned. Note: kept the verb portion unpadded per design intent — fixed-width padding was the exact UX the `/indicator` command was added to remove. Stable width comes from the glyph; verbs cycling is part of the kawaii aesthetic. Reply on the verb thread will explain. * fix(tui): drop type collapse + gate verb timer + DEFAULT_INDICATOR_STYLE Round 2 Copilot review on PR #17150: - `tui_status_indicator?: 'ascii' | ... | string` collapses to `string` in TS — consumers got no narrowing. Documented as plain `string` with a comment about runtime validation via `normalizeIndicatorStyle`. - `FaceTicker` always started a 2.5s verb interval, even for the `unicode` style which hides the verb entirely. Now gated on `showVerb` from `renderIndicator` — `unicode` stays calm. Pre-emptive self-review (avoid round 3): - Three call sites duplicated the literal `'kaomoji'` default (uiStore, normalizeIndicatorStyle, slash command). Added `DEFAULT_INDICATOR_STYLE` to interfaces.ts and threaded it through so changing the default touches one line. * fix(tui-gateway): normalize config.get indicator output to match TUI render Round 4 Copilot review on PR #17150: `config.get` for `indicator` returned the raw `display.tui_status_indicator` value without validation, so a hand-edited config.yaml with stray casing or an unknown style would leave `/indicator` printing one thing while the TUI rendered the kaomoji default (frontend's `normalizeIndicatorStyle` does this normalization on receive). Lifted the allow-list to module scope as `_INDICATOR_STYLES` / `_INDICATOR_DEFAULT`, reused by both `config.set` and `config.get`. Comment notes the alignment with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in interfaces.ts so adding/removing a style is a one-line change on each end. Tests cover: known value verbatim, casing/whitespace normalize, unknown→default, unset→default. * fix(tui-gateway): preserve falsy-input diagnostics in config.set indicator error Round 5 Copilot review on PR #17150: `raw = str(value or "").strip().lower()` collapsed any falsy non-string (`0`, `False`, `[]`) to empty string, so the error message read `unknown indicator: ` with nothing after — losing the original input. Switched to `("" if value is None else str(value)).strip().lower()` so only `None` (the genuine 'no value' case) becomes blank. Used `{raw!r}` in the error so the diagnostic is unambiguous (`'0'` vs `0`). Tests: - known-value happy path (`'EMOJI'` → `'emoji'`) - falsy non-string inputs (`0` / `False` / `[]`) surface meaningfully - `None` keeps the blank-repr error
This commit is contained in:
parent
258efb2575
commit
7d81d76366
12 changed files with 367 additions and 9 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
||||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
import type { IndicatorStyle } from '../app/interfaces.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { useTurnSelector } from '../app/turnStore.js'
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
|
|
@ -17,23 +20,96 @@ import type { Msg, Usage } from '../types.js'
|
|||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
// Compact alternates for the `emoji` and `ascii` indicator styles.
|
||||
// Each entry is a fixed-width (display-width) glyph.
|
||||
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
|
||||
const ASCII_FRAMES = ['|', '/', '-', '\\']
|
||||
|
||||
// Faster tick for spinner-style indicators — they read as motion only
|
||||
// at frame rates closer to their authored interval.
|
||||
const SPINNER_TICK_MS = 100
|
||||
|
||||
interface IndicatorRender {
|
||||
frame: string
|
||||
intervalMs: number
|
||||
// When false, FaceTicker hides the rotating verb and just shows the
|
||||
// glyph + duration. Lets `unicode` stay minimal while the other
|
||||
// styles keep the verb-rotation flavour users associate with the
|
||||
// running… status.
|
||||
showVerb: boolean
|
||||
}
|
||||
|
||||
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
|
||||
if (style === 'kaomoji') {
|
||||
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
|
||||
}
|
||||
|
||||
if (style === 'emoji') {
|
||||
return {
|
||||
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
|
||||
intervalMs: SPINNER_TICK_MS * 6,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
if (style === 'ascii') {
|
||||
return {
|
||||
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
|
||||
intervalMs: SPINNER_TICK_MS,
|
||||
showVerb: true
|
||||
}
|
||||
}
|
||||
|
||||
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
|
||||
// ~80ms; honour it but bound below at a safe minimum so React
|
||||
// re-renders stay reasonable. This style is for users who want
|
||||
// the cleanest possible status, so no verb rotation either.
|
||||
const spinner = unicodeSpinners.braille
|
||||
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
|
||||
|
||||
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
|
||||
}
|
||||
|
||||
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
|
||||
const ui = useStore($uiState)
|
||||
const style = ui.indicatorStyle
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Pre-compute cadence + verb-visibility for the active style so an
|
||||
// `/indicator` switch re-arms the interval (and skips the verb timer
|
||||
// for verb-less styles like `unicode`) without leaving the previous
|
||||
// timer dangling.
|
||||
const { intervalMs, showVerb } = renderIndicator(style, 0)
|
||||
|
||||
useEffect(() => {
|
||||
const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
|
||||
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
|
||||
const clock = setInterval(() => setNow(Date.now()), 1000)
|
||||
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
|
||||
// entirely, so cycling `verbTick` would be an avoidable re-render.
|
||||
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
|
||||
|
||||
return () => {
|
||||
clearInterval(face)
|
||||
clearInterval(glyph)
|
||||
clearInterval(clock)
|
||||
|
||||
if (verb !== null) {
|
||||
clearInterval(verb)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [intervalMs, showVerb])
|
||||
|
||||
const { frame } = renderIndicator(style, tick)
|
||||
const verb = VERBS[verbTick % VERBS.length] ?? ''
|
||||
const verbSegment = showVerb ? ` ${verb}…` : ''
|
||||
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
{frame}
|
||||
{verbSegment}
|
||||
{durationSegment}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue