diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 195192a5c..67f9f0c47 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -205,7 +205,13 @@ def resolve_skin() -> dict:
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
init_skin_from_config(_load_cfg())
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:
return {}
diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json
index e378fa2c6..85d196129 100644
--- a/ui-tui/package-lock.json
+++ b/ui-tui/package-lock.json
@@ -73,6 +73,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1092,6 +1093,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1102,6 +1104,7 @@
"integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.0",
@@ -1131,6 +1134,7 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
@@ -1335,6 +1339,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1647,6 +1652,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2313,6 +2319,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4238,6 +4245,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -4308,6 +4316,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5060,6 +5069,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5351,6 +5361,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx
index 858aadec2..574f69f7b 100644
--- a/ui-tui/src/app.tsx
+++ b/ui-tui/src/app.tsx
@@ -476,7 +476,7 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (ev.type) {
case 'gateway.ready':
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', {})
diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts
index afc8a94dd..6324cafe1 100644
--- a/ui-tui/src/banner.ts
+++ b/ui-tui/src/banner.ts
@@ -2,6 +2,50 @@ import type { ThemeColors } from './theme.js'
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 = [
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
@@ -39,6 +83,22 @@ function colorize(art: string[], gradient: readonly number[], c: ThemeColors): L
}
export const LOGO_WIDTH = 98
+export const CADUCEUS_WIDTH = 30
-export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c)
-export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
+export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
+ 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
+}
diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx
index e18b5523b..cd8f0b9d5 100644
--- a/ui-tui/src/components/branding.tsx
+++ b/ui-tui/src/components/branding.tsx
@@ -1,6 +1,6 @@
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 type { Theme } from '../theme.js'
import type { SessionInfo } from '../types.js'
@@ -19,16 +19,19 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80
+ const logoLines = logo(t.color, t.bannerLogo || undefined)
+ const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
return (
- {cols >= LOGO_WIDTH ? (
-
+ {cols >= logoW ? (
+
) : (
{t.brand.icon} NOUS HERMES
)}
+
{t.brand.icon} Nous Research · Messenger of the Digital Gods
)
@@ -36,8 +39,10 @@ export function Banner({ t }: { t: Theme }) {
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
const cols = useStdout().stdout?.columns ?? 100
- const wide = cols >= 90
- const leftW = wide ? 34 : 0
+ const heroLines = caduceus(t.color, t.bannerHero || undefined)
+ 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 cwd = info.cwd || process.cwd()
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
{wide && (
-
+
{info.model.split('/').pop()}
diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts
index caa194f38..29db1480f 100644
--- a/ui-tui/src/theme.ts
+++ b/ui-tui/src/theme.ts
@@ -30,6 +30,8 @@ export interface ThemeBrand {
export interface Theme {
color: ThemeColors
brand: ThemeBrand
+ bannerLogo: string
+ bannerHero: string
}
export const DEFAULT_THEME: Theme = {
@@ -60,10 +62,18 @@ export const DEFAULT_THEME: Theme = {
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊'
- }
+ },
+
+ bannerLogo: '',
+ bannerHero: ''
}
-export function fromSkin(colors: Record, branding: Record): Theme {
+export function fromSkin(
+ colors: Record,
+ branding: Record,
+ bannerLogo = '',
+ bannerHero = ''
+): Theme {
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
@@ -95,6 +105,9 @@ export function fromSkin(colors: Record, branding: Record