Commit Graph

366 Commits

Author SHA1 Message Date
brooklyn! f27d45e288
feat(tui): reach the model picker without wrecking your draft, and switch mid-turn (#74756)
* feat(tui): Ctrl+O opens the model picker without clearing your draft

Reaching the model picker meant typing /model, which forces you to wipe
whatever you'd already drafted. Bind Ctrl+O to open the same picker overlay
directly, leaving the composer untouched. Ctrl+O is added to the textInput
pass-through allowlist so the composer doesn't swallow it, mirroring the
existing Ctrl+X session-switcher path.

* feat(tui): apply a mid-turn model switch at the next turn instead of rejecting it

Picking a model while a turn was streaming hit a 4009 'session busy' reject:
switch_model() mutates the agent's model/provider/base_url/client in place and
the worker thread reads those every iteration. Now config.set queues the pick
in session[pending_model_switch] and _apply_pending_model_switch applies it on
the turn thread at the next turn start, before any model call — no race, no
interrupt, no waiting on the client rebuild. The TUI paints the pick optimistically
and notes '(applies next turn)'.
2026-07-30 10:18:30 +00:00
Brooklyn Nicholson 59a7da9a89 fix(tui): scope Ctrl+U / Ctrl+K to the current line, per readline
Both kills operated on the whole buffer: Ctrl+U wiped everything before
the cursor and Ctrl+K everything after, regardless of newlines. Readline
scopes them to the current logical line, and Claude Code documents Ctrl+U
as "repeat to clear across lines in multiline input" — which only works
if a press at a line boundary consumes the newline and makes progress.

Extract killToLineStart / killToLineEnd and route all four call sites
through them, so the Cmd chords and their Ctrl equivalents cannot drift.
2026-07-30 04:26:42 -05:00
Brooklyn Nicholson 3f5ec8b45d fix(tui): Cmd+Backspace kills the line; Option/Ctrl+Backspace stay delete-word
On terminals that send Cmd+Backspace as a CSI-u sequence rather than
rewriting it to Ctrl+U, the keystroke fell through to the word-delete
branch and erased a single word.

The modifier this hinges on is easy to get wrong. isActionMod accepts
key.meta on macOS, and hermes-ink reports Option as meta — so keying on
it would turn Option+Backspace, the platform's delete-word shortcut, into
delete-the-whole-line. On Linux/Windows isActionMod is key.ctrl, where
Ctrl+Backspace is likewise delete-word in readline, VS Code, browsers,
and Windows Terminal. Only the super bit, set by kitty CSI-u and xterm
modifyOtherKeys, unambiguously means Cmd.

Extract that decision into isLineKillModifier so the reasoning lives in
one place and the regressions above are pinned by tests.
2026-07-30 03:39:34 -05:00
Shag 644590369f fix(tui): keep local tmux native copy fallback 2026-07-29 08:42:16 -07:00
hermes-seaeye[bot] 7d3075d0d5
fmt(js): `npm run fix` on merge (#73960)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-29 07:13:20 +00:00
Teknium ba13132298 fix(voice): bare stop phrase ends the voice chat on every surface, spoken or typed
Saying OR typing a configured stop phrase (voice.stop_phrases, default
"stop") now ends the voice chat everywhere, not just classic CLI PTT:

- hermes_cli/voice.py: new explicit on_stop_phrase callback through
  start_continuous/stop_continuous. The force-transcribe path previously
  DISCARDED the stop phrase silently — with auto_restart=False the client
  re-arms the next capture, so the conversation never ended. Both halt
  paths now fire on_stop_phrase (fallback: on_silent_limit for legacy
  callers) as user intent, distinct from the no-speech timeout.
- tui_gateway/server.py: voice.record wires on_stop_phrase and emits
  voice.transcript {stop_phrase: true} after flipping HERMES_VOICE(_TTS)
  off and stopping streaming TTS — same teardown as /voice off. The TTS
  barge-in monitor stop-checks its transcript too. prompt.submit consumes
  a TYPED bare stop phrase at the server-side choke point when voice mode
  is on (returns {voice_stopped: true}, no turn starts).
- ui-tui: voice.transcript {stop_phrase} ends voice mode with a clear
  'voice chat ended' notice (distinct from the no-speech-limit message);
  submitPrompt releases the busy latch on a consumed voice_stopped reply.
- cli.py: _typed_voice_stop in process_loop — typing a bare stop phrase
  while voice mode/continuous is active ends voice mode instead of
  sending 'stop' to the agent; typed 'stop' outside voice mode is
  unchanged. Voice transcripts skip the check (already stop-checked).
- desktop: interceptsTypedVoiceStop — the composer's onSubmit ends the
  live voice conversation (same path as clicking end on the pill) when a
  bare stop command is typed with no attachments; renderer-owned loop, so
  handled client-side like the existing spoken isVoiceStopCommand.
- tools/voice_mode.py: transcribe_recording never lets the Whisper
  hallucination filter swallow a configured stop phrase (e.g. 'bye'
  configured as a stop phrase is both a hallucination-blocklist entry and
  a stop phrase — stop-phrase check now wins).

Tests: continuous-loop signal (sabotage-verified), force-transcribe stop
signal + legacy fallback, hallucination-filter ordering, typed-stop CLI
unit tests (voice on/off/longer text), prompt.submit typed-stop gateway
tests, TUI vitest for stop_phrase event handling, desktop vitest for the
typed-stop interceptor.
2026-07-29 00:01:06 -07:00
Teknium 0cf58de85e
Merge remote-tracking branch 'origin/main' into wake-toggle-config
# Conflicts:
#	tests/test_tui_gateway_server.py
#	tui_gateway/server.py
2026-07-28 12:37:35 -07:00
hermes-seaeye[bot] 2faac36866
fmt(js): `npm run fix` on merge (#73552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 18:30:19 +00:00
teknium1 e8f9d471c6
feat(wake): the toggle IS the config — explicit on/off persists wake_word.enabled
Clicking the desktop ear button or running /wake on|off now writes
wake_word.enabled to config.yaml (live, saved for future sessions), so the
feature no longer requires hand-editing config before the UI toggle works.

- wake.start accepts persist:true (explicit gesture): flips
  wake_word.enabled on in config before arming; response reports
  enabled_persisted. Passive auto-arm paths (desktop gateway-ready,
  TUI reconnect) never pass it, so a mic can't become persistently
  enabled without a deliberate user action.
- wake.stop accepts persist:true: writes wake_word.enabled: false so
  auto-arm stays off next session; reports disabled_persisted.
- Split the refusal reason: 'disabled' (feature off in config — a
  persisted gesture turns it on) vs 'disabled_for_surface' (explicit
  wake_word.surface scoping, which persist does NOT override).
- Classic CLI /wake on|off and bare-toggle persist the flag too
  (skips the write when config already matches).
- Desktop tooltip now maps refusal codes to friendly text (mirrors
  the TUI's START_REASON_TEXT) instead of showing raw codes like
  disabled_for_surface.
- Docs: quick-start notes the toggle persists; ear-button mention.
2026-07-28 07:59:34 -07:00
Hermes Agent 71a2feeade
feat(tui): /wake on|off|status slash command
TUI-local handler over the wake.start/stop/status RPCs with friendly
transcript one-liners (phrase, provider, foreign-owner note, refusal
reasons, unavailability hints). /wake off sets a session-scoped opt-out
the gateway.ready auto-arm respects, so the listener stays off across
reconnects until /wake on. cli_only already means CLI+TUI (verified:
messaging menus exclude it); zero Python changes needed.
2026-07-28 07:58:16 -07:00
Brooklyn Nicholson 9f84bc30bd
feat(voice): bundle the trained "hey hermes" model as the out-of-the-box default
From #53378: ships hey_hermes.onnx/.tflite (openWakeWord pipeline,
Apache-2.0) under tools/wakewords/, resolves the default (and hey_hermes
aliases) to the bundled file, ensures openWakeWord base feature models
are fetched for custom paths too, and updates config defaults + docs
from hey_jarvis to hey hermes.
2026-07-28 07:58:16 -07:00
Omid Saadat 5839aad13d
fix(wake-word): enforce single-owner lifecycle 2026-07-28 07:58:01 -07:00
Brooklyn Nicholson 42fc6227f6 fix(ci): sort desktop imports and stub skill display in TUI test
Eslint wants @hermes/shared before react, and the slash handler only
passes a display override when command.dispatch includes one.
2026-07-28 04:03:48 -05:00
Brooklyn Nicholson 5a940180b4 fix(tui): show the invocation for a skill send, not the skill body
Carry a display string through the submit path so the transcript renders
what the user typed while the agent still receives the expanded skill.
Drops the '⚡ loading skill' line — the invocation bubble says it.
2026-07-28 03:44:31 -05:00
Brooklyn Nicholson 0b1ee22b9e fix(tui): paint the OSC-10 default foreground on quantizing terminals
A skin that authors a background paints both terminal defaults: OSC-11
for the backdrop, OSC-10 to re-base every default-fg token (markdown
body, borders, anything rendered without an explicit color) onto the
theme's text tone.

The OSC-10 half never fired on a limited-palette terminal.
`normalizeThemeForAnsiLightTerminal` rewrites the foreground tones to
`ansi256(N)`, and `setTerminalForeground` only accepts `#rrggbb` — so
the argument failed the hex test and the write was silently skipped.
The background moved to the skin while default-fg text stayed on the
host profile's foreground.

That split is the reported symptom: prose renders in the terminal's own
near-black while every themed token beside it renders the skin's gray,
so the base text color appears to change between adjacent words. A
resize repaints the affected cells from the screen buffer, which is why
the text "goes black" on resize and why the mix looks scattered rather
than uniform.

Resolve the tone through a new `themeToneHex` before handing it to
OSC-10: `ansi256(N)` maps through the xterm grayscale ramp and 6x6x6
cube, an authored hex passes through, and anything with no paintable
color yields '' (which correctly clears back to the terminal default).

Verified on Terminal.app + the `brooklyn` skin: `theme.color.text` is
`ansi256(238)`, previously dropped, now emitted as
`ESC]10;#444444 BEL` alongside the existing `ESC]11;#f6f9fd BEL`.
2026-07-27 01:59:51 -05:00
brooklyn! 2ea1ea0894
Merge pull request #71741 from NousResearch/bb/tui-dim-fallback
fix(tui): keep assistant body text inside the theme palette
2026-07-25 23:12:18 -05:00
Brooklyn Nicholson b8bf368b02 fix(tui): anchor markdown body prose to the theme foreground
Plain prose rendered with no color at all, so it fell through to the
terminal's default foreground while inline tokens on the same line
(code, links, math, muted markers) carried a theme tone. One rendered
line therefore mixed two foregrounds — and because an inline token can
match mid-word (`re-render_terminal_output` trips underscore-italic),
so could a single word.

MdInline now takes an optional color and the body-prose callers
(paragraphs, bullets, numbered items, definitions) pass t.color.text.
Callers that already wrap it in a colored parent — headings, quotes,
footnotes — keep inheriting and are untouched.
2026-07-25 22:59:58 -05:00
Brooklyn Nicholson 5c5f11d23a test(tui,desktop): cover authored link labels and not-found titles
Warm the shared title cache before rendering so a resolved title is
available synchronously — the previous TUI assertion only proved a label
survived when no title had resolved, which passed on the buggy ordering.

Asserts the bug class on both surfaces: an authored label outranks a
resolved title and suppresses the fetch, an unlabeled link still resolves
one, and a "Page not found" title is discarded rather than rendered.

Co-authored-by: Kinkoolino-Hermes <297364961+Kinkoolino-Hermes@users.noreply.github.com>
2026-07-25 22:52:38 -05:00
Brooklyn Nicholson ec6fa9bdb2 test(tui): cover inline skill references end to end
inlineSlashTrigger and completionRequestForInput are driven through the
mid-message shape, the position-0 shape, and the path cases that must not
claim a slash (/usr/local/bin, src/foo/bar, and/or, 3 /4).

splitSlashSkillRefs asserts the round-trip invariant — the segments always
rejoin to the exact input — rather than freezing a segment list.

The applyCompletion cases reproduce the reported bug rather than restating
the implementation: reverting the fix fails with
`expected 'please run //clean' to be 'please run /clean'`.
2026-07-25 21:19:23 -05:00
hermes-seaeye[bot] a7a696ba59
fmt(js): `npm run fix` on merge (#70455)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-24 01:58:39 +00:00
Teknium ad6a2ae401 feat(tui,desktop): surface MoA fan-out progress from moa.progress/moa.phase
Frontend consumers for the events added by PR #59646: the TUI shows a
replace-in-place 'MoA: refs k/n' activity line (swapped for 'MoA:
aggregating…' on the aggregator phase), and desktop streams '◇ MoA refs
k/n' lines into the reasoning disclosure, self-cleaned by the first
moa.reference block.
2026-07-23 18:11:57 -07:00
Simplicio, Wesley (ext) d7fbd13997 fix(tui): fix mount-collapse and live-progress gating for MoA reference panels
Maintainer review (hermes-sweeper) on this PR found the fix was
incomplete: two paths still hid the MoA reference panel under
thinking: hidden.

1. thinking.tsx: the mount useState correctly seeds openThinking from
   (visible.thinking === 'expanded' || reasoningAlwaysVisible), but the
   re-sync effect on [visible] fires after the FIRST render too, not
   just later updates, and lacks the reasoningAlwaysVisible OR — so it
   immediately collapsed a just-opened MoA panel right after mount.
   Skip only the effect's very first run (a ref flag); every later
   visible change still re-syncs without the override, preserving the
   documented no-OR-at-effect-time contract (manual collapse sticks).

2. useMainApp.ts: showProgressArea's streamSegments predicate gated
   thinking content on thinkingPanelVisible alone, so an MoA reference
   segment (segment.isMoaReference, same flag messageLine.tsx's
   shouldShowThinkingTrail already honors per #64657) never kept the
   live progress area up when thinking was hidden — StreamingAssistant
   then returned early before MessageLine was ever reached. Added the
   same override.

Added tests/thinkingMoaReferenceVisibility.test.tsx: mounts ToolTrail
with reasoningAlwaysVisible + sections.thinking: hidden, awaits queued
effects, and asserts the chevron is still open (▾, not ▸) once they
settle.

Validation:
  npx vitest run src/__tests__/thinkingMoaReferenceVisibility.test.tsx
  -> 1 passed
  Fail-before: reverting only the thinking.tsx ref-guard reproduces the
  exact regression -- the same test's frame capture shows the panel
  open on first paint then collapsing to ▸ once the effect fires, and
  the 'not.toContain(▸)' assertion fails as expected.
  npx vitest run (full ui-tui suite): 1115 passed, 8 failed -- all 8
  pre-existing and unrelated (terminalSetup/terminalParity/editor
  resolution env-path tests), confirmed by running them in isolation
  with the same result regardless of this diff.
  npx tsc --noEmit: clean.
  npx eslint src/components/thinking.tsx src/app/useMainApp.ts: clean.
2026-07-23 18:11:57 -07:00
Simplicio, Wesley (ext) 07a732c2e5 fix(tui): keep MoA reference blocks visible when the thinking section is hidden
Every moa.reference gateway event stores its labelled reference-model
output in a Msg's generic `thinking` field (turnController's
recordMoaReference), which messageLine.tsx and the ToolTrail component gate
on `display.sections.thinking`'s resolved mode. When that mode resolves to
`hidden`, MoA reference blocks were suppressed along with ordinary model
reasoning — even though (per #53855) references are the mixture-of-agents
process the user explicitly opted into, not private reasoning, and should
stay visible regardless of the thinking-section setting.

Adds Msg.isMoaReference (set by recordMoaReference), a shouldShowThinkingTrail
helper mirroring the existing shouldShowResponseSeparator pattern, and a
reasoningAlwaysVisible prop threaded into ToolTrail to bypass the two
suppression gates (the trail-wrapper return-null check and the
allHidden/panel-push checks) plus the panel's initial open state and the
shift-click expand-all gesture, so a MoA reference panel is not just present
in the tree but actually visible and openable on first paint.

Fixes #64657
2026-07-23 18:11:57 -07:00
hermes-seaeye[bot] 053f162f94
fmt(js): `npm run fix` on merge (#70291)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 20:23:02 +00:00
ethernet a4bc1ca502
fix(timeline): persist typed display events (#69771)
* fix(desktop): hide persisted agent-only history scaffolding

Filter verification-stop nudges and context-compaction handoffs at the
stored-history mapper boundary. Preserve a real reply when a compaction
handoff shares its stored message.

* test(desktop): build persisted E2E sessions through the real agent

Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock
provider, wait for real completion events, and persist normal session history
through AIAgent and SessionDB. Migrate resume and hidden-history coverage,
including real compression and live verify-on-stop scaffolding, then remove
the unused direct SessionDB import scripts.

* fix(desktop): use the provisioned Python for real-session E2Es

Run the stdio gateway through uv's synced project environment outside the
Nix dev shell, while retaining the fully provisioned Nix Python when the
shell advertises HERMES_PYTHON_SRC_ROOT.

* fix(nix): expose the provisioned Python environment to uv

Mark the Nix-built Python environment active in the dev shell so the shared
E2E session builder can always run through `uv run --active --no-sync`.

* fix(timeline): persist typed display events

* fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history

Three review findings from PR #69771:

1. Provider payload leak: display_kind and display_metadata were forwarded
   to the provider API as unknown message fields. Strict OpenAI-compatible
   backends can reject the next request after a model switch or resumed
   typed event. Strip both from the per-request api_msg copy in
   conversation_loop alongside the existing api_content pop.

2. Rewrite/import data loss: _insert_message_rows preserved display_kind
   but silently dropped display_metadata. After replace_messages,
   archive_and_compact, or session import, async-delegation completion
   events lost their task counts and fell back to generic display text.
   Add display_metadata to the INSERT columns and bind tuple.

3. CLI /resume stale recap: startup --resume A set _resume_display_history
   from A's lineage. A subsequent in-session /resume B loaded B only into
   conversation_history via get_messages_as_conversation, leaving the stale
   A display projection. _display_resumed_history preferentially read the
   stale attribute, showing A's recap for B. Switch /resume to
   get_resume_conversations and update _resume_display_history alongside
   conversation_history.

Tests: 890 Python (5 files), 35 desktop TS — all green.

* feat(tui): render typed display events as ◈ markers in the Ink TUI

The TUI was not handling display_kind at all — model switch markers and
async delegation completions rendered as opaque user messages with the
full [System: ...] text, and hidden compaction handoffs were visible.

Wire display_kind through the full TUI chain:

- _history_to_messages (tui_gateway/server.py) forwards display_kind
  and display_metadata to the gateway transcript payload.
- GatewayTranscriptMessage (gatewayTypes.ts) gains both fields.
- Msg.kind (types.ts) gains 'event' value.
- toTranscriptMessages (domain/messages.ts) maps:
  - hidden → skip entirely
  - model_switch → event "model changed"
  - async_delegation_complete → event "N background agents finished"
    (or "background agent work finished" without metadata)
- messageGroup (blockLayout.ts) routes event to its own group, with
  SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins.
- messageLine.tsx renders event-kind as a dim ◈ marker with no gutter,
  matching the CLI's ◈ event rendering.
- 4 new TUI tests for hidden/model_switch/async_delegation mapping.

TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings).
TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
2026-07-23 14:46:24 -04:00
brooklyn! 2c1d585002
Merge pull request #69655 from NousResearch/bb/out-of-credits-ux
feat(billing): consistent out-of-credits UX across CLI, TUI, and desktop
2026-07-22 21:03:38 -05:00
Brooklyn Nicholson e762ea1741 fix(ui-tui): a skin that authors a background owns its polarity
The theme engine picked light/dark adaptation from the HOST terminal
(detectLightMode) even when the skin authors its own background — which
the TUI then paints onto the terminal via OSC-11. On a light-mode Apple
Terminal without truecolor, a pure-black skin (e.g. Bloomberg) got its
foregrounds ansi256-bucketed *for a light background that no longer
exists*: theme.color.text became 'ansi256(214)', which also fails the
OSC-10 hex gate, so the terminal default fg stayed the light profile's
near-black — markdown body text rendered black-on-black.

fromSkin now resolves polarity from the skin's authored background when
present (skinIsLight), uses that background as the reference canvas for
the derived tone ladder and contrast adaptation, and only falls back to
host detection for skins without a background (they render on the
terminal's own surface). The paired light_colors/dark_colors pick in
themeForSkin follows the same rule.
2026-07-22 18:11:27 -05:00
Brooklyn Nicholson 9c274db89f feat(tui): billing wall opens a confirm dialog
Open the shared ConfirmPrompt (full-width, themed, same lane as approval/clarify)
rather than a truncating status-bar notice. One recovery action: Nous → /topup
(opens the rich billing overlay), other providers → their billing page, or /model
to switch when there's no URL. The transcript keeps the full guidance; the dialog
is the concise actionable layer.
2026-07-22 18:09:08 -05:00
Brooklyn Nicholson 1f652214df fix(ui-tui): skin owns BOTH terminal defaults — OSC-10 foreground beside the OSC-11 background
The input fix's sibling, hit immediately after: the composer was themed but
AGENT text went black-on-black the same way. Root cause is the class, not
the call site — markdown body, borders, and every token rendered without an
explicit color falls back to the terminal's DEFAULT foreground, which
belongs to the HOST profile's polarity, not the skin's. A dark skin on a
light terminal repaints the backdrop via OSC-11 while thousands of
default-fg cells stay near-black.

Chasing every <Text> is unwinnable. Instead own the default itself: when a
skin authors a background (the existing opt-in), paint the default
foreground from the resolved theme's text color via OSC-10. Every unthemed
token — present and future — re-bases onto the skin atomically, exactly
like the background.

terminalModes: the OSC-11 slot generalizes to defaultColorSlot(10|11) —
same paint/clear/exit-restore contract, tracked per slot, so a skinless
session still never touches the terminal. reapplyTheme repaints the fg too:
polarity flips swap paired palettes, moving the text tone while the
background stays.

Tests: slot contract runs table-driven over both OSC codes; handler test
pins the invariant (default fg == theme text; dropping the background
releases both defaults). Suite 1344✓, typecheck/lint/prettier clean.
2026-07-22 16:28:57 -05:00
Brooklyn Nicholson 2a3e3157d2 fix(ui-tui): themed input text — typed text tracked the HOST terminal's fg, not the skin's
Live-repaint's composer gap: flip a light terminal to a dark skin and the
input goes black-on-black. The placeholder was already explicit truecolor
(theme muted), but TYPED text rendered with no color at all — the terminal's
default foreground — in both paint paths:

- the Ink render (<Text wrap="wrap">{rendered}</Text>, no color), and
- the fast-echo bypass, which writes raw cells straight to stdout.

The skin owns the background (OSC-11) but the default fg still belongs to
the host terminal's polarity, so any skin/terminal polarity mismatch made
input invisible. Every other transcript line already paints
theme.color.text (the completed inputBuf rows directly above the composer).

Give TextInput a color prop and paint both paths with it: the Ink <Text>
(chalk re-opens the outer color after the placeholder chips' embedded [39m
closes; INV cursor/selection cells never touch fg) and the fast-echo write
via colorizeEcho — same explicit-truecolor-only rule as colorizeHint, so
the bypass cell can't flash terminal-default before the next frame. All six
TextInput sites (composer, prompts, masked, billing ×2, session switcher)
pass theme text; no color ⇒ passthrough, unthemed inputs keep the terminal
default.

Tests: colorizeEcho SGR wrap + passthrough contracts; full ui-tui suite
1338✓; typecheck clean.
2026-07-22 15:56:39 -05:00
brooklyn! 367810a942
Merge pull request #68857 from NousResearch/bb/theme-sdk
feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop, live
2026-07-21 21:23:44 -05:00
Brooklyn Nicholson 50170fdd2d fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline
Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys
flow through buildPalette → adaptColorsToBackground instead of a hand-mapped
color block, so they inherit #20379's contrast/polarity machinery. thinking
and syntaxComment track the EFFECTIVE muted (banner_dim override included);
the skin's `background` feeds the surface (it also paints the terminal via
OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the
routing/independence contracts rather than pre-adaptation hexes.
2026-07-21 21:00:43 -05:00
Brooklyn Nicholson 3ba6eebc7c feat(themes): dedicated code-syntax palette keys
Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't
be themed independently. Add syntax_string/number/keyword/comment skin keys →
syntax* theme tokens (defaulting to those brand tokens, so defaults are
unchanged) and point the highlighter at them. Documented in the element→key map.
2026-07-21 21:00:43 -05:00
Brooklyn Nicholson 30ee6f749d feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs
Theming was semantic-only: the gold tool `●` was `accent`, shared with
headings/links/chevrons, so "recolor tool calls" was impossible and the agent
had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking`
(reasoning body) tokens that fall back to accent/muted — defaults unchanged,
but now independently settable. Make diffs skinnable too (`diff_*`), which
fromSkin previously hardcoded. Document the full element→key map in the skill so
Hermes knows which knob turns what.
2026-07-21 21:00:43 -05:00
Brooklyn Nicholson 727f6704a7 feat(themes): TUI paints its own background from the skin (OSC 11)
The TUI inherited the terminal's background; now a skin's `background` paints the
whole surface via OSC 11 when a skin is applied, and clears back to the terminal
default (OSC 111) on revert and on exit (ridden in through resetTerminalModes).
Opt-in: a skin with no `background` leaves the terminal untouched, and the
restore only fires if we actually painted. Desktop already themed its own bg;
this closes the loop so Hermes owns its background on every surface.
2026-07-21 21:00:43 -05:00
Brooklyn Nicholson a8444fbcae feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop
Make the Python skin engine the single source of truth for a canonical theme
shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml
(by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at
once — the theme analogue of the plugin SDK.

- @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum,
  consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it).
- Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style
  derive-from-seed) + `backend-sync` that registers backend skins into the theme
  registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on
  gateway.ready (never stomps a persisted pick), applies on skin.changed and the
  post-turn `config.get skin` poll (catch-all for agent-edited config.yaml).
- TUI: `fromSkin` now maps the status bar + `background` keys it was dropping.
- Gateway: `config.get skin` also returns the full resolved palette (additive).
- Skill: `hermes-themes` teaches the agent to author + activate a skin.

Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for
the desktop, prompt_toolkit/Rich for the CLI).
2026-07-21 21:00:42 -05:00
Brooklyn Nicholson 2ed61d486c refactor(ui-tui): host placement router + grid-test width-floor fix
host.tsx collapses to one placement router over a shared render context, and the
grid-test app drops its width floor too (carrying the #20379 review rule). Final
formatting pass folded in.
2026-07-21 20:59:55 -05:00
Brooklyn Nicholson 9627d4f43f feat(ui-tui): ambient zone system + widget crash boundary
A full placement grid so the agent can put a widget where it asks — dock-top/
bottom and corner zones, with corners as reserved rails that take real space
instead of floating over content. A per-widget error boundary plus lenient
ShimmerRows means generated widget code can't crash the TUI.
2026-07-21 20:59:54 -05:00
Brooklyn Nicholson ed93d6afe0 feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams
Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.
2026-07-21 20:59:54 -05:00
Brooklyn Nicholson ca0b1b130c feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill
Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs,
fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill
teaches the agent the contract and the openWidget-at-register auto-open recipe.
Load/error/remove events announce themselves in the transcript; a lazy intro
skeleton covers the first paint.
2026-07-21 20:59:54 -05:00
Brooklyn Nicholson 76b58d6127 feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock
Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.
2026-07-21 20:59:53 -05:00
Brooklyn Nicholson fc3be32a9a feat(ui-tui): weather reference app — the async-data contract, themed ASCII art
/weather [location]: wttr.in current conditions behind a Dialog, art bucket
table-driven off WWO weather codes, every tint a theme family tone (sun =
primary, rain = shell blue, thunder = warn). Proves the async story the
demos don't: init returns a loading phase and fires the fetch; results land
through the new host.updateWidget, which patches state ONLY while the app
is still active — a late resolution can never resurrect a closed app or
clobber a different one. `r` refetches; Esc/q/Enter close.

Four async-contract tests (loading→ready via updateWidget, late-resolution
guard, error phase, keymap). 1253 TS tests green.
2026-07-21 20:59:52 -05:00
Brooklyn Nicholson d8fcab4736 feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps
The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.
2026-07-21 20:59:52 -05:00
Brooklyn Nicholson 7d757cf7e2 fmt(js): prettier pass on the new review tests 2026-07-21 20:29:48 -05:00
Brooklyn Nicholson cd24efec61 perf(ui-tui): shimmer loaders share one clock and stop after a bounded period
Review on #20379, finding 5 (Perf). Every ShimmerRows mounted its own 90 ms
setInterval — the session panel can show lazy skills AND lazy tools at
once, and a lazy watch session stays lazy indefinitely, so an otherwise-
idle TUI ran ~22 React state updates per second forever.

All shimmer compositions now subscribe to a single module-level clock: one
interval regardless of how many skeletons are on screen, updates delivered
in one timer callback so React batches them into a single render pass, and
the interval is torn down with the last subscriber. Each mount's animation
is also bounded (SHIMMER_ANIMATE_MS, 30 s): after the budget the skeleton
freezes in place — it still reads as "loading" — and stops costing renders
entirely.

Tests: fake-timer coverage that N subscribers share one timer in lockstep,
the interval stops with the last unsubscribe, and a late subscriber
restarts the clock cleanly.
2026-07-21 20:29:48 -05:00
Brooklyn Nicholson c25e08aa2b fix(ui-tui): stacked dialog gets input before the grid under it
Review on #20379, finding 3 (Medium). /grid-test's `d` opens a dialog on
top without clearing the grid, but the grid's input branch ran FIRST — so
Esc/q/Enter mutated the hidden grid (close/unzoom/promote) instead of
closing the visible dialog, contradicting its "Esc/q/Enter close" hint.

Input routing now follows visual stacking: the dialog/grid dispatch is
extracted into handleStackedModalInput() with the dialog branch first, the
hook consumes through it, and tests drive the real dispatch against the
overlay store — each advertised close key closes only the dialog (grid
byte-identical), grid keys don't leak through while the dialog is up, and
the same keys route to the grid again after it closes.
2026-07-21 20:27:15 -05:00
Brooklyn Nicholson e6cc5612e6 fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal
Review on #20379, finding 2 (High). The boot cache seeded the previous
session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT
OSC-11 answer occupies — so a cache written on a light terminal pinned a
now-pure-black terminal to light forever: the new OSC-11 #000000 answer is
distrusted by design, the pure-white OSC-10 foreground is distrusted too,
and the macOS appearance fallback refuses to run while the slot is set.

Seeding now records provenance (themeBoot.seedBootEnvironment, extracted
and testable against a passed env). When the current terminal answers the
background probe with the untrusted fingerprint, the gateway handler calls
invalidateBootBackground(): the slot clears ONLY while it still holds the
seeded value (a trusted answer that overwrote it is authoritative), OSC-10
gets first claim in the same startup batch, and a short settle pass re-
derives from the live fallback chain if nothing answered.

The cache is also pin-coherent now: commitTheme persists the config mode
pin (display.tui_theme) alongside the resolved theme + physical background.
Previously "/theme light" on a dark terminal cached a light theme next to a
dark background — the next launch painted light, flipped dark when the skin
resolved against the seeded background, then flipped light again on config
hydration: the exact multi-stage flash the cache exists to eliminate. The
seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can
still clear it instead of mistaking it for a user shell export.

Tests cover the review's sequences: stale-light cache vs current dark
terminal (invalidate → fallback chain), stale-dark vs ambiguous light,
pinned light on a dark physical background across restart (and the
inverse), trusted-overwrite protection, and the explicit-signal guards.
2026-07-21 20:27:15 -05:00
Brooklyn Nicholson b11b5ece2e fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED
Review on #20379, finding 1 (High). Two ways an MCP config revision could
be silently acknowledged without ever being applied:

Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp,
and quietRpc collapses failures to null — a reload that failed against a
temporarily broken server left the revision recorded as applied, and no
subsequent poll retried until an unrelated MCP edit. The handshake is now
syncMcpReload(): send the observed rev with the request, advance `accepted`
only when the server answers status=reloaded (to the server's loaded_rev,
falling back to the requested rev on older gateways), and re-compare on
EVERY poll tick — decoupled from mtime — so a transient failure heals on
the next tick. An in-flight guard stops the 5s poll from stacking requests
behind a slow reload.

Server: generation-only coalescing let a follower triggered by revision B
ack against revision A's registry when the config changed under a slow
leader. The leader now re-hashes the MCP-relevant config after discovery
and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a
follower coalesces only when the revision it was asked to load matches —
otherwise it re-runs the full reload itself. Responses carry loaded_rev.

Deterministic tests for the exact failure sequences: failed reload → no
ack, no generation advance; A-then-B overlap → follower re-runs; matching
rev → coalesces; failed leader → follower re-runs; legacy no-rev callers
keep generation-only coalescing (thread ordering via an instrumented lock,
no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract.
2026-07-21 20:27:15 -05:00
hermes-seaeye[bot] 8208fc5270
fmt(js): `npm run fix` on merge (#68977)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-21 23:05:16 +00:00
hermes-seaeye[bot] 96ba7ec529
fmt(js): `npm run fix` on merge (#68976)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-21 22:59:05 +00:00