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
This commit is contained in:
Brooklyn Nicholson
2026-04-25 23:33:40 -05:00
parent 70c43d5da1
commit 72ca0809c4
10 changed files with 1076 additions and 417 deletions
+55 -41
View File
@@ -1,70 +1,84 @@
# TUI Showroom
Scripted, record-ready demos for `ui-tui`. Drop a JSON workflow into `workflows/`, hit play.
Cinematic demos of the real `ui-tui`. Workflows are built from actual Ink-rendered ANSI captured from `MessageLine`, `Panel`, and friends — replayed in xterm.js with timeline overlays (captions, spotlights, fades, highlights).
```bash
npm run showroom
npm run showroom:build
npm run showroom # dev server at http://127.0.0.1:4317
npm run showroom:record # re-record all workflows (regenerates JSON)
npm run showroom:build # builds dist/<name>.html for every workflow
npm run showroom:type-check
```
`npm run showroom` serves every workflow in `workflows/` at `http://127.0.0.1:4317`. Use the dropdown in the top-right or pass `?w=<name>` to deep-link a workflow.
```bash
npm run showroom -- --port 4318
npm run showroom -- --workflow .showroom/workflows/feature-tour.json
npm run showroom:build # builds dist/<name>.html for every workflow + dist/index.html
npm run showroom:build .showroom/workflows/voice-mode.json dist/voice.html
```
## Bundled workflows
| File | Demonstrates |
| -------------------------------------- | -------------------------------------- |
| `workflows/feature-tour.json` | Plan → tool trail → result highlight |
| `workflows/subagent-trail.json` | Parallel subagents, hot lanes, summary |
| `workflows/slash-commands.json` | Slash palette: /skills, /model, /agents |
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
| File | Demonstrates |
| -------------------------------------- | ----------------------------------------------------- |
| `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` panels |
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
Use the dropdown in the top-right or pass `?w=<name>` to deep-link a workflow.
## Architecture
```
record.tsx ─┐
↳ MessageLine, │ Ink renders → custom Writable → ANSI string
Panel, Box, Text │
workflows/<name>.json
│ served at /api/workflow/<name>
showroom.js │ xterm.js writes ANSI; DOM overlays target frame ids
browser
```
Every `frame` action embeds the ANSI bytes from a real Ink render; the browser replays them via `@xterm/xterm` (loaded from jsDelivr) so the surface is the actual TUI, not a CSS approximation. Cinematic overlays (captions, spotlights, highlights, fades) are positioned by frame `id` and rendered via DOM.
## Workflow Shape
```json
{
"title": "Hermes TUI Feature Tour",
"viewport": { "cols": 96, "rows": 30, "scale": 4 },
"title": "Hermes TUI · Feature Tour",
"viewport": { "cols": 80, "rows": 16 },
"composer": "ask hermes anything",
"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." }
{ "at": 200, "type": "frame", "id": "user-row", "ansi": "..." },
{ "at": 1500, "type": "frame", "id": "assistant", "ansi": "..." },
{ "at": 1700, "type": "spotlight", "target": "assistant" },
{ "at": 1900, "type": "caption", "target": "assistant", "text": "..." }
]
}
```
## Timeline Actions
| Action | Required | Optional |
| ----------- | -------------------- | ------------------------------------------- |
| `status` | `text` | `detail` |
| `compose` | `text` | `duration` (typewriter) |
| `message` | `role`, `text` | `id`, `duration` |
| `tool` | `title`, `items` | `id` |
| `caption` | `target`, `text` | `position` (`left`/`right`/`top`), `duration` |
| `spotlight` | `target` | `pad`, `duration` |
| `highlight` | `target` | `duration` |
| `fade` | `target` | `to` (default `0`), `duration` |
| `clear` | — | — |
| 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 `message`, `tool`, or caption. `viewport.scale` is the upscale factor — `scale: 4` produces a 4x capture surface without rescaling the source terminal proportions.
`target` references the `id` of an earlier `frame`. `viewport.scale` (default = best-fit integer) controls the upscale factor; manual buttons offer 1x4x for capture-ready output.
## Player
- Restart, Clear, and 0.5x / 1x / 2x speed buttons under the stage.
- Restart, Clear, 1x4x scale, 0.5x/1x/2x speed.
- Keyboard: `R` restart, `C` clear, `1`/`2`/`3` speed.
- Progress bar tracks elapsed/total based on the slowest action's `at + duration`.
## Authoring tips
## Adding a workflow
- Keep `at` values in milliseconds; sort happens automatically.
- Use `id`s on every element you want to spotlight, fade, or caption later.
- Captions auto-position next to their target; pass `position: "left"` or `"top"` when the right side is busy.
- Test at a non-default speed before recording — fast reads are unforgiving.
1. Add a scene fn to `record.tsx` that returns a `{ title, viewport, composer, timeline }` shape.
2. Compose Ink primitives (`Box`, `Text`) or import real ui-tui components (`MessageLine`, `Panel`).
3. Snap each scene with `await snap(<Component />)` to capture ANSI.
4. Run `npm run showroom:record`.
Components rendered to ANSI must be **state-free** at first paint — `useEffect` hooks usually haven't fired by the time the recorder unmounts. For accordions like the live `ToolTrail`, render an inline scene with `Box` + `Text` instead.
+434
View File
@@ -0,0 +1,434 @@
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 { DEFAULT_THEME } from '../src/theme.js'
import type { 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 in 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 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 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 agentsStatus = await snap(
<Panel
sections={[
{
rows: [
['delegation', 'paused'],
['max children', '4'],
['running tasks', 'queued for resume']
]
}
]}
t={t}
title="/agents · paused"
/>,
180
)
return {
composer: 'press / to open the palette',
timeline: [
{ at: 200, duration: 500, text: '/skills search vibe', type: 'compose' },
{ ansi: skillsResults, at: 800, id: 'skills', type: 'frame' },
{ at: 1100, duration: 1500, target: 'skills', type: 'spotlight' },
{
at: 1300,
duration: 1700,
position: 'right',
target: 'skills',
text: 'Slash commands stream live results without blocking the composer.',
type: 'caption'
},
{ at: 3300, duration: 600, text: '/model claude-4.6-sonnet', type: 'compose' },
{ ansi: modelSwitch, at: 4100, id: 'model', type: 'frame' },
{
at: 4400,
duration: 1700,
position: 'right',
target: 'model',
text: '/model also pops the inline picker when no arg is given.',
type: 'caption'
},
{ at: 6300, duration: 600, text: '/agents pause', type: 'compose' },
{ ansi: agentsStatus, at: 7000, id: 'agents', type: 'frame' },
{ at: 7300, duration: 1300, target: 'agents', type: 'highlight' },
{
at: 7500,
duration: 1700,
position: 'right',
target: 'agents',
text: 'Same registry powers TUI, gateway, Telegram, Discord — one source of truth.',
type: 'caption'
},
{ at: 9300, duration: 600, text: '/resume', type: 'compose' }
],
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 }
}
}
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', '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())
console.log('done')
}
void main().catch(error => {
console.error(error)
process.exit(1)
})
+2 -2
View File
@@ -6,8 +6,8 @@ import {
listWorkflows,
readWorkflow,
renderPage,
type WorkflowEntry,
workflowsDir
workflowsDir,
type WorkflowEntry
} from './page.js'
const FLAG_VALUES = new Set(['--port', '--workflow'])
+172 -86
View File
@@ -1,7 +1,17 @@
:root {
color-scheme: dark;
background: #070707;
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;
--status-bg: #1a1a2e;
--status-fg: #c0c0c0;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
@@ -15,19 +25,24 @@ body {
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;
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: 48px 48px 96px;
padding: 24px 24px 60px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.showroom-shell {
display: grid;
gap: 18px;
gap: 10px;
width: max-content;
max-width: 100%;
opacity: 0;
transform: translateY(12px);
transition:
@@ -45,8 +60,8 @@ body {
align-items: end;
justify-content: space-between;
gap: 24px;
color: #f5e8c7;
font-size: 20px;
color: var(--cornsilk);
font-size: 18px;
letter-spacing: 0.04em;
}
@@ -54,13 +69,12 @@ body {
display: flex;
align-items: baseline;
gap: 12px;
color: #f5e8c7;
}
.showroom-title-tag {
color: #8f856f;
color: var(--dim);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
}
@@ -69,28 +83,28 @@ body {
display: flex;
gap: 12px;
align-items: center;
color: #8f856f;
color: var(--dim);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.showroom-picker {
appearance: none;
border: 1px solid rgba(245, 232, 199, 0.18);
border: 1px solid rgba(205, 127, 50, 0.4);
border-radius: 999px;
padding: 6px 30px 6px 14px;
background:
rgba(245, 232, 199, 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='%23f5e8c7' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
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: #f5e8c7;
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
cursor: pointer;
}
.showroom-picker:focus {
outline: 1px solid rgba(241, 203, 120, 0.6);
outline: 1px solid var(--bronze);
}
.showroom-stage {
@@ -98,12 +112,12 @@ body {
width: var(--stage-w);
height: var(--stage-h);
overflow: hidden;
border: 1px solid rgba(245, 232, 199, 0.18);
border-radius: 28px;
background: #080808;
border: 1px solid rgba(205, 127, 50, 0.45);
border-radius: 14px;
background: var(--bg);
box-shadow:
0 48px 160px rgba(0, 0, 0, 0.56),
0 0 0 1px rgba(255, 255, 255, 0.035) inset;
0 32px 120px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.showroom-terminal {
@@ -116,41 +130,65 @@ body {
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;
padding: 8px 10px;
background: var(--bg);
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
line-height: 18px;
}
.showroom-status,
.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;
}
.showroom-composer {
display: flex;
align-items: center;
min-height: 22px;
color: #8f856f;
padding: 6px 4px 0;
color: var(--cornsilk);
white-space: nowrap;
}
.showroom-status {
justify-content: space-between;
border-bottom: 1px solid rgba(245, 232, 199, 0.1);
padding-bottom: 7px;
.showroom-composer:empty {
display: none;
}
.showroom-composer {
border-top: 1px solid rgba(245, 232, 199, 0.1);
padding-top: 7px;
.showroom-composer::before {
content: '';
color: var(--gold);
font-weight: 700;
margin-right: 8px;
}
.showroom-composer::after {
.showroom-composer:not(:empty)::after {
content: '';
display: inline-block;
width: 7px;
height: 14px;
margin-left: 4px;
background: #f1cb78;
background: var(--gold);
vertical-align: middle;
animation: showroom-blink 1100ms steps(2) infinite;
}
@@ -164,15 +202,32 @@ body {
.showroom-body {
display: flex;
flex-direction: column;
gap: 9px;
gap: 6px;
overflow: hidden;
padding: 12px 0;
padding: 4px 0 6px;
}
.showroom-body.is-frame-mode {
padding: 0;
gap: 0;
display: block;
}
.showroom-xterm {
width: 100%;
height: 100%;
overflow: hidden;
}
.showroom-xterm .xterm-viewport {
overflow: hidden !important;
background: transparent !important;
}
.showroom-line,
.showroom-tool {
opacity: 0;
transform: translateY(6px);
transform: translateY(4px);
animation: showroom-enter 320ms var(--ease-out) forwards;
transition:
opacity 420ms var(--ease-in-out),
@@ -190,8 +245,8 @@ body {
.showroom-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 6px;
grid-template-columns: 22px 1fr;
gap: 4px;
}
.showroom-glyph {
@@ -200,39 +255,58 @@ body {
}
.showroom-copy {
color: var(--role);
color: var(--copy);
white-space: pre-wrap;
}
.showroom-line-user .showroom-copy {
color: var(--label);
font-weight: 600;
}
.showroom-line-assistant .showroom-copy {
color: #d8d0bd;
color: var(--cornsilk);
}
.showroom-line-system .showroom-copy {
color: var(--dim);
}
.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;
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: #f1cb78;
color: var(--gold);
font-weight: 700;
}
.showroom-tool-title::before {
content: '⚡ ';
color: var(--bronze);
}
.showroom-tool-items {
display: grid;
gap: 2px;
margin-top: 5px;
color: #908872;
gap: 1px;
margin-top: 4px;
color: var(--dim);
font-size: 12px;
}
.showroom-tool-items div::before {
content: '┊ ';
color: var(--bronze);
}
.is-highlighted {
filter: brightness(1.45);
background: rgba(214, 168, 79, 0.12);
transform: translateX(4px);
filter: brightness(1.4);
background: rgba(255, 215, 0, 0.1);
transform: translateX(3px);
}
.showroom-overlays {
@@ -251,24 +325,24 @@ body {
}
.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;
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 rgba(241, 203, 120, 0.7);
border-radius: 16px;
border: 2px solid var(--gold);
border-radius: 8px;
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.34),
0 0 40px rgba(241, 203, 120, 0.22);
0 0 0 9999px rgba(0, 0, 0, 0.42),
0 0 32px rgba(255, 215, 0, 0.32);
}
.showroom-caption.is-visible,
@@ -285,40 +359,52 @@ body {
}
.showroom-controls button {
border: 1px solid rgba(245, 232, 199, 0.18);
border: 1px solid rgba(205, 127, 50, 0.35);
border-radius: 999px;
padding: 8px 14px;
background: rgba(245, 232, 199, 0.06);
color: #f5e8c7;
padding: 6px 14px;
background: rgba(205, 127, 50, 0.06);
color: var(--cornsilk);
cursor: pointer;
font: inherit;
font-size: 13px;
}
.showroom-controls button:hover {
background: rgba(245, 232, 199, 0.12);
background: rgba(205, 127, 50, 0.14);
}
.showroom-speed {
.showroom-segmented {
display: inline-flex;
border: 1px solid rgba(245, 232, 199, 0.18);
border: 1px solid rgba(205, 127, 50, 0.35);
border-radius: 999px;
padding: 2px;
background: rgba(245, 232, 199, 0.06);
background: rgba(205, 127, 50, 0.04);
}
.showroom-speed button {
.showroom-segmented button {
border: 0;
border-radius: 999px;
padding: 6px 12px;
padding: 4px 12px;
background: transparent;
color: #c7b891;
color: var(--dim);
cursor: pointer;
font: inherit;
font-size: 12px;
}
.showroom-speed button.is-active {
background: rgba(241, 203, 120, 0.18);
color: #f5e8c7;
.showroom-segmented button.is-active {
background: rgba(255, 215, 0, 0.18);
color: var(--cornsilk);
}
.showroom-segmented-label {
align-self: center;
margin-right: 4px;
color: var(--dim);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.showroom-progress {
@@ -327,7 +413,7 @@ body {
gap: 12px;
width: 100%;
margin-top: 4px;
color: #8f856f;
color: var(--dim);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
}
@@ -335,9 +421,9 @@ body {
.showroom-progress-track {
position: relative;
flex: 1;
height: 4px;
height: 3px;
border-radius: 999px;
background: rgba(245, 232, 199, 0.08);
background: rgba(205, 127, 50, 0.1);
overflow: hidden;
}
@@ -345,6 +431,6 @@ body {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, rgba(214, 168, 79, 0.7), rgba(241, 203, 120, 1));
background: linear-gradient(90deg, var(--bronze), var(--gold));
transition: width 80ms linear;
}
+237 -75
View File
@@ -2,12 +2,14 @@ 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 XTERM_VERSION = '6.0.0'
const role = {
assistant: { color: '#d8d0bd', glyph: '' },
system: { color: '#8f856f', glyph: '·' },
tool: { color: '#f1cb78', glyph: '' },
user: { color: '#f1cb78', glyph: '' }
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
user: { copy: '#daa520', glyph: '', tone: '#ffd700' }
}
const escapeHtml = value =>
@@ -19,15 +21,20 @@ const escapeHtml = value =>
const state = {
body: null,
composer: null,
frameMode: false,
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,
@@ -45,7 +52,13 @@ const clearTimers = () => {
}
}
const target = id => (id ? document.querySelector(`[data-target="${CSS.escape(id)}"]`) : 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) {
@@ -81,8 +94,8 @@ const removeAfter = (node, duration = 1400) => {
)
}
const rectFor = (id, pad = 10) => {
const el = target(id)
const rectFor = (id, pad = 8) => {
const el = resolveTarget(id)
if (!el || !state.overlays) {
return null
@@ -103,21 +116,25 @@ const placeNear = (node, id, position = 'right') => {
const rect = rectFor(id, 0)
if (!rect) {
node.style.left = `${state.viewport.scale * 28}px`
node.style.top = `${state.viewport.scale * 28}px`
node.style.left = '24px'
node.style.top = '24px'
return
}
const gap = 24
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(18, left)}px`
node.style.top = `${Math.max(18, top)}px`
node.style.left = `${Math.max(12, left)}px`
node.style.top = `${Math.max(12, top)}px`
}
const message = action => {
if (state.frameMode) {
return
}
const spec = role[action.role] ?? role.assistant
const line = document.createElement('div')
const glyph = document.createElement('span')
@@ -125,7 +142,8 @@ const message = action => {
line.className = `showroom-line showroom-line-${action.role ?? 'assistant'}`
line.dataset.target = action.id ?? ''
line.style.setProperty('--role', spec.color)
line.style.setProperty('--role', spec.tone)
line.style.setProperty('--copy', spec.copy)
glyph.className = 'showroom-glyph'
glyph.textContent = spec.glyph
@@ -138,6 +156,10 @@ const message = action => {
}
const tool = action => {
if (state.frameMode) {
return
}
const box = document.createElement('div')
const title = document.createElement('div')
const items = document.createElement('div')
@@ -161,8 +183,20 @@ const tool = action => {
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 = target(action.target)
const el = resolveTarget(action.target)
if (!el) {
return
@@ -173,7 +207,7 @@ const fade = action => {
}
const highlight = action => {
const el = target(action.target)
const el = resolveTarget(action.target)
if (!el) {
return
@@ -196,7 +230,7 @@ const caption = action => {
}
const spotlight = action => {
const rect = rectFor(action.target, action.pad ?? 10)
const rect = rectFor(action.target, action.pad ?? 6)
if (!rect) {
return
@@ -222,20 +256,27 @@ const status = action => {
const compose = action => setText(state.composer, action.text ?? '', action.duration ?? 0)
const clearTranscript = () => {
state.body.textContent = ''
state.overlays.textContent = ''
state.frameTargets.clear()
if (state.frameMode && state.term) {
state.term.reset()
state.term.write('\x1b[?25l')
return
}
state.body.textContent = ''
}
const ACTIONS = { caption, clear: clearTranscript, compose, fade, highlight, message, spotlight, status, tool }
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
const fmtTime = ms => {
if (!Number.isFinite(ms)) {
return '0.0s'
}
const sec = Math.max(0, ms) / 1000
return `${sec.toFixed(1)}s`
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
}
const tickProgress = () => {
@@ -254,12 +295,68 @@ const tickProgress = () => {
}
}
const ensureXtermStylesheet = () => {
const id = 'xterm-css'
if (document.getElementById(id)) {
return
}
const link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
link.href = `https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/css/xterm.css`
document.head.append(link)
}
const initXterm = async () => {
ensureXtermStylesheet()
const mod = await import(`https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/+esm`)
const { Terminal } = mod
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')
}
const play = () => {
clearTimers()
clearTranscript()
state.statusLeft.textContent = ''
state.statusRight.textContent = ''
state.composer.textContent = state.workflow.composer ?? ''
state.composer.textContent = state.workflow.composer ?? ''
const timeline = [...(state.workflow.timeline ?? [])].sort((a, b) => a.at - b.at)
@@ -278,11 +375,42 @@ const play = () => {
const setSpeed = next => {
state.speed = next
for (const button of state.shell.querySelectorAll('.showroom-speed button')) {
button.classList.toggle('is-active', Number(button.dataset.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)
}
state.shell.querySelector('[data-role="meta"]').textContent =
`${state.viewport.cols}x${state.viewport.rows} · ${next}x`
}
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)
@@ -295,10 +423,10 @@ const loadWorkflow = async name => {
state.workflow = await response.json()
}
} catch {
/* fall through to current workflow */
/* fall through */
}
play()
await rebuild()
}
const buildOptions = () => {
@@ -315,48 +443,55 @@ const buildOptions = () => {
.join('')
}
const buildSpeed = () =>
SPEEDS.map(
speed =>
`<button type="button" data-speed="${speed}" class="${speed === 1 ? 'is-active' : ''}">${speed}x</button>`
).join('')
const buildSegmented = (values, active) =>
values
.map(value => `<button type="button" data-value="${value}" class="${value === active ? 'is-active' : ''}">${value}x</button>`)
.join('')
const mount = () => {
const viewport = { cellWidth: 9, cols: 96, lineHeight: 18, rows: 30, scale: 4, ...(state.workflow.viewport ?? {}) }
const shell = document.createElement('section')
const computeViewport = () => {
const fromWorkflow = state.workflow.viewport ?? {}
const usesFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
state.viewport = viewport
state.shell = shell
return {
cellWidth: usesFrames ? 9 : 8,
cols: 80,
lineHeight: usesFrames ? 19 : 18,
rows: 24,
scale: 2,
...fromWorkflow
}
}
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`)
const renderShell = () => {
state.viewport = computeViewport()
state.frameMode = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
state.frameTargets.clear()
shell.innerHTML = `
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 = `
<header class="showroom-title">
<span class="showroom-title-name">
<span data-role="title">${escapeHtml(state.workflow.title ?? 'Hermes TUI Showroom')}</span>
<span class="showroom-title-tag">showroom</span>
<span class="showroom-title-tag">${state.frameMode ? 'real ink' : 'showroom'}</span>
</span>
<span class="showroom-meta">
<span>${viewport.cols}x${viewport.rows} · ${viewport.scale}x</span>
<span data-role="meta">${state.viewport.cols}x${state.viewport.rows} · ${state.scale}x</span>
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
</span>
</header>
<div class="showroom-stage">
<div class="showroom-terminal">
<div class="showroom-status" data-target="status">
<span></span>
<span></span>
<span class="showroom-status-left"></span>
<span class="showroom-status-right"></span>
</div>
<div class="showroom-body"></div>
<div class="showroom-body${state.frameMode ? ' is-frame-mode' : ''}"></div>
<div class="showroom-composer" data-target="composer"></div>
</div>
<div class="showroom-overlays"></div>
@@ -368,42 +503,72 @@ const mount = () => {
<footer class="showroom-controls">
<button type="button" data-action="restart">Restart</button>
<button type="button" data-action="clear">Clear</button>
<span class="showroom-speed">${buildSpeed()}</span>
<span class="showroom-segmented-label">scale</span>
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
<span class="showroom-segmented-label">speed</span>
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
</footer>
`
root.replaceChildren(shell)
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.body = shell.querySelector('.showroom-body')
state.composer = shell.querySelector('.showroom-composer')
state.overlays = shell.querySelector('.showroom-overlays')
state.statusLeft = shell.querySelector('.showroom-status span:first-child')
state.statusRight = shell.querySelector('.showroom-status span:last-child')
state.progressFill = shell.querySelector('.showroom-progress-fill')
state.progressLabel = shell.querySelector('[data-role="time"]')
shell.querySelector('[data-action="restart"]').addEventListener('click', play)
shell.querySelector('[data-action="clear"]').addEventListener('click', () => {
state.shell.querySelector('[data-action="restart"]').addEventListener('click', play)
state.shell.querySelector('[data-action="clear"]').addEventListener('click', () => {
clearTimers()
clearTranscript()
})
for (const button of shell.querySelectorAll('.showroom-speed button')) {
button.addEventListener('click', () => setSpeed(Number(button.dataset.speed)))
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
button.addEventListener('click', () => setSpeed(Number(button.dataset.value)))
}
const picker = shell.querySelector('[data-action="picker"]')
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 => {
const next = event.target.value
shell.querySelector('[data-role="title"]').textContent =
catalog.find(c => c.name === next)?.title ?? next
void loadWorkflow(next)
void loadWorkflow(event.target.value)
})
}
if (state.frameMode) {
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
state.termContainer = state.body.querySelector('.showroom-xterm')
} else {
state.term = null
state.termContainer = null
}
}
const rebuild = async () => {
renderShell()
setScale(state.workflow.viewport?.scale ?? fitScale())
if (state.frameMode) {
await initXterm()
}
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()
@@ -416,9 +581,6 @@ const mount = () => {
setSpeed(SPEEDS[Number(key) - 1])
}
})
requestAnimationFrame(() => shell.classList.add('is-mounted'))
play()
}
mount()
+31 -64
View File
@@ -1,78 +1,46 @@
{
"title": "Hermes TUI Feature Tour",
"composer": " ask hermes anything",
"viewport": {
"cellWidth": 9,
"cols": 96,
"lineHeight": 18,
"rows": 30,
"scale": 4
},
"composer": "ask hermes anything",
"timeline": [
{
"at": 0,
"detail": "showroom mode",
"text": "summoning hermes...",
"type": "status"
"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"
},
{
"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,
"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",
"role": "assistant",
"text": "Ill trace the gateway guards first, then patch the smallest boundary that keeps approval commands live while an agent is blocked.",
"type": "message"
"type": "frame"
},
{
"at": 2180,
"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",
"items": [
"rg \"approval.request\" gateway/ tui_gateway/",
"ReadFile gateway/run.py",
"ReadFile gateway/platforms/base.py"
],
"title": "tool trail",
"type": "tool"
"type": "frame"
},
{
"at": 2500,
"duration": 1500,
"at": 3200,
"duration": 1700,
"target": "tool-trail",
"type": "spotlight"
},
{
"at": 2680,
"duration": 1600,
"at": 3400,
"duration": 1700,
"position": "right",
"target": "tool-trail",
"text": "Tool activity is scripted as named targets, so captions and fades can follow the exact beat.",
"text": "Real ui-tui MessageLine + Panel rendered to ANSI and replayed in xterm.js.",
"type": "caption"
},
{
"at": 4450,
"duration": 500,
"target": "tool-trail",
"to": 0.22,
"type": "fade"
},
{
"at": 5050,
"duration": 700,
"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",
"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"
"type": "frame"
},
{
"at": 6050,
"at": 6100,
"duration": 1300,
"target": "assistant-result",
"type": "highlight"
@@ -82,20 +50,19 @@
"duration": 1700,
"position": "right",
"target": "assistant-result",
"text": "Highlights, captions, opacity fades, and spotlight boxes are all timeline actions.",
"text": "Captions, spotlights, and fades layer on top of real ANSI. Best of both.",
"type": "caption"
},
{
"at": 8300,
"duration": 700,
"text": " /approve",
"at": 8100,
"duration": 600,
"text": "/approve",
"type": "compose"
},
{
"at": 9400,
"detail": "record-ready at 4x",
"text": "session complete",
"type": "status"
}
]
}
],
"title": "Hermes TUI · Feature Tour",
"viewport": {
"cols": 80,
"rows": 16
}
}
+55 -51
View File
@@ -1,84 +1,88 @@
{
"title": "Slash Command Tour",
"composer": " press / to open the palette",
"viewport": {
"cols": 96,
"rows": 30,
"scale": 4
},
"composer": "press / to open the palette",
"timeline": [
{ "at": 0, "type": "status", "text": "session active", "detail": "model · gpt-5 codex" },
{ "at": 200, "duration": 500, "text": " /skills search vibe", "type": "compose" },
{
"at": 800,
"id": "skills-results",
"title": "/skills search vibe",
"items": [
"anthropics/skills/frontend-design ★ trusted",
"openai/skills/skill-creator · official",
"skills.sh/community/vibe-coding ⚙ community"
],
"type": "tool"
"at": 200,
"duration": 500,
"text": "/skills search vibe",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[29C/skills\u001b[1Csearch\u001b[1Cvibe\u001b[30C│\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": 800,
"id": "skills",
"type": "frame"
},
{
"at": 1100,
"duration": 1500,
"target": "skills",
"type": "spotlight"
},
{ "at": 1100, "duration": 1500, "target": "skills-results", "type": "spotlight" },
{
"at": 1300,
"duration": 1700,
"position": "right",
"target": "skills-results",
"target": "skills",
"text": "Slash commands stream live results without blocking the composer.",
"type": "caption"
},
{ "at": 3100, "duration": 600, "target": "skills-results", "to": 0.2, "type": "fade" },
{
"at": 3300,
"duration": 600,
"id": "model-prompt",
"role": "user",
"text": "/model claude-4.6-sonnet",
"type": "message"
"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": 4100,
"id": "model-result",
"title": "model switched",
"items": [
"from gpt-5-codex",
"to claude-4.6-sonnet",
"scope this session"
],
"type": "tool"
"id": "model",
"type": "frame"
},
{
"at": 4400,
"duration": 1700,
"position": "right",
"target": "model-result",
"text": "/model also pops the inline picker when you dont pass an arg.",
"target": "model",
"text": "/model also pops the inline picker when no arg is given.",
"type": "caption"
},
{ "at": 6300, "duration": 600, "text": " /agents pause", "type": "compose" },
{
"at": 7000,
"id": "agents-status",
"title": "/agents · paused",
"items": [
"delegation paused",
"max children · 4",
"running tasks queued for resume"
],
"type": "tool"
"at": 6300,
"duration": 600,
"text": "/agents pause",
"type": "compose"
},
{
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[31C/agents\u001b[1C·\u001b[1Cpaused\u001b[31C│\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": 7000,
"id": "agents",
"type": "frame"
},
{
"at": 7300,
"duration": 1300,
"target": "agents",
"type": "highlight"
},
{ "at": 7300, "duration": 1300, "target": "agents-status", "type": "highlight" },
{
"at": 7500,
"duration": 1600,
"duration": 1700,
"position": "right",
"target": "agents-status",
"target": "agents",
"text": "Same registry powers TUI, gateway, Telegram, Discord — one source of truth.",
"type": "caption"
},
{ "at": 9300, "duration": 600, "text": " /resume", "type": "compose" },
{ "at": 10000, "type": "status", "text": "ready", "detail": "/help for the full list" }
]
}
{
"at": 9300,
"duration": 600,
"text": "/resume",
"type": "compose"
}
],
"title": "Hermes TUI · Slash Commands",
"viewport": {
"cols": 80,
"rows": 16
}
}
+48 -54
View File
@@ -1,88 +1,82 @@
{
"title": "Subagent Trail",
"composer": " spawn the deploy fan-out",
"viewport": {
"cols": 96,
"rows": 30,
"scale": 4
},
"composer": "spawn the deploy fan-out",
"timeline": [
{ "at": 0, "type": "status", "text": "delegating…", "detail": "depth 1 / max 3" },
{
"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,
"duration": 600,
"id": "ask",
"role": "user",
"text": "Run tests, lint, and a Railway preview deploy in parallel.",
"type": "message"
"type": "frame"
},
{
"at": 950,
"duration": 800,
"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",
"role": "assistant",
"text": "Spawning three subagents on the fan-out lane and watching their tool counts.",
"type": "message"
"type": "frame"
},
{
"at": 1900,
"id": "agents-table",
"title": "/agents · live",
"items": [
"├─ tests running 12 tools ⏱ 14.2s",
"├─ lint running 4 tools ⏱ 14.2s",
"└─ deploy queued 0 tools ⏱ 0.0s"
],
"type": "tool"
"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": 2200, "duration": 1500, "target": "agents-table", "type": "spotlight" },
{
"at": 2400,
"at": 2300,
"duration": 1500,
"target": "live",
"type": "spotlight"
},
{
"at": 2500,
"duration": 1700,
"position": "right",
"target": "agents-table",
"text": "Each subagent gets its own depth and budget; the dashboard tracks tool count + duration live.",
"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": "lane-update",
"title": "depth 2 · hot lane",
"items": [
"├─ tests complete 18 tools ⏱ 22.7s ✓",
"├─ lint complete 6 tools ⏱ 18.1s ✓",
"└─ deploy running 9 tools ⏱ 9.4s"
],
"type": "tool"
"id": "hot",
"type": "frame"
},
{ "at": 4500, "duration": 800, "target": "agents-table", "to": 0.18, "type": "fade" },
{ "at": 5000, "duration": 1300, "target": "lane-update", "type": "highlight" },
{
"at": 5200,
"at": 4600,
"duration": 1300,
"target": "hot",
"type": "highlight"
},
{
"at": 4800,
"duration": 1700,
"position": "right",
"target": "lane-update",
"target": "hot",
"text": "Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.",
"type": "caption"
},
{
"at": 7100,
"duration": 800,
"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",
"role": "assistant",
"text": "All three landed: 24 tests pass, lint clean, preview at https://pr-128.railway.app.",
"type": "message"
"type": "frame"
},
{ "at": 8050, "duration": 1100, "target": "summary", "type": "highlight" },
{
"at": 8200,
"duration": 1500,
"at": 7000,
"duration": 1700,
"position": "right",
"target": "summary",
"text": "Subagent results stream back into the parent transcript as a single highlight.",
"type": "caption"
},
{ "at": 9700, "duration": 600, "text": " /agents", "type": "compose" },
{ "at": 10500, "type": "status", "text": "trail complete", "detail": "3 subagents · 28 tools" }
]
}
{
"at": 8800,
"duration": 600,
"text": "/agents",
"type": "compose"
}
],
"title": "Hermes TUI · Subagent Trail",
"viewport": {
"cols": 80,
"rows": 16
}
}
+40 -43
View File
@@ -1,25 +1,18 @@
{
"title": "Voice Mode",
"composer": " ctrl+b to start recording",
"viewport": {
"cols": 96,
"rows": 30,
"scale": 4
},
"composer": "ctrl+b to start recording",
"timeline": [
{ "at": 0, "type": "status", "text": "voice · listening", "detail": "STT · openai · TTS · 11labs" },
{
"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",
"title": "VAD · capturing",
"items": [
"▮ ▮▮ ▮ ▮▮▮▮ ▮▮ ▮▮▮▮▮▮ ▮▮▮ ▮",
"rms 0.42 · 1.6s captured",
"auto-stop · silence 380ms"
],
"type": "tool"
"type": "frame"
},
{
"at": 600,
"duration": 1500,
"target": "vad",
"type": "spotlight"
},
{ "at": 600, "duration": 1500, "target": "vad", "type": "spotlight" },
{
"at": 800,
"duration": 1700,
@@ -28,52 +21,56 @@
"text": "Continuous loop: VAD detects silence, transcribes, restarts — no key holds.",
"type": "caption"
},
{ "at": 2700, "duration": 500, "target": "vad", "to": 0.18, "type": "fade" },
{
"at": 3000,
"duration": 700,
"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",
"role": "user",
"text": "what's in my inbox today and what needs a reply before noon?",
"type": "message"
"type": "frame"
},
{ "at": 3900, "duration": 1100, "target": "transcript", "type": "highlight" },
{
"at": 4100,
"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.",
"text": "Transcript flows straight into the composer with the standard user glyph.",
"type": "caption"
},
{
"at": 6000,
"duration": 1100,
"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",
"role": "assistant",
"text": "Three threads need you before noon: vendor renewal, podcast intro feedback, and the design review at 11.",
"type": "message"
"type": "frame"
},
{
"at": 7300,
"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",
"title": "tts · playing",
"items": [
"voice 11labs · grace_v3",
"elapsed 4.6s · 2 chunks queued",
"ducking mic input"
],
"type": "tool"
"type": "frame"
},
{
"at": 7600,
"at": 7000,
"duration": 1700,
"position": "right",
"target": "tts",
"text": "TTS auto-ducks the mic so the loop never echoes itself back.",
"type": "caption"
},
{ "at": 9400, "duration": 600, "text": " /voice off", "type": "compose" },
{ "at": 10100, "type": "status", "text": "voice off", "detail": "transcript saved · 1 turn" }
]
}
{
"at": 8800,
"duration": 600,
"text": "/voice off",
"type": "compose"
}
],
"title": "Hermes TUI · Voice Mode",
"viewport": {
"cols": 80,
"rows": 16
}
}
+2 -1
View File
@@ -16,7 +16,8 @@
"test:watch": "vitest",
"showroom": "tsx .showroom/server.ts",
"showroom:build": "tsx .showroom/build.ts",
"showroom:type-check": "tsc --noEmit -p .showroom/tsconfig.json"
"showroom:type-check": "tsc --noEmit -p .showroom/tsconfig.json",
"showroom:record": "tsx .showroom/record.tsx"
},
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",