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