feat: add prettier etc for ui-tui

This commit is contained in:
Brooklyn Nicholson 2026-04-02 19:34:30 -05:00
parent 2ea5345a7b
commit 2818dd8611
11 changed files with 5529 additions and 730 deletions

View file

@ -159,7 +159,7 @@ def _(req_id, params: dict) -> dict:
status_callback=lambda text: _emit("status.update", sid, {"text": text}), status_callback=lambda text: _emit("status.update", sid, {"text": text}),
clarify_callback=_make_clarify_cb(sid), clarify_callback=_make_clarify_cb(sid),
) )
_sessions[sid] = {"agent": agent, "session_key": session_key} _sessions[sid] = {"agent": agent, "session_key": session_key, "history": []}
except Exception as e: except Exception as e:
return _err(req_id, 5000, f"agent init failed: {e}") return _err(req_id, 5000, f"agent init failed: {e}")
@ -180,16 +180,21 @@ def _(req_id, params: dict) -> dict:
return _err(req_id, 4001, "session not found") return _err(req_id, 4001, "session not found")
agent = session["agent"] agent = session["agent"]
history = session["history"]
_emit("message.start", sid) _emit("message.start", sid)
def run(): def run():
try: try:
result = agent.run_conversation( result = agent.run_conversation(
text, text,
conversation_history=list(history),
stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}),
) )
if isinstance(result, dict): if isinstance(result, dict):
returned_msgs = result.get("messages")
if isinstance(returned_msgs, list):
session["history"] = returned_msgs
final = result.get("final_response", "") final = result.get("final_response", "")
status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
_emit("message.complete", sid, { _emit("message.complete", sid, {
@ -248,8 +253,7 @@ def _(req_id, params: dict) -> dict:
session = _sessions.get(params.get("session_id", "")) session = _sessions.get(params.get("session_id", ""))
if not session: if not session:
return _err(req_id, 4001, "session not found") return _err(req_id, 4001, "session not found")
history = getattr(session["agent"], "conversation_history", []) return _ok(req_id, {"count": len(session.get("history", []))})
return _ok(req_id, {"count": len(history)})
@method("session.undo") @method("session.undo")
@ -257,7 +261,7 @@ def _(req_id, params: dict) -> dict:
session = _sessions.get(params.get("session_id", "")) session = _sessions.get(params.get("session_id", ""))
if not session: if not session:
return _err(req_id, 4001, "session not found") return _err(req_id, 4001, "session not found")
history = getattr(session["agent"], "conversation_history", []) history = session.get("history", [])
removed = 0 removed = 0
while history and history[-1].get("role") in ("assistant", "tool"): while history and history[-1].get("role") in ("assistant", "tool"):
history.pop(); removed += 1 history.pop(); removed += 1

11
ui-tui/.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"endOfLine": "auto",
"printWidth": 120,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

68
ui-tui/eslint.config.mjs Normal file
View file

@ -0,0 +1,68 @@
import js from '@eslint/js'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
export default [
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: { ...globals.node },
parser: typescriptParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescriptEslint,
perfectionist,
react: reactPlugin,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
rules: {
curly: ['error', 'all'],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'@typescript-eslint/no-unused-vars': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'padding-line-between-statements': [
1,
{ blankLine: 'always', next: ['block-like', 'block', 'return', 'if', 'class', 'continue', 'debugger', 'break', 'multiline-const', 'multiline-let'], prev: '*' },
{ blankLine: 'always', next: '*', prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] },
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
{ blankLine: 'always', next: ['empty'], prev: 'export' },
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
],
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-imports': [
'error',
{
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
order: 'asc',
type: 'natural'
}
],
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'
},
settings: {
react: { version: 'detect' }
}
},
{
ignores: ['node_modules/', 'dist/', '*.config.*']
}
]

4161
ui-tui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,10 @@
"dev": "tsx --watch src/main.tsx", "dev": "tsx --watch src/main.tsx",
"start": "tsx src/main.tsx", "start": "tsx src/main.tsx",
"build": "tsc", "build": "tsc",
"test": "echo 'no tests yet'" "lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}'",
"fix": "npm run lint:fix && npm run fmt"
}, },
"dependencies": { "dependencies": {
"ink": "^6.8.0", "ink": "^6.8.0",
@ -15,8 +18,18 @@
"react": "^19.2.4" "react": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"eslint": "^9",
"eslint-plugin-perfectionist": "^5",
"eslint-plugin-react": "^7",
"eslint-plugin-react-hooks": "^7",
"eslint-plugin-unused-imports": "^4",
"globals": "^16",
"prettier": "^3",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }

View file

@ -1,5 +1,5 @@
import { useEffect, type PropsWithChildren } from 'react'
import { Box, useStdout } from 'ink' import { Box, useStdout } from 'ink'
import { type PropsWithChildren, useEffect } from 'react'
const ENTER = '\x1b[?1049h\x1b[2J\x1b[H' const ENTER = '\x1b[?1049h\x1b[2J\x1b[H'
const LEAVE = '\x1b[?1049l' const LEAVE = '\x1b[?1049l'
@ -22,7 +22,7 @@ export function AltScreen({ children }: PropsWithChildren) {
}, []) }, [])
return ( return (
<Box flexDirection="column" height={rows} width={cols} overflow="hidden"> <Box flexDirection="column" height={rows} overflow="hidden" width={cols}>
{children} {children}
</Box> </Box>
) )

View file

@ -8,7 +8,7 @@ const LOGO_ART = [
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ', '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ '
] ]
const CADUCEUS_ART = [ const CADUCEUS_ART = [
@ -26,7 +26,7 @@ const CADUCEUS_ART = [
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
'⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀'
] ]
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
@ -34,6 +34,7 @@ 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[] { function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
const palette = [c.gold, c.amber, c.bronze, c.dim] const palette = [c.gold, c.amber, c.bronze, c.dim]
return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text]) return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
} }

View file

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

View file

@ -1,46 +0,0 @@
"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 />);

File diff suppressed because it is too large Load diff

View file

@ -32,7 +32,6 @@ export interface Theme {
brand: ThemeBrand brand: ThemeBrand
} }
export const DEFAULT_THEME: Theme = { export const DEFAULT_THEME: Theme = {
color: { color: {
gold: '#FFD700', gold: '#FFD700',
@ -51,7 +50,7 @@ export const DEFAULT_THEME: Theme = {
statusGood: '#8FBC8F', statusGood: '#8FBC8F',
statusWarn: '#FFD700', statusWarn: '#FFD700',
statusBad: '#FF8C00', statusBad: '#FF8C00',
statusCritical: '#FF6B6B', statusCritical: '#FF6B6B'
}, },
brand: { brand: {
@ -60,15 +59,11 @@ export const DEFAULT_THEME: Theme = {
prompt: '', prompt: '',
welcome: 'Type your message or /help for commands.', welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕', goodbye: 'Goodbye! ⚕',
tool: '┊', tool: '┊'
}, }
} }
export function fromSkin(colors: Record<string, string>, branding: Record<string, string>): Theme {
export function fromSkin(
colors: Record<string, string>,
branding: Record<string, string>,
): Theme {
const d = DEFAULT_THEME const d = DEFAULT_THEME
const c = (k: string) => colors[k] const c = (k: string) => colors[k]
@ -90,7 +85,7 @@ export function fromSkin(
statusGood: c('ui_ok') ?? d.color.statusGood, statusGood: c('ui_ok') ?? d.color.statusGood,
statusWarn: c('ui_warn') ?? d.color.statusWarn, statusWarn: c('ui_warn') ?? d.color.statusWarn,
statusBad: d.color.statusBad, statusBad: d.color.statusBad,
statusCritical: d.color.statusCritical, statusCritical: d.color.statusCritical
}, },
brand: { brand: {
@ -99,7 +94,7 @@ export function fromSkin(
prompt: branding.prompt_symbol ?? d.brand.prompt, prompt: branding.prompt_symbol ?? d.brand.prompt,
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
}, }
} }
} }