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:
+55
-41
@@ -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 1x–4x for capture-ready output.
|
||||
|
||||
## Player
|
||||
|
||||
- Restart, Clear, and 0.5x / 1x / 2x speed buttons under the stage.
|
||||
- Restart, Clear, 1x–4x 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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
listWorkflows,
|
||||
readWorkflow,
|
||||
renderPage,
|
||||
type WorkflowEntry,
|
||||
workflowsDir
|
||||
workflowsDir,
|
||||
type WorkflowEntry
|
||||
} from './page.js'
|
||||
|
||||
const FLAG_VALUES = new Set(['--port', '--workflow'])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "I’ll 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
|
||||
}
|
||||
}
|
||||
@@ -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 don’t 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user