feat: new tui based on ink

This commit is contained in:
Brooklyn Nicholson 2026-04-02 19:06:42 -05:00
parent 624ad582a5
commit 2ea5345a7b
13 changed files with 4177 additions and 0 deletions

29
ui-tui/src/altScreen.tsx Normal file
View file

@ -0,0 +1,29 @@
import { useEffect, type PropsWithChildren } from 'react'
import { Box, useStdout } from 'ink'
const ENTER = '\x1b[?1049h\x1b[2J\x1b[H'
const LEAVE = '\x1b[?1049l'
export function AltScreen({ children }: PropsWithChildren) {
const { stdout } = useStdout()
const rows = stdout?.rows ?? 24
const cols = stdout?.columns ?? 80
useEffect(() => {
process.stdout.write(ENTER)
const leave = () => process.stdout.write(LEAVE)
process.on('exit', leave)
return () => {
leave()
process.off('exit', leave)
}
}, [])
return (
<Box flexDirection="column" height={rows} width={cols} overflow="hidden">
{children}
</Box>
)
}

43
ui-tui/src/banner.ts Normal file
View file

@ -0,0 +1,43 @@
import type { ThemeColors } from './theme.js'
type Line = [string, string]
const LOGO_ART = [
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ',
]
const CADUCEUS_ART = [
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀',
'⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀',
'⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
]
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
const palette = [c.gold, c.amber, c.bronze, c.dim]
return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
}
export const LOGO_WIDTH = 98
export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c)
export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c)

View file

@ -0,0 +1,72 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { createInterface } from 'node:readline'
import { EventEmitter } from 'node:events'
import { resolve } from 'node:path'
export interface GatewayEvent {
type: string
session_id?: string
payload?: Record<string, unknown>
}
interface Pending {
resolve: (v: unknown) => void
reject: (e: Error) => void
}
export class GatewayClient extends EventEmitter {
private proc: ChildProcess | null = null
private reqId = 0
private pending = new Map<string, Pending>()
start() {
const root = resolve(import.meta.dirname, '../../')
this.proc = spawn(
process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'),
['-m', 'tui_gateway.entry'],
{ cwd: root, stdio: ['pipe', 'pipe', 'inherit'] },
)
createInterface({ input: this.proc.stdout! }).on('line', (raw) => {
try { this.dispatch(JSON.parse(raw)) } catch {}
})
this.proc.on('exit', (code) => this.emit('exit', code))
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined
const p = id ? this.pending.get(id) : undefined
if (p) {
this.pending.delete(id!)
msg.error
? p.reject(new Error((msg.error as any).message))
: p.resolve(msg.result)
return
}
if (msg.method === 'event')
this.emit('event', msg.params as GatewayEvent)
}
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
const id = `r${++this.reqId}`
this.proc!.stdin!.write(
JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
)
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
setTimeout(() => {
if (this.pending.delete(id))
reject(new Error(`timeout: ${method}`))
}, 30_000)
})
}
kill() { this.proc?.kill() }
}

46
ui-tui/src/main.js Normal file
View file

@ -0,0 +1,46 @@
"use strict";
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
var react_1 = require("react");
var ink_1 = require("ink");
var ink_text_input_1 = require("ink-text-input");
function App() {
var _a = (0, react_1.useState)(''), input = _a[0], setInput = _a[1];
var _b = (0, react_1.useState)([]), messages = _b[0], setMessages = _b[1];
var handleSubmit = function (value) {
if (!value.trim())
return;
setMessages(function (prev) { return __spreadArray(__spreadArray([], prev, true), ["> ".concat(value), "[echo] ".concat(value)], false); });
setInput('');
};
return (<ink_1.Box flexDirection="column" padding={1}>
<ink_1.Box marginBottom={1}>
<ink_1.Text bold color="yellow">hermes</ink_1.Text>
<ink_1.Text dimColor> (ink proof-of-concept)</ink_1.Text>
</ink_1.Box>
<ink_1.Box flexDirection="column" marginBottom={1}>
{messages.map(function (msg, i) { return (<ink_1.Text key={i}>{msg}</ink_1.Text>); })}
</ink_1.Box>
<ink_1.Box>
<ink_1.Text bold color="cyan">{'> '}</ink_1.Text>
<ink_text_input_1.default value={input} onChange={setInput} onSubmit={handleSubmit}/>
</ink_1.Box>
</ink_1.Box>);
}
var isTTY = (_a = process.stdin.isTTY) !== null && _a !== void 0 ? _a : false;
if (!isTTY) {
console.log('hermes-tui: ink loaded, no TTY attached (run in a real terminal)');
process.exit(0);
}
(0, ink_1.render)(<App />);

1073
ui-tui/src/main.tsx Normal file

File diff suppressed because it is too large Load diff

105
ui-tui/src/theme.ts Normal file
View file

@ -0,0 +1,105 @@
export interface ThemeColors {
gold: string
amber: string
bronze: string
cornsilk: string
dim: string
label: string
ok: string
error: string
warn: string
statusBg: string
statusFg: string
statusGood: string
statusWarn: string
statusBad: string
statusCritical: string
}
export interface ThemeBrand {
name: string
icon: string
prompt: string
welcome: string
goodbye: string
tool: string
}
export interface Theme {
color: ThemeColors
brand: ThemeBrand
}
export const DEFAULT_THEME: Theme = {
color: {
gold: '#FFD700',
amber: '#FFBF00',
bronze: '#CD7F32',
cornsilk: '#FFF8DC',
dim: '#B8860B',
label: '#4dd0e1',
ok: '#4caf50',
error: '#ef5350',
warn: '#ffa726',
statusBg: '#1a1a2e',
statusFg: '#C0C0C0',
statusGood: '#8FBC8F',
statusWarn: '#FFD700',
statusBad: '#FF8C00',
statusCritical: '#FF6B6B',
},
brand: {
name: 'Hermes Agent',
icon: '⚕',
prompt: '',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊',
},
}
export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
): Theme {
const d = DEFAULT_THEME
const c = (k: string) => colors[k]
return {
color: {
gold: c('banner_title') ?? d.color.gold,
amber: c('banner_accent') ?? d.color.amber,
bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk,
dim: c('banner_dim') ?? d.color.dim,
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,
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,
},
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: d.brand.tool,
},
}
}