Compare commits

...

9 Commits

Author SHA1 Message Date
Brooklyn Nicholson
036dd14425 feat(tui): add model picker and approval/clarify prompt workflows
Three new static prompt components for showroom capture (no useInput):

- ApprovalPromptStatic: double-bordered warning box with command preview,
  4 options (allow once/session/always/deny), ▸ selection, footer hints
- ClarifyPromptStatic: heading + numbered choices with 'Other' option
- ModelPickerStatic: double-bordered popup with provider/model lists,
  current model header, persist toggle, quick-pick numbers

New workflows:
- interactive-prompts: approval → clarify → deploy result
- model-picker: provider stage → model stage → switch result
2026-04-26 01:17:21 -05:00
Brooklyn Nicholson
1e499a7136 feat(tui): add interactive prompts workflow to showroom
Approval prompt and clarify prompt rendered as static mocks (no useInput)
so they work with the snap() capture pipeline. Shows:
- user asking for npm install
- approval prompt with double-bordered warning box, 4 options
- user asking to deploy
- clarify prompt with region selection
- deployment result panel

Static components match real prompt visuals: same borders, colors,
selection indicators, and footer hints.
2026-04-26 01:15:42 -05:00
Brooklyn Nicholson
e58308c680 feat(tui): restore xterm.js via importmap, add frame fade animations
- xterm.js loads via importmap (CDN, cached by browser) instead of
  dynamic import — no async fetch latency after page load
- CSS link tag in head for parallel xterm.css download
- Terminal container fades in on init (300ms ease-out via .is-visible)
- Frame elements targetable by id for fade/highlight/spotlight overlays
- Proper viewport cellWidth (9px) matching real xterm rendering
- Clean CSS: removed dead .showroom-frame styles, added transitions
  for highlights, smooth overlay animations
2026-04-26 01:11:27 -05:00
Brooklyn Nicholson
6147a867cd perf(tui): drop xterm.js from showroom, use lightweight ANSI parser
The ANSI frames from Ink renders only use cursor-forward (ESC[NC) and
control sequences (cursor hide/show, bracketed paste). No color SGR.
Loading a full terminal emulator from CDN was pure overhead — ~200ms
network + heavy DOM reconciliation for a few <150 byte strings.

Replaced with a 30-line ANSI-to-HTML parser. Zero network deps,
instant render.
2026-04-26 01:02:32 -05:00
Brooklyn Nicholson
7603126c86 chore(tui): run formatter on showroom files 2026-04-26 00:44:36 -05:00
Brooklyn Nicholson
3eadf10047 chore(tui): strip showroom chrome and beef up slash demo
- drop title bar, "real ink" tag, meta line — terminal box is the surface
- single bottom controls row: ↻ · scale · speed · picker · progress
- slash workflow now types each command, echoes a slash msg, then renders the panel
- adds a /help scene (real Panel grouped by category)
- README minus the "real ink" marketing
2026-04-25 23:39:06 -05:00
Brooklyn Nicholson
72ca0809c4 feat(tui): showroom now renders real ui-tui frames via xterm.js
- record.tsx imports MessageLine, Panel, Box, Text and snapshots Ink output as ANSI
- frame action writes captured ANSI into xterm.js (jsDelivr CDN)
- captions, spotlights, fades, highlights still layer over frames by id
- dropped CSS-mock workflows; all 4 sample workflows now use real Ink output
- compact 80x16 viewport, 1x–4x scale picker, blink cursor, intro fade
2026-04-25 23:33:40 -05:00
Brooklyn Nicholson
70c43d5da1 feat(tui): showroom MVP with picker, speeds, sample workflows
- multi-workflow listing + browser picker, /api/workflows + /api/workflow/:name
- build emits dist/<name>.html for every workflow + dist/index.html
- player adds 0.5x/1x/2x speed control, blinking cursor, intro fade, progress bar
- new sample workflows: subagent trail, slash commands tour, voice mode
2026-04-25 23:08:01 -05:00
Brooklyn Nicholson
7d79dbc5ad feat(tui): add scripted showroom demos 2026-04-25 22:04:12 -05:00
15 changed files with 2639 additions and 1 deletions

View File

@@ -0,0 +1,67 @@
# TUI Showroom
Scripted demos of `ui-tui`. Workflows snapshot real ui-tui components (`MessageLine`, `Panel`, `Box`, `Text`) into ANSI and replay them through xterm.js with cinematic overlays. Recorded once, played any number of times — built for screen capture.
```bash
npm run showroom # dev server at http://127.0.0.1:4317
npm run showroom:record # regenerate every workflow JSON
npm run showroom:build # dist/<name>.html for every workflow
npm run showroom:type-check
```
## Bundled workflows
| File | Shows |
| ------------------------------- | -------------------------------------------------------------- |
| `workflows/feature-tour.json` | Plan → tool trail → result highlight |
| `workflows/subagent-trail.json` | Parallel subagents, hot lanes, summary |
| `workflows/slash-commands.json` | `/skills`, `/model`, `/agents`, `/help` typed → echoed → panel |
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
Pick a workflow from the dropdown or deep-link with `?w=<name>`.
## Architecture
```
record.tsx ─┐
↳ MessageLine, │ Ink renders → Writable → ANSI string
Panel, Box, Text │
workflows/<name>.json
│ served at /api/workflow/<name>
showroom.js │ xterm.js renders ANSI; DOM overlays target frame ids
browser
```
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (CDN, cached) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
## Timeline actions
| Action | Required | Optional |
| ----------- | ---------------- | --------------------------------------------- |
| `frame` | `ansi` | `id` |
| `status` | `text` | `detail` |
| `compose` | `text` | `duration` (typewriter) |
| `caption` | `target`, `text` | `position` (`left`/`right`/`top`), `duration` |
| `spotlight` | `target` | `pad`, `duration` |
| `highlight` | `target` | `duration` |
| `fade` | `target` | `to` (default `0`), `duration` |
| `clear` | — | — |
`target` references the `id` of an earlier `frame`. `viewport.scale` (or the 1x4x picker) controls the upscale factor for capture.
## Player
- Restart (`R`), 1x4x scale, 0.5x/1x/2x speed (`1`/`2`/`3`).
- Progress bar reads `at + duration` from the slowest action.
## Adding a workflow
1. Add a scene fn to `record.tsx` returning `{ title, viewport, composer, timeline }`.
2. Compose Ink primitives or pull `MessageLine` / `Panel` from `../src`.
3. `await snap(<Component />)` for each frame.
4. `npm run showroom:record`.
Components must be state-free at first paint — `useEffect` hooks won't fire by the time the recorder unmounts. For accordions like the live `ToolTrail`, render a flat `Box` + `Text` scene instead.

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

@@ -0,0 +1,70 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { listWorkflows, readWorkflow, renderPage, showroomRoot } from './page.js'
const FLAG_VALUES = new Set<string>([])
const positionals = (() => {
const argv = process.argv.slice(2)
const out: string[] = []
for (let i = 0; i < argv.length; i++) {
const value = argv[i]!
if (FLAG_VALUES.has(value)) {
i += 1
continue
}
if (value.startsWith('-')) {
continue
}
out.push(value)
}
return out
})()
const explicitWorkflow = positionals[0]
const explicitOut = positionals[1]
const distDir = resolve(showroomRoot, 'dist')
const writeHtml = (path: string, html: string) => {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, html)
}
const buildAll = () => {
const catalog = listWorkflows()
for (const entry of catalog) {
const html = renderPage({ name: entry.name, workflow: readWorkflow(entry.path) }, catalog)
const out = join(distDir, `${entry.name}.html`)
writeHtml(out, html)
console.log(out)
}
if (catalog.length) {
const indexEntry = catalog.find(w => w.name === 'feature-tour') ?? catalog[0]!
const html = renderPage({ name: indexEntry.name, workflow: readWorkflow(indexEntry.path) }, catalog)
const out = join(distDir, 'index.html')
writeHtml(out, html)
console.log(out)
}
}
if (explicitWorkflow) {
const path = resolve(process.cwd(), explicitWorkflow)
const out = resolve(process.cwd(), explicitOut ?? join(distDir, 'index.html'))
const catalog = listWorkflows()
const html = renderPage({ name: 'override', workflow: readWorkflow(path) }, catalog)
writeHtml(out, html)
console.log(out)
} else {
buildAll()
}

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

@@ -0,0 +1,58 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, parse } from 'node:path'
import { fileURLToPath } from 'node:url'
export const showroomRoot = dirname(fileURLToPath(import.meta.url))
export const workflowsDir = join(showroomRoot, 'workflows')
export interface WorkflowEntry {
name: string
path: string
title: string
}
export const listWorkflows = (): WorkflowEntry[] =>
readdirSync(workflowsDir)
.filter(file => file.endsWith('.json') && statSync(join(workflowsDir, file)).isFile())
.map(file => {
const path = join(workflowsDir, file)
const data = JSON.parse(readFileSync(path, 'utf8'))
return { name: parse(file).name, path, title: String(data.title ?? parse(file).name) }
})
.sort((a, b) => a.name.localeCompare(b.name))
export const defaultWorkflowPath =
listWorkflows().find(w => w.name === 'feature-tour')?.path ?? listWorkflows()[0]?.path ?? ''
export const readWorkflow = (path = defaultWorkflowPath) => JSON.parse(readFileSync(path, 'utf8'))
export const renderPage = (initial: { name: string; workflow: unknown }, catalog: WorkflowEntry[]) => {
const css = readFileSync(join(showroomRoot, 'src', 'showroom.css'), 'utf8')
const js = readFileSync(join(showroomRoot, 'src', 'showroom.js'), 'utf8')
const safeCatalog = catalog.map(({ name, title }) => ({ name, title }))
const initialJson = JSON.stringify(initial).replace(/</g, '\\u003c')
const catalogJson = JSON.stringify(safeCatalog).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>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" />
<style>${css}</style>
</head>
<body>
<main id="showroom"></main>
<script>
window.__SHOWROOM_INITIAL__ = ${initialJson};
window.__SHOWROOM_CATALOG__ = ${catalogJson};
</script>
<script type="importmap">
{ "imports": { "@xterm/": "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/" } }
</script>
<script type="module">${js}</script>
</body>
</html>`
}

802
ui-tui/.showroom/record.tsx Normal file
View File

@@ -0,0 +1,802 @@
import { rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { Writable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { Box, render, Text } from '@hermes/ink'
import { Panel } from '../src/components/branding.js'
import { MessageLine } from '../src/components/messageLine.js'
import type { Theme } from '../src/theme.js'
import { DEFAULT_THEME } from '../src/theme.js'
import type { Msg } from '../src/types.js'
const showroomRoot = dirname(fileURLToPath(import.meta.url))
class Capture extends Writable {
buffer = ''
isTTY = true
columns: number
rows: number
constructor(cols: number, rows: number) {
super()
this.columns = cols
this.rows = rows
}
override _write(chunk: any, _encoding: any, callback: any) {
this.buffer += chunk.toString()
callback()
}
}
const COLS = 80
const ROWS = 16
const t = DEFAULT_THEME
const snap = async (node: React.ReactElement, settle = 120): Promise<string> => {
const stdout = new Capture(COLS, ROWS) as unknown as NodeJS.WriteStream
const inst = await render(node, { stdout, exitOnCtrlC: false, patchConsole: false })
await new Promise(resolve => setTimeout(resolve, settle))
inst.unmount()
return (stdout as unknown as Capture).buffer
}
const Msg = (msg: Msg) => <MessageLine cols={COLS} msg={msg} t={t} />
const ToolPanel = ({ items, title, theme }: { items: string[]; theme: Theme; title: string }) => (
<Box flexDirection="column" marginLeft={2}>
<Box>
<Text color={theme.color.bronze}> </Text>
<Text bold color={theme.color.amber}>
{title}
</Text>
<Text color={theme.color.dim}> ({items.length})</Text>
</Box>
{items.map((item, i) => (
<Box key={i}>
<Text color={theme.color.bronze}>{i === items.length - 1 ? '└─ ' : '├─ '}</Text>
<Text color={theme.color.dim}>{item}</Text>
</Box>
))}
</Box>
)
const Tree = ({
rows,
theme
}: {
rows: { branch: 'mid' | 'last'; cols: string[]; tone?: 'amber' | 'dim' | 'gold' | 'ok' }[]
theme: Theme
}) => (
<Box flexDirection="column" marginLeft={2}>
{rows.map((row, i) => {
const stem = row.branch === 'last' ? '└─ ' : '├─ '
const tone =
row.tone === 'gold'
? theme.color.gold
: row.tone === 'amber'
? theme.color.amber
: row.tone === 'ok'
? theme.color.ok
: theme.color.dim
return (
<Box key={i}>
<Text color={theme.color.bronze}>{stem}</Text>
<Text color={tone}>{row.cols.join(' ')}</Text>
</Box>
)
})}
</Box>
)
const writeWorkflow = (name: string, workflow: Record<string, unknown>) => {
const out = join(showroomRoot, 'workflows', `${name}.json`)
writeFileSync(out, JSON.stringify(workflow, null, 2))
console.log(` wrote ${out}`)
}
const featureTour = async () => {
const userPrompt = await snap(<Msg role="user" text="Build a focused plan for a safer gateway approval flow." />)
const assistantPlan = await snap(
<Msg
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."
/>
)
const toolTrail = await snap(
<ToolPanel
items={[
'rg "approval.request" gateway/ tui_gateway/',
'ReadFile gateway/run.py',
'ReadFile gateway/platforms/base.py'
]}
theme={t}
title="tool trail"
/>
)
const assistantResult = await snap(
<Msg
role="assistant"
text="Found the split guard. Bypass both queues only for approval commands; normal chat ordering stays intact."
/>
)
return {
composer: 'ask hermes anything',
timeline: [
{ ansi: userPrompt, at: 200, id: 'user-row', type: 'frame' },
{ ansi: assistantPlan, at: 1500, id: 'assistant-plan', type: 'frame' },
{ ansi: toolTrail, at: 2900, id: 'tool-trail', type: 'frame' },
{ at: 3200, duration: 1700, target: 'tool-trail', type: 'spotlight' },
{
at: 3400,
duration: 1700,
position: 'right',
target: 'tool-trail',
text: 'Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.',
type: 'caption'
},
{ ansi: assistantResult, at: 5400, id: 'assistant-result', type: 'frame' },
{ at: 6100, duration: 1300, target: 'assistant-result', type: 'highlight' },
{
at: 6300,
duration: 1700,
position: 'right',
target: 'assistant-result',
text: 'Captions, spotlights, and fades layer on top of real ANSI. Best of both.',
type: 'caption'
},
{ at: 8100, duration: 600, text: '/approve', type: 'compose' }
],
title: 'Hermes TUI · Feature Tour',
viewport: { cols: COLS, rows: ROWS }
}
}
const subagentTrail = async () => {
const userPrompt = await snap(<Msg role="user" text="Run tests, lint, and a Railway preview deploy in parallel." />)
const plan = await snap(
<Msg role="assistant" text="Spawning three subagents on the fan-out lane and watching their tool counts." />
)
const live = await snap(
<Tree
rows={[
{ branch: 'mid', cols: ['tests running 12 tools ⏱ 14.2s'], tone: 'amber' },
{ branch: 'mid', cols: ['lint running 4 tools ⏱ 14.2s'], tone: 'amber' },
{ branch: 'last', cols: ['deploy queued 0 tools ⏱ 0.0s'], tone: 'dim' }
]}
theme={t}
/>
)
const hot = await snap(
<Tree
rows={[
{ branch: 'mid', cols: ['tests complete 18 tools ⏱ 22.7s ✓'], tone: 'ok' },
{ branch: 'mid', cols: ['lint complete 6 tools ⏱ 18.1s ✓'], tone: 'ok' },
{ branch: 'last', cols: ['deploy running 9 tools ⏱ 9.4s'], tone: 'gold' }
]}
theme={t}
/>
)
const summary = await snap(
<Msg role="assistant" text="All three landed: 24 tests pass, lint clean, preview at https://pr-128.railway.app." />
)
return {
composer: 'spawn the deploy fan-out',
timeline: [
{ ansi: userPrompt, at: 200, id: 'ask', type: 'frame' },
{ ansi: plan, at: 1100, id: 'plan', type: 'frame' },
{ ansi: live, at: 2100, id: 'live', type: 'frame' },
{ at: 2300, duration: 1500, target: 'live', type: 'spotlight' },
{
at: 2500,
duration: 1700,
position: 'right',
target: 'live',
text: 'Each subagent gets its own depth and tool budget; the dashboard tracks them live.',
type: 'caption'
},
{ ansi: hot, at: 4400, id: 'hot', type: 'frame' },
{ at: 4600, duration: 1300, target: 'hot', type: 'highlight' },
{
at: 4800,
duration: 1700,
position: 'right',
target: 'hot',
text: 'Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.',
type: 'caption'
},
{ ansi: summary, at: 6800, id: 'summary', type: 'frame' },
{
at: 7000,
duration: 1700,
position: 'right',
target: 'summary',
text: 'Subagent results stream back into the parent transcript as a single highlight.',
type: 'caption'
},
{ at: 8800, duration: 600, text: '/agents', type: 'compose' }
],
title: 'Hermes TUI · Subagent Trail',
viewport: { cols: COLS, rows: ROWS }
}
}
const slashCommands = async () => {
const slashEcho = (text: string) => snap(<Msg kind="slash" role="user" text={text} />)
const skillsEcho = await slashEcho('/skills search vibe')
const skillsResults = await snap(
<Panel
sections={[
{
rows: [
['anthropics/skills/frontend-design', '★ trusted'],
['openai/skills/skill-creator', '· official'],
['skills.sh/community/vibe-coding', '⚙ community']
]
}
]}
t={t}
title="skills · search vibe"
/>,
180
)
const modelEcho = await slashEcho('/model claude-4.6-sonnet')
const modelSwitch = await snap(
<Panel
sections={[
{
rows: [
['from', 'gpt-5-codex'],
['to', 'claude-4.6-sonnet'],
['scope', 'this session']
]
}
]}
t={t}
title="model switched"
/>,
180
)
const agentsEcho = await slashEcho('/agents pause')
const agentsStatus = await snap(
<Panel
sections={[
{
rows: [
['delegation', 'paused'],
['max children', '4'],
['running tasks', 'queued for resume']
]
}
]}
t={t}
title="agents · paused"
/>,
180
)
const helpEcho = await slashEcho('/help')
const helpPanel = await snap(
<Panel
sections={[
{
items: ['/skills search · install · inspect', '/model switch model · pop picker'],
title: 'Tools & Skills'
},
{
items: [
'/agents spawn-tree dashboard',
'/queue queue prompt for next turn',
'/steer inject after next tool call'
],
title: 'Session'
},
{
items: ['/voice toggle voice mode', '/details thinking · tools · subagents · activity'],
title: 'Configuration'
}
]}
t={t}
title="(^_^)? Commands"
/>,
220
)
return {
composer: '',
timeline: [
{ at: 200, duration: 700, text: '/skills search vibe', type: 'compose' },
{ ansi: skillsEcho, at: 1100, type: 'frame' },
{ at: 1100, duration: 200, text: '', type: 'compose' },
{ ansi: skillsResults, at: 1400, id: 'skills', type: 'frame' },
{
at: 1700,
duration: 2000,
position: 'right',
target: 'skills',
text: 'Typed /skills, hit return — same Panel the live TUI renders.',
type: 'caption'
},
{ at: 4000, duration: 700, text: '/model claude-4.6-sonnet', type: 'compose' },
{ ansi: modelEcho, at: 4900, type: 'frame' },
{ at: 4900, duration: 200, text: '', type: 'compose' },
{ ansi: modelSwitch, at: 5200, id: 'model', type: 'frame' },
{
at: 5500,
duration: 1900,
position: 'right',
target: 'model',
text: '/model swaps mid-session; transcript and cache stay intact.',
type: 'caption'
},
{ at: 7600, duration: 600, text: '/agents pause', type: 'compose' },
{ ansi: agentsEcho, at: 8400, type: 'frame' },
{ at: 8400, duration: 200, text: '', type: 'compose' },
{ ansi: agentsStatus, at: 8700, id: 'agents', type: 'frame' },
{
at: 9000,
duration: 1800,
position: 'right',
target: 'agents',
text: 'Same registry powers TUI, gateway, Telegram, Discord — one truth.',
type: 'caption'
},
{ at: 11000, duration: 400, text: '/help', type: 'compose' },
{ ansi: helpEcho, at: 11500, type: 'frame' },
{ at: 11500, duration: 200, text: '', type: 'compose' },
{ ansi: helpPanel, at: 11800, id: 'help', type: 'frame' }
],
title: 'Hermes TUI · Slash Commands',
viewport: { cols: COLS, rows: ROWS }
}
}
const voiceMode = async () => {
const vad = await snap(
<ToolPanel
items={['▮ ▮▮ ▮ ▮▮▮▮ ▮▮ ▮▮▮▮▮▮ ▮▮▮ ▮', 'rms 0.42 · 1.6s captured', 'auto-stop · silence 380ms']}
theme={t}
title="VAD · capturing"
/>
)
const transcript = await snap(<Msg role="user" text="what's in my inbox today and what needs a reply before noon?" />)
const answer = await snap(
<Msg
role="assistant"
text="Three threads need you before noon: vendor renewal, podcast intro feedback, and the design review at 11."
/>
)
const tts = await snap(
<ToolPanel
items={['voice 11labs · grace_v3', 'elapsed 4.6s · 2 chunks queued', 'ducking mic input']}
theme={t}
title="tts · playing"
/>
)
return {
composer: 'ctrl+b to start recording',
timeline: [
{ ansi: vad, at: 250, id: 'vad', type: 'frame' },
{ at: 600, duration: 1500, target: 'vad', type: 'spotlight' },
{
at: 800,
duration: 1700,
position: 'right',
target: 'vad',
text: 'Continuous loop: VAD detects silence, transcribes, restarts — no key holds.',
type: 'caption'
},
{ ansi: transcript, at: 2700, id: 'transcript', type: 'frame' },
{ at: 3400, duration: 1100, target: 'transcript', type: 'highlight' },
{
at: 3600,
duration: 1700,
position: 'right',
target: 'transcript',
text: 'Transcript flows straight into the composer with the standard user glyph.',
type: 'caption'
},
{ ansi: answer, at: 5500, id: 'answer', type: 'frame' },
{ ansi: tts, at: 6700, id: 'tts', type: 'frame' },
{
at: 7000,
duration: 1700,
position: 'right',
target: 'tts',
text: 'TTS auto-ducks the mic so the loop never echoes itself back.',
type: 'caption'
},
{ at: 8800, duration: 600, text: '/voice off', type: 'compose' }
],
title: 'Hermes TUI · Voice Mode',
viewport: { cols: COLS, rows: ROWS }
}
}
// --- Static prompt mocks (no useInput, safe for snap()) ---
const ApprovalPromptStatic = ({
command,
description,
selected = 0,
theme
}: {
command: string
description: string
selected?: number
theme: Theme
}) => {
const labels = ['Allow once', 'Allow this session', 'Always allow', 'Deny']
const lines = command.split('\n').slice(0, 5)
return (
<Box borderColor={theme.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={theme.color.warn}>
approval required · {description}
</Text>
<Box flexDirection="column" paddingLeft={1}>
{lines.map((line, i) => (
<Text color={theme.color.cornsilk} key={i}>
{line || ' '}
</Text>
))}
</Box>
<Text />
{labels.map((label, i) => (
<Text key={label}>
<Text bold={i === selected} color={i === selected ? theme.color.warn : theme.color.dim} inverse={i === selected}>
{i === selected ? '▸ ' : ' '}
{i + 1}. {label}
</Text>
</Text>
))}
<Text color={theme.color.dim}>/ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
</Box>
)
}
const ClarifyPromptStatic = ({
choices,
question,
selected = 0,
theme
}: {
choices: string[]
question: string
selected?: number
theme: Theme
}) => (
<Box flexDirection="column">
<Text bold>
<Text color={theme.color.amber}>ask</Text>
<Text color={theme.color.cornsilk}> {question}</Text>
</Text>
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text bold={i === selected} color={i === selected ? theme.color.label : theme.color.dim} inverse={i === selected}>
{i === selected ? '▸ ' : ' '}
{i + 1}. {c}
</Text>
</Text>
))}
<Text color={theme.color.dim}>
/ select · Enter confirm · 1-{choices.length + 1} quick pick · Esc cancel
</Text>
</Box>
)
const ModelPickerStatic = ({
currentModel,
items,
selected = 0,
stage,
theme
}: {
currentModel: string
items: string[]
selected?: number
stage: 'model' | 'provider'
theme: Theme
}) => (
<Box borderStyle="double" borderColor={theme.color.amber} flexDirection="column" paddingX={1} width={50}>
<Text bold color={theme.color.amber} wrap="truncate-end">
{stage === 'provider' ? 'Select Provider' : 'Select Model'}
</Text>
<Text color={theme.color.dim} wrap="truncate-end">
{stage === 'provider' ? `Current model: ${currentModel}` : currentModel}
</Text>
<Text color={theme.color.label} wrap="truncate-end">
{' '}
</Text>
<Text color={theme.color.dim}>{' '}</Text>
{items.map((item, i) => (
<Text
bold={i === selected}
color={i === selected ? theme.color.amber : theme.color.dim}
inverse={i === selected}
key={item}
wrap="truncate-end"
>
{i === selected ? '▸ ' : ' '}
{i + 1}. {item}
</Text>
))}
<Text color={theme.color.dim}>{' '}</Text>
<Text color={theme.color.dim}>persist: session · g toggle</Text>
<Text color={theme.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</Text>
</Box>
)
const interactivePrompts = async () => {
// User asks for something that triggers approval
const userAsk = await snap(
<Msg role="user" text="Run npm install express in the project root." />
)
const assistantExplains = await snap(
<Msg
role="assistant"
text="I'll install express. The package manager needs approval — here's the command."
/>
)
// Approval prompt
const approval = await snap(
<ApprovalPromptStatic
command={'npm install express\nadded 58 packages in 3.2s\n\n+ express@5.1.0'}
description="install dependency"
theme={t}
/>,
180
)
// After approval, user asks something ambiguous
const userClarify = await snap(
<Msg role="user" text="Deploy this to staging." />
)
const assistantAsks = await snap(
<Msg role="assistant" text="Which environment should I target?" />
)
// Clarify prompt
const clarify = await snap(
<ClarifyPromptStatic
choices={['staging-us-east', 'staging-eu-west', 'staging-ap-south']}
question="Which region?"
theme={t}
/>,
180
)
const confirmResult = await snap(
<Panel
sections={[
{
rows: [
['target', 'staging-us-east'],
['branch', 'main'],
['preview', 'https://pr-128.railway.app']
]
}
]}
t={t}
title="deployment queued"
/>,
180
)
return {
composer: 'deploy this to staging',
timeline: [
{ ansi: userAsk, at: 200, id: 'ask', type: 'frame' },
{ ansi: assistantExplains, at: 1200, id: 'explain', type: 'frame' },
{ ansi: approval, at: 2600, id: 'approval', type: 'frame' },
{ at: 2900, duration: 1500, target: 'approval', type: 'spotlight' },
{
at: 3100,
duration: 2000,
position: 'right',
target: 'approval',
text: 'Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.',
type: 'caption'
},
{ at: 5400, duration: 400, text: '1', type: 'compose' },
{ at: 5900, duration: 500, text: '', type: 'compose' },
{ ansi: userClarify, at: 6600, id: 'clarify-ask', type: 'frame' },
{ ansi: assistantAsks, at: 7600, id: 'clarify-reply', type: 'frame' },
{ ansi: clarify, at: 8800, id: 'clarify', type: 'frame' },
{ at: 9100, duration: 1500, target: 'clarify', type: 'spotlight' },
{
at: 9300,
duration: 2000,
position: 'right',
target: 'clarify',
text: 'Clarify prompts handle ambiguous requests — numbered choices or free text.',
type: 'caption'
},
{ at: 11600, duration: 400, text: '1', type: 'compose' },
{ ansi: confirmResult, at: 12200, id: 'result', type: 'frame' },
{ at: 12500, duration: 1300, target: 'result', type: 'highlight' }
],
title: 'Hermes TUI · Interactive Prompts',
viewport: { cols: COLS, rows: ROWS }
}
}
const modelPicker = async () => {
const userAsk = await snap(
<Msg role="user" text="Switch to Claude." />
)
const assistantReply = await snap(
<Msg role="assistant" text="Opening the model picker — pick a provider first, then a model." />
)
// Provider selection stage
const providers = await snap(
<ModelPickerStatic
currentModel="gpt-5-codex"
items={[
'OpenAI · 8 models',
'Anthropic · 6 models',
'Google · 5 models',
'OpenRouter · 42 models',
'xAI · 3 models'
]}
selected={1}
stage="provider"
theme={t}
/>,
180
)
// Model selection stage
const models = await snap(
<ModelPickerStatic
currentModel="Anthropic"
items={[
'claude-opus-4',
'claude-sonnet-4',
'claude-sonnet-3.7',
'claude-haiku-3.5',
'claude-sonnet-3.5'
]}
selected={1}
stage="model"
theme={t}
/>,
180
)
const result = await snap(
<Panel
sections={[
{
rows: [
['from', 'gpt-5-codex'],
['to', 'claude-sonnet-4'],
['scope', 'this session']
]
}
]}
t={t}
title="model switched"
/>,
180
)
return {
composer: '',
timeline: [
{ at: 200, duration: 500, text: '/model', type: 'compose' },
{ ansi: userAsk, at: 900, id: 'ask', type: 'frame' },
{ ansi: assistantReply, at: 1800, id: 'reply', type: 'frame' },
{ ansi: providers, at: 3000, id: 'providers', type: 'frame' },
{ at: 3300, duration: 1800, target: 'providers', type: 'spotlight' },
{
at: 3500,
duration: 2000,
position: 'right',
target: 'providers',
text: 'Provider stage: pick from authenticated backends. Shows model count per provider.',
type: 'caption'
},
{ at: 5600, duration: 300, text: '2', type: 'compose' },
{ ansi: models, at: 6200, id: 'models', type: 'frame' },
{ at: 6500, duration: 1800, target: 'models', type: 'spotlight' },
{
at: 6700,
duration: 2000,
position: 'right',
target: 'models',
text: 'Model stage: scrollable list with ▸ selection. Number keys for quick pick.',
type: 'caption'
},
{ at: 9000, duration: 300, text: '2', type: 'compose' },
{ ansi: result, at: 9600, id: 'result', type: 'frame' },
{ at: 9900, duration: 1300, target: 'result', type: 'highlight' },
{
at: 10100,
duration: 1700,
position: 'right',
target: 'result',
text: 'Model swap mid-session. Transcript and cache stay intact.',
type: 'caption'
}
],
title: 'Hermes TUI · Model Picker',
viewport: { cols: COLS, rows: ROWS }
}
}
const main = async () => {
console.log('recording workflows…')
// Wipe the workflows dir so deleted/renamed scenes don't linger.
const workflowsDir = join(showroomRoot, 'workflows')
for (const file of [
'feature-tour.json',
'subagent-trail.json',
'slash-commands.json',
'voice-mode.json',
'interactive-prompts.json',
'model-picker.json',
'ink-frames.json'
]) {
try {
rmSync(join(workflowsDir, file))
} catch {
/* ignore */
}
}
writeWorkflow('feature-tour', await featureTour())
writeWorkflow('subagent-trail', await subagentTrail())
writeWorkflow('slash-commands', await slashCommands())
writeWorkflow('voice-mode', await voiceMode())
writeWorkflow('interactive-prompts', await interactivePrompts())
writeWorkflow('model-picker', await modelPicker())
console.log('done')
}
void main().catch(error => {
console.error(error)
process.exit(1)
})

109
ui-tui/.showroom/server.ts Normal file
View File

@@ -0,0 +1,109 @@
import { createServer } from 'node:http'
import { resolve } from 'node:path'
import {
defaultWorkflowPath,
listWorkflows,
readWorkflow,
renderPage,
workflowsDir,
type WorkflowEntry
} from './page.js'
const FLAG_VALUES = new Set(['--port', '--workflow'])
const arg = (name: string) => {
const index = process.argv.indexOf(name)
return index === -1 ? undefined : process.argv[index + 1]
}
const positional = (() => {
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
const value = argv[i]!
if (FLAG_VALUES.has(value)) {
i += 1
continue
}
if (value.startsWith('-')) {
continue
}
return value
}
return undefined
})()
const port = Number(arg('--port') ?? process.env.PORT ?? 4317)
const overridePath = arg('--workflow') ?? positional
const pickInitial = (catalog: WorkflowEntry[], requested: null | string): WorkflowEntry => {
if (overridePath) {
const fullPath = resolve(process.cwd(), overridePath)
return { name: 'override', path: fullPath, title: requested ?? 'override' }
}
if (requested) {
const hit = catalog.find(w => w.name === requested)
if (hit) {
return hit
}
}
return catalog.find(w => w.path === defaultWorkflowPath) ?? catalog[0]!
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/healthz') {
res.writeHead(200).end('ok')
return
}
if (url.pathname === '/api/workflows') {
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(listWorkflows()))
return
}
if (url.pathname.startsWith('/api/workflow/')) {
const name = decodeURIComponent(url.pathname.slice('/api/workflow/'.length))
const hit = listWorkflows().find(w => w.name === name)
if (!hit) {
res.writeHead(404).end('not found')
return
}
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(readWorkflow(hit.path)))
return
}
try {
const catalog = listWorkflows()
const initial = pickInitial(catalog, url.searchParams.get('w'))
const page = renderPage({ name: initial.name, workflow: readWorkflow(initial.path) }, catalog)
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(`workflows dir: ${workflowsDir}`)
})

View File

@@ -0,0 +1,422 @@
:root {
color-scheme: dark;
background: #050505;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
--gold: #ffd700;
--amber: #ffbf00;
--bronze: #cd7f32;
--cornsilk: #fff8dc;
--dim: #cc9b1f;
--label: #daa520;
--bg: #0a0a0a;
--bg-deep: #050505;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
overflow: auto;
background:
radial-gradient(circle at 18% 12%, rgba(205, 127, 50, 0.12), transparent 36rem),
radial-gradient(circle at 82% 14%, rgba(255, 215, 0, 0.05), transparent 30rem),
var(--bg-deep);
}
#showroom {
min-height: 100vh;
padding: 24px 24px 60px;
display: flex;
justify-content: center;
align-items: flex-start;
}
/* --- Shell --- */
.showroom-shell {
display: grid;
gap: 14px;
width: max-content;
max-width: 100%;
opacity: 0;
transform: translateY(12px);
transition:
opacity 600ms var(--ease-out),
transform 600ms var(--ease-out);
}
.showroom-shell.is-mounted {
opacity: 1;
transform: translateY(0);
}
/* --- Stage --- */
.showroom-stage {
position: relative;
width: var(--stage-w);
height: var(--stage-h);
overflow: hidden;
border: 1px solid rgba(205, 127, 50, 0.45);
border-radius: 14px;
background: var(--bg);
box-shadow:
0 32px 120px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.03) 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: 8px 10px;
background: var(--bg);
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
line-height: 18px;
}
/* --- Status bar --- */
.showroom-status {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
padding: 0 4px;
color: var(--dim);
font-size: 11px;
white-space: nowrap;
}
.showroom-status:empty,
.showroom-status-left:empty,
.showroom-status-right:empty {
display: none;
}
.showroom-status-left,
.showroom-status-right {
display: flex;
align-items: center;
gap: 8px;
}
/* --- Composer --- */
.showroom-composer {
display: flex;
align-items: center;
min-height: 22px;
padding: 6px 4px 0;
color: var(--cornsilk);
white-space: nowrap;
}
.showroom-composer:empty {
display: none;
}
.showroom-composer::before {
content: '';
color: var(--gold);
font-weight: 700;
margin-right: 8px;
}
.showroom-composer:not(:empty)::after {
content: '';
display: inline-block;
width: 7px;
height: 14px;
margin-left: 4px;
background: var(--gold);
vertical-align: middle;
animation: showroom-blink 1100ms steps(2) infinite;
}
@keyframes showroom-blink {
50% {
opacity: 0;
}
}
/* --- Body (DOM message mode) --- */
.showroom-body {
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
padding: 4px 0 6px;
}
/* --- xterm container (frame mode) --- */
.showroom-xterm {
width: 100%;
height: 100%;
overflow: hidden;
opacity: 0;
transition: opacity 300ms var(--ease-out);
}
.showroom-xterm.is-visible {
opacity: 1;
}
.showroom-xterm .xterm-viewport {
overflow: hidden !important;
background: transparent !important;
}
/* --- DOM-mode lines --- */
.showroom-line,
.showroom-tool {
opacity: 0;
transform: translateY(4px);
animation: showroom-enter 320ms var(--ease-out) forwards;
}
@keyframes showroom-enter {
to {
opacity: 1;
transform: translateY(0);
}
}
.showroom-line {
display: grid;
grid-template-columns: 22px 1fr;
gap: 4px;
}
.showroom-glyph {
color: var(--role);
font-weight: 700;
}
.showroom-copy {
color: var(--copy);
white-space: pre-wrap;
}
.showroom-line-user .showroom-copy {
color: var(--label);
font-weight: 600;
}
.showroom-line-assistant .showroom-copy {
color: var(--cornsilk);
}
.showroom-line-system .showroom-copy {
color: var(--dim);
}
/* --- Tool panel --- */
.showroom-tool {
margin-left: 22px;
border: 1px solid rgba(205, 127, 50, 0.32);
border-radius: 4px;
padding: 6px 10px;
background: rgba(205, 127, 50, 0.05);
}
.showroom-tool-title {
color: var(--gold);
font-weight: 700;
}
.showroom-tool-title::before {
content: '⚡ ';
color: var(--bronze);
}
.showroom-tool-items {
display: grid;
gap: 1px;
margin-top: 4px;
color: var(--dim);
font-size: 12px;
}
.showroom-tool-items div::before {
content: '┊ ';
color: var(--bronze);
}
/* --- Highlight --- */
.is-highlighted {
filter: brightness(1.4);
background: rgba(255, 215, 0, 0.1);
transform: translateX(3px);
transition:
filter 420ms var(--ease-in-out),
background 420ms var(--ease-in-out),
transform 420ms var(--ease-in-out);
}
/* --- Overlays (captions, spotlights) --- */
.showroom-overlays {
position: absolute;
inset: 0;
pointer-events: none;
}
.showroom-caption,
.showroom-spotlight {
position: absolute;
opacity: 0;
transition:
opacity 360ms var(--ease-out),
transform 360ms var(--ease-out);
}
.showroom-caption {
max-width: 360px;
border: 1px solid rgba(205, 127, 50, 0.5);
border-radius: 12px;
padding: 12px 14px;
background: rgba(10, 10, 10, 0.92);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
color: var(--cornsilk);
font-size: 14px;
line-height: 1.45;
transform: translateY(8px);
}
.showroom-spotlight {
border: 2px solid var(--gold);
border-radius: 8px;
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.42),
0 0 32px rgba(255, 215, 0, 0.32);
}
.showroom-caption.is-visible,
.showroom-spotlight.is-visible {
opacity: 1;
transform: translateY(0);
}
/* --- Picker --- */
.showroom-picker {
appearance: none;
border: 1px solid rgba(205, 127, 50, 0.4);
border-radius: 999px;
padding: 6px 30px 6px 14px;
background: rgba(205, 127, 50, 0.06)
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23cd7f32' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
no-repeat right 12px center / 10px;
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
cursor: pointer;
}
.showroom-picker:focus {
outline: 1px solid var(--bronze);
}
/* --- Controls bar --- */
.showroom-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.showroom-controls button {
border: 1px solid rgba(205, 127, 50, 0.25);
border-radius: 999px;
padding: 4px 10px;
background: rgba(205, 127, 50, 0.04);
color: var(--dim);
cursor: pointer;
font: inherit;
}
.showroom-controls button:hover {
background: rgba(205, 127, 50, 0.12);
color: var(--cornsilk);
}
.showroom-controls button[data-action='restart'] {
width: 28px;
height: 28px;
padding: 0;
font-size: 14px;
line-height: 1;
}
.showroom-segmented {
display: inline-flex;
border: 1px solid rgba(205, 127, 50, 0.25);
border-radius: 999px;
padding: 2px;
background: rgba(205, 127, 50, 0.04);
}
.showroom-segmented button {
border: 0;
border-radius: 999px;
padding: 3px 10px;
background: transparent;
color: var(--dim);
cursor: pointer;
font: inherit;
}
.showroom-segmented button.is-active {
background: rgba(255, 215, 0, 0.18);
color: var(--cornsilk);
}
/* --- Progress --- */
.showroom-progress {
display: inline-flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 140px;
color: var(--dim);
}
.showroom-progress-track {
position: relative;
flex: 1;
height: 2px;
border-radius: 999px;
background: rgba(205, 127, 50, 0.1);
overflow: hidden;
}
.showroom-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, var(--bronze), var(--gold));
transition: width 80ms linear;
}

View File

@@ -0,0 +1,541 @@
import { Terminal } from '@xterm/xterm'
const initial = window.__SHOWROOM_INITIAL__
const catalog = window.__SHOWROOM_CATALOG__ ?? []
const root = document.getElementById('showroom')
const SPEEDS = [0.5, 1, 2]
const SCALES = [1, 2, 3, 4]
const state = {
body: null,
composer: null,
frameTargets: new Map(),
overlays: null,
progressFill: null,
progressLabel: null,
raf: null,
scale: 2,
shell: null,
speed: 1,
startedAt: 0,
statusLeft: null,
statusRight: null,
term: null,
termContainer: null,
timers: [],
total: 0,
viewport: null,
workflow: initial?.workflow ?? { timeline: [] }
}
const clearTimers = () => {
while (state.timers.length) {
clearTimeout(state.timers.pop())
}
if (state.raf) {
cancelAnimationFrame(state.raf)
state.raf = null
}
}
const resolveTarget = id => {
if (!id) {
return null
}
return state.frameTargets.get(id) ?? document.querySelector(`[data-target="${CSS.escape(id)}"]`)
}
const setText = (node, text = '', duration = 0) => {
if (!duration || state.speed <= 0) {
node.textContent = text
return
}
const chars = [...text]
const adjusted = duration / state.speed
const started = performance.now()
const frame = now => {
const n = Math.min(chars.length, Math.ceil(((now - started) / adjusted) * chars.length))
node.textContent = chars.slice(0, n).join('')
if (n < chars.length) {
requestAnimationFrame(frame)
}
}
requestAnimationFrame(frame)
}
const removeAfter = (node, duration = 1400) => {
const wait = duration / state.speed
state.timers.push(
setTimeout(() => {
node.classList.remove('is-visible')
state.timers.push(setTimeout(() => node.remove(), 420 / state.speed))
}, wait)
)
}
const rectFor = (id, pad = 8) => {
const el = resolveTarget(id)
if (!el || !state.overlays) {
return null
}
const stage = state.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 = '24px'
node.style.top = '24px'
return
}
const gap = 18
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(12, left)}px`
node.style.top = `${Math.max(12, top)}px`
}
// --- Actions ---
const message = action => {
const spec = {
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
user: { copy: '#daa520', glyph: '', tone: '#ffd700' }
}[action.role] ?? { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' }
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.tone)
line.style.setProperty('--copy', spec.copy)
glyph.className = 'showroom-glyph'
glyph.textContent = spec.glyph
copy.className = 'showroom-copy'
line.append(glyph, copy)
state.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)
state.body.append(box)
}
const frame = action => {
if (!state.term || !action.ansi) {
return
}
state.term.write(action.ansi)
if (action.id) {
state.frameTargets.set(action.id, state.termContainer)
}
}
const fade = action => {
const el = resolveTarget(action.target)
if (!el) {
return
}
el.style.transition = `opacity ${(action.duration ?? 420) / state.speed}ms var(--ease-in-out)`
el.style.opacity = String(action.to ?? 0)
}
const highlight = action => {
const el = resolveTarget(action.target)
if (!el) {
return
}
el.classList.add('is-highlighted')
state.timers.push(setTimeout(() => el.classList.remove('is-highlighted'), (action.duration ?? 1200) / state.speed))
}
const caption = action => {
const node = document.createElement('div')
node.className = 'showroom-caption'
node.dataset.target = action.id ?? ''
node.textContent = action.text ?? ''
state.overlays.append(node)
placeNear(node, action.target, action.position)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration ?? 1600)
}
const spotlight = action => {
const rect = rectFor(action.target, action.pad ?? 6)
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`
state.overlays.append(node)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration ?? 1500)
}
const status = action => {
state.statusLeft.textContent = action.text ?? ''
state.statusRight.textContent = action.detail ?? ''
}
const compose = action => setText(state.composer, action.text ?? '', action.duration ?? 0)
const clearTranscript = () => {
state.overlays.textContent = ''
state.frameTargets.clear()
if (state.term) {
state.term.reset()
state.term.write('\x1b[?25l')
return
}
state.body.textContent = ''
}
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
// --- Progress ---
const fmtTime = ms => {
if (!Number.isFinite(ms)) {
return '0.0s'
}
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
}
const tickProgress = () => {
if (!state.startedAt) {
return
}
const elapsed = Math.min(state.total, (performance.now() - state.startedAt) * state.speed)
const ratio = state.total ? elapsed / state.total : 0
state.progressFill.style.width = `${(ratio * 100).toFixed(2)}%`
state.progressLabel.textContent = `${fmtTime(elapsed)} / ${fmtTime(state.total)}`
if (elapsed < state.total) {
state.raf = requestAnimationFrame(tickProgress)
}
}
// --- xterm ---
const initXterm = () => {
const hasFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
if (!hasFrames) {
state.term = null
state.termContainer = null
return
}
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
state.termContainer = state.body.querySelector('.showroom-xterm')
state.term = new Terminal({
cols: state.viewport.cols,
rows: state.viewport.rows,
fontFamily: 'JetBrains Mono, "SF Mono", Consolas, monospace',
fontSize: 13,
cursorBlink: false,
scrollback: 0,
convertEol: true,
allowProposedApi: true,
theme: {
background: '#0a0a0a',
foreground: '#fff8dc',
cursor: '#ffd700',
selectionBackground: '#3a3a55',
black: '#0a0a0a',
red: '#ef5350',
green: '#8fbc8f',
yellow: '#ffd700',
blue: '#5a82ff',
magenta: '#cd7f32',
cyan: '#daa520',
white: '#fff8dc',
brightBlack: '#cc9b1f',
brightRed: '#ef5350',
brightGreen: '#8fbc8f',
brightYellow: '#ffbf00',
brightBlue: '#5a82ff',
brightMagenta: '#cd7f32',
brightCyan: '#daa520',
brightWhite: '#fff8dc'
}
})
state.term.open(state.termContainer)
state.term.write('\x1b[?25l')
// Fade in
requestAnimationFrame(() => state.termContainer.classList.add('is-visible'))
}
// --- Playback ---
const play = () => {
clearTimers()
clearTranscript()
state.statusLeft.textContent = ''
state.statusRight.textContent = ''
state.composer.textContent = state.workflow.composer ?? ''
const timeline = [...(state.workflow.timeline ?? [])].sort((a, b) => a.at - b.at)
state.total = timeline.reduce((max, action) => Math.max(max, action.at + (action.duration ?? 0)), 0)
state.startedAt = performance.now()
state.progressFill.style.width = '0%'
state.progressLabel.textContent = `0.0s / ${fmtTime(state.total)}`
for (const action of timeline) {
state.timers.push(setTimeout(() => ACTIONS[action.type]?.(action), action.at / state.speed))
}
state.raf = requestAnimationFrame(tickProgress)
}
// --- Controls ---
const setSpeed = next => {
state.speed = next
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
button.classList.toggle('is-active', Number(button.dataset.value) === next)
}
}
const setScale = next => {
state.scale = next
state.shell.style.setProperty('--scale', `${next}`)
state.shell.style.setProperty('--stage-w', `${state.viewport.cols * state.viewport.cellWidth * next}px`)
state.shell.style.setProperty('--stage-h', `${state.viewport.rows * state.viewport.lineHeight * next}px`)
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
button.classList.toggle('is-active', Number(button.dataset.value) === next)
}
}
const fitScale = () => {
const margin = 96
const baseW = state.viewport.cols * state.viewport.cellWidth
const baseH = state.viewport.rows * state.viewport.lineHeight
const maxW = Math.max(1, window.innerWidth - margin)
const maxH = Math.max(1, window.innerHeight - 240)
const fit = Math.max(1, Math.floor(Math.min(maxW / baseW, maxH / baseH)))
return Math.max(1, Math.min(SCALES[SCALES.length - 1], fit))
}
const loadWorkflow = async name => {
const url = new URL(window.location.href)
url.searchParams.set('w', name)
window.history.replaceState(null, '', url)
try {
const response = await fetch(`/api/workflow/${encodeURIComponent(name)}`)
if (response.ok) {
state.workflow = await response.json()
}
} catch {
/* fall through */
}
await rebuild()
}
// --- DOM ---
const buildOptions = () => {
if (!catalog.length) {
return ''
}
return catalog
.map(({ name, title }) => {
const selected = name === initial?.name ? ' selected' : ''
return `<option value="${name}"${selected}>${title}</option>`
})
.join('')
}
const buildSegmented = (values, active) =>
values
.map(
value =>
`<button type="button" data-value="${value}" class="${value === active ? 'is-active' : ''}">${value}x</button>`
)
.join('')
const computeViewport = () => {
const fromWorkflow = state.workflow.viewport ?? {}
return {
cellWidth: 9,
cols: 80,
lineHeight: 19,
rows: 24,
scale: 2,
...fromWorkflow
}
}
const renderShell = () => {
state.viewport = computeViewport()
state.frameTargets.clear()
state.shell.style.setProperty('--cell-w', `${state.viewport.cellWidth}px`)
state.shell.style.setProperty('--cols', `${state.viewport.cols}`)
state.shell.style.setProperty('--line-h', `${state.viewport.lineHeight}px`)
state.shell.style.setProperty('--rows', `${state.viewport.rows}`)
state.shell.style.setProperty('--term-w', `${state.viewport.cols * state.viewport.cellWidth}px`)
state.shell.style.setProperty('--term-h', `${state.viewport.rows * state.viewport.lineHeight}px`)
state.shell.innerHTML = `
<div class="showroom-stage">
<div class="showroom-terminal">
<div class="showroom-status" data-target="status">
<span class="showroom-status-left"></span>
<span class="showroom-status-right"></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" title="restart (R)">&#8635;</button>
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
<span class="showroom-progress">
<span data-role="time">0.0s / 0.0s</span>
<div class="showroom-progress-track"><div class="showroom-progress-fill"></div></div>
</span>
</footer>
`
state.body = state.shell.querySelector('.showroom-body')
state.composer = state.shell.querySelector('.showroom-composer')
state.overlays = state.shell.querySelector('.showroom-overlays')
state.statusLeft = state.shell.querySelector('.showroom-status-left')
state.statusRight = state.shell.querySelector('.showroom-status-right')
state.progressFill = state.shell.querySelector('.showroom-progress-fill')
state.progressLabel = state.shell.querySelector('[data-role="time"]')
state.shell.querySelector('[data-action="restart"]').addEventListener('click', play)
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
button.addEventListener('click', () => setSpeed(Number(button.dataset.value)))
}
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
button.addEventListener('click', () => setScale(Number(button.dataset.value)))
}
const picker = state.shell.querySelector('[data-action="picker"]')
if (picker) {
picker.addEventListener('change', event => {
void loadWorkflow(event.target.value)
})
}
}
const rebuild = async () => {
renderShell()
initXterm()
setScale(state.workflow.viewport?.scale ?? fitScale())
play()
}
const mount = () => {
state.shell = document.createElement('section')
state.shell.className = 'showroom-shell'
root.replaceChildren(state.shell)
void rebuild().then(() => {
requestAnimationFrame(() => state.shell.classList.add('is-mounted'))
})
window.addEventListener('keydown', event => {
const key = event.key.toLowerCase()
if (key === 'r') {
play()
} else if (key === '1' || key === '2' || key === '3') {
setSpeed(SPEEDS[Number(key) - 1])
}
})
}
mount()

View File

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

View File

@@ -0,0 +1,68 @@
{
"composer": "ask hermes anything",
"timeline": [
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2CBuild\u001b[1Ca\u001b[1Cfocused\u001b[1Cplan\u001b[1Cfor\u001b[1Ca\u001b[1Csafer\u001b[1Cgateway\u001b[1Capproval\u001b[1Cflow.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 200,
"id": "user-row",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CI'll\u001b[1Ctrace\u001b[1Cthe\u001b[1Cgateway\u001b[1Cguards\u001b[1Cfirst,\u001b[1Cthen\u001b[1Cpatch\u001b[1Cthe\u001b[1Csmallest\u001b[1Cboundary\u001b[1Cthat\r\n\u001b[3Ckeeps\u001b[1Capproval\u001b[1Ccommands\u001b[1Clive\u001b[1Cwhile\u001b[1Can\u001b[1Cagent\u001b[1Cis\u001b[1Cblocked.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1500,
"id": "assistant-plan",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1Ctool\u001b[1Ctrail\u001b[1C(3)\r\n\u001b[2C├─\u001b[1Crg\u001b[1C\"approval.request\"\u001b[1Cgateway/\u001b[1Ctui_gateway/\r\n\u001b[2C├─\u001b[1CReadFile\u001b[1Cgateway/run.py\r\n\u001b[2C└─\u001b[1CReadFile\u001b[1Cgateway/platforms/base.py\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 2900,
"id": "tool-trail",
"type": "frame"
},
{
"at": 3200,
"duration": 1700,
"target": "tool-trail",
"type": "spotlight"
},
{
"at": 3400,
"duration": 1700,
"position": "right",
"target": "tool-trail",
"text": "Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.",
"type": "caption"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CFound\u001b[1Cthe\u001b[1Csplit\u001b[1Cguard.\u001b[1CBypass\u001b[1Cboth\u001b[1Cqueues\u001b[1Conly\u001b[1Cfor\u001b[1Capproval\u001b[1Ccommands;\r\n\u001b[3Cnormal\u001b[1Cchat\u001b[1Cordering\u001b[1Cstays\u001b[1Cintact.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 5400,
"id": "assistant-result",
"type": "frame"
},
{
"at": 6100,
"duration": 1300,
"target": "assistant-result",
"type": "highlight"
},
{
"at": 6300,
"duration": 1700,
"position": "right",
"target": "assistant-result",
"text": "Captions, spotlights, and fades layer on top of real ANSI. Best of both.",
"type": "caption"
},
{
"at": 8100,
"duration": 600,
"text": "/approve",
"type": "compose"
}
],
"title": "Hermes TUI · Feature Tour",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -0,0 +1,104 @@
{
"composer": "deploy this to staging",
"timeline": [
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2CRun\u001b[1Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[1Cin\u001b[1Cthe\u001b[1Cproject\u001b[1Croot.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 200,
"id": "ask",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CI'll\u001b[1Cinstall\u001b[1Cexpress.\u001b[1CThe\u001b[1Cpackage\u001b[1Cmanager\u001b[1Cneeds\u001b[1Capproval\u001b[1C—\u001b[1Chere's\u001b[1Cthe\r\n\u001b[3Ccommand.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1200,
"id": "explain",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╔══════════════════════════════════════════════════════════════════════════════╗\r\n║\u001b[1C⚠\u001b[1Capproval\u001b[1Crequired\u001b[1C·\u001b[1Cinstall\u001b[1Cdependency\u001b[36C║\r\n║\u001b[2Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[57C║\r\n║\u001b[2Cadded\u001b[1C58\u001b[1Cpackages\u001b[1Cin\u001b[1C3.2s\u001b[51C║\r\n║\u001b[78C║\r\n║\u001b[2C+\u001b[1Cexpress@5.1.0\u001b[61C║\r\n║\u001b[1C▸\u001b[1C1.\u001b[1CAllow\u001b[1Conce\u001b[62C║\r\n║\u001b[3C2.\u001b[1CAllow\u001b[1Cthis\u001b[1Csession\u001b[54C║\r\n║\u001b[3C3.\u001b[1CAlways\u001b[1Callow\u001b[60C║\r\n║\u001b[3C4.\u001b[1CDeny\u001b[68C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CCtrl+C\u001b[1Cdeny\u001b[20C║\r\n╚══════════════════════════════════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 2600,
"id": "approval",
"type": "frame"
},
{
"at": 2900,
"duration": 1500,
"target": "approval",
"type": "spotlight"
},
{
"at": 3100,
"duration": 2000,
"position": "right",
"target": "approval",
"text": "Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.",
"type": "caption"
},
{
"at": 5400,
"duration": 400,
"text": "1",
"type": "compose"
},
{
"at": 5900,
"duration": 500,
"text": "",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2CDeploy\u001b[1Cthis\u001b[1Cto\u001b[1Cstaging.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 6600,
"id": "clarify-ask",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CWhich\u001b[1Cenvironment\u001b[1Cshould\u001b[1CI\u001b[1Ctarget?\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 7600,
"id": "clarify-reply",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026hask\u001b[1CWhich\u001b[1Cregion?\r\n▸\u001b[1C1.\u001b[1Cstaging-us-east\r\n\u001b[2C2.\u001b[1Cstaging-eu-west\r\n\u001b[2C3.\u001b[1Cstaging-ap-south\r\n\u001b[2C4.\u001b[1COther\u001b[1C(type\u001b[1Cyour\u001b[1Canswer)\r\n↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CEsc\u001b[1Ccancel\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 8800,
"id": "clarify",
"type": "frame"
},
{
"at": 9100,
"duration": 1500,
"target": "clarify",
"type": "spotlight"
},
{
"at": 9300,
"duration": 2000,
"position": "right",
"target": "clarify",
"text": "Clarify prompts handle ambiguous requests — numbered choices or free text.",
"type": "caption"
},
{
"at": 11600,
"duration": 400,
"text": "1",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[30Cdeployment\u001b[1Cqueued\u001b[31C│\r\n│\u001b[78C│\r\n│\u001b[2Ctarget\u001b[14Cstaging-us-east\u001b[41C│\r\n│\u001b[2Cbranch\u001b[14Cmain\u001b[52C│\r\n│\u001b[2Cpreview\u001b[13Chttps://pr-128.railway.app\u001b[30C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 12200,
"id": "result",
"type": "frame"
},
{
"at": 12500,
"duration": 1300,
"target": "result",
"type": "highlight"
}
],
"title": "Hermes TUI · Interactive Prompts",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -0,0 +1,100 @@
{
"composer": "",
"timeline": [
{
"at": 200,
"duration": 500,
"text": "/model",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2CSwitch\u001b[1Cto\u001b[1CClaude.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 900,
"id": "ask",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2COpening\u001b[1Cthe\u001b[1Cmodel\u001b[1Cpicker\u001b[1C—\u001b[1Cpick\u001b[1Ca\u001b[1Cprovider\u001b[1Cfirst,\u001b[1Cthen\u001b[1Ca\u001b[1Cmodel.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1800,
"id": "reply",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CProvider\u001b[32C║\r\n║\u001b[1CCurrent\u001b[1Cmodel:\u001b[1Cgpt-5-codex\u001b[21C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1COpenAI\u001b[1C·\u001b[1C8\u001b[1Cmodels\u001b[25C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1CAnthropic\u001b[1C·\u001b[1C6\u001b[1Cmodels\u001b[22C║\r\n║\u001b[3C3.\u001b[1CGoogle\u001b[1C·\u001b[1C5\u001b[1Cmodels\u001b[25C║\r\n║\u001b[3C4.\u001b[1COpenRouter\u001b[1C·\u001b[1C42\u001b[1Cmodels\u001b[20C║\r\n║\u001b[3C5.\u001b[1CxAI\u001b[1C·\u001b[1C3\u001b[1Cmodels\u001b[28C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 3000,
"id": "providers",
"type": "frame"
},
{
"at": 3300,
"duration": 1800,
"target": "providers",
"type": "spotlight"
},
{
"at": 3500,
"duration": 2000,
"position": "right",
"target": "providers",
"text": "Provider stage: pick from authenticated backends. Shows model count per provider.",
"type": "caption"
},
{
"at": 5600,
"duration": 300,
"text": "2",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CModel\u001b[35C║\r\n║\u001b[1CAnthropic\u001b[38C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1Cclaude-opus-4\u001b[29C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1Cclaude-sonnet-4\u001b[27C║\r\n║\u001b[3C3.\u001b[1Cclaude-sonnet-3.7\u001b[25C║\r\n║\u001b[3C4.\u001b[1Cclaude-haiku-3.5\u001b[26C║\r\n║\u001b[3C5.\u001b[1Cclaude-sonnet-3.5\u001b[25C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 6200,
"id": "models",
"type": "frame"
},
{
"at": 6500,
"duration": 1800,
"target": "models",
"type": "spotlight"
},
{
"at": 6700,
"duration": 2000,
"position": "right",
"target": "models",
"text": "Model stage: scrollable list with ▸ selection. Number keys for quick pick.",
"type": "caption"
},
{
"at": 9000,
"duration": 300,
"text": "2",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[32Cmodel\u001b[1Cswitched\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cfrom\u001b[16Cgpt-5-codex\u001b[45C│\r\n│\u001b[2Cto\u001b[18Cclaude-sonnet-4\u001b[41C│\r\n│\u001b[2Cscope\u001b[15Cthis\u001b[1Csession\u001b[44C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 9600,
"id": "result",
"type": "frame"
},
{
"at": 9900,
"duration": 1300,
"target": "result",
"type": "highlight"
},
{
"at": 10100,
"duration": 1700,
"position": "right",
"target": "result",
"text": "Model swap mid-session. Transcript and cache stay intact.",
"type": "caption"
}
],
"title": "Hermes TUI · Model Picker",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -0,0 +1,126 @@
{
"composer": "",
"timeline": [
{
"at": 200,
"duration": 700,
"text": "/skills search vibe",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2C/skills\u001b[1Csearch\u001b[1Cvibe\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1100,
"type": "frame"
},
{
"at": 1100,
"duration": 200,
"text": "",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[29Cskills\u001b[1C·\u001b[1Csearch\u001b[1Cvibe\u001b[29C│\r\n│\u001b[78C│\r\n│\u001b[2Canthropics/skills/frontend-design★\u001b[1Ctrusted\u001b[34C│\r\n│\u001b[2Copenai/skills/skill-creator·\u001b[1Cofficial\u001b[39C│\r\n│\u001b[2Cskills.sh/community/vibe-coding⚙\u001b[1Ccommunity\u001b[33C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1400,
"id": "skills",
"type": "frame"
},
{
"at": 1700,
"duration": 2000,
"position": "right",
"target": "skills",
"text": "Typed /skills, hit return — same Panel the live TUI renders.",
"type": "caption"
},
{
"at": 4000,
"duration": 700,
"text": "/model claude-4.6-sonnet",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2C/model\u001b[1Cclaude-4.6-sonnet\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 4900,
"type": "frame"
},
{
"at": 4900,
"duration": 200,
"text": "",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[32Cmodel\u001b[1Cswitched\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cfrom\u001b[16Cgpt-5-codex\u001b[45C│\r\n│\u001b[2Cto\u001b[18Cclaude-4.6-sonnet\u001b[39C│\r\n│\u001b[2Cscope\u001b[15Cthis\u001b[1Csession\u001b[44C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 5200,
"id": "model",
"type": "frame"
},
{
"at": 5500,
"duration": 1900,
"position": "right",
"target": "model",
"text": "/model swaps mid-session; transcript and cache stay intact.",
"type": "caption"
},
{
"at": 7600,
"duration": 600,
"text": "/agents pause",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2C/agents\u001b[1Cpause\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 8400,
"type": "frame"
},
{
"at": 8400,
"duration": 200,
"text": "",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[31Cagents\u001b[1C·\u001b[1Cpaused\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cdelegation\u001b[10Cpaused\u001b[50C│\r\n│\u001b[2Cmax\u001b[1Cchildren\u001b[8C4\u001b[55C│\r\n│\u001b[2Crunning\u001b[1Ctasks\u001b[7Cqueued\u001b[1Cfor\u001b[1Cresume\u001b[39C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 8700,
"id": "agents",
"type": "frame"
},
{
"at": 9000,
"duration": 1800,
"position": "right",
"target": "agents",
"text": "Same registry powers TUI, gateway, Telegram, Discord — one truth.",
"type": "caption"
},
{
"at": 11000,
"duration": 400,
"text": "/help",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2C/help\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 11500,
"type": "frame"
},
{
"at": 11500,
"duration": 200,
"text": "",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[31C(^_^)?\u001b[1CCommands\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2CTools\u001b[1C&\u001b[1CSkills\u001b[62C│\r\n│\u001b[2C/skills\u001b[4Csearch\u001b[1C·\u001b[1Cinstall\u001b[1C·\u001b[1Cinspect\u001b[39C│\r\n│\u001b[2C/model\u001b[5Cswitch\u001b[1Cmodel\u001b[1C·\u001b[1Cpop\u001b[1Cpicker\u001b[40C│\r\n│\u001b[78C│\r\n│\u001b[2CSession\u001b[69C│\r\n│\u001b[2C/agents\u001b[4Cspawn-tree\u001b[1Cdashboard\u001b[45C│\r\n│\u001b[2C/queue\u001b[5Cqueue\u001b[1Cprompt\u001b[1Cfor\u001b[1Cnext\u001b[1Cturn\u001b[39C│\r\n│\u001b[2C/steer\u001b[5Cinject\u001b[1Cafter\u001b[1Cnext\u001b[1Ctool\u001b[1Ccall\u001b[38C│\r\n│\u001b[78C│\r\n│\u001b[2CConfiguration\u001b[63C│\r\n│\u001b[2C/voice\u001b[5Ctoggle\u001b[1Cvoice\u001b[1Cmode\u001b[48C│\r\n│\u001b[2C/details\u001b[3Cthinking\u001b[1C·\u001b[1Ctools\u001b[1C·\u001b[1Csubagents\u001b[1C·\u001b[1Cactivity\u001b[26C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 11800,
"id": "help",
"type": "frame"
}
],
"title": "Hermes TUI · Slash Commands",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -0,0 +1,82 @@
{
"composer": "spawn the deploy fan-out",
"timeline": [
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2CRun\u001b[1Ctests,\u001b[1Clint,\u001b[1Cand\u001b[1Ca\u001b[1CRailway\u001b[1Cpreview\u001b[1Cdeploy\u001b[1Cin\u001b[1Cparallel.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 200,
"id": "ask",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CSpawning\u001b[1Cthree\u001b[1Csubagents\u001b[1Con\u001b[1Cthe\u001b[1Cfan-out\u001b[1Clane\u001b[1Cand\u001b[1Cwatching\u001b[1Ctheir\u001b[1Ctool\r\n\u001b[3Ccounts.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 1100,
"id": "plan",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C├─\u001b[1Ctests\u001b[3Crunning\u001b[3C12\u001b[1Ctools\u001b[3C⏱\u001b[1C14.2s\r\n\u001b[2C├─\u001b[1Clint\u001b[4Crunning\u001b[4C4\u001b[1Ctools\u001b[3C⏱\u001b[1C14.2s\r\n\u001b[2C└─\u001b[1Cdeploy\u001b[2Cqueued\u001b[5C0\u001b[1Ctools\u001b[3C⏱\u001b[2C0.0s\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 2100,
"id": "live",
"type": "frame"
},
{
"at": 2300,
"duration": 1500,
"target": "live",
"type": "spotlight"
},
{
"at": 2500,
"duration": 1700,
"position": "right",
"target": "live",
"text": "Each subagent gets its own depth and tool budget; the dashboard tracks them live.",
"type": "caption"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C├─\u001b[1Ctests\u001b[3Ccomplete\u001b[2C18\u001b[1Ctools\u001b[3C⏱\u001b[1C22.7s\u001b[3C✓\r\n\u001b[2C├─\u001b[1Clint\u001b[4Ccomplete\u001b[3C6\u001b[1Ctools\u001b[3C⏱\u001b[1C18.1s\u001b[3C✓\r\n\u001b[2C└─\u001b[1Cdeploy\u001b[2Crunning\u001b[4C9\u001b[1Ctools\u001b[3C⏱\u001b[2C9.4s\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 4400,
"id": "hot",
"type": "frame"
},
{
"at": 4600,
"duration": 1300,
"target": "hot",
"type": "highlight"
},
{
"at": 4800,
"duration": 1700,
"position": "right",
"target": "hot",
"text": "Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.",
"type": "caption"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CAll\u001b[1Cthree\u001b[1Clanded:\u001b[1C24\u001b[1Ctests\u001b[1Cpass,\u001b[1Clint\u001b[1Cclean,\u001b[1Cpreview\u001b[1Cat\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 6800,
"id": "summary",
"type": "frame"
},
{
"at": 7000,
"duration": 1700,
"position": "right",
"target": "summary",
"text": "Subagent results stream back into the parent transcript as a single highlight.",
"type": "caption"
},
{
"at": 8800,
"duration": 600,
"text": "/agents",
"type": "compose"
}
],
"title": "Hermes TUI · Subagent Trail",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -0,0 +1,76 @@
{
"composer": "ctrl+b to start recording",
"timeline": [
{
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1CVAD\u001b[1C·\u001b[1Ccapturing\u001b[1C(3)\r\n\u001b[2C├─\u001b[1C▮\u001b[1C▮▮\u001b[1C▮\u001b[1C▮▮▮▮\u001b[1C▮▮\u001b[1C▮▮▮▮▮▮\u001b[1C▮▮▮\u001b[1C▮\r\n\u001b[2C├─\u001b[1Crms\u001b[1C0.42\u001b[1C·\u001b[1C1.6s\u001b[1Ccaptured\r\n\u001b[2C└─\u001b[1Cauto-stop\u001b[1C·\u001b[1Csilence\u001b[1C380ms\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 250,
"id": "vad",
"type": "frame"
},
{
"at": 600,
"duration": 1500,
"target": "vad",
"type": "spotlight"
},
{
"at": 800,
"duration": 1700,
"position": "right",
"target": "vad",
"text": "Continuous loop: VAD detects silence, transcribes, restarts — no key holds.",
"type": "caption"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\r\n\u001b[2Cwhat's\u001b[1Cin\u001b[1Cmy\u001b[1Cinbox\u001b[1Ctoday\u001b[1Cand\u001b[1Cwhat\u001b[1Cneeds\u001b[1Ca\u001b[1Creply\u001b[1Cbefore\u001b[1Cnoon?\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 2700,
"id": "transcript",
"type": "frame"
},
{
"at": 3400,
"duration": 1100,
"target": "transcript",
"type": "highlight"
},
{
"at": 3600,
"duration": 1700,
"position": "right",
"target": "transcript",
"text": "Transcript flows straight into the composer with the standard user glyph.",
"type": "caption"
},
{
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CThree\u001b[1Cthreads\u001b[1Cneed\u001b[1Cyou\u001b[1Cbefore\u001b[1Cnoon:\u001b[1Cvendor\u001b[1Crenewal,\u001b[1Cpodcast\u001b[1Cintro\u001b[1Cfeedback,\r\n\u001b[4Cand\u001b[1Cthe\u001b[1Cdesign\u001b[1Creview\u001b[1Cat\u001b[1C11.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 5500,
"id": "answer",
"type": "frame"
},
{
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1Ctts\u001b[1C·\u001b[1Cplaying\u001b[1C(3)\r\n\u001b[2C├─\u001b[1Cvoice\u001b[1C11labs\u001b[1C·\u001b[1Cgrace_v3\r\n\u001b[2C├─\u001b[1Celapsed\u001b[1C4.6s\u001b[1C·\u001b[1C2\u001b[1Cchunks\u001b[1Cqueued\r\n\u001b[2C└─\u001b[1Cducking\u001b[1Cmic\u001b[1Cinput\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
"at": 6700,
"id": "tts",
"type": "frame"
},
{
"at": 7000,
"duration": 1700,
"position": "right",
"target": "tts",
"text": "TTS auto-ducks the mic so the loop never echoes itself back.",
"type": "caption"
},
{
"at": 8800,
"duration": 600,
"text": "/voice off",
"type": "compose"
}
],
"title": "Hermes TUI · Voice Mode",
"viewport": {
"cols": 80,
"rows": 16
}
}

View File

@@ -13,7 +13,11 @@
"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",
"showroom:record": "tsx .showroom/record.tsx"
},
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",