hermes-agent/ui-tui/scripts/visual/render.tsx
Brooklyn Nicholson 9fb2a63db8 fix(ui-tui): make npm run visual portable off POSIX — zero new deps
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.
2026-07-21 20:29:48 -05:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 &quot;fix the lint errors&quot;</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)