diff --git a/ui-tui/.showroom/README.md b/ui-tui/.showroom/README.md
new file mode 100644
index 00000000000..b45d3a8a2c1
--- /dev/null
+++ b/ui-tui/.showroom/README.md
@@ -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.
diff --git a/ui-tui/.showroom/build.ts b/ui-tui/.showroom/build.ts
new file mode 100644
index 00000000000..b815c3959e3
--- /dev/null
+++ b/ui-tui/.showroom/build.ts
@@ -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)
diff --git a/ui-tui/.showroom/page.ts b/ui-tui/.showroom/page.ts
new file mode 100644
index 00000000000..4a0395d4a62
--- /dev/null
+++ b/ui-tui/.showroom/page.ts
@@ -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(/
+
+
+
+
+ Hermes TUI Showroom
+
+
+
+
+
+
+
+`
+}
diff --git a/ui-tui/.showroom/server.ts b/ui-tui/.showroom/server.ts
new file mode 100644
index 00000000000..b1db1766842
--- /dev/null
+++ b/ui-tui/.showroom/server.ts
@@ -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}`)
+})
diff --git a/ui-tui/.showroom/src/showroom.css b/ui-tui/.showroom/src/showroom.css
new file mode 100644
index 00000000000..65a2351fff2
--- /dev/null
+++ b/ui-tui/.showroom/src/showroom.css
@@ -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);
+}
diff --git a/ui-tui/.showroom/src/showroom.js b/ui-tui/.showroom/src/showroom.js
new file mode 100644
index 00000000000..588722d7c83
--- /dev/null
+++ b/ui-tui/.showroom/src/showroom.js
@@ -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 =>
+ ({
+ '&': '&',
+ '"': '"',
+ "'": ''',
+ '<': '<',
+ '>': '>'
+ })[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 = `
+
+ ${escapeHtml(workflow.title ?? 'Hermes TUI Showroom')}
+ ${viewport.cols}x${viewport.rows} · ${viewport.scale}x
+
+
+
+ `
+
+ 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()
diff --git a/ui-tui/.showroom/tsconfig.json b/ui-tui/.showroom/tsconfig.json
new file mode 100644
index 00000000000..eaa84f810fc
--- /dev/null
+++ b/ui-tui/.showroom/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": ".",
+ "types": ["node"]
+ },
+ "include": ["*.ts"]
+}
diff --git a/ui-tui/.showroom/workflows/feature-tour.json b/ui-tui/.showroom/workflows/feature-tour.json
new file mode 100644
index 00000000000..1d5cf31d37a
--- /dev/null
+++ b/ui-tui/.showroom/workflows/feature-tour.json
@@ -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": "I’ll 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"
+ }
+ ]
+}
diff --git a/ui-tui/package.json b/ui-tui/package.json
index 4776f0830db..39630082546 100644
--- a/ui-tui/package.json
+++ b/ui-tui/package.json
@@ -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",