mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Review on #20379, finding 4 (Medium). Three portability defects in the visual verification harness: - `FORCE_COLOR=3 COLORTERM=truecolor tsx ...` POSIX env assignment does not work under the Windows npm command shell. The script is now a plain Node launcher (scripts/visual/run.mjs) that sets the env itself and spawns tsx via require.resolve('tsx/cli') — no cross-env, no shell syntax. - Hardcoded /tmp/tui-visual.{html,png} resolve to a drive-root path like C:\tmp on native Windows (and fail when that directory doesn't exist). Both scripts now derive the output directory from a shared paths.mjs helper: os.tmpdir()/hermes-tui-visual (created recursively; HERMES_TUI_VISUAL_DIR overrides for CI or side-by-side runs). - electron was undeclared by ui-tui and only worked via hoisting luck. The launcher now resolves it EXPLICITLY from the install tree the desktop workspace already provides (require('electron') in plain Node returns the binary path), with an ELECTRON_BIN override and a clear error pointing at the repo-root install when it's absent — instead of declaring a second ~100MB dependency on a TUI workspace for a dev-only harness. Also fixes the trailing-whitespace line in render.tsx that `git diff --check` flags. Verified end-to-end: render writes the HTML scene sheet and the electron shot step produces the screenshot from the tmpdir path; the missing-electron error path prints the guidance message.
319 lines
9.6 KiB
TypeScript
319 lines
9.6 KiB
TypeScript
/* Visual self-verification tool: `npm run visual` renders real TUI surfaces
|
|
* across theme x background scenes to <tmpdir>/hermes-tui-visual/tui-visual.html,
|
|
* then shot.mjs screenshots it to tui-visual.png for eyeball + agent review.
|
|
*
|
|
* Original note: : render real TUI surfaces with ANSI colors intact,
|
|
* convert to HTML on the actual background, and screenshot in a browser. */
|
|
process.env.FORCE_COLOR = '3'
|
|
process.env.COLORTERM = 'truecolor'
|
|
|
|
import '../../src/lib/forceTruecolor.js'
|
|
|
|
import { mkdirSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { PassThrough } from 'stream'
|
|
|
|
import { visualOutDir } from './paths.mjs'
|
|
|
|
import { Box, renderSync, Text } from '@hermes/ink'
|
|
import React, { type ReactElement } from 'react'
|
|
|
|
import { GatewayProvider } from '../../src/app/gatewayContext.js'
|
|
import { patchOverlayState, resetOverlayState } from '../../src/app/overlayStore.js'
|
|
import { patchUiState, resetUiState } from '../../src/app/uiStore.js'
|
|
import { FloatingOverlays } from '../../src/components/appOverlays.js'
|
|
import { Banner, SessionPanel } from '../../src/components/branding.js'
|
|
import { fromSkin, type Theme } from '../../src/theme.js'
|
|
import type { SessionInfo } from '../../src/types.js'
|
|
|
|
const noop = () => {}
|
|
const pending = () => new Promise<never>(() => {})
|
|
|
|
const fakeGateway = { gw: { notify: noop, off: noop, on: noop, request: pending }, rpc: pending } as any
|
|
|
|
const SLATE = {
|
|
banner_border: '#4169e1',
|
|
banner_title: '#7eb8f6',
|
|
banner_accent: '#8EA8FF',
|
|
banner_dim: '#4b5563',
|
|
banner_text: '#c9d1d9',
|
|
ui_accent: '#7eb8f6',
|
|
ui_label: '#8EA8FF',
|
|
ui_ok: '#63D0A6',
|
|
ui_error: '#F7A072',
|
|
ui_warn: '#e6a855',
|
|
prompt: '#c9d1d9',
|
|
session_label: '#7eb8f6',
|
|
session_border: '#545E6B',
|
|
status_bar_bg: '#151C2F',
|
|
status_bar_text: '#C9D1D9'
|
|
}
|
|
|
|
// The regenerated slate light_colors block from hermes_cli/skin_engine.py
|
|
// (relight recipe: vivid hue-preserved accents, airy capped-saturation text,
|
|
// darker calm dims).
|
|
|
|
const info: SessionInfo = {
|
|
cwd: '/Users/brooklyn/www/hermes-agent',
|
|
mcp_servers: [{ connected: true, name: 'figma', tools: 12, transport: 'sse' }],
|
|
model: 'claude-opus-4.8-fast',
|
|
skills: {
|
|
devops: ['docker', 'kubernetes', 'terraform'],
|
|
github: ['pr-review', 'issue-triage'],
|
|
productivity: ['powerpoint', 'excel', 'notion-sync']
|
|
},
|
|
tools: {
|
|
browser: ['browser_back', 'browser_click', 'browser_console', 'browser_get_images'],
|
|
clarify: ['clarify'],
|
|
code_execution: ['execute_code'],
|
|
cronjob: ['cronjob'],
|
|
delegation: ['delegate_task'],
|
|
file: ['patch', 'read_file', 'search_files', 'write_file']
|
|
},
|
|
update_behind: 1,
|
|
version: '3.2.1'
|
|
}
|
|
|
|
const completions = [
|
|
{ display: '/new', meta: 'Start a new session (fresh session ID + history)', text: '/new' },
|
|
{ display: '/reset', meta: 'Start a new session (alias for /new)', text: '/reset' },
|
|
{ display: '/clear', meta: 'Clear screen and start a new session', text: '/clear' },
|
|
{ display: '/redraw', meta: 'Force a full UI repaint', text: '/redraw' },
|
|
{ display: '/history', meta: 'Show conversation history', text: '/history' }
|
|
]
|
|
|
|
function renderAnsi(node: ReactElement, columns: number): string {
|
|
const stdout = new PassThrough()
|
|
const stdin = new PassThrough()
|
|
const stderr = new PassThrough()
|
|
|
|
let output = ''
|
|
|
|
;(process.stdout as unknown as { columns: number }).columns = columns
|
|
Object.assign(stdout, { columns, isTTY: false, rows: 60 })
|
|
Object.assign(stdin, {
|
|
isTTY: true,
|
|
pause: noop,
|
|
ref: noop,
|
|
resume: noop,
|
|
setEncoding: noop,
|
|
setRawMode: noop,
|
|
unref: noop
|
|
})
|
|
Object.assign(stderr, { isTTY: false })
|
|
stdout.on('data', chunk => {
|
|
output += chunk.toString()
|
|
})
|
|
|
|
const instance = renderSync(
|
|
<GatewayProvider value={fakeGateway}>
|
|
<Box flexDirection="column" width={columns}>{node}</Box>
|
|
</GatewayProvider>,
|
|
{
|
|
exitOnCtrlC: false,
|
|
patchConsole: false,
|
|
stderr: stderr as unknown as NodeJS.WriteStream,
|
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
|
stdout: stdout as unknown as NodeJS.WriteStream
|
|
}
|
|
)
|
|
|
|
instance.unmount()
|
|
instance.cleanup()
|
|
|
|
return output
|
|
}
|
|
|
|
// ── ANSI → HTML (handles ink's SGR set: 38;2/48;2 truecolor, named resets, bold/dim/italic/inverse) ──
|
|
|
|
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
function ansiToHtml(raw: string, defaultFg: string, defaultBg: string): string {
|
|
let fg = defaultFg
|
|
let bg = defaultBg
|
|
let bold = false
|
|
let dim = false
|
|
let italic = false
|
|
let inverse = false
|
|
let html = ''
|
|
|
|
const openSpan = () => {
|
|
const f = inverse ? bg : fg
|
|
const b = inverse ? fg : bg
|
|
const styles = [`color:${f}`]
|
|
|
|
if (b !== defaultBg || inverse) {
|
|
styles.push(`background-color:${b}`)
|
|
}
|
|
|
|
if (bold) {
|
|
styles.push('font-weight:bold')
|
|
}
|
|
|
|
if (dim) {
|
|
styles.push('opacity:0.55')
|
|
}
|
|
|
|
if (italic) {
|
|
styles.push('font-style:italic')
|
|
}
|
|
|
|
return `<span style="${styles.join(';')}">`
|
|
}
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
const parts = raw.split(/(\x1b\[[0-9;]*m)/)
|
|
|
|
html += openSpan()
|
|
|
|
for (const part of parts) {
|
|
// eslint-disable-next-line no-control-regex
|
|
const m = /^\x1b\[([0-9;]*)m$/.exec(part)
|
|
|
|
if (!m) {
|
|
// Drop non-SGR escapes (cursor moves etc.) — renderSync output for a
|
|
// static frame is line-oriented, so this is safe for inspection.
|
|
// eslint-disable-next-line no-control-regex
|
|
html += escapeHtml(part.replace(/\x1b\[[^m]*[A-Za-z]/g, ''))
|
|
|
|
continue
|
|
}
|
|
|
|
const codes = (m[1] || '0').split(';').map(Number)
|
|
|
|
for (let i = 0; i < codes.length; i++) {
|
|
const c = codes[i]!
|
|
|
|
if (c === 0) {
|
|
fg = defaultFg
|
|
bg = defaultBg
|
|
bold = dim = italic = inverse = false
|
|
} else if (c === 1) {bold = true}
|
|
else if (c === 2) {dim = true}
|
|
else if (c === 3) {italic = true}
|
|
else if (c === 7) {inverse = true}
|
|
else if (c === 22) { bold = false; dim = false }
|
|
else if (c === 23) {italic = false}
|
|
else if (c === 27) {inverse = false}
|
|
else if (c === 39) {fg = defaultFg}
|
|
else if (c === 49) {bg = defaultBg}
|
|
else if (c === 38 && codes[i + 1] === 2) {
|
|
fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
|
|
i += 4
|
|
} else if (c === 48 && codes[i + 1] === 2) {
|
|
bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
|
|
i += 4
|
|
} else if (c === 38 && codes[i + 1] === 5) {
|
|
fg = `var(--a${codes[i + 2]}, #888)`
|
|
i += 2
|
|
} else if (c === 48 && codes[i + 1] === 5) {
|
|
bg = `var(--a${codes[i + 2]}, #888)`
|
|
i += 2
|
|
}
|
|
}
|
|
|
|
html += `</span>${openSpan()}`
|
|
}
|
|
|
|
return html + '</span>'
|
|
}
|
|
|
|
// ── Scenes ──
|
|
|
|
interface Scene {
|
|
bg: string
|
|
fg: string
|
|
name: string
|
|
theme: Theme
|
|
}
|
|
|
|
const setup = (bgHex: string) => {
|
|
process.env.HERMES_TUI_BACKGROUND = bgHex
|
|
resetOverlayState()
|
|
resetUiState()
|
|
}
|
|
|
|
const scenes: Scene[] = []
|
|
|
|
const addScene = (name: string, bgHex: string, skin: Record<string, string>) => {
|
|
setup(bgHex)
|
|
|
|
const theme = fromSkin(skin, {})
|
|
|
|
scenes.push({ bg: bgHex, fg: theme.color.text, name, theme })
|
|
}
|
|
|
|
addScene('default · dark terminal', '#101014', {})
|
|
addScene('default · light terminal (Cursor)', '#ffffff', {})
|
|
addScene('slate · dark terminal', '#101014', SLATE)
|
|
addScene('slate · light terminal (raw palette + display shim)', '#ffffff', SLATE)
|
|
|
|
let page = `<!doctype html><meta charset="utf-8"><body style="margin:0;background:#666;font:13px/1.35 Menlo,monospace"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px">`
|
|
|
|
for (const scene of scenes) {
|
|
setup(scene.bg)
|
|
patchUiState({ sid: 'd2a6ecf8', theme: scene.theme })
|
|
|
|
const intro = renderAnsi(
|
|
<Box flexDirection="column">
|
|
<Banner maxWidth={86} t={scene.theme} />
|
|
<SessionPanel info={info} maxWidth={86} sid="d2a6ecf8" t={scene.theme} />
|
|
</Box>,
|
|
88
|
|
)
|
|
|
|
patchOverlayState({})
|
|
|
|
const comps = renderAnsi(
|
|
<Box flexDirection="column" height={10} position="relative" width={88}>
|
|
<Box flexGrow={1} />
|
|
<FloatingOverlays
|
|
cols={88}
|
|
compIdx={0}
|
|
completions={completions}
|
|
onActiveSessionClose={pending}
|
|
onActiveSessionSelect={noop}
|
|
onModelSelect={noop}
|
|
onNewLiveSession={noop}
|
|
onNewPromptSession={noop}
|
|
onResumeSelect={noop}
|
|
pagerPageSize={8}
|
|
/>
|
|
</Box>,
|
|
88
|
|
)
|
|
|
|
const statusLine = renderAnsi(
|
|
<Box flexDirection="column">
|
|
<Text>
|
|
<Text color={scene.theme.color.statusGood}>— ready </Text>
|
|
<Text color={scene.theme.color.muted}>| opus 4.8 fast | 4s | voice off</Text>
|
|
</Text>
|
|
<Text>
|
|
<Text color={scene.theme.color.muted}>{scene.theme.brand.prompt} </Text>
|
|
<Text backgroundColor={scene.theme.color.muted} color={scene.bg}>T</Text>
|
|
<Text color={scene.theme.color.muted}>ry "fix the lint errors"</Text>
|
|
</Text>
|
|
</Box>,
|
|
88
|
|
)
|
|
|
|
page += `<div style="background:${scene.bg};color:${scene.fg};padding:14px;border-radius:6px">`
|
|
page += `<div style="font:bold 12px sans-serif;opacity:.6;margin-bottom:8px;color:${scene.fg}">${scene.name}</div>`
|
|
page += `<pre style="margin:0;white-space:pre">${ansiToHtml(intro, scene.fg, scene.bg)}</pre>`
|
|
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(comps, scene.fg, scene.bg)}</pre>`
|
|
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(statusLine, scene.fg, scene.bg)}</pre>`
|
|
page += `</div>`
|
|
}
|
|
|
|
page += '</div></body>'
|
|
|
|
const outDir = visualOutDir()
|
|
|
|
mkdirSync(outDir, { recursive: true })
|
|
|
|
const outFile = join(outDir, 'tui-visual.html')
|
|
|
|
writeFileSync(outFile, page)
|
|
console.log(`wrote ${outFile}`)
|
|
process.exit(0)
|