feat(tui): add scripted showroom demos

This commit is contained in:
Brooklyn Nicholson
2026-04-25 22:04:12 -05:00
parent 14dd8e9a72
commit 7d79dbc5ad
9 changed files with 758 additions and 1 deletions

View File

@@ -0,0 +1,46 @@
# TUI Showroom
Scripted, record-ready demos for `ui-tui`.
```bash
npm run showroom
npm run showroom:build
npm run showroom:type-check
```
`npm run showroom` serves the default workflow at `http://127.0.0.1:4317`.
```bash
npm run showroom -- --workflow .showroom/workflows/feature-tour.json --port 4318
npm run showroom:build -- .showroom/workflows/feature-tour.json .showroom/dist/feature-tour.html
```
## Workflow Shape
Workflows are JSON so the renderer has no extra deps.
```json
{
"title": "Hermes TUI Feature Tour",
"viewport": { "cols": 96, "rows": 30, "scale": 4 },
"timeline": [
{ "at": 0, "type": "status", "text": "summoning hermes..." },
{ "at": 250, "type": "message", "id": "prompt", "role": "user", "text": "Build a plan." },
{ "at": 900, "type": "caption", "target": "prompt", "text": "Named targets drive overlays." }
]
}
```
## Timeline Actions
- `status`: set top status text, with optional `detail`
- `compose`: type into the composer
- `message`: append a transcript line; supports `role`, `id`, `text`, `duration`
- `tool`: append a tool activity card; supports `id`, `title`, `items`
- `caption`: fade in a caption near `target`; supports `position`, `duration`
- `spotlight`: draw a spotlight around `target`; supports `pad`, `duration`
- `highlight`: temporarily emphasize `target`
- `fade`: set `target` opacity over `duration`
- `clear`: reset transcript and overlays
Targets are `id` values from `message`, `tool`, and captions. The stage is rendered at `viewport.scale`, so `scale: 4` creates a 4x capture surface without changing the source terminal proportions.

12
ui-tui/.showroom/build.ts Normal file
View File

@@ -0,0 +1,12 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { defaultWorkflowPath, readWorkflow, renderPage, showroomRoot } from './page.js'
const workflowPath = resolve(process.cwd(), process.argv[2] ?? defaultWorkflowPath)
const outPath = resolve(process.cwd(), process.argv[3] ?? join(showroomRoot, 'dist', 'index.html'))
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(outPath, renderPage(readWorkflow(workflowPath)))
console.log(outPath)

29
ui-tui/.showroom/page.ts Normal file
View File

@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
export const showroomRoot = dirname(fileURLToPath(import.meta.url))
export const defaultWorkflowPath = join(showroomRoot, 'workflows', 'feature-tour.json')
export const readWorkflow = (path = defaultWorkflowPath) => JSON.parse(readFileSync(path, 'utf8'))
export const renderPage = (workflow: unknown) => {
const css = readFileSync(join(showroomRoot, 'src', 'showroom.css'), 'utf8')
const js = readFileSync(join(showroomRoot, 'src', 'showroom.js'), 'utf8')
const data = JSON.stringify(workflow).replace(/</g, '\\u003c')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hermes TUI Showroom</title>
<style>${css}</style>
</head>
<body>
<main id="showroom"></main>
<script>window.__SHOWROOM_WORKFLOW__ = ${data}</script>
<script type="module">${js}</script>
</body>
</html>`
}

View File

@@ -0,0 +1,36 @@
import { createServer } from 'node:http'
import { resolve } from 'node:path'
import { defaultWorkflowPath, readWorkflow, renderPage } from './page.js'
const arg = (name: string) => {
const index = process.argv.indexOf(name)
return index === -1 ? undefined : process.argv[index + 1]
}
const port = Number(arg('--port') ?? process.env.PORT ?? 4317)
const workflowPath = resolve(process.cwd(), arg('--workflow') ?? process.argv[2] ?? defaultWorkflowPath)
const server = createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200).end('ok')
return
}
try {
const page = renderPage(readWorkflow(workflowPath))
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(page)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end(message)
}
})
server.listen(port, '127.0.0.1', () => {
console.log(`showroom: http://127.0.0.1:${port}`)
console.log(`workflow: ${workflowPath}`)
})

View File

@@ -0,0 +1,219 @@
:root {
color-scheme: dark;
background: #070707;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
overflow: auto;
background:
radial-gradient(circle at 18% 12%, rgba(214, 168, 79, 0.18), transparent 34rem),
radial-gradient(circle at 82% 18%, rgba(90, 130, 255, 0.14), transparent 30rem), #050505;
}
#showroom {
min-height: 100vh;
padding: 48px;
}
.showroom-shell {
display: grid;
gap: 18px;
width: max-content;
}
.showroom-title {
display: flex;
align-items: end;
justify-content: space-between;
color: #f5e8c7;
font-size: 20px;
letter-spacing: 0.04em;
}
.showroom-meta {
color: #8f856f;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.showroom-stage {
position: relative;
width: var(--stage-w);
height: var(--stage-h);
overflow: hidden;
border: 1px solid rgba(245, 232, 199, 0.18);
border-radius: 28px;
background: #080808;
box-shadow:
0 48px 160px rgba(0, 0, 0, 0.56),
0 0 0 1px rgba(255, 255, 255, 0.035) inset;
}
.showroom-terminal {
position: absolute;
inset: 0 auto auto 0;
display: grid;
grid-template-rows: auto 1fr auto;
width: var(--term-w);
height: var(--term-h);
transform: scale(var(--scale));
transform-origin: top left;
overflow: hidden;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 18%), #0a0a0a;
color: #d8d0bd;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
line-height: 18px;
}
.showroom-status,
.showroom-composer {
display: flex;
align-items: center;
min-height: 22px;
color: #8f856f;
white-space: nowrap;
}
.showroom-status {
justify-content: space-between;
border-bottom: 1px solid rgba(245, 232, 199, 0.1);
padding-bottom: 7px;
}
.showroom-composer {
border-top: 1px solid rgba(245, 232, 199, 0.1);
padding-top: 7px;
}
.showroom-body {
display: flex;
flex-direction: column;
gap: 9px;
overflow: hidden;
padding: 12px 0;
}
.showroom-line,
.showroom-tool {
transition:
opacity 420ms ease,
filter 420ms ease,
transform 420ms ease,
background 420ms ease;
}
.showroom-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 6px;
}
.showroom-glyph {
color: var(--role);
font-weight: 700;
}
.showroom-copy {
color: var(--role);
white-space: pre-wrap;
}
.showroom-line-assistant .showroom-copy {
color: #d8d0bd;
}
.showroom-tool {
margin-left: 32px;
border: 1px solid rgba(214, 168, 79, 0.18);
border-radius: 11px;
padding: 8px 10px;
background: rgba(214, 168, 79, 0.055);
color: #c7b891;
}
.showroom-tool-title {
color: #f1cb78;
font-weight: 700;
}
.showroom-tool-items {
display: grid;
gap: 2px;
margin-top: 5px;
color: #908872;
}
.is-highlighted {
filter: brightness(1.45);
background: rgba(214, 168, 79, 0.12);
transform: translateX(4px);
}
.showroom-overlays {
position: absolute;
inset: 0;
pointer-events: none;
}
.showroom-caption,
.showroom-spotlight {
position: absolute;
opacity: 0;
transition:
opacity 360ms ease,
transform 360ms ease;
}
.showroom-caption {
max-width: 420px;
border: 1px solid rgba(245, 232, 199, 0.2);
border-radius: 18px;
padding: 14px 16px;
background: rgba(12, 12, 12, 0.82);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42);
color: #f5e8c7;
font-size: 18px;
line-height: 1.35;
transform: translateY(8px);
}
.showroom-spotlight {
border: 2px solid rgba(241, 203, 120, 0.7);
border-radius: 16px;
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.34),
0 0 40px rgba(241, 203, 120, 0.22);
}
.showroom-caption.is-visible,
.showroom-spotlight.is-visible {
opacity: 1;
transform: translateY(0);
}
.showroom-controls {
display: flex;
gap: 10px;
}
.showroom-controls button {
border: 1px solid rgba(245, 232, 199, 0.18);
border-radius: 999px;
padding: 8px 14px;
background: rgba(245, 232, 199, 0.06);
color: #f5e8c7;
cursor: pointer;
}
.showroom-controls button:hover {
background: rgba(245, 232, 199, 0.12);
}

View File

@@ -0,0 +1,302 @@
const workflow = window.__SHOWROOM_WORKFLOW__
const root = document.getElementById('showroom')
const timers = []
let body
let composer
let overlays
let statusLeft
let statusRight
let viewportConfig
const role = {
assistant: { color: '#d8d0bd', glyph: '✦' },
system: { color: '#8f856f', glyph: '·' },
tool: { color: '#f1cb78', glyph: '┊' },
user: { color: '#f1cb78', glyph: '' }
}
const escapeHtml = value =>
String(value ?? '').replace(
/[&<>"']/g,
char =>
({
'&': '&amp;',
'"': '&quot;',
"'": '&#39;',
'<': '&lt;',
'>': '&gt;'
})[char]
)
const clearTimers = () => {
while (timers.length) {
clearTimeout(timers.pop())
}
}
const target = id => (id ? document.querySelector(`[data-target="${CSS.escape(id)}"]`) : null)
const setText = (node, text = '', duration = 0) => {
if (!duration) {
node.textContent = text
return
}
const chars = [...text]
const started = performance.now()
const frame = now => {
const n = Math.min(chars.length, Math.ceil(((now - started) / duration) * chars.length))
node.textContent = chars.slice(0, n).join('')
if (n < chars.length) {
requestAnimationFrame(frame)
}
}
requestAnimationFrame(frame)
}
const removeAfter = (node, duration = 1400) => {
timers.push(
setTimeout(() => {
node.classList.remove('is-visible')
timers.push(setTimeout(() => node.remove(), 420))
}, duration)
)
}
const rectFor = (id, pad = 10) => {
const el = target(id)
if (!el) {
return null
}
const stage = overlays.getBoundingClientRect()
const rect = el.getBoundingClientRect()
return {
height: rect.height + pad * 2,
left: rect.left - stage.left - pad,
top: rect.top - stage.top - pad,
width: rect.width + pad * 2
}
}
const placeNear = (node, id, position = 'right') => {
const rect = rectFor(id, 0)
if (!rect) {
node.style.left = `${viewportConfig.scale * 28}px`
node.style.top = `${viewportConfig.scale * 28}px`
return
}
const gap = 24
const left = position === 'left' ? rect.left - node.offsetWidth - gap : rect.left + rect.width + gap
const top = position === 'top' ? rect.top - node.offsetHeight - gap : rect.top
node.style.left = `${Math.max(18, left)}px`
node.style.top = `${Math.max(18, top)}px`
}
const message = action => {
const spec = role[action.role] ?? role.assistant
const line = document.createElement('div')
const glyph = document.createElement('span')
const copy = document.createElement('div')
line.className = `showroom-line showroom-line-${action.role ?? 'assistant'}`
line.dataset.target = action.id ?? ''
line.style.setProperty('--role', spec.color)
glyph.className = 'showroom-glyph'
glyph.textContent = spec.glyph
copy.className = 'showroom-copy'
line.append(glyph, copy)
body.append(line)
setText(copy, action.text, action.duration)
}
const tool = action => {
const box = document.createElement('div')
const title = document.createElement('div')
const items = document.createElement('div')
box.className = 'showroom-tool'
box.dataset.target = action.id ?? ''
title.className = 'showroom-tool-title'
title.textContent = action.title ?? 'tool activity'
items.className = 'showroom-tool-items'
for (const item of action.items ?? []) {
const row = document.createElement('div')
row.textContent = item
items.append(row)
}
box.append(title, items)
body.append(box)
}
const fade = action => {
const el = target(action.target)
if (!el) {
return
}
el.style.transition = `opacity ${action.duration ?? 420}ms ease`
el.style.opacity = String(action.to ?? 0)
}
const highlight = action => {
const el = target(action.target)
if (!el) {
return
}
el.classList.add('is-highlighted')
timers.push(setTimeout(() => el.classList.remove('is-highlighted'), action.duration ?? 1200))
}
const caption = action => {
const node = document.createElement('div')
node.className = 'showroom-caption'
node.dataset.target = action.id ?? ''
node.textContent = action.text ?? ''
overlays.append(node)
placeNear(node, action.target, action.position)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration)
}
const spotlight = action => {
const rect = rectFor(action.target, action.pad ?? 10)
if (!rect) {
return
}
const node = document.createElement('div')
node.className = 'showroom-spotlight'
node.style.left = `${rect.left}px`
node.style.top = `${rect.top}px`
node.style.width = `${rect.width}px`
node.style.height = `${rect.height}px`
overlays.append(node)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration)
}
const status = action => {
statusLeft.textContent = action.text ?? ''
statusRight.textContent = action.detail ?? ''
}
const compose = action => setText(composer, action.text ?? '', action.duration)
const clear = () => {
body.textContent = ''
overlays.textContent = ''
}
const run = action =>
({
caption,
clear,
compose,
fade,
highlight,
message,
spotlight,
status,
tool
})[action.type]?.(action)
const play = () => {
clearTimers()
clear()
statusLeft.textContent = ''
statusRight.textContent = ''
composer.textContent = workflow.composer ?? ''
for (const action of [...(workflow.timeline ?? [])].sort((a, b) => a.at - b.at)) {
timers.push(setTimeout(() => run(action), action.at))
}
}
const mount = () => {
const viewport = { cellWidth: 9, cols: 96, lineHeight: 18, rows: 30, scale: 4, ...workflow.viewport }
const shell = document.createElement('section')
viewportConfig = viewport
shell.className = 'showroom-shell'
shell.style.setProperty('--cell-w', `${viewport.cellWidth}px`)
shell.style.setProperty('--cols', `${viewport.cols}`)
shell.style.setProperty('--line-h', `${viewport.lineHeight}px`)
shell.style.setProperty('--rows', `${viewport.rows}`)
shell.style.setProperty('--scale', `${viewport.scale}`)
shell.style.setProperty('--stage-h', `${viewport.rows * viewport.lineHeight * viewport.scale}px`)
shell.style.setProperty('--stage-w', `${viewport.cols * viewport.cellWidth * viewport.scale}px`)
shell.style.setProperty('--term-h', `${viewport.rows * viewport.lineHeight}px`)
shell.style.setProperty('--term-w', `${viewport.cols * viewport.cellWidth}px`)
shell.innerHTML = `
<header class="showroom-title">
<span>${escapeHtml(workflow.title ?? 'Hermes TUI Showroom')}</span>
<span class="showroom-meta">${viewport.cols}x${viewport.rows} · ${viewport.scale}x</span>
</header>
<div class="showroom-stage">
<div class="showroom-terminal">
<div class="showroom-status" data-target="status">
<span></span>
<span></span>
</div>
<div class="showroom-body"></div>
<div class="showroom-composer" data-target="composer"></div>
</div>
<div class="showroom-overlays"></div>
</div>
<footer class="showroom-controls">
<button type="button" data-action="restart">Restart</button>
<button type="button" data-action="clear">Clear</button>
</footer>
`
root.replaceChildren(shell)
body = shell.querySelector('.showroom-body')
composer = shell.querySelector('.showroom-composer')
overlays = shell.querySelector('.showroom-overlays')
statusLeft = shell.querySelector('.showroom-status span:first-child')
statusRight = shell.querySelector('.showroom-status span:last-child')
shell.querySelector('[data-action="restart"]').addEventListener('click', play)
shell.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearTimers()
clear()
})
window.addEventListener('keydown', event => {
if (event.key.toLowerCase() === 'r') {
play()
}
})
play()
}
mount()

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"types": ["node"]
},
"include": ["*.ts"]
}

View File

@@ -0,0 +1,101 @@
{
"title": "Hermes TUI Feature Tour",
"composer": " ask hermes anything",
"viewport": {
"cellWidth": 9,
"cols": 96,
"lineHeight": 18,
"rows": 30,
"scale": 4
},
"timeline": [
{
"at": 0,
"detail": "showroom mode",
"text": "summoning hermes...",
"type": "status"
},
{
"at": 250,
"duration": 650,
"id": "prompt",
"role": "user",
"text": "Build a focused plan for a safer gateway approval flow.",
"type": "message"
},
{
"at": 1050,
"duration": 950,
"id": "assistant-plan",
"role": "assistant",
"text": "Ill trace the gateway guards first, then patch the smallest boundary that keeps approval commands live while an agent is blocked.",
"type": "message"
},
{
"at": 2180,
"id": "tool-trail",
"items": [
"rg \"approval.request\" gateway/ tui_gateway/",
"ReadFile gateway/run.py",
"ReadFile gateway/platforms/base.py"
],
"title": "tool trail",
"type": "tool"
},
{
"at": 2500,
"duration": 1500,
"target": "tool-trail",
"type": "spotlight"
},
{
"at": 2680,
"duration": 1600,
"position": "right",
"target": "tool-trail",
"text": "Tool activity is scripted as named targets, so captions and fades can follow the exact beat.",
"type": "caption"
},
{
"at": 4450,
"duration": 500,
"target": "tool-trail",
"to": 0.22,
"type": "fade"
},
{
"at": 5050,
"duration": 700,
"id": "assistant-result",
"role": "assistant",
"text": "Found the split guard. The fix is to bypass both queues only for approval/control commands and leave normal chat ordering untouched.",
"type": "message"
},
{
"at": 6050,
"duration": 1300,
"target": "assistant-result",
"type": "highlight"
},
{
"at": 6300,
"duration": 1700,
"position": "right",
"target": "assistant-result",
"text": "Highlights, captions, opacity fades, and spotlight boxes are all timeline actions.",
"type": "caption"
},
{
"at": 8300,
"duration": 700,
"text": " /approve",
"type": "compose"
},
{
"at": 9400,
"detail": "record-ready at 4x",
"text": "session complete",
"type": "status"
}
]
}

View File

@@ -13,7 +13,10 @@
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
"fix": "npm run lint:fix && npm run fmt",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"showroom": "tsx .showroom/server.ts",
"showroom:build": "tsx .showroom/build.ts",
"showroom:type-check": "tsc --noEmit -p .showroom/tsconfig.json"
},
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",