mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Themes and plugins can now pull off arbitrary dashboard reskins (cockpit
HUD, retro terminal, etc.) without touching core code.
Themes gain four new fields:
- layoutVariant: standard | cockpit | tiled — shell layout selector
- assets: {bg, hero, logo, crest, sidebar, header, custom: {...}} —
artwork URLs exposed as --theme-asset-* CSS vars
- customCSS: raw CSS injected as a scoped <style> tag on theme apply
(32 KiB cap, cleaned up on theme switch)
- componentStyles: per-component CSS-var overrides (clipPath,
borderImage, background, boxShadow, ...) for card/header/sidebar/
backdrop/tab/progress/badge/footer/page
Plugin manifests gain three new fields:
- tab.override: replaces a built-in route instead of adding a tab
- tab.hidden: register component + slots without adding a nav entry
- slots: declares shell slots the plugin populates
10 named shell slots: backdrop, header-left/right/banner, sidebar,
pre-main, post-main, footer-left/right, overlay. Plugins register via
window.__HERMES_PLUGINS__.registerSlot(name, slot, Component). A
<PluginSlot> React helper is exported on the plugin SDK.
Ships a full demo at plugins/strike-freedom-cockpit/ — theme YAML +
slot-only plugin that reproduces a Gundam cockpit dashboard: MS-STATUS
sidebar with live telemetry, COMPASS crest in header, notched card
corners via componentStyles, scanline overlay via customCSS, gold/cyan
palette, Orbitron typography.
Validation:
- 15 new tests in test_web_server.py covering every extended field
- tests/hermes_cli/: 2615 passed (3 pre-existing unrelated failures)
- tsc -b --noEmit: clean
- vite build: 418 kB bundle, ~2 kB delta for slots/theme extensions
Co-authored-by: Teknium <p@nousresearch.com>
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
/**
|
|
* Plugin slot registry.
|
|
*
|
|
* Plugins can inject components into named locations in the app shell
|
|
* (header-left, sidebar, backdrop, etc.) by calling
|
|
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
|
|
* from their JS bundle. Multiple plugins can populate the same slot — they
|
|
* render stacked in registration order.
|
|
*
|
|
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
|
|
* registry accepts any string so plugin ecosystems can define their own
|
|
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
|
|
* it knows about.
|
|
*/
|
|
|
|
import React, { Fragment, useEffect, useState } from "react";
|
|
|
|
/** Slot locations the built-in shell renders. Plugins declaring any of
|
|
* these in their manifest's `slots` field get wired in automatically.
|
|
*
|
|
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
|
|
* - `header-left` — injected before the Hermes brand in the top bar
|
|
* - `header-right` — injected before the theme/language switchers
|
|
* - `header-banner` — injected below the top nav bar, full-width
|
|
* - `sidebar` — the cockpit sidebar rail (only rendered when
|
|
* `layoutVariant === "cockpit"`)
|
|
* - `pre-main` — rendered above the route outlet (inside `<main>`)
|
|
* - `post-main` — rendered below the route outlet (inside `<main>`)
|
|
* - `footer-left` — replaces the left footer cell content
|
|
* - `footer-right` — replaces the right footer cell content
|
|
* - `overlay` — fixed-position layer above everything else;
|
|
* useful for chrome (scanlines, vignettes) the
|
|
* theme's customCSS can't achieve alone
|
|
*/
|
|
export const KNOWN_SLOT_NAMES = [
|
|
"backdrop",
|
|
"header-left",
|
|
"header-right",
|
|
"header-banner",
|
|
"sidebar",
|
|
"pre-main",
|
|
"post-main",
|
|
"footer-left",
|
|
"footer-right",
|
|
"overlay",
|
|
] as const;
|
|
|
|
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
|
|
|
|
type SlotListener = () => void;
|
|
|
|
interface SlotEntry {
|
|
plugin: string;
|
|
component: React.ComponentType;
|
|
}
|
|
|
|
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
|
|
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
|
|
const _slotListeners: Set<SlotListener> = new Set();
|
|
|
|
function _notifySlots() {
|
|
for (const fn of _slotListeners) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Register a component for a slot. Called by plugin bundles via
|
|
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
|
|
*
|
|
* If the same (plugin, slot) pair is registered twice, the later call
|
|
* replaces the earlier one — this matches how React HMR expects plugin
|
|
* re-mounts to behave. */
|
|
export function registerSlot(
|
|
plugin: string,
|
|
slot: string,
|
|
component: React.ComponentType,
|
|
): void {
|
|
const existing = _slotRegistry.get(slot) ?? [];
|
|
const filtered = existing.filter((e) => e.plugin !== plugin);
|
|
filtered.push({ plugin, component });
|
|
_slotRegistry.set(slot, filtered);
|
|
_notifySlots();
|
|
}
|
|
|
|
/** Read current entries for a slot. Returns a copy so callers can't mutate
|
|
* registry state. */
|
|
export function getSlotEntries(slot: string): SlotEntry[] {
|
|
return (_slotRegistry.get(slot) ?? []).slice();
|
|
}
|
|
|
|
/** Subscribe to registry changes. Returns an unsubscribe function. */
|
|
export function onSlotRegistered(fn: SlotListener): () => void {
|
|
_slotListeners.add(fn);
|
|
return () => {
|
|
_slotListeners.delete(fn);
|
|
};
|
|
}
|
|
|
|
/** Clear a specific plugin's slot registrations. Useful for HMR /
|
|
* plugin reload flows — not wired in by default. */
|
|
export function unregisterPluginSlots(plugin: string): void {
|
|
let changed = false;
|
|
for (const [slot, entries] of _slotRegistry.entries()) {
|
|
const kept = entries.filter((e) => e.plugin !== plugin);
|
|
if (kept.length !== entries.length) {
|
|
changed = true;
|
|
if (kept.length === 0) _slotRegistry.delete(slot);
|
|
else _slotRegistry.set(slot, kept);
|
|
}
|
|
}
|
|
if (changed) _notifySlots();
|
|
}
|
|
|
|
interface PluginSlotProps {
|
|
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
|
|
name: string;
|
|
/** Optional content rendered when no plugins have claimed the slot.
|
|
* Useful for built-in defaults the plugin would replace. */
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
/** Render all components registered for a given slot, stacked in order.
|
|
*
|
|
* Component re-renders when the slot registry changes so plugins that
|
|
* arrive after initial mount show up without a manual refresh. */
|
|
export function PluginSlot({ name, fallback }: PluginSlotProps) {
|
|
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
|
|
|
|
useEffect(() => {
|
|
// Pick up anything registered between the initial `useState` call
|
|
// and the first effect tick, then subscribe for future changes.
|
|
setEntries(getSlotEntries(name));
|
|
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
|
|
return unsub;
|
|
}, [name]);
|
|
|
|
if (entries.length === 0) {
|
|
return fallback ? React.createElement(Fragment, null, fallback) : null;
|
|
}
|
|
|
|
return React.createElement(
|
|
Fragment,
|
|
null,
|
|
...entries.map((entry) =>
|
|
React.createElement(entry.component, { key: entry.plugin }),
|
|
),
|
|
);
|
|
}
|