mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
Adds a live + post-hoc audit surface for recursive delegate_task fan-out. None of cc/oc/oclaw tackle nested subagent trees inside an Ink overlay; this ships a view-switched dashboard that handles arbitrary depth + width. Python - delegate_tool: every subagent event now carries subagent_id, parent_id, depth, model, tool_count; subagent.complete also ships input/output/ reasoning tokens, cost, api_calls, files_read/files_written, and a tail of tool-call outputs - delegate_tool: new subagent.spawn_requested event + _active_subagents registry so the overlay can kill a branch by id and pause new spawns - tui_gateway: new RPCs delegation.status, delegation.pause, subagent.interrupt, spawn_tree.save/list/load (disk under \$HERMES_HOME/spawn-trees/<session>/<ts>.json) TUI - /agents overlay: full-width list mode (gantt strip + row picker) and Enter-to-drill full-width scrollable detail mode; inverse+amber selection, heat-coloured branch markers, wall-clock gantt with tick ruler, per-branch rollups - Detail pane: collapsible accordions (Budget, Files, Tool calls, Output, Progress, Summary); open-state persists across agents + mode switches via a shared atom - /replay [N|last|list|load <path>] for in-memory + disk history; /replay-diff <a> <b> for side-by-side tree comparison - Status-bar SpawnHud warns as depth/concurrency approaches caps; overlay auto-follows the just-finished turn onto history[1] - Theme: bump DARK dim #B8860B → #CC9B1F for readable secondary text globally; keep LIGHT untouched Tests: +29 new subagentTree unit tests; 215/215 passing.
267 lines
7.2 KiB
TypeScript
267 lines
7.2 KiB
TypeScript
export interface ThemeColors {
|
||
gold: string
|
||
amber: string
|
||
bronze: string
|
||
cornsilk: string
|
||
dim: string
|
||
completionBg: string
|
||
completionCurrentBg: string
|
||
|
||
label: string
|
||
ok: string
|
||
error: string
|
||
warn: string
|
||
|
||
prompt: string
|
||
sessionLabel: string
|
||
sessionBorder: string
|
||
|
||
statusBg: string
|
||
statusFg: string
|
||
statusGood: string
|
||
statusWarn: string
|
||
statusBad: string
|
||
statusCritical: string
|
||
selectionBg: string
|
||
|
||
diffAdded: string
|
||
diffRemoved: string
|
||
diffAddedWord: string
|
||
diffRemovedWord: string
|
||
|
||
shellDollar: string
|
||
}
|
||
|
||
export interface ThemeBrand {
|
||
name: string
|
||
icon: string
|
||
prompt: string
|
||
welcome: string
|
||
goodbye: string
|
||
tool: string
|
||
helpHeader: string
|
||
}
|
||
|
||
export interface Theme {
|
||
color: ThemeColors
|
||
brand: ThemeBrand
|
||
bannerLogo: string
|
||
bannerHero: string
|
||
}
|
||
|
||
// ── Color math ───────────────────────────────────────────────────────
|
||
|
||
function parseHex(h: string): [number, number, number] | null {
|
||
const m = /^#?([0-9a-f]{6})$/i.exec(h)
|
||
|
||
if (!m) {
|
||
return null
|
||
}
|
||
|
||
const n = parseInt(m[1]!, 16)
|
||
|
||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
||
}
|
||
|
||
function mix(a: string, b: string, t: number) {
|
||
const pa = parseHex(a)
|
||
const pb = parseHex(b)
|
||
|
||
if (!pa || !pb) {
|
||
return a
|
||
}
|
||
|
||
const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t)
|
||
|
||
return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1)
|
||
}
|
||
|
||
// ── Defaults ─────────────────────────────────────────────────────────
|
||
|
||
const BRAND: ThemeBrand = {
|
||
name: 'Hermes Agent',
|
||
icon: '⚕',
|
||
prompt: '❯',
|
||
welcome: 'Type your message or /help for commands.',
|
||
goodbye: 'Goodbye! ⚕',
|
||
tool: '┊',
|
||
helpHeader: '(^_^)? Commands'
|
||
}
|
||
|
||
export const DARK_THEME: Theme = {
|
||
color: {
|
||
gold: '#FFD700',
|
||
amber: '#FFBF00',
|
||
bronze: '#CD7F32',
|
||
cornsilk: '#FFF8DC',
|
||
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
||
// read as barely-visible on dark terminals for long body text. The
|
||
// new value sits ~60% luminance — readable without losing the "muted /
|
||
// secondary" semantic. Field labels still use `label` (65%) which
|
||
// stays brighter so hierarchy holds.
|
||
dim: '#CC9B1F',
|
||
completionBg: '#FFFFFF',
|
||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||
|
||
label: '#DAA520',
|
||
ok: '#4caf50',
|
||
error: '#ef5350',
|
||
warn: '#ffa726',
|
||
|
||
prompt: '#FFF8DC',
|
||
// sessionLabel/sessionBorder intentionally track the `dim` value — they
|
||
// are "same role, same colour" by design. fromSkin's banner_dim fallback
|
||
// relies on this pairing (#11300).
|
||
sessionLabel: '#CC9B1F',
|
||
sessionBorder: '#CC9B1F',
|
||
|
||
statusBg: '#1a1a2e',
|
||
statusFg: '#C0C0C0',
|
||
statusGood: '#8FBC8F',
|
||
statusWarn: '#FFD700',
|
||
statusBad: '#FF8C00',
|
||
statusCritical: '#FF6B6B',
|
||
selectionBg: '#3a3a55',
|
||
|
||
diffAdded: 'rgb(220,255,220)',
|
||
diffRemoved: 'rgb(255,220,220)',
|
||
diffAddedWord: 'rgb(36,138,61)',
|
||
diffRemovedWord: 'rgb(207,34,46)',
|
||
shellDollar: '#4dabf7'
|
||
},
|
||
|
||
brand: BRAND,
|
||
|
||
bannerLogo: '',
|
||
bannerHero: ''
|
||
}
|
||
|
||
// Light-terminal palette: darker golds/ambers that stay legible on white
|
||
// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top
|
||
// cleanly (#11300).
|
||
export const LIGHT_THEME: Theme = {
|
||
color: {
|
||
gold: '#8B6914',
|
||
amber: '#A0651C',
|
||
bronze: '#7A4F1F',
|
||
cornsilk: '#3D2F13',
|
||
dim: '#7A5A0F',
|
||
completionBg: '#F5F5F5',
|
||
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||
|
||
label: '#7A5A0F',
|
||
ok: '#2E7D32',
|
||
error: '#C62828',
|
||
warn: '#E65100',
|
||
|
||
prompt: '#2B2014',
|
||
sessionLabel: '#7A5A0F',
|
||
sessionBorder: '#7A5A0F',
|
||
|
||
statusBg: '#F5F5F5',
|
||
statusFg: '#333333',
|
||
statusGood: '#2E7D32',
|
||
statusWarn: '#8B6914',
|
||
statusBad: '#D84315',
|
||
statusCritical: '#B71C1C',
|
||
selectionBg: '#D4E4F7',
|
||
|
||
diffAdded: 'rgb(200,240,200)',
|
||
diffRemoved: 'rgb(240,200,200)',
|
||
diffAddedWord: 'rgb(27,94,32)',
|
||
diffRemovedWord: 'rgb(183,28,28)',
|
||
shellDollar: '#1565C0'
|
||
},
|
||
|
||
brand: BRAND,
|
||
|
||
bannerLogo: '',
|
||
bannerHero: ''
|
||
}
|
||
|
||
// Pick light vs dark. Explicit `HERMES_TUI_LIGHT` wins; otherwise sniff
|
||
// `COLORFGBG` (set by XFCE Terminal, rxvt, Terminal.app, etc.) — last field is the
|
||
// background ANSI index; 7/15 are the "white" slots most light themes emit (#11300).
|
||
export function detectLightMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
||
const explicit = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||
|
||
if (/^(?:1|true|yes|on)$/.test(explicit)) {
|
||
return true
|
||
}
|
||
|
||
if (/^(?:0|false|no|off)$/.test(explicit)) {
|
||
return false
|
||
}
|
||
|
||
const bg = Number((env.COLORFGBG ?? '').trim().split(';').at(-1))
|
||
|
||
return bg === 7 || bg === 15
|
||
}
|
||
|
||
export const DEFAULT_THEME: Theme = detectLightMode() ? LIGHT_THEME : DARK_THEME
|
||
|
||
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||
|
||
export function fromSkin(
|
||
colors: Record<string, string>,
|
||
branding: Record<string, string>,
|
||
bannerLogo = '',
|
||
bannerHero = '',
|
||
toolPrefix = '',
|
||
helpHeader = ''
|
||
): Theme {
|
||
const d = DEFAULT_THEME
|
||
const c = (k: string) => colors[k]
|
||
|
||
const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber
|
||
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
|
||
const dim = c('banner_dim') ?? d.color.dim
|
||
|
||
return {
|
||
color: {
|
||
gold: c('banner_title') ?? d.color.gold,
|
||
amber,
|
||
bronze: c('banner_border') ?? d.color.bronze,
|
||
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
||
dim,
|
||
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
|
||
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
|
||
|
||
label: c('ui_label') ?? d.color.label,
|
||
ok: c('ui_ok') ?? d.color.ok,
|
||
error: c('ui_error') ?? d.color.error,
|
||
warn: c('ui_warn') ?? d.color.warn,
|
||
|
||
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
||
sessionLabel: c('session_label') ?? dim,
|
||
sessionBorder: c('session_border') ?? dim,
|
||
|
||
statusBg: d.color.statusBg,
|
||
statusFg: d.color.statusFg,
|
||
statusGood: c('ui_ok') ?? d.color.statusGood,
|
||
statusWarn: c('ui_warn') ?? d.color.statusWarn,
|
||
statusBad: d.color.statusBad,
|
||
statusCritical: d.color.statusCritical,
|
||
selectionBg: c('selection_bg') ?? d.color.selectionBg,
|
||
|
||
diffAdded: d.color.diffAdded,
|
||
diffRemoved: d.color.diffRemoved,
|
||
diffAddedWord: d.color.diffAddedWord,
|
||
diffRemovedWord: d.color.diffRemovedWord,
|
||
shellDollar: c('shell_dollar') ?? d.color.shellDollar
|
||
},
|
||
|
||
brand: {
|
||
name: branding.agent_name ?? d.brand.name,
|
||
icon: d.brand.icon,
|
||
prompt: branding.prompt_symbol ?? d.brand.prompt,
|
||
welcome: branding.welcome ?? d.brand.welcome,
|
||
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||
tool: toolPrefix || d.brand.tool,
|
||
helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
|
||
},
|
||
|
||
bannerLogo,
|
||
bannerHero
|
||
}
|
||
}
|