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",