- acp_adapter/server.py: rename compact -> compress for context compression command
- tui_gateway/server.py: rename /compact -> /density for display density toggle
- ui-tui/core.ts: rename compact -> density for display density toggle
- Internal config keys (tui_compact) and UI state (ctx.ui.compact) unchanged
* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art
Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.
- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
and at most one button — "View plans" (free/no-sub + can_change_plan),
"Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
(is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
(opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
unknown name → text-only card. Imported via vite static imports for packaged
file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
$ fields with a pre-allocated error line) and the action column (Manage → Save/
Cancel) in place, with the row height reserved for the tallest state so the Usage
section never shifts. Fixes the spurious on-open validation error (errors now show
only after an edit or a save attempt). Save/disable API calls + confirm-disable
flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.
Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.
* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription
Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".
Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.
CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).
Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.
* chore(desktop): shrink bundled tier art to 128px thumbnails
The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.
* fix(desktop): address 6 adversarial-review findings on the Billing revamp
1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
now resolves current identity/ordering against the UNFILTERED tiers and keeps the
grandfathered current tier in the grid as the inert "Current plan" card; downgrades
classify against its tier_order. (Non-current disabled tiers are still dropped.)
2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
can_change_plan, but the grid could be empty / current-only and showPlans refused,
so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
actionable (non-current) tier; otherwise it falls back to the portal link.
3. Deep-link bypass. showPlans now gates on the same capability that renders the button
(view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
back to overview instead of a grid of live Choose buttons.
4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
no longer promises a portal the UI didn't render.
5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
instead of a bare return, so a null portal_url never strips the routing params.
6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
inputs stack below @2xl) with exact reservation: the edit form is always rendered and
both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
when not editing — the row equals the tallest state at every width, no breakpoint math.
The refusal stays inside the reserved layer.
Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.
* refactor(desktop): reuse the shared openExternalLink helper in the plans view
* fix(desktop): honor the auto_reload wire contract — null card + disable amounts
A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:
A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
but the shared BillingAutoReload.card union had no null arm and use-billing-state
dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
to the shared union (contract honesty) and guard the read (`card?.kind`); null now
falls through to the default enabled path, same as a canonical card.
B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
disable() now sends the current threshold_usd/reload_to_usd from the autoReload
prop alongside enabled: false, matching the TUI.
Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.
* fix(tui): guard the nullable auto_reload card in the auto-reload screen
The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.
* fix(desktop): align billing inputs to the sm control height
The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.
* fix(desktop): plan-card actionability + billing view-model hardening
Code-quality review of the Billing revamp (PR #68722).
BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.
Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
derived once in deriveBillingView and threaded to BOTH derivePlanCard and
derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
`accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
from RowValue (via a destructured const) and the plan-card link handler.
Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.
* refactor(desktop): adopt inline-review nits on the billing plan card
Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
(union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) rather than relying on outer narrowing.
Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
The lazy-loaded Available Tools section rendered a BLANK gap that popped
when data landed. It now shows animated shimmer rows shaped like the real
content (label block + value run, diagonal band sweep), and the summary
line prints "… tools · … skills" instead of "0 tools · 0 skills" while
counts load.
components/loaders.tsx ships the primitives (shimmerSegments band math,
Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme
tones, one interval per composition. Cherry-picked from the SDK-shape
branch so the skeleton lands with this PR; the widget-SDK consumers stay
in the follow-up.
Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark')
returned early when HERMES_TUI_THEME already matched, leaving
configPinnedTheme false — so a later '/theme auto' refused to clear the pin
and the session stayed forced to the old mode while config said auto. Set
configPinnedTheme before the match short-circuit.
Bugbot's other three r6 findings (GatewaySkin paired-palette fields,
ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation
false positives — all declared in gatewayTypes.ts / the picker prop
interfaces; typecheck is green.
The startup config.get seeded mtimeRef but not mcpRevRef; after a normal
boot mtime is already non-zero so the poller's baseline branch never runs,
leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped
mtime with an unchanged mcp_rev, and empty !== hash tripped a full
reload.mcp — exactly the reconnect this optimization removes. Seed the
revision alongside mtime at boot.
- commitTheme compares the first commit against the SEED theme uiStore
mounted with (boot cache/default), not null — a cold start that resolves
to a skin differing from the default previously skipped the anti-tearing
forceRedraw on that first swap.
- applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked
via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their
shell — that env override outranks auto-detection per detectLightMode's
documented priority, and a config hydrate was wiping it.
(Bugbot's recurring "GatewaySkin missing paired palette fields" is a
diff-truncation false positive — gatewayTypes.ts declares light_colors/
dark_colors, typecheck green.) 81 handler + build/lint green.
- mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic
CLI watches) alongside `mcp`/`tools` — editing a server previously bumped
mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never
connected until a manual /reload-mcp.
- reload.mcp coalescing survives a failed leader: a completed-generation
counter (bumped only after a successful shutdown+discover) gates the
follower. If the leader threw (flapping server) the follower re-runs the
full reload itself instead of returning a bogus success over an empty
registry.
- /theme applies AFTER config.set confirms (mirrors /indicator) with a
guardedErr catch — a failed persist no longer leaves the session showing a
theme that reverts on restart.
384 tui_gateway + 1246 TS tests, lint, build green.
Three real findings from the review of the reload.mcp pooling + OSC-10 work:
- Lock released too early: the leader now holds _mcp_reload_lock across
shutdown+discover AND its own agent refresh — releasing after discover let
a second reload tear the registry down while the first was still reading it
to rebuild the session's tool snapshot.
- Coalesced reload skipped the agent refresh: a follower returned
"reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced
session kept stale tools. Followers now wait, then refresh their agent
against the freshly-built registry (under the lock, skipping the redundant
shutdown/discover). Shared _finish_reload tail for the `always` opt-out.
- macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10
foreground reply could re-flip the committed inference (visible churn). It
now marks resolved; a real OSC-11 background measurement still corrects it
(that listener intentionally doesn't gate on resolved — measurement beats
inference).
Bugbot's other four findings were diff-truncation false positives (color.ts,
themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist;
typecheck + build green). 384 tui_gateway + TS suites pass.
Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth,
so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4
cols of chrome) overflowed and clipped at the terminal edge. One shared
clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is
ABSOLUTE (a cell knows its budget), the usability floor applies only when
the cap allows it, uncapped keeps the old floor semantics. Five call
sites (model/pet pickers, skills/plugins hubs, session switcher) route
through it; the grid-test FloatBox drops its own 24 floor. Contract-tested
including the sub-floor cap case from the review.
/agents, /journey, model picker, skills hub, plugins hub, pet picker, and
the approval/confirm prompts all used SGR inverse for the active row —
terminal-interpreted against unknowable defaults (black slab on transparent
profiles) and visually divergent from completions/session-switcher. New
spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted
ink + bold; spread after `color` so chip ink wins) converts each site to
the one selection treatment. rg confirms zero inverse={} remaining in
components/.
Two-column grid: the name track auto-sizes to the widest visible command so
descriptions align in their own column instead of running under the names.
Descriptions render in the neutral statusFg gray — label and muted are
near-twins on the gold skins, which made command and description read as
one unparseable run.
scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme
for both scrollbars (was duplicated formulas); textInput's hex parsing
collapses into one hintRgb helper feeding colorizeHint and hintCursorCell.
Comment bloat trimmed. No behavior change — 1243 tests byte-green.
mix(text, surface) was invisible whenever text was already pale (the
light-rendered default: cream blended toward white = nothing) and inherited
wrong-polarity detection through the surface. mix(muted, text, .5) is
pole-proof: muted is mid-luminance by construction, so the midpoint stays
readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white,
5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark).
Transparent profiles make OSC-11 useless (xterm reports the unset default,
pure black, regardless of the composited surface) so polarity detection
lagged editor theme flips. OSC-10 reports the theme's REAL foreground on
those hosts — its luminance reveals the pole. hermes-ink grows a foreground
slot (shared reportedColorSlot factory), App.tsx queries both in the
startup batch (background first so a trusted answer wins without churn),
and the app commits an inferred pole only when the background was
distrusted AND the foreground is decisive (bright=dark theme, dark=light;
mid-grays and #000/#fff defaults commit nothing). User pins still outrank.
Tool/skill rows rendered labels in muted and member lists in text — which
inverts per skin (muted is the strong family tone on gold skins but the
weak gray on blue ones; slate/poseidon read backwards). Labels now lead in
the theme's label tone, values recede via an explicit fade toward the
surface; audited across all 9 built-ins x both poles. The composer
placeholder lands on muted — the "(and N more toolsets…)" tone — a
mid-luminance family color that reads receded on both poles even when
polarity detection is wrong.
On terminal.background #00000000 xterm paints "drawn blank" and
attribute-styled cells against an opaque black RGB the user never sees
elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit
no black backgrounds — then removed every trigger: the banner's opaque
space-fills, the scrollbar's non-scrollable space column and SGR-dim
track, the placeholder's SGR inverse cursor and dim fallback, and the bold
full-width banner rule. Chrome styling is now explicit truecolor only:
scrollbar thumb rides primary (accent on hover/drag) over a blended track,
the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked
ink), hints always carry an explicit 38;2 foreground.
Pixel-sampled against the reference screenshots: the beloved classic
light-mode look is the vivid authored golds rendered essentially raw
(#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no
contrast lift of their own, and pre-darkening every foreground to WCAG 4.5
produced the reported mustard mud. Light-mode display floors become
near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps
1.45/2.2); the default skin ships a fills-only light_colors OVERLAY
(polarity-flip the navy menu/status fills, foregrounds inherit the vivid
colors), and themeForSkin merges polarity overlays instead of replacing.
Palette audit reworked: base colors audited fully, overlays for valid keys
and fill polarity.
Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.
The desktop color-mix system, ported: a theme is a handful of identity
SEEDS (text, primary, accent, border, status hues); every secondary tone
(muted, label, surfaces, chips, selection) is a color-mix derivative
against the real terminal background. lib/color.ts consolidates the
primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative
liftForContrast). Knobs are grid-search fitted so the math reproduces the
classic hand-tuned literals (contract-tested). The display shim renders
authored palettes RAW and only rescues near-invisible colors. Boot reads
the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first
frame paints in the right palette (no default-dark flash), and the
placeholder cursor follows the bubbles textinput pattern.
OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.
Banner (responsive tiers: full logo -> compact rule -> text -> hidden),
SessionPanel (fixed hero track + flexible info track, the desktop pane
shell's fixed-vs-flex contract), floating overlays, prompt zone, and the
pickers all render through WidgetGrid instead of hand-rolled flex math.
Pickers gain maxWidth so grid cells can cap them.
Rebase of the widget-grid PR onto current main, then grow it into a real
2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts
and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow),
and layoutGridAreas (2D absolute placement with row/col spans and implicit
row growth). Overlay/Dialog primitives give zoned viewport-level modals
with an optional scrim. /grid-test (interactive: areas, nesting, zoom,
gap/padding) and /grid-test streams (4x3 mission-control GridAreas board
with promote-to-main) exercise everything end to end.
- TUI /battery matches the CLI surface: adds `status` (live reading via
system.battery), and the help/usage strings now consistently read
[on|off|status].
- batteryLabel() renders `--` for an unknown percent so a null can never
surface as "null%" even without the showBattery guard.
- Move the system.battery RPC out of the config section into
"Methods: tools & system" where system.* RPCs belong.
Add an opt-in battery indicator to the CLI and TUI status bars, shown as
the first element and colour-coded by charge (green/yellow/orange/red, or
green while charging). Off by default and a no-op on machines without a
battery.
- agent/battery.py: shared psutil-backed reader with a short TTL cache,
category bucketing, and a compact 🔋/⚡ label. Fails open to
"unavailable" everywhere.
- CLI: /battery [on|off|status] toggle persisted to display.battery,
rendered first in every status-bar width tier.
- TUI: /battery slash command, config sync, a system.battery RPC polled
while enabled, and a pinned first segment in StatusRule.
* feat(tui): show the plan catalog in /subscription on Free
The server returns the tier list even with no subscription, but the
overlay hid the picker behind can_change_plan && !isFree, so a Free
account got only "Start a subscription" with no idea what the plans
cost. Now:
- Overview on Free offers "Choose a plan" whenever the catalog has
enabled paid tiers.
- The picker on Free lists each plan as name · price · monthly credits
(no upgrade/downgrade hints — there is nothing to move from), and
picking one opens the portal, where starting a subscription actually
happens (card capture + checkout live there; the upgrade RPC requires
an existing subscription).
- Paid-plan behavior (preview → confirm → apply) is unchanged.
* refactor(tui): compute the picker row suffix once
Review feedback: the isFree fork duplicated the label template and run
handler; only the suffix differs.
* fix(tui): arm the busy guard before the Free portal handoff
Adversarial review: the Free branch returned before setting busyRef, so
a double-Enter could open the portal twice; and the picker narrated a
handoff that openManageLink already narrates (duplicate on success,
contradictory on failure). Guard first, let the helper do the talking.
* fix(tui): monthly credits are dollars — label them as such
The Free picker showed "1000 credits/mo" for what is $1,000 of monthly
credit — render "$1,000 credits/mo" (grouped, dollar-signed).
* feat(tui): render the Free-plan catalog inline in the /subscription overview
Sid ruling: the upsell belongs where the user already is — no
intermediate "Choose a plan" hop. On Free the overview lists each paid
plan (name · $/mo · $credits/mo) as a pickable row; picking opens the
portal (openManageLink narrates). The generic "Start a subscription"
row survives only when the catalog is empty. The picker reverts to its
original change-only form (Free never reaches it).
* feat(desktop): tier catalog chips on the Subscription row
Desktop parity with the TUI inline catalog (Sid ruling): accounts that
can act see the plans where they already are — Free gets the upsell
list (every chip opens the portal), a subscriber sees all tiers with
the current one marked inert. Members and team contexts see no chips.
Chips learn an optional url (portal handoff) in the shared row model.
* chore(tui): fixture harness mirrors the live tier catalog
The dev screenshot fixtures showed invented plans ($50 Super / $99
Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200
with $22/$110/$220 monthly credits) so fixture renders cannot be
mistaken for product truth. The overlay itself always reads tiers from
the subscription API.
* chore: trim narration comments
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending
The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.
- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
admin can turn it on from the portal's Hermes Agent page") instead of
the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
unchanged; copy, comments, docs, and test expectations only.
* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename
Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
Large pastes collapse to a placeholder in the composer, but input history
stored the placeholder — so up-arrow recall showed a truncated reference
(CLI) or lost the content entirely (TUI, where the `[[…]]` label has no
backing snip after submit).
Store the expanded content in history instead:
- CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer
before `reset(append_to_history=True)`; also reused by the external editor
(dedup). History nav suppresses re-collapse of recalled content.
- TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent
on label-free text so re-submitting a recalled entry stays stable.