mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +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
369 lines
9.5 KiB
TypeScript
369 lines
9.5 KiB
TypeScript
import type { ScrollBoxHandle } from '@hermes/ink'
|
|
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
|
|
|
|
import type { PasteEvent } from '../components/textInput.js'
|
|
import type { GatewayClient } from '../gatewayClient.js'
|
|
import type { ImageAttachResponse } from '../gatewayTypes.js'
|
|
import type { RpcResult } from '../lib/rpc.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type {
|
|
ApprovalReq,
|
|
ClarifyReq,
|
|
ConfirmReq,
|
|
DetailsMode,
|
|
Msg,
|
|
PanelSection,
|
|
SecretReq,
|
|
SectionVisibility,
|
|
SessionInfo,
|
|
SlashCatalog,
|
|
SudoReq,
|
|
Usage
|
|
} from '../types.js'
|
|
|
|
export interface StateSetter<T> {
|
|
(value: SetStateAction<T>): void
|
|
}
|
|
|
|
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
|
|
copySelection: () => Promise<string>
|
|
getState: () => unknown
|
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
|
}
|
|
|
|
export interface CompletionItem {
|
|
display: string
|
|
meta?: string
|
|
text: string
|
|
}
|
|
|
|
export interface GatewayRpc {
|
|
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
|
|
}
|
|
|
|
export interface GatewayServices {
|
|
gw: GatewayClient
|
|
rpc: GatewayRpc
|
|
}
|
|
|
|
export interface GatewayProviderProps {
|
|
children: ReactNode
|
|
value: GatewayServices
|
|
}
|
|
|
|
export interface OverlayState {
|
|
agents: boolean
|
|
agentsInitialHistoryIndex: number
|
|
approval: ApprovalReq | null
|
|
clarify: ClarifyReq | null
|
|
confirm: ConfirmReq | null
|
|
modelPicker: boolean
|
|
pager: null | PagerState
|
|
picker: boolean
|
|
secret: null | SecretReq
|
|
skillsHub: boolean
|
|
sudo: null | SudoReq
|
|
}
|
|
|
|
export interface PagerState {
|
|
lines: string[]
|
|
offset: number
|
|
title?: string
|
|
}
|
|
|
|
export interface TranscriptRow {
|
|
index: number
|
|
key: string
|
|
msg: Msg
|
|
}
|
|
|
|
export interface UiState {
|
|
bgTasks: Set<string>
|
|
busy: boolean
|
|
busyInputMode: BusyInputMode
|
|
compact: boolean
|
|
detailsMode: DetailsMode
|
|
detailsModeCommandOverride: boolean
|
|
info: null | SessionInfo
|
|
inlineDiffs: boolean
|
|
mouseTracking: boolean
|
|
sections: SectionVisibility
|
|
showCost: boolean
|
|
showReasoning: boolean
|
|
indicatorStyle: IndicatorStyle
|
|
sid: null | string
|
|
status: string
|
|
statusBar: StatusBarMode
|
|
streaming: boolean
|
|
theme: Theme
|
|
usage: Usage
|
|
}
|
|
|
|
export interface VirtualHistoryState {
|
|
bottomSpacer: number
|
|
end: number
|
|
measureRef: (key: string) => (el: unknown) => void
|
|
offsets: ArrayLike<number>
|
|
start: number
|
|
topSpacer: number
|
|
}
|
|
|
|
export interface ComposerPasteResult {
|
|
cursor: number
|
|
value: string
|
|
}
|
|
|
|
export type MaybePromise<T> = Promise<T> | T
|
|
|
|
export interface ComposerActions {
|
|
clearIn: () => void
|
|
dequeue: () => string | undefined
|
|
enqueue: (text: string) => void
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
openEditor: () => Promise<void>
|
|
pushHistory: (text: string) => void
|
|
removeQueue: (index: number) => void
|
|
replaceQueue: (index: number, text: string) => void
|
|
setCompIdx: StateSetter<number>
|
|
setHistoryIdx: StateSetter<null | number>
|
|
setInput: StateSetter<string>
|
|
setInputBuf: StateSetter<string[]>
|
|
setPasteSnips: StateSetter<PasteSnippet[]>
|
|
setQueueEdit: (index: null | number) => void
|
|
syncQueue: () => void
|
|
}
|
|
|
|
export interface ComposerRefs {
|
|
historyDraftRef: MutableRefObject<string>
|
|
historyRef: MutableRefObject<string[]>
|
|
queueEditRef: MutableRefObject<null | number>
|
|
queueRef: MutableRefObject<string[]>
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface ComposerState {
|
|
compIdx: number
|
|
compReplace: number
|
|
completions: CompletionItem[]
|
|
historyIdx: null | number
|
|
input: string
|
|
inputBuf: string[]
|
|
pasteSnips: PasteSnippet[]
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
}
|
|
|
|
export interface UseComposerStateOptions {
|
|
gw: GatewayClient
|
|
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
|
|
onImageAttached?: (info: ImageAttachResponse) => void
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface UseComposerStateResult {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
|
|
export interface InputHandlerActions {
|
|
answerClarify: (answer: string) => void
|
|
appendMessage: (msg: Msg) => void
|
|
die: () => void
|
|
dispatchSubmission: (full: string) => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newSession: (msg?: string) => void
|
|
sys: (text: string) => void
|
|
}
|
|
|
|
export interface InputHandlerContext {
|
|
actions: InputHandlerActions
|
|
composer: {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
gateway: GatewayServices
|
|
terminal: {
|
|
hasSelection: boolean
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
scrollWithSelection: (delta: number) => void
|
|
selection: SelectionApi
|
|
stdout?: NodeJS.WriteStream
|
|
}
|
|
voice: {
|
|
enabled: boolean
|
|
recording: boolean
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
}
|
|
wheelStep: number
|
|
}
|
|
|
|
export interface InputHandlerResult {
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface GatewayEventHandlerContext {
|
|
composer: {
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
session: {
|
|
STARTUP_RESUME_ID: string
|
|
colsRef: MutableRefObject<number>
|
|
newSession: (msg?: string) => void
|
|
resetSession: () => void
|
|
resumeById: (id: string) => void
|
|
setCatalog: StateSetter<null | SlashCatalog>
|
|
}
|
|
submission: {
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
system: {
|
|
bellOnComplete: boolean
|
|
stdout?: NodeJS.WriteStream
|
|
sys: (text: string) => void
|
|
}
|
|
transcript: {
|
|
appendMessage: (msg: Msg) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
}
|
|
voice: {
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
}
|
|
}
|
|
|
|
export interface SlashHandlerContext {
|
|
composer: {
|
|
enqueue: (text: string) => void
|
|
hasSelection: boolean
|
|
paste: (quiet?: boolean) => void
|
|
queueRef: MutableRefObject<string[]>
|
|
selection: SelectionApi
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
local: {
|
|
catalog: null | SlashCatalog
|
|
getHistoryItems: () => Msg[]
|
|
getLastUserMsg: () => string
|
|
maybeWarn: (value: unknown) => void
|
|
}
|
|
session: {
|
|
closeSession: (targetSid?: null | string) => Promise<unknown>
|
|
die: () => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newSession: (msg?: string) => void
|
|
resetVisibleHistory: (info?: null | SessionInfo) => void
|
|
resumeById: (id: string) => void
|
|
setSessionStartedAt: StateSetter<number>
|
|
}
|
|
slashFlightRef: MutableRefObject<number>
|
|
transcript: {
|
|
page: (text: string, title?: string) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
send: (text: string) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
sys: (text: string) => void
|
|
trimLastExchange: (items: Msg[]) => Msg[]
|
|
}
|
|
voice: {
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
}
|
|
}
|
|
|
|
export interface AppLayoutActions {
|
|
answerApproval: (choice: string) => void
|
|
answerClarify: (answer: string) => void
|
|
answerSecret: (value: string) => void
|
|
answerSudo: (pw: string) => void
|
|
clearSelection: () => void
|
|
onModelSelect: (value: string) => void
|
|
resumeById: (id: string) => void
|
|
setStickyPrompt: (value: string) => void
|
|
}
|
|
|
|
export interface AppLayoutComposerProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
empty: boolean
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
input: string
|
|
inputBuf: string[]
|
|
pagerPageSize: number
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
submit: (value: string) => void
|
|
updateInput: StateSetter<string>
|
|
}
|
|
|
|
export interface AppLayoutProgressProps {
|
|
showProgressArea: boolean
|
|
}
|
|
|
|
export interface AppLayoutStatusProps {
|
|
cwdLabel: string
|
|
goodVibesTick: number
|
|
sessionStartedAt: null | number
|
|
showStickyPrompt: boolean
|
|
statusColor: string
|
|
stickyPrompt: string
|
|
turnStartedAt: null | number
|
|
voiceLabel: string
|
|
}
|
|
|
|
export interface AppLayoutTranscriptProps {
|
|
historyItems: Msg[]
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
virtualHistory: VirtualHistoryState
|
|
virtualRows: TranscriptRow[]
|
|
}
|
|
|
|
export interface AppLayoutProps {
|
|
actions: AppLayoutActions
|
|
composer: AppLayoutComposerProps
|
|
mouseTracking: boolean
|
|
progress: AppLayoutProgressProps
|
|
status: AppLayoutStatusProps
|
|
transcript: AppLayoutTranscriptProps
|
|
}
|
|
|
|
export interface AppOverlaysProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
onApprovalChoice: (choice: string) => void
|
|
onClarifyAnswer: (value: string) => void
|
|
onModelSelect: (value: string) => void
|
|
onPickerSelect: (sessionId: string) => void
|
|
onSecretSubmit: (value: string) => void
|
|
onSudoSubmit: (pw: string) => void
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface PasteSnippet {
|
|
label: string
|
|
path?: string
|
|
text: string
|
|
}
|