feat: add skin logo support

This commit is contained in:
Austin Pickett 2026-04-07 23:59:11 -04:00
parent af077b2c0d
commit ebd2d83ef2
6 changed files with 108 additions and 13 deletions

View file

@ -205,7 +205,13 @@ def resolve_skin() -> dict:
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
init_skin_from_config(_load_cfg()) init_skin_from_config(_load_cfg())
skin = get_active_skin() skin = get_active_skin()
return {"name": skin.name, "colors": skin.colors, "branding": skin.branding} return {
"name": skin.name,
"colors": skin.colors,
"branding": skin.branding,
"banner_logo": skin.banner_logo,
"banner_hero": skin.banner_hero,
}
except Exception: except Exception:
return {} return {}

View file

@ -73,6 +73,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1092,6 +1093,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1102,6 +1104,7 @@
"integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/scope-manager": "8.58.0",
@ -1131,6 +1134,7 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0", "@typescript-eslint/types": "8.58.0",
@ -1335,6 +1339,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1647,6 +1652,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -2313,6 +2319,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -4238,6 +4245,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4308,6 +4316,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5060,6 +5069,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5351,6 +5361,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -476,7 +476,7 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (ev.type) { switch (ev.type) {
case 'gateway.ready': case 'gateway.ready':
if (p?.skin) { if (p?.skin) {
setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? ''))
} }
rpc('commands.catalog', {}) rpc('commands.catalog', {})

View file

@ -2,6 +2,50 @@ import type { ThemeColors } from './theme.js'
type Line = [string, string] type Line = [string, string]
// ── Rich markup parser ──────────────────────────────────────────────
// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[].
const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
export function parseRichMarkup(markup: string): Line[] {
const lines: Line[] = []
for (const raw of markup.split('\n')) {
const trimmed = raw.trimEnd()
if (!trimmed) {
lines.push(['', ' '])
continue
}
let lastIndex = 0
let matched = false
let m: RegExpExecArray | null
RICH_RE.lastIndex = 0
while ((m = RICH_RE.exec(trimmed)) !== null) {
matched = true
const before = trimmed.slice(lastIndex, m.index)
if (before) {
lines.push(['', before])
}
lines.push([m[1]!, m[2]!])
lastIndex = m.index + m[0].length
}
if (!matched) {
lines.push(['', trimmed])
} else if (lastIndex < trimmed.length) {
lines.push(['', trimmed.slice(lastIndex)])
}
}
return lines
}
const LOGO_ART = [ const LOGO_ART = [
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
@ -39,6 +83,22 @@ function colorize(art: string[], gradient: readonly number[], c: ThemeColors): L
} }
export const LOGO_WIDTH = 98 export const LOGO_WIDTH = 98
export const CADUCEUS_WIDTH = 30
export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c) export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c) customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c)
export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
export function artWidth(lines: Line[]): number {
let max = 0
for (const [, text] of lines) {
if (text.length > max) {
max = text.length
}
}
return max
}

View file

@ -1,6 +1,6 @@
import { Box, Text, useStdout } from 'ink' import { Box, Text, useStdout } from 'ink'
import { caduceus, logo, LOGO_WIDTH } from '../banner.js' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { flat } from '../lib/text.js' import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import type { SessionInfo } from '../types.js' import type { SessionInfo } from '../types.js'
@ -19,16 +19,19 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
export function Banner({ t }: { t: Theme }) { export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80 const cols = useStdout().stdout?.columns ?? 80
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
{cols >= LOGO_WIDTH ? ( {cols >= logoW ? (
<ArtLines lines={logo(t.color)} /> <ArtLines lines={logoLines} />
) : ( ) : (
<Text bold color={t.color.gold}> <Text bold color={t.color.gold}>
{t.brand.icon} NOUS HERMES {t.brand.icon} NOUS HERMES
</Text> </Text>
)} )}
<Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text> <Text color={t.color.dim}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
</Box> </Box>
) )
@ -36,8 +39,10 @@ export function Banner({ t }: { t: Theme }) {
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) { export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
const cols = useStdout().stdout?.columns ?? 100 const cols = useStdout().stdout?.columns ?? 100
const wide = cols >= 90 const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = wide ? 34 : 0 const heroW = artWidth(heroLines) || CADUCEUS_WIDTH
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
const w = wide ? cols - leftW - 12 : cols - 10 const w = wide ? cols - leftW - 12 : cols - 10
const cwd = info.cwd || process.cwd() const cwd = info.cwd || process.cwd()
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
@ -88,7 +93,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}> <Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && ( {wide && (
<Box flexDirection="column" marginRight={2} width={leftW}> <Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={caduceus(t.color)} /> <ArtLines lines={heroLines} />
<Text /> <Text />
<Text color={t.color.amber}> <Text color={t.color.amber}>
{info.model.split('/').pop()} {info.model.split('/').pop()}

View file

@ -30,6 +30,8 @@ export interface ThemeBrand {
export interface Theme { export interface Theme {
color: ThemeColors color: ThemeColors
brand: ThemeBrand brand: ThemeBrand
bannerLogo: string
bannerHero: string
} }
export const DEFAULT_THEME: Theme = { export const DEFAULT_THEME: Theme = {
@ -60,10 +62,18 @@ export const DEFAULT_THEME: Theme = {
welcome: 'Type your message or /help for commands.', welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕', goodbye: 'Goodbye! ⚕',
tool: '┊' tool: '┊'
} },
bannerLogo: '',
bannerHero: ''
} }
export function fromSkin(colors: Record<string, string>, branding: Record<string, string>): Theme { export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
bannerLogo = '',
bannerHero = ''
): Theme {
const d = DEFAULT_THEME const d = DEFAULT_THEME
const c = (k: string) => colors[k] const c = (k: string) => colors[k]
@ -95,6 +105,9 @@ export function fromSkin(colors: Record<string, string>, branding: Record<string
welcome: branding.welcome ?? d.brand.welcome, welcome: branding.welcome ?? d.brand.welcome,
goodbye: branding.goodbye ?? d.brand.goodbye, goodbye: branding.goodbye ?? d.brand.goodbye,
tool: d.brand.tool tool: d.brand.tool
} },
bannerLogo,
bannerHero
} }
} }