* 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:
@@ -128,6 +128,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
||||
cli_only=True, args_hint="[kaomoji|emoji|unicode|ascii]",
|
||||
subcommands=("kaomoji", "emoji", "unicode", "ascii")),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
|
||||
|
||||
@@ -715,6 +715,9 @@ DEFAULT_CONFIG = {
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
|
||||
"first_lines": 2,
|
||||
"last_lines": 2,
|
||||
|
||||
@@ -3010,3 +3010,96 @@ def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
|
||||
assert "BROWSER_CDP_URL" not in os.environ
|
||||
# Two cleanups: once before env removal, once after, matching connect.
|
||||
assert cleanup_count["n"] == 2
|
||||
|
||||
|
||||
# ── config.get indicator normalization ───────────────────────────────
|
||||
|
||||
|
||||
def test_config_get_indicator_returns_known_value_verbatim(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "emoji"}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "emoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_normalizes_casing_and_whitespace(monkeypatch):
|
||||
"""Hand-edited config.yaml stays consistent with what the TUI shows.
|
||||
|
||||
Frontend's `normalizeIndicatorStyle` lowercases + trims, so config.get
|
||||
must do the same — otherwise `/indicator` prints 'EMOJI ' while the
|
||||
UI is actually rendering the kaomoji default."""
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": " EMOJI "}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "emoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_falls_back_to_default_for_unknown(monkeypatch):
|
||||
"""An unknown value in config.yaml falls back to the same default
|
||||
the frontend uses (`_INDICATOR_DEFAULT`)."""
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"display": {"tui_status_indicator": "rainbow"}}
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "kaomoji"}
|
||||
|
||||
|
||||
def test_config_get_indicator_falls_back_when_unset(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "indicator"}}
|
||||
)
|
||||
assert resp["result"] == {"value": "kaomoji"}
|
||||
|
||||
|
||||
# ── config.set indicator validation ──────────────────────────────────
|
||||
|
||||
|
||||
def test_config_set_indicator_accepts_known_value(monkeypatch):
|
||||
written: dict = {}
|
||||
monkeypatch.setattr(
|
||||
server, "_write_config_key",
|
||||
lambda k, v: written.update({k: v}),
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": "EMOJI"}}
|
||||
)
|
||||
assert resp["result"] == {"key": "indicator", "value": "emoji"}
|
||||
assert written == {"display.tui_status_indicator": "emoji"}
|
||||
|
||||
|
||||
def test_config_set_indicator_falsy_non_string_surfaces_in_error(monkeypatch):
|
||||
"""`0` / `False` / `[]` are not valid styles, but the error message
|
||||
must still tell the user what they sent — `value or ""` would have
|
||||
erased them to a blank string."""
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
|
||||
|
||||
for bad in (0, False, []):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": bad}}
|
||||
)
|
||||
assert "error" in resp
|
||||
msg = resp["error"]["message"]
|
||||
assert "unknown indicator" in msg
|
||||
# The exact repr varies; `0`/`False` stringify with content,
|
||||
# `[]` becomes an empty list — what matters is the diagnostic
|
||||
# is no longer just `unknown indicator: ` with nothing after.
|
||||
assert msg.split("; ")[0] != "unknown indicator: ''"
|
||||
|
||||
|
||||
def test_config_set_indicator_none_keeps_blank_repr(monkeypatch):
|
||||
"""`None` is the genuine 'no value' case — empty raw is acceptable."""
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda *a, **k: None)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "indicator", "value": None}}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert "unknown indicator: ''" in resp["error"]["message"]
|
||||
|
||||
@@ -491,6 +491,13 @@ def _normalize_completion_path(path_part: str) -> str:
|
||||
# ── Config I/O ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Keep aligned with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in
|
||||
# ``ui-tui/src/app/interfaces.ts`` — both ends validate against the
|
||||
# same shape so `config.get indicator` and the live TUI render agree.
|
||||
_INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
|
||||
_INDICATOR_DEFAULT = "kaomoji"
|
||||
|
||||
|
||||
def _load_cfg() -> dict:
|
||||
global _cfg_cache, _cfg_mtime, _cfg_path
|
||||
try:
|
||||
@@ -3184,6 +3191,19 @@ def _(rid, params: dict) -> dict:
|
||||
_write_config_key("display.tui_mouse", nv)
|
||||
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
|
||||
|
||||
if key == "indicator":
|
||||
# Use an explicit None check rather than `value or ""` so falsy
|
||||
# non-string inputs (0, False, []) still surface as themselves
|
||||
# in the error message instead of looking like a blank value.
|
||||
raw = ("" if value is None else str(value)).strip().lower()
|
||||
if raw not in _INDICATOR_STYLES:
|
||||
return _err(
|
||||
rid, 4002,
|
||||
f"unknown indicator: {raw!r}; pick one of {'|'.join(_INDICATOR_STYLES)}",
|
||||
)
|
||||
_write_config_key("display.tui_status_indicator", raw)
|
||||
return _ok(rid, {"key": key, "value": raw})
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
cfg = _load_cfg()
|
||||
@@ -3254,6 +3274,18 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(
|
||||
rid, {"value": (_load_cfg().get("display") or {}).get("skin", "default")}
|
||||
)
|
||||
if key == "indicator":
|
||||
# Normalize so a hand-edited config.yaml with stray casing or
|
||||
# an unknown value reads back the SAME value the TUI actually
|
||||
# rendered (frontend's `normalizeIndicatorStyle` falls back to
|
||||
# `_INDICATOR_DEFAULT` for the same inputs). Otherwise
|
||||
# `/indicator` would print one thing while the UI shows another.
|
||||
raw = (_load_cfg().get("display") or {}).get("tui_status_indicator", "")
|
||||
norm = str(raw).strip().lower()
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": norm if norm in _INDICATOR_STYLES else _INDICATOR_DEFAULT},
|
||||
)
|
||||
if key == "personality":
|
||||
return _ok(
|
||||
rid,
|
||||
|
||||
@@ -195,7 +195,8 @@ describe('createSlashHandler', () => {
|
||||
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||
['/stop', 'process.stop', {}],
|
||||
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||
['/busy status', 'config.get', { key: 'busy' }]
|
||||
['/busy status', 'config.get', { key: 'busy' }],
|
||||
['/indicator', 'config.get', { key: 'indicator' }]
|
||||
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
@@ -215,6 +216,24 @@ describe('createSlashHandler', () => {
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
|
||||
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
|
||||
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
|
||||
})
|
||||
|
||||
it('rejects unknown indicator styles before hitting the gateway', () => {
|
||||
const rpc = vi.fn(() => Promise.resolve({}))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
|
||||
})
|
||||
|
||||
it('drops stale slash.exec output after a newer slash', async () => {
|
||||
let resolveLate: (v: { output?: string }) => void
|
||||
let slashExecCalls = 0
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay, normalizeBusyInputMode, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
import {
|
||||
applyDisplay,
|
||||
normalizeBusyInputMode,
|
||||
normalizeIndicatorStyle,
|
||||
normalizeStatusBar
|
||||
} from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
@@ -187,6 +192,28 @@ describe('normalizeBusyInputMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndicatorStyle', () => {
|
||||
it('passes through the canonical enum', () => {
|
||||
expect(normalizeIndicatorStyle('kaomoji')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('emoji')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('unicode')).toBe('unicode')
|
||||
expect(normalizeIndicatorStyle('ascii')).toBe('ascii')
|
||||
})
|
||||
|
||||
it('trims and lowercases input', () => {
|
||||
expect(normalizeIndicatorStyle(' Emoji ')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('UNICODE')).toBe('unicode')
|
||||
})
|
||||
|
||||
it('defaults to kaomoji for missing/unknown values', () => {
|
||||
expect(normalizeIndicatorStyle(undefined)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(null)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('sparkle')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(42)).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → busy_input_mode', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
@@ -212,3 +239,29 @@ describe('applyDisplay → busy_input_mode', () => {
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → tui_status_indicator', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('threads display.tui_status_indicator into $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'emoji' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('emoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'unicode' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('unicode')
|
||||
})
|
||||
|
||||
it('falls back to kaomoji default when missing or invalid', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'rainbow' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,14 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
// Single source of truth for indicator style names. Union type is
|
||||
// derived from this tuple so adding/removing a style only touches one
|
||||
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
||||
// validation + usage hint) both import it.
|
||||
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
|
||||
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
|
||||
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
@@ -97,6 +105,7 @@ export interface UiState {
|
||||
sections: SectionVisibility
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
indicatorStyle: IndicatorStyle
|
||||
sid: null | string
|
||||
status: string
|
||||
statusBar: StatusBarMode
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { fmtK } from '../../../lib/text.js'
|
||||
import type { PanelSection } from '../../../types.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
@@ -268,6 +269,45 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
|
||||
name: 'indicator',
|
||||
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
|
||||
run: (arg, ctx) => {
|
||||
const value = arg.trim().toLowerCase()
|
||||
|
||||
if (!value) {
|
||||
return ctx.gateway
|
||||
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
|
||||
.then(
|
||||
ctx.guarded<ConfigGetValueResponse>(r =>
|
||||
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
|
||||
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-swap the running TUI immediately so the next render
|
||||
// uses the new style without waiting for the 5s mtime poll
|
||||
// to re-apply config.full.
|
||||
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
||||
ctx.transcript.sys(`indicator → ${r.value}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle yolo mode (per-session approvals)',
|
||||
name: 'yolo',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MOUSE_TRACKING } from '../config/env.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
import type { UiState } from './interfaces.js'
|
||||
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
|
||||
|
||||
const buildUiState = (): UiState => ({
|
||||
bgTasks: new Set(),
|
||||
@@ -13,6 +13,7 @@ const buildUiState = (): UiState => ({
|
||||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: DEFAULT_INDICATOR_STYLE,
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
|
||||
@@ -10,7 +10,13 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { BusyInputMode, StatusBarMode } from './interfaces.js'
|
||||
import {
|
||||
DEFAULT_INDICATOR_STYLE,
|
||||
INDICATOR_STYLES,
|
||||
type BusyInputMode,
|
||||
type IndicatorStyle,
|
||||
type StatusBarMode,
|
||||
} from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
@@ -45,6 +51,18 @@ export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
|
||||
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
|
||||
|
||||
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
|
||||
if (typeof raw !== 'string') {
|
||||
return DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const v = raw.trim().toLowerCase() as IndicatorStyle
|
||||
|
||||
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
|
||||
}
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
@@ -68,6 +86,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
mouseTracking: d.tui_mouse !== false,
|
||||
sections: resolveSections(d.sections),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ export interface ConfigDisplayConfig {
|
||||
tui_auto_resume_recent?: boolean
|
||||
tui_compact?: boolean
|
||||
tui_mouse?: boolean
|
||||
// Forward-compat: backend may send styles this client doesn't know yet —
|
||||
// `normalizeIndicatorStyle` falls back to 'kaomoji' for those — but the
|
||||
// wire type is documented as `string` so consumers don't get a false
|
||||
// narrowing-and-autocomplete contract on a value that requires runtime
|
||||
// validation anyway.
|
||||
tui_status_indicator?: string
|
||||
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
|
||||
}
|
||||
|
||||
@@ -424,7 +430,11 @@ export type GatewayEvent =
|
||||
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
|
||||
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string; stderr_tail?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
| {
|
||||
payload?: { cwd?: string; python?: string; stderr_tail?: string }
|
||||
session_id?: string
|
||||
type: 'gateway.start_timeout'
|
||||
}
|
||||
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
|
||||
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
|
||||
|
||||
Reference in New Issue
Block a user