mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
Compare commits
9 Commits
fix/analyt
...
036dd14425
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
036dd14425 | ||
|
|
1e499a7136 | ||
|
|
e58308c680 | ||
|
|
6147a867cd | ||
|
|
7603126c86 | ||
|
|
3eadf10047 | ||
|
|
72ca0809c4 | ||
|
|
70c43d5da1 | ||
|
|
7d79dbc5ad |
67
ui-tui/.showroom/README.md
Normal file
67
ui-tui/.showroom/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# TUI Showroom
|
||||
|
||||
Scripted demos of `ui-tui`. Workflows snapshot real ui-tui components (`MessageLine`, `Panel`, `Box`, `Text`) into ANSI and replay them through xterm.js with cinematic overlays. Recorded once, played any number of times — built for screen capture.
|
||||
|
||||
```bash
|
||||
npm run showroom # dev server at http://127.0.0.1:4317
|
||||
npm run showroom:record # regenerate every workflow JSON
|
||||
npm run showroom:build # dist/<name>.html for every workflow
|
||||
npm run showroom:type-check
|
||||
```
|
||||
|
||||
## Bundled workflows
|
||||
|
||||
| File | Shows |
|
||||
| ------------------------------- | -------------------------------------------------------------- |
|
||||
| `workflows/feature-tour.json` | Plan → tool trail → result highlight |
|
||||
| `workflows/subagent-trail.json` | Parallel subagents, hot lanes, summary |
|
||||
| `workflows/slash-commands.json` | `/skills`, `/model`, `/agents`, `/help` typed → echoed → panel |
|
||||
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
|
||||
|
||||
Pick a workflow from the dropdown or deep-link with `?w=<name>`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
record.tsx ─┐
|
||||
↳ MessageLine, │ Ink renders → Writable → ANSI string
|
||||
Panel, Box, Text │
|
||||
▼
|
||||
workflows/<name>.json
|
||||
│ served at /api/workflow/<name>
|
||||
▼
|
||||
showroom.js │ xterm.js renders ANSI; DOM overlays target frame ids
|
||||
▼
|
||||
browser
|
||||
```
|
||||
|
||||
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (CDN, cached) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
|
||||
|
||||
## Timeline actions
|
||||
|
||||
| Action | Required | Optional |
|
||||
| ----------- | ---------------- | --------------------------------------------- |
|
||||
| `frame` | `ansi` | `id` |
|
||||
| `status` | `text` | `detail` |
|
||||
| `compose` | `text` | `duration` (typewriter) |
|
||||
| `caption` | `target`, `text` | `position` (`left`/`right`/`top`), `duration` |
|
||||
| `spotlight` | `target` | `pad`, `duration` |
|
||||
| `highlight` | `target` | `duration` |
|
||||
| `fade` | `target` | `to` (default `0`), `duration` |
|
||||
| `clear` | — | — |
|
||||
|
||||
`target` references the `id` of an earlier `frame`. `viewport.scale` (or the 1x–4x picker) controls the upscale factor for capture.
|
||||
|
||||
## Player
|
||||
|
||||
- Restart (`R`), 1x–4x scale, 0.5x/1x/2x speed (`1`/`2`/`3`).
|
||||
- Progress bar reads `at + duration` from the slowest action.
|
||||
|
||||
## Adding a workflow
|
||||
|
||||
1. Add a scene fn to `record.tsx` returning `{ title, viewport, composer, timeline }`.
|
||||
2. Compose Ink primitives or pull `MessageLine` / `Panel` from `../src`.
|
||||
3. `await snap(<Component />)` for each frame.
|
||||
4. `npm run showroom:record`.
|
||||
|
||||
Components must be state-free at first paint — `useEffect` hooks won't fire by the time the recorder unmounts. For accordions like the live `ToolTrail`, render a flat `Box` + `Text` scene instead.
|
||||
70
ui-tui/.showroom/build.ts
Normal file
70
ui-tui/.showroom/build.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
import { listWorkflows, readWorkflow, renderPage, showroomRoot } from './page.js'
|
||||
|
||||
const FLAG_VALUES = new Set<string>([])
|
||||
|
||||
const positionals = (() => {
|
||||
const argv = process.argv.slice(2)
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const value = argv[i]!
|
||||
|
||||
if (FLAG_VALUES.has(value)) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (value.startsWith('-')) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(value)
|
||||
}
|
||||
|
||||
return out
|
||||
})()
|
||||
|
||||
const explicitWorkflow = positionals[0]
|
||||
const explicitOut = positionals[1]
|
||||
const distDir = resolve(showroomRoot, 'dist')
|
||||
|
||||
const writeHtml = (path: string, html: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, html)
|
||||
}
|
||||
|
||||
const buildAll = () => {
|
||||
const catalog = listWorkflows()
|
||||
|
||||
for (const entry of catalog) {
|
||||
const html = renderPage({ name: entry.name, workflow: readWorkflow(entry.path) }, catalog)
|
||||
const out = join(distDir, `${entry.name}.html`)
|
||||
|
||||
writeHtml(out, html)
|
||||
console.log(out)
|
||||
}
|
||||
|
||||
if (catalog.length) {
|
||||
const indexEntry = catalog.find(w => w.name === 'feature-tour') ?? catalog[0]!
|
||||
const html = renderPage({ name: indexEntry.name, workflow: readWorkflow(indexEntry.path) }, catalog)
|
||||
const out = join(distDir, 'index.html')
|
||||
|
||||
writeHtml(out, html)
|
||||
console.log(out)
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitWorkflow) {
|
||||
const path = resolve(process.cwd(), explicitWorkflow)
|
||||
const out = resolve(process.cwd(), explicitOut ?? join(distDir, 'index.html'))
|
||||
const catalog = listWorkflows()
|
||||
const html = renderPage({ name: 'override', workflow: readWorkflow(path) }, catalog)
|
||||
|
||||
writeHtml(out, html)
|
||||
console.log(out)
|
||||
} else {
|
||||
buildAll()
|
||||
}
|
||||
58
ui-tui/.showroom/page.ts
Normal file
58
ui-tui/.showroom/page.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, parse } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const showroomRoot = dirname(fileURLToPath(import.meta.url))
|
||||
export const workflowsDir = join(showroomRoot, 'workflows')
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const listWorkflows = (): WorkflowEntry[] =>
|
||||
readdirSync(workflowsDir)
|
||||
.filter(file => file.endsWith('.json') && statSync(join(workflowsDir, file)).isFile())
|
||||
.map(file => {
|
||||
const path = join(workflowsDir, file)
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||
|
||||
return { name: parse(file).name, path, title: String(data.title ?? parse(file).name) }
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
export const defaultWorkflowPath =
|
||||
listWorkflows().find(w => w.name === 'feature-tour')?.path ?? listWorkflows()[0]?.path ?? ''
|
||||
|
||||
export const readWorkflow = (path = defaultWorkflowPath) => JSON.parse(readFileSync(path, 'utf8'))
|
||||
|
||||
export const renderPage = (initial: { name: string; workflow: unknown }, catalog: WorkflowEntry[]) => {
|
||||
const css = readFileSync(join(showroomRoot, 'src', 'showroom.css'), 'utf8')
|
||||
const js = readFileSync(join(showroomRoot, 'src', 'showroom.js'), 'utf8')
|
||||
const safeCatalog = catalog.map(({ name, title }) => ({ name, title }))
|
||||
const initialJson = JSON.stringify(initial).replace(/</g, '\\u003c')
|
||||
const catalogJson = JSON.stringify(safeCatalog).replace(/</g, '\\u003c')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hermes TUI Showroom</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" />
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="showroom"></main>
|
||||
<script>
|
||||
window.__SHOWROOM_INITIAL__ = ${initialJson};
|
||||
window.__SHOWROOM_CATALOG__ = ${catalogJson};
|
||||
</script>
|
||||
<script type="importmap">
|
||||
{ "imports": { "@xterm/": "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/" } }
|
||||
</script>
|
||||
<script type="module">${js}</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
802
ui-tui/.showroom/record.tsx
Normal file
802
ui-tui/.showroom/record.tsx
Normal file
@@ -0,0 +1,802 @@
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { Box, render, Text } from '@hermes/ink'
|
||||
|
||||
import { Panel } from '../src/components/branding.js'
|
||||
import { MessageLine } from '../src/components/messageLine.js'
|
||||
import type { Theme } from '../src/theme.js'
|
||||
import { DEFAULT_THEME } from '../src/theme.js'
|
||||
import type { Msg } from '../src/types.js'
|
||||
|
||||
const showroomRoot = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
class Capture extends Writable {
|
||||
buffer = ''
|
||||
isTTY = true
|
||||
columns: number
|
||||
rows: number
|
||||
|
||||
constructor(cols: number, rows: number) {
|
||||
super()
|
||||
this.columns = cols
|
||||
this.rows = rows
|
||||
}
|
||||
|
||||
override _write(chunk: any, _encoding: any, callback: any) {
|
||||
this.buffer += chunk.toString()
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const COLS = 80
|
||||
const ROWS = 16
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
const snap = async (node: React.ReactElement, settle = 120): Promise<string> => {
|
||||
const stdout = new Capture(COLS, ROWS) as unknown as NodeJS.WriteStream
|
||||
const inst = await render(node, { stdout, exitOnCtrlC: false, patchConsole: false })
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, settle))
|
||||
inst.unmount()
|
||||
|
||||
return (stdout as unknown as Capture).buffer
|
||||
}
|
||||
|
||||
const Msg = (msg: Msg) => <MessageLine cols={COLS} msg={msg} t={t} />
|
||||
|
||||
const ToolPanel = ({ items, title, theme }: { items: string[]; theme: Theme; title: string }) => (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<Box>
|
||||
<Text color={theme.color.bronze}>⚡ </Text>
|
||||
<Text bold color={theme.color.amber}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text color={theme.color.dim}> ({items.length})</Text>
|
||||
</Box>
|
||||
{items.map((item, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={theme.color.bronze}>{i === items.length - 1 ? '└─ ' : '├─ '}</Text>
|
||||
<Text color={theme.color.dim}>{item}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const Tree = ({
|
||||
rows,
|
||||
theme
|
||||
}: {
|
||||
rows: { branch: 'mid' | 'last'; cols: string[]; tone?: 'amber' | 'dim' | 'gold' | 'ok' }[]
|
||||
theme: Theme
|
||||
}) => (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{rows.map((row, i) => {
|
||||
const stem = row.branch === 'last' ? '└─ ' : '├─ '
|
||||
const tone =
|
||||
row.tone === 'gold'
|
||||
? theme.color.gold
|
||||
: row.tone === 'amber'
|
||||
? theme.color.amber
|
||||
: row.tone === 'ok'
|
||||
? theme.color.ok
|
||||
: theme.color.dim
|
||||
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text color={theme.color.bronze}>{stem}</Text>
|
||||
<Text color={tone}>{row.cols.join(' ')}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const writeWorkflow = (name: string, workflow: Record<string, unknown>) => {
|
||||
const out = join(showroomRoot, 'workflows', `${name}.json`)
|
||||
writeFileSync(out, JSON.stringify(workflow, null, 2))
|
||||
console.log(` wrote ${out}`)
|
||||
}
|
||||
|
||||
const featureTour = async () => {
|
||||
const userPrompt = await snap(<Msg role="user" text="Build a focused plan for a safer gateway approval flow." />)
|
||||
|
||||
const assistantPlan = await snap(
|
||||
<Msg
|
||||
role="assistant"
|
||||
text="I'll trace the gateway guards first, then patch the smallest boundary that keeps approval commands live while an agent is blocked."
|
||||
/>
|
||||
)
|
||||
|
||||
const toolTrail = await snap(
|
||||
<ToolPanel
|
||||
items={[
|
||||
'rg "approval.request" gateway/ tui_gateway/',
|
||||
'ReadFile gateway/run.py',
|
||||
'ReadFile gateway/platforms/base.py'
|
||||
]}
|
||||
theme={t}
|
||||
title="tool trail"
|
||||
/>
|
||||
)
|
||||
|
||||
const assistantResult = await snap(
|
||||
<Msg
|
||||
role="assistant"
|
||||
text="Found the split guard. Bypass both queues only for approval commands; normal chat ordering stays intact."
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
composer: 'ask hermes anything',
|
||||
timeline: [
|
||||
{ ansi: userPrompt, at: 200, id: 'user-row', type: 'frame' },
|
||||
{ ansi: assistantPlan, at: 1500, id: 'assistant-plan', type: 'frame' },
|
||||
{ ansi: toolTrail, at: 2900, id: 'tool-trail', type: 'frame' },
|
||||
{ at: 3200, duration: 1700, target: 'tool-trail', type: 'spotlight' },
|
||||
{
|
||||
at: 3400,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'tool-trail',
|
||||
text: 'Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: assistantResult, at: 5400, id: 'assistant-result', type: 'frame' },
|
||||
{ at: 6100, duration: 1300, target: 'assistant-result', type: 'highlight' },
|
||||
{
|
||||
at: 6300,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'assistant-result',
|
||||
text: 'Captions, spotlights, and fades layer on top of real ANSI. Best of both.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 8100, duration: 600, text: '/approve', type: 'compose' }
|
||||
],
|
||||
title: 'Hermes TUI · Feature Tour',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const subagentTrail = async () => {
|
||||
const userPrompt = await snap(<Msg role="user" text="Run tests, lint, and a Railway preview deploy in parallel." />)
|
||||
|
||||
const plan = await snap(
|
||||
<Msg role="assistant" text="Spawning three subagents on the fan-out lane and watching their tool counts." />
|
||||
)
|
||||
|
||||
const live = await snap(
|
||||
<Tree
|
||||
rows={[
|
||||
{ branch: 'mid', cols: ['tests running 12 tools ⏱ 14.2s'], tone: 'amber' },
|
||||
{ branch: 'mid', cols: ['lint running 4 tools ⏱ 14.2s'], tone: 'amber' },
|
||||
{ branch: 'last', cols: ['deploy queued 0 tools ⏱ 0.0s'], tone: 'dim' }
|
||||
]}
|
||||
theme={t}
|
||||
/>
|
||||
)
|
||||
|
||||
const hot = await snap(
|
||||
<Tree
|
||||
rows={[
|
||||
{ branch: 'mid', cols: ['tests complete 18 tools ⏱ 22.7s ✓'], tone: 'ok' },
|
||||
{ branch: 'mid', cols: ['lint complete 6 tools ⏱ 18.1s ✓'], tone: 'ok' },
|
||||
{ branch: 'last', cols: ['deploy running 9 tools ⏱ 9.4s'], tone: 'gold' }
|
||||
]}
|
||||
theme={t}
|
||||
/>
|
||||
)
|
||||
|
||||
const summary = await snap(
|
||||
<Msg role="assistant" text="All three landed: 24 tests pass, lint clean, preview at https://pr-128.railway.app." />
|
||||
)
|
||||
|
||||
return {
|
||||
composer: 'spawn the deploy fan-out',
|
||||
timeline: [
|
||||
{ ansi: userPrompt, at: 200, id: 'ask', type: 'frame' },
|
||||
{ ansi: plan, at: 1100, id: 'plan', type: 'frame' },
|
||||
{ ansi: live, at: 2100, id: 'live', type: 'frame' },
|
||||
{ at: 2300, duration: 1500, target: 'live', type: 'spotlight' },
|
||||
{
|
||||
at: 2500,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'live',
|
||||
text: 'Each subagent gets its own depth and tool budget; the dashboard tracks them live.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: hot, at: 4400, id: 'hot', type: 'frame' },
|
||||
{ at: 4600, duration: 1300, target: 'hot', type: 'highlight' },
|
||||
{
|
||||
at: 4800,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'hot',
|
||||
text: 'Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: summary, at: 6800, id: 'summary', type: 'frame' },
|
||||
{
|
||||
at: 7000,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'summary',
|
||||
text: 'Subagent results stream back into the parent transcript as a single highlight.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 8800, duration: 600, text: '/agents', type: 'compose' }
|
||||
],
|
||||
title: 'Hermes TUI · Subagent Trail',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const slashCommands = async () => {
|
||||
const slashEcho = (text: string) => snap(<Msg kind="slash" role="user" text={text} />)
|
||||
|
||||
const skillsEcho = await slashEcho('/skills search vibe')
|
||||
const skillsResults = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['anthropics/skills/frontend-design', '★ trusted'],
|
||||
['openai/skills/skill-creator', '· official'],
|
||||
['skills.sh/community/vibe-coding', '⚙ community']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="skills · search vibe"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const modelEcho = await slashEcho('/model claude-4.6-sonnet')
|
||||
const modelSwitch = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['from', 'gpt-5-codex'],
|
||||
['to', 'claude-4.6-sonnet'],
|
||||
['scope', 'this session']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="model switched"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const agentsEcho = await slashEcho('/agents pause')
|
||||
const agentsStatus = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['delegation', 'paused'],
|
||||
['max children', '4'],
|
||||
['running tasks', 'queued for resume']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="agents · paused"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const helpEcho = await slashEcho('/help')
|
||||
const helpPanel = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
items: ['/skills search · install · inspect', '/model switch model · pop picker'],
|
||||
title: 'Tools & Skills'
|
||||
},
|
||||
{
|
||||
items: [
|
||||
'/agents spawn-tree dashboard',
|
||||
'/queue queue prompt for next turn',
|
||||
'/steer inject after next tool call'
|
||||
],
|
||||
title: 'Session'
|
||||
},
|
||||
{
|
||||
items: ['/voice toggle voice mode', '/details thinking · tools · subagents · activity'],
|
||||
title: 'Configuration'
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="(^_^)? Commands"
|
||||
/>,
|
||||
220
|
||||
)
|
||||
|
||||
return {
|
||||
composer: '',
|
||||
timeline: [
|
||||
{ at: 200, duration: 700, text: '/skills search vibe', type: 'compose' },
|
||||
{ ansi: skillsEcho, at: 1100, type: 'frame' },
|
||||
{ at: 1100, duration: 200, text: '', type: 'compose' },
|
||||
{ ansi: skillsResults, at: 1400, id: 'skills', type: 'frame' },
|
||||
{
|
||||
at: 1700,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'skills',
|
||||
text: 'Typed /skills, hit return — same Panel the live TUI renders.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 4000, duration: 700, text: '/model claude-4.6-sonnet', type: 'compose' },
|
||||
{ ansi: modelEcho, at: 4900, type: 'frame' },
|
||||
{ at: 4900, duration: 200, text: '', type: 'compose' },
|
||||
{ ansi: modelSwitch, at: 5200, id: 'model', type: 'frame' },
|
||||
{
|
||||
at: 5500,
|
||||
duration: 1900,
|
||||
position: 'right',
|
||||
target: 'model',
|
||||
text: '/model swaps mid-session; transcript and cache stay intact.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 7600, duration: 600, text: '/agents pause', type: 'compose' },
|
||||
{ ansi: agentsEcho, at: 8400, type: 'frame' },
|
||||
{ at: 8400, duration: 200, text: '', type: 'compose' },
|
||||
{ ansi: agentsStatus, at: 8700, id: 'agents', type: 'frame' },
|
||||
{
|
||||
at: 9000,
|
||||
duration: 1800,
|
||||
position: 'right',
|
||||
target: 'agents',
|
||||
text: 'Same registry powers TUI, gateway, Telegram, Discord — one truth.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 11000, duration: 400, text: '/help', type: 'compose' },
|
||||
{ ansi: helpEcho, at: 11500, type: 'frame' },
|
||||
{ at: 11500, duration: 200, text: '', type: 'compose' },
|
||||
{ ansi: helpPanel, at: 11800, id: 'help', type: 'frame' }
|
||||
],
|
||||
title: 'Hermes TUI · Slash Commands',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const voiceMode = async () => {
|
||||
const vad = await snap(
|
||||
<ToolPanel
|
||||
items={['▮ ▮▮ ▮ ▮▮▮▮ ▮▮ ▮▮▮▮▮▮ ▮▮▮ ▮', 'rms 0.42 · 1.6s captured', 'auto-stop · silence 380ms']}
|
||||
theme={t}
|
||||
title="VAD · capturing"
|
||||
/>
|
||||
)
|
||||
|
||||
const transcript = await snap(<Msg role="user" text="what's in my inbox today and what needs a reply before noon?" />)
|
||||
|
||||
const answer = await snap(
|
||||
<Msg
|
||||
role="assistant"
|
||||
text="Three threads need you before noon: vendor renewal, podcast intro feedback, and the design review at 11."
|
||||
/>
|
||||
)
|
||||
|
||||
const tts = await snap(
|
||||
<ToolPanel
|
||||
items={['voice 11labs · grace_v3', 'elapsed 4.6s · 2 chunks queued', 'ducking mic input']}
|
||||
theme={t}
|
||||
title="tts · playing"
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
composer: 'ctrl+b to start recording',
|
||||
timeline: [
|
||||
{ ansi: vad, at: 250, id: 'vad', type: 'frame' },
|
||||
{ at: 600, duration: 1500, target: 'vad', type: 'spotlight' },
|
||||
{
|
||||
at: 800,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'vad',
|
||||
text: 'Continuous loop: VAD detects silence, transcribes, restarts — no key holds.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: transcript, at: 2700, id: 'transcript', type: 'frame' },
|
||||
{ at: 3400, duration: 1100, target: 'transcript', type: 'highlight' },
|
||||
{
|
||||
at: 3600,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'transcript',
|
||||
text: 'Transcript flows straight into the composer with the standard ❯ user glyph.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: answer, at: 5500, id: 'answer', type: 'frame' },
|
||||
{ ansi: tts, at: 6700, id: 'tts', type: 'frame' },
|
||||
{
|
||||
at: 7000,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'tts',
|
||||
text: 'TTS auto-ducks the mic so the loop never echoes itself back.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 8800, duration: 600, text: '/voice off', type: 'compose' }
|
||||
],
|
||||
title: 'Hermes TUI · Voice Mode',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Static prompt mocks (no useInput, safe for snap()) ---
|
||||
|
||||
const ApprovalPromptStatic = ({
|
||||
command,
|
||||
description,
|
||||
selected = 0,
|
||||
theme
|
||||
}: {
|
||||
command: string
|
||||
description: string
|
||||
selected?: number
|
||||
theme: Theme
|
||||
}) => {
|
||||
const labels = ['Allow once', 'Allow this session', 'Always allow', 'Deny']
|
||||
const lines = command.split('\n').slice(0, 5)
|
||||
|
||||
return (
|
||||
<Box borderColor={theme.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.color.warn}>
|
||||
⚠ approval required · {description}
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{lines.map((line, i) => (
|
||||
<Text color={theme.color.cornsilk} key={i}>
|
||||
{line || ' '}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Text />
|
||||
|
||||
{labels.map((label, i) => (
|
||||
<Text key={label}>
|
||||
<Text bold={i === selected} color={i === selected ? theme.color.warn : theme.color.dim} inverse={i === selected}>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {label}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const ClarifyPromptStatic = ({
|
||||
choices,
|
||||
question,
|
||||
selected = 0,
|
||||
theme
|
||||
}: {
|
||||
choices: string[]
|
||||
question: string
|
||||
selected?: number
|
||||
theme: Theme
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
<Text color={theme.color.amber}>ask</Text>
|
||||
<Text color={theme.color.cornsilk}> {question}</Text>
|
||||
</Text>
|
||||
|
||||
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
||||
<Text key={i}>
|
||||
<Text bold={i === selected} color={i === selected ? theme.color.label : theme.color.dim} inverse={i === selected}>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {c}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>
|
||||
↑/↓ select · Enter confirm · 1-{choices.length + 1} quick pick · Esc cancel
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const ModelPickerStatic = ({
|
||||
currentModel,
|
||||
items,
|
||||
selected = 0,
|
||||
stage,
|
||||
theme
|
||||
}: {
|
||||
currentModel: string
|
||||
items: string[]
|
||||
selected?: number
|
||||
stage: 'model' | 'provider'
|
||||
theme: Theme
|
||||
}) => (
|
||||
<Box borderStyle="double" borderColor={theme.color.amber} flexDirection="column" paddingX={1} width={50}>
|
||||
<Text bold color={theme.color.amber} wrap="truncate-end">
|
||||
{stage === 'provider' ? 'Select Provider' : 'Select Model'}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.dim} wrap="truncate-end">
|
||||
{stage === 'provider' ? `Current model: ${currentModel}` : currentModel}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.label} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.dim}>{' '}</Text>
|
||||
|
||||
{items.map((item, i) => (
|
||||
<Text
|
||||
bold={i === selected}
|
||||
color={i === selected ? theme.color.amber : theme.color.dim}
|
||||
inverse={i === selected}
|
||||
key={item}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {item}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>{' '}</Text>
|
||||
<Text color={theme.color.dim}>persist: session · g toggle</Text>
|
||||
<Text color={theme.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const interactivePrompts = async () => {
|
||||
// User asks for something that triggers approval
|
||||
const userAsk = await snap(
|
||||
<Msg role="user" text="Run npm install express in the project root." />
|
||||
)
|
||||
|
||||
const assistantExplains = await snap(
|
||||
<Msg
|
||||
role="assistant"
|
||||
text="I'll install express. The package manager needs approval — here's the command."
|
||||
/>
|
||||
)
|
||||
|
||||
// Approval prompt
|
||||
const approval = await snap(
|
||||
<ApprovalPromptStatic
|
||||
command={'npm install express\nadded 58 packages in 3.2s\n\n+ express@5.1.0'}
|
||||
description="install dependency"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
// After approval, user asks something ambiguous
|
||||
const userClarify = await snap(
|
||||
<Msg role="user" text="Deploy this to staging." />
|
||||
)
|
||||
|
||||
const assistantAsks = await snap(
|
||||
<Msg role="assistant" text="Which environment should I target?" />
|
||||
)
|
||||
|
||||
// Clarify prompt
|
||||
const clarify = await snap(
|
||||
<ClarifyPromptStatic
|
||||
choices={['staging-us-east', 'staging-eu-west', 'staging-ap-south']}
|
||||
question="Which region?"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const confirmResult = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['target', 'staging-us-east'],
|
||||
['branch', 'main'],
|
||||
['preview', 'https://pr-128.railway.app']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="deployment queued"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
return {
|
||||
composer: 'deploy this to staging',
|
||||
timeline: [
|
||||
{ ansi: userAsk, at: 200, id: 'ask', type: 'frame' },
|
||||
{ ansi: assistantExplains, at: 1200, id: 'explain', type: 'frame' },
|
||||
{ ansi: approval, at: 2600, id: 'approval', type: 'frame' },
|
||||
{ at: 2900, duration: 1500, target: 'approval', type: 'spotlight' },
|
||||
{
|
||||
at: 3100,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'approval',
|
||||
text: 'Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 5400, duration: 400, text: '1', type: 'compose' },
|
||||
{ at: 5900, duration: 500, text: '', type: 'compose' },
|
||||
{ ansi: userClarify, at: 6600, id: 'clarify-ask', type: 'frame' },
|
||||
{ ansi: assistantAsks, at: 7600, id: 'clarify-reply', type: 'frame' },
|
||||
{ ansi: clarify, at: 8800, id: 'clarify', type: 'frame' },
|
||||
{ at: 9100, duration: 1500, target: 'clarify', type: 'spotlight' },
|
||||
{
|
||||
at: 9300,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'clarify',
|
||||
text: 'Clarify prompts handle ambiguous requests — numbered choices or free text.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 11600, duration: 400, text: '1', type: 'compose' },
|
||||
{ ansi: confirmResult, at: 12200, id: 'result', type: 'frame' },
|
||||
{ at: 12500, duration: 1300, target: 'result', type: 'highlight' }
|
||||
],
|
||||
title: 'Hermes TUI · Interactive Prompts',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const modelPicker = async () => {
|
||||
const userAsk = await snap(
|
||||
<Msg role="user" text="Switch to Claude." />
|
||||
)
|
||||
|
||||
const assistantReply = await snap(
|
||||
<Msg role="assistant" text="Opening the model picker — pick a provider first, then a model." />
|
||||
)
|
||||
|
||||
// Provider selection stage
|
||||
const providers = await snap(
|
||||
<ModelPickerStatic
|
||||
currentModel="gpt-5-codex"
|
||||
items={[
|
||||
'OpenAI · 8 models',
|
||||
'Anthropic · 6 models',
|
||||
'Google · 5 models',
|
||||
'OpenRouter · 42 models',
|
||||
'xAI · 3 models'
|
||||
]}
|
||||
selected={1}
|
||||
stage="provider"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
// Model selection stage
|
||||
const models = await snap(
|
||||
<ModelPickerStatic
|
||||
currentModel="Anthropic"
|
||||
items={[
|
||||
'claude-opus-4',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-3.7',
|
||||
'claude-haiku-3.5',
|
||||
'claude-sonnet-3.5'
|
||||
]}
|
||||
selected={1}
|
||||
stage="model"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const result = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['from', 'gpt-5-codex'],
|
||||
['to', 'claude-sonnet-4'],
|
||||
['scope', 'this session']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="model switched"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
return {
|
||||
composer: '',
|
||||
timeline: [
|
||||
{ at: 200, duration: 500, text: '/model', type: 'compose' },
|
||||
{ ansi: userAsk, at: 900, id: 'ask', type: 'frame' },
|
||||
{ ansi: assistantReply, at: 1800, id: 'reply', type: 'frame' },
|
||||
{ ansi: providers, at: 3000, id: 'providers', type: 'frame' },
|
||||
{ at: 3300, duration: 1800, target: 'providers', type: 'spotlight' },
|
||||
{
|
||||
at: 3500,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'providers',
|
||||
text: 'Provider stage: pick from authenticated backends. Shows model count per provider.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 5600, duration: 300, text: '2', type: 'compose' },
|
||||
{ ansi: models, at: 6200, id: 'models', type: 'frame' },
|
||||
{ at: 6500, duration: 1800, target: 'models', type: 'spotlight' },
|
||||
{
|
||||
at: 6700,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'models',
|
||||
text: 'Model stage: scrollable list with ▸ selection. Number keys for quick pick.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 9000, duration: 300, text: '2', type: 'compose' },
|
||||
{ ansi: result, at: 9600, id: 'result', type: 'frame' },
|
||||
{ at: 9900, duration: 1300, target: 'result', type: 'highlight' },
|
||||
{
|
||||
at: 10100,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'result',
|
||||
text: 'Model swap mid-session. Transcript and cache stay intact.',
|
||||
type: 'caption'
|
||||
}
|
||||
],
|
||||
title: 'Hermes TUI · Model Picker',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('recording workflows…')
|
||||
|
||||
// Wipe the workflows dir so deleted/renamed scenes don't linger.
|
||||
const workflowsDir = join(showroomRoot, 'workflows')
|
||||
|
||||
for (const file of [
|
||||
'feature-tour.json',
|
||||
'subagent-trail.json',
|
||||
'slash-commands.json',
|
||||
'voice-mode.json',
|
||||
'interactive-prompts.json',
|
||||
'model-picker.json',
|
||||
'ink-frames.json'
|
||||
]) {
|
||||
try {
|
||||
rmSync(join(workflowsDir, file))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
writeWorkflow('feature-tour', await featureTour())
|
||||
writeWorkflow('subagent-trail', await subagentTrail())
|
||||
writeWorkflow('slash-commands', await slashCommands())
|
||||
writeWorkflow('voice-mode', await voiceMode())
|
||||
writeWorkflow('interactive-prompts', await interactivePrompts())
|
||||
writeWorkflow('model-picker', await modelPicker())
|
||||
|
||||
console.log('done')
|
||||
}
|
||||
|
||||
void main().catch(error => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
109
ui-tui/.showroom/server.ts
Normal file
109
ui-tui/.showroom/server.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
defaultWorkflowPath,
|
||||
listWorkflows,
|
||||
readWorkflow,
|
||||
renderPage,
|
||||
workflowsDir,
|
||||
type WorkflowEntry
|
||||
} from './page.js'
|
||||
|
||||
const FLAG_VALUES = new Set(['--port', '--workflow'])
|
||||
|
||||
const arg = (name: string) => {
|
||||
const index = process.argv.indexOf(name)
|
||||
|
||||
return index === -1 ? undefined : process.argv[index + 1]
|
||||
}
|
||||
|
||||
const positional = (() => {
|
||||
const argv = process.argv.slice(2)
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const value = argv[i]!
|
||||
|
||||
if (FLAG_VALUES.has(value)) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (value.startsWith('-')) {
|
||||
continue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const port = Number(arg('--port') ?? process.env.PORT ?? 4317)
|
||||
const overridePath = arg('--workflow') ?? positional
|
||||
|
||||
const pickInitial = (catalog: WorkflowEntry[], requested: null | string): WorkflowEntry => {
|
||||
if (overridePath) {
|
||||
const fullPath = resolve(process.cwd(), overridePath)
|
||||
|
||||
return { name: 'override', path: fullPath, title: requested ?? 'override' }
|
||||
}
|
||||
|
||||
if (requested) {
|
||||
const hit = catalog.find(w => w.name === requested)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return catalog.find(w => w.path === defaultWorkflowPath) ?? catalog[0]!
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
||||
|
||||
if (url.pathname === '/healthz') {
|
||||
res.writeHead(200).end('ok')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/workflows') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(listWorkflows()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/workflow/')) {
|
||||
const name = decodeURIComponent(url.pathname.slice('/api/workflow/'.length))
|
||||
const hit = listWorkflows().find(w => w.name === name)
|
||||
|
||||
if (!hit) {
|
||||
res.writeHead(404).end('not found')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(readWorkflow(hit.path)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = listWorkflows()
|
||||
const initial = pickInitial(catalog, url.searchParams.get('w'))
|
||||
const page = renderPage({ name: initial.name, workflow: readWorkflow(initial.path) }, catalog)
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(page)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end(message)
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`showroom: http://127.0.0.1:${port}`)
|
||||
console.log(`workflows dir: ${workflowsDir}`)
|
||||
})
|
||||
422
ui-tui/.showroom/src/showroom.css
Normal file
422
ui-tui/.showroom/src/showroom.css
Normal file
@@ -0,0 +1,422 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
background: #050505;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--gold: #ffd700;
|
||||
--amber: #ffbf00;
|
||||
--bronze: #cd7f32;
|
||||
--cornsilk: #fff8dc;
|
||||
--dim: #cc9b1f;
|
||||
--label: #daa520;
|
||||
--bg: #0a0a0a;
|
||||
--bg-deep: #050505;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(205, 127, 50, 0.12), transparent 36rem),
|
||||
radial-gradient(circle at 82% 14%, rgba(255, 215, 0, 0.05), transparent 30rem),
|
||||
var(--bg-deep);
|
||||
}
|
||||
|
||||
#showroom {
|
||||
min-height: 100vh;
|
||||
padding: 24px 24px 60px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* --- Shell --- */
|
||||
|
||||
.showroom-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition:
|
||||
opacity 600ms var(--ease-out),
|
||||
transform 600ms var(--ease-out);
|
||||
}
|
||||
|
||||
.showroom-shell.is-mounted {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* --- Stage --- */
|
||||
|
||||
.showroom-stage {
|
||||
position: relative;
|
||||
width: var(--stage-w);
|
||||
height: var(--stage-h);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(205, 127, 50, 0.45);
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
box-shadow:
|
||||
0 32px 120px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.showroom-terminal {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
width: var(--term-w);
|
||||
height: var(--term-h);
|
||||
transform: scale(var(--scale));
|
||||
transform-origin: top left;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
color: var(--cornsilk);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* --- Status bar --- */
|
||||
|
||||
.showroom-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 4px;
|
||||
color: var(--dim);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showroom-status:empty,
|
||||
.showroom-status-left:empty,
|
||||
.showroom-status-right:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.showroom-status-left,
|
||||
.showroom-status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Composer --- */
|
||||
|
||||
.showroom-composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 6px 4px 0;
|
||||
color: var(--cornsilk);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.showroom-composer:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.showroom-composer::before {
|
||||
content: '❯';
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.showroom-composer:not(:empty)::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
background: var(--gold);
|
||||
vertical-align: middle;
|
||||
animation: showroom-blink 1100ms steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes showroom-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Body (DOM message mode) --- */
|
||||
|
||||
.showroom-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
|
||||
/* --- xterm container (frame mode) --- */
|
||||
|
||||
.showroom-xterm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 300ms var(--ease-out);
|
||||
}
|
||||
|
||||
.showroom-xterm.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showroom-xterm .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* --- DOM-mode lines --- */
|
||||
|
||||
.showroom-line,
|
||||
.showroom-tool {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
animation: showroom-enter 320ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes showroom-enter {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.showroom-line {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.showroom-glyph {
|
||||
color: var(--role);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.showroom-copy {
|
||||
color: var(--copy);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showroom-line-user .showroom-copy {
|
||||
color: var(--label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.showroom-line-assistant .showroom-copy {
|
||||
color: var(--cornsilk);
|
||||
}
|
||||
|
||||
.showroom-line-system .showroom-copy {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* --- Tool panel --- */
|
||||
|
||||
.showroom-tool {
|
||||
margin-left: 22px;
|
||||
border: 1px solid rgba(205, 127, 50, 0.32);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(205, 127, 50, 0.05);
|
||||
}
|
||||
|
||||
.showroom-tool-title {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.showroom-tool-title::before {
|
||||
content: '⚡ ';
|
||||
color: var(--bronze);
|
||||
}
|
||||
|
||||
.showroom-tool-items {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin-top: 4px;
|
||||
color: var(--dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.showroom-tool-items div::before {
|
||||
content: '┊ ';
|
||||
color: var(--bronze);
|
||||
}
|
||||
|
||||
/* --- Highlight --- */
|
||||
|
||||
.is-highlighted {
|
||||
filter: brightness(1.4);
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
transform: translateX(3px);
|
||||
transition:
|
||||
filter 420ms var(--ease-in-out),
|
||||
background 420ms var(--ease-in-out),
|
||||
transform 420ms var(--ease-in-out);
|
||||
}
|
||||
|
||||
/* --- Overlays (captions, spotlights) --- */
|
||||
|
||||
.showroom-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.showroom-caption,
|
||||
.showroom-spotlight {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 360ms var(--ease-out),
|
||||
transform 360ms var(--ease-out);
|
||||
}
|
||||
|
||||
.showroom-caption {
|
||||
max-width: 360px;
|
||||
border: 1px solid rgba(205, 127, 50, 0.5);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(10, 10, 10, 0.92);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
|
||||
color: var(--cornsilk);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.showroom-spotlight {
|
||||
border: 2px solid var(--gold);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 0 0 9999px rgba(0, 0, 0, 0.42),
|
||||
0 0 32px rgba(255, 215, 0, 0.32);
|
||||
}
|
||||
|
||||
.showroom-caption.is-visible,
|
||||
.showroom-spotlight.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* --- Picker --- */
|
||||
|
||||
.showroom-picker {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(205, 127, 50, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 6px 30px 6px 14px;
|
||||
background: rgba(205, 127, 50, 0.06)
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23cd7f32' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
|
||||
no-repeat right 12px center / 10px;
|
||||
color: var(--cornsilk);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showroom-picker:focus {
|
||||
outline: 1px solid var(--bronze);
|
||||
}
|
||||
|
||||
/* --- Controls bar --- */
|
||||
|
||||
.showroom-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.showroom-controls button {
|
||||
border: 1px solid rgba(205, 127, 50, 0.25);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(205, 127, 50, 0.04);
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.showroom-controls button:hover {
|
||||
background: rgba(205, 127, 50, 0.12);
|
||||
color: var(--cornsilk);
|
||||
}
|
||||
|
||||
.showroom-controls button[data-action='restart'] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.showroom-segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(205, 127, 50, 0.25);
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
background: rgba(205, 127, 50, 0.04);
|
||||
}
|
||||
|
||||
.showroom-segmented button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.showroom-segmented button.is-active {
|
||||
background: rgba(255, 215, 0, 0.18);
|
||||
color: var(--cornsilk);
|
||||
}
|
||||
|
||||
/* --- Progress --- */
|
||||
|
||||
.showroom-progress {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.showroom-progress-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(205, 127, 50, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.showroom-progress-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--bronze), var(--gold));
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
541
ui-tui/.showroom/src/showroom.js
Normal file
541
ui-tui/.showroom/src/showroom.js
Normal file
@@ -0,0 +1,541 @@
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
|
||||
const initial = window.__SHOWROOM_INITIAL__
|
||||
const catalog = window.__SHOWROOM_CATALOG__ ?? []
|
||||
const root = document.getElementById('showroom')
|
||||
const SPEEDS = [0.5, 1, 2]
|
||||
const SCALES = [1, 2, 3, 4]
|
||||
|
||||
const state = {
|
||||
body: null,
|
||||
composer: null,
|
||||
frameTargets: new Map(),
|
||||
overlays: null,
|
||||
progressFill: null,
|
||||
progressLabel: null,
|
||||
raf: null,
|
||||
scale: 2,
|
||||
shell: null,
|
||||
speed: 1,
|
||||
startedAt: 0,
|
||||
statusLeft: null,
|
||||
statusRight: null,
|
||||
term: null,
|
||||
termContainer: null,
|
||||
timers: [],
|
||||
total: 0,
|
||||
viewport: null,
|
||||
workflow: initial?.workflow ?? { timeline: [] }
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
while (state.timers.length) {
|
||||
clearTimeout(state.timers.pop())
|
||||
}
|
||||
|
||||
if (state.raf) {
|
||||
cancelAnimationFrame(state.raf)
|
||||
state.raf = null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTarget = id => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.frameTargets.get(id) ?? document.querySelector(`[data-target="${CSS.escape(id)}"]`)
|
||||
}
|
||||
|
||||
const setText = (node, text = '', duration = 0) => {
|
||||
if (!duration || state.speed <= 0) {
|
||||
node.textContent = text
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const chars = [...text]
|
||||
const adjusted = duration / state.speed
|
||||
const started = performance.now()
|
||||
|
||||
const frame = now => {
|
||||
const n = Math.min(chars.length, Math.ceil(((now - started) / adjusted) * chars.length))
|
||||
node.textContent = chars.slice(0, n).join('')
|
||||
|
||||
if (n < chars.length) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
const removeAfter = (node, duration = 1400) => {
|
||||
const wait = duration / state.speed
|
||||
|
||||
state.timers.push(
|
||||
setTimeout(() => {
|
||||
node.classList.remove('is-visible')
|
||||
state.timers.push(setTimeout(() => node.remove(), 420 / state.speed))
|
||||
}, wait)
|
||||
)
|
||||
}
|
||||
|
||||
const rectFor = (id, pad = 8) => {
|
||||
const el = resolveTarget(id)
|
||||
|
||||
if (!el || !state.overlays) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stage = state.overlays.getBoundingClientRect()
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
height: rect.height + pad * 2,
|
||||
left: rect.left - stage.left - pad,
|
||||
top: rect.top - stage.top - pad,
|
||||
width: rect.width + pad * 2
|
||||
}
|
||||
}
|
||||
|
||||
const placeNear = (node, id, position = 'right') => {
|
||||
const rect = rectFor(id, 0)
|
||||
|
||||
if (!rect) {
|
||||
node.style.left = '24px'
|
||||
node.style.top = '24px'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const gap = 18
|
||||
const left = position === 'left' ? rect.left - node.offsetWidth - gap : rect.left + rect.width + gap
|
||||
const top = position === 'top' ? rect.top - node.offsetHeight - gap : rect.top
|
||||
|
||||
node.style.left = `${Math.max(12, left)}px`
|
||||
node.style.top = `${Math.max(12, top)}px`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const message = action => {
|
||||
const spec = {
|
||||
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
|
||||
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
|
||||
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
|
||||
user: { copy: '#daa520', glyph: '❯', tone: '#ffd700' }
|
||||
}[action.role] ?? { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' }
|
||||
|
||||
const line = document.createElement('div')
|
||||
const glyph = document.createElement('span')
|
||||
const copy = document.createElement('div')
|
||||
|
||||
line.className = `showroom-line showroom-line-${action.role ?? 'assistant'}`
|
||||
line.dataset.target = action.id ?? ''
|
||||
line.style.setProperty('--role', spec.tone)
|
||||
line.style.setProperty('--copy', spec.copy)
|
||||
|
||||
glyph.className = 'showroom-glyph'
|
||||
glyph.textContent = spec.glyph
|
||||
|
||||
copy.className = 'showroom-copy'
|
||||
|
||||
line.append(glyph, copy)
|
||||
state.body.append(line)
|
||||
setText(copy, action.text, action.duration)
|
||||
}
|
||||
|
||||
const tool = action => {
|
||||
const box = document.createElement('div')
|
||||
const title = document.createElement('div')
|
||||
const items = document.createElement('div')
|
||||
|
||||
box.className = 'showroom-tool'
|
||||
box.dataset.target = action.id ?? ''
|
||||
|
||||
title.className = 'showroom-tool-title'
|
||||
title.textContent = action.title ?? 'tool activity'
|
||||
|
||||
items.className = 'showroom-tool-items'
|
||||
|
||||
for (const item of action.items ?? []) {
|
||||
const row = document.createElement('div')
|
||||
|
||||
row.textContent = item
|
||||
items.append(row)
|
||||
}
|
||||
|
||||
box.append(title, items)
|
||||
state.body.append(box)
|
||||
}
|
||||
|
||||
const frame = action => {
|
||||
if (!state.term || !action.ansi) {
|
||||
return
|
||||
}
|
||||
|
||||
state.term.write(action.ansi)
|
||||
|
||||
if (action.id) {
|
||||
state.frameTargets.set(action.id, state.termContainer)
|
||||
}
|
||||
}
|
||||
|
||||
const fade = action => {
|
||||
const el = resolveTarget(action.target)
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.style.transition = `opacity ${(action.duration ?? 420) / state.speed}ms var(--ease-in-out)`
|
||||
el.style.opacity = String(action.to ?? 0)
|
||||
}
|
||||
|
||||
const highlight = action => {
|
||||
const el = resolveTarget(action.target)
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.classList.add('is-highlighted')
|
||||
state.timers.push(setTimeout(() => el.classList.remove('is-highlighted'), (action.duration ?? 1200) / state.speed))
|
||||
}
|
||||
|
||||
const caption = action => {
|
||||
const node = document.createElement('div')
|
||||
|
||||
node.className = 'showroom-caption'
|
||||
node.dataset.target = action.id ?? ''
|
||||
node.textContent = action.text ?? ''
|
||||
state.overlays.append(node)
|
||||
placeNear(node, action.target, action.position)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
removeAfter(node, action.duration ?? 1600)
|
||||
}
|
||||
|
||||
const spotlight = action => {
|
||||
const rect = rectFor(action.target, action.pad ?? 6)
|
||||
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
|
||||
const node = document.createElement('div')
|
||||
|
||||
node.className = 'showroom-spotlight'
|
||||
node.style.left = `${rect.left}px`
|
||||
node.style.top = `${rect.top}px`
|
||||
node.style.width = `${rect.width}px`
|
||||
node.style.height = `${rect.height}px`
|
||||
state.overlays.append(node)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
removeAfter(node, action.duration ?? 1500)
|
||||
}
|
||||
|
||||
const status = action => {
|
||||
state.statusLeft.textContent = action.text ?? ''
|
||||
state.statusRight.textContent = action.detail ?? ''
|
||||
}
|
||||
|
||||
const compose = action => setText(state.composer, action.text ?? '', action.duration ?? 0)
|
||||
|
||||
const clearTranscript = () => {
|
||||
state.overlays.textContent = ''
|
||||
state.frameTargets.clear()
|
||||
|
||||
if (state.term) {
|
||||
state.term.reset()
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state.body.textContent = ''
|
||||
}
|
||||
|
||||
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
|
||||
|
||||
// --- Progress ---
|
||||
|
||||
const fmtTime = ms => {
|
||||
if (!Number.isFinite(ms)) {
|
||||
return '0.0s'
|
||||
}
|
||||
|
||||
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const tickProgress = () => {
|
||||
if (!state.startedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Math.min(state.total, (performance.now() - state.startedAt) * state.speed)
|
||||
const ratio = state.total ? elapsed / state.total : 0
|
||||
|
||||
state.progressFill.style.width = `${(ratio * 100).toFixed(2)}%`
|
||||
state.progressLabel.textContent = `${fmtTime(elapsed)} / ${fmtTime(state.total)}`
|
||||
|
||||
if (elapsed < state.total) {
|
||||
state.raf = requestAnimationFrame(tickProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// --- xterm ---
|
||||
|
||||
const initXterm = () => {
|
||||
const hasFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
|
||||
|
||||
if (!hasFrames) {
|
||||
state.term = null
|
||||
state.termContainer = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
|
||||
state.termContainer = state.body.querySelector('.showroom-xterm')
|
||||
|
||||
state.term = new Terminal({
|
||||
cols: state.viewport.cols,
|
||||
rows: state.viewport.rows,
|
||||
fontFamily: 'JetBrains Mono, "SF Mono", Consolas, monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: false,
|
||||
scrollback: 0,
|
||||
convertEol: true,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#fff8dc',
|
||||
cursor: '#ffd700',
|
||||
selectionBackground: '#3a3a55',
|
||||
black: '#0a0a0a',
|
||||
red: '#ef5350',
|
||||
green: '#8fbc8f',
|
||||
yellow: '#ffd700',
|
||||
blue: '#5a82ff',
|
||||
magenta: '#cd7f32',
|
||||
cyan: '#daa520',
|
||||
white: '#fff8dc',
|
||||
brightBlack: '#cc9b1f',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#8fbc8f',
|
||||
brightYellow: '#ffbf00',
|
||||
brightBlue: '#5a82ff',
|
||||
brightMagenta: '#cd7f32',
|
||||
brightCyan: '#daa520',
|
||||
brightWhite: '#fff8dc'
|
||||
}
|
||||
})
|
||||
|
||||
state.term.open(state.termContainer)
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
// Fade in
|
||||
requestAnimationFrame(() => state.termContainer.classList.add('is-visible'))
|
||||
}
|
||||
|
||||
// --- Playback ---
|
||||
|
||||
const play = () => {
|
||||
clearTimers()
|
||||
clearTranscript()
|
||||
state.statusLeft.textContent = ''
|
||||
state.statusRight.textContent = ''
|
||||
state.composer.textContent = state.workflow.composer ?? ''
|
||||
|
||||
const timeline = [...(state.workflow.timeline ?? [])].sort((a, b) => a.at - b.at)
|
||||
|
||||
state.total = timeline.reduce((max, action) => Math.max(max, action.at + (action.duration ?? 0)), 0)
|
||||
state.startedAt = performance.now()
|
||||
state.progressFill.style.width = '0%'
|
||||
state.progressLabel.textContent = `0.0s / ${fmtTime(state.total)}`
|
||||
|
||||
for (const action of timeline) {
|
||||
state.timers.push(setTimeout(() => ACTIONS[action.type]?.(action), action.at / state.speed))
|
||||
}
|
||||
|
||||
state.raf = requestAnimationFrame(tickProgress)
|
||||
}
|
||||
|
||||
// --- Controls ---
|
||||
|
||||
const setSpeed = next => {
|
||||
state.speed = next
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
|
||||
button.classList.toggle('is-active', Number(button.dataset.value) === next)
|
||||
}
|
||||
}
|
||||
|
||||
const setScale = next => {
|
||||
state.scale = next
|
||||
state.shell.style.setProperty('--scale', `${next}`)
|
||||
state.shell.style.setProperty('--stage-w', `${state.viewport.cols * state.viewport.cellWidth * next}px`)
|
||||
state.shell.style.setProperty('--stage-h', `${state.viewport.rows * state.viewport.lineHeight * next}px`)
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
|
||||
button.classList.toggle('is-active', Number(button.dataset.value) === next)
|
||||
}
|
||||
}
|
||||
|
||||
const fitScale = () => {
|
||||
const margin = 96
|
||||
const baseW = state.viewport.cols * state.viewport.cellWidth
|
||||
const baseH = state.viewport.rows * state.viewport.lineHeight
|
||||
const maxW = Math.max(1, window.innerWidth - margin)
|
||||
const maxH = Math.max(1, window.innerHeight - 240)
|
||||
const fit = Math.max(1, Math.floor(Math.min(maxW / baseW, maxH / baseH)))
|
||||
|
||||
return Math.max(1, Math.min(SCALES[SCALES.length - 1], fit))
|
||||
}
|
||||
|
||||
const loadWorkflow = async name => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('w', name)
|
||||
window.history.replaceState(null, '', url)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflow/${encodeURIComponent(name)}`)
|
||||
|
||||
if (response.ok) {
|
||||
state.workflow = await response.json()
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
await rebuild()
|
||||
}
|
||||
|
||||
// --- DOM ---
|
||||
|
||||
const buildOptions = () => {
|
||||
if (!catalog.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return catalog
|
||||
.map(({ name, title }) => {
|
||||
const selected = name === initial?.name ? ' selected' : ''
|
||||
|
||||
return `<option value="${name}"${selected}>${title}</option>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const buildSegmented = (values, active) =>
|
||||
values
|
||||
.map(
|
||||
value =>
|
||||
`<button type="button" data-value="${value}" class="${value === active ? 'is-active' : ''}">${value}x</button>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const computeViewport = () => {
|
||||
const fromWorkflow = state.workflow.viewport ?? {}
|
||||
|
||||
return {
|
||||
cellWidth: 9,
|
||||
cols: 80,
|
||||
lineHeight: 19,
|
||||
rows: 24,
|
||||
scale: 2,
|
||||
...fromWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
const renderShell = () => {
|
||||
state.viewport = computeViewport()
|
||||
state.frameTargets.clear()
|
||||
|
||||
state.shell.style.setProperty('--cell-w', `${state.viewport.cellWidth}px`)
|
||||
state.shell.style.setProperty('--cols', `${state.viewport.cols}`)
|
||||
state.shell.style.setProperty('--line-h', `${state.viewport.lineHeight}px`)
|
||||
state.shell.style.setProperty('--rows', `${state.viewport.rows}`)
|
||||
state.shell.style.setProperty('--term-w', `${state.viewport.cols * state.viewport.cellWidth}px`)
|
||||
state.shell.style.setProperty('--term-h', `${state.viewport.rows * state.viewport.lineHeight}px`)
|
||||
|
||||
state.shell.innerHTML = `
|
||||
<div class="showroom-stage">
|
||||
<div class="showroom-terminal">
|
||||
<div class="showroom-status" data-target="status">
|
||||
<span class="showroom-status-left"></span>
|
||||
<span class="showroom-status-right"></span>
|
||||
</div>
|
||||
<div class="showroom-body"></div>
|
||||
<div class="showroom-composer" data-target="composer"></div>
|
||||
</div>
|
||||
<div class="showroom-overlays"></div>
|
||||
</div>
|
||||
<footer class="showroom-controls">
|
||||
<button type="button" data-action="restart" title="restart (R)">↻</button>
|
||||
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
|
||||
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
|
||||
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
|
||||
<span class="showroom-progress">
|
||||
<span data-role="time">0.0s / 0.0s</span>
|
||||
<div class="showroom-progress-track"><div class="showroom-progress-fill"></div></div>
|
||||
</span>
|
||||
</footer>
|
||||
`
|
||||
|
||||
state.body = state.shell.querySelector('.showroom-body')
|
||||
state.composer = state.shell.querySelector('.showroom-composer')
|
||||
state.overlays = state.shell.querySelector('.showroom-overlays')
|
||||
state.statusLeft = state.shell.querySelector('.showroom-status-left')
|
||||
state.statusRight = state.shell.querySelector('.showroom-status-right')
|
||||
state.progressFill = state.shell.querySelector('.showroom-progress-fill')
|
||||
state.progressLabel = state.shell.querySelector('[data-role="time"]')
|
||||
|
||||
state.shell.querySelector('[data-action="restart"]').addEventListener('click', play)
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
|
||||
button.addEventListener('click', () => setSpeed(Number(button.dataset.value)))
|
||||
}
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
|
||||
button.addEventListener('click', () => setScale(Number(button.dataset.value)))
|
||||
}
|
||||
|
||||
const picker = state.shell.querySelector('[data-action="picker"]')
|
||||
|
||||
if (picker) {
|
||||
picker.addEventListener('change', event => {
|
||||
void loadWorkflow(event.target.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rebuild = async () => {
|
||||
renderShell()
|
||||
initXterm()
|
||||
setScale(state.workflow.viewport?.scale ?? fitScale())
|
||||
play()
|
||||
}
|
||||
|
||||
const mount = () => {
|
||||
state.shell = document.createElement('section')
|
||||
state.shell.className = 'showroom-shell'
|
||||
root.replaceChildren(state.shell)
|
||||
|
||||
void rebuild().then(() => {
|
||||
requestAnimationFrame(() => state.shell.classList.add('is-mounted'))
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'r') {
|
||||
play()
|
||||
} else if (key === '1' || key === '2' || key === '3') {
|
||||
setSpeed(SPEEDS[Number(key) - 1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mount()
|
||||
9
ui-tui/.showroom/tsconfig.json
Normal file
9
ui-tui/.showroom/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
68
ui-tui/.showroom/workflows/feature-tour.json
Normal file
68
ui-tui/.showroom/workflows/feature-tour.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"composer": "ask hermes anything",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CBuild\u001b[1Ca\u001b[1Cfocused\u001b[1Cplan\u001b[1Cfor\u001b[1Ca\u001b[1Csafer\u001b[1Cgateway\u001b[1Capproval\u001b[1Cflow.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 200,
|
||||
"id": "user-row",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CI'll\u001b[1Ctrace\u001b[1Cthe\u001b[1Cgateway\u001b[1Cguards\u001b[1Cfirst,\u001b[1Cthen\u001b[1Cpatch\u001b[1Cthe\u001b[1Csmallest\u001b[1Cboundary\u001b[1Cthat\r\n\u001b[3Ckeeps\u001b[1Capproval\u001b[1Ccommands\u001b[1Clive\u001b[1Cwhile\u001b[1Can\u001b[1Cagent\u001b[1Cis\u001b[1Cblocked.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1500,
|
||||
"id": "assistant-plan",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1Ctool\u001b[1Ctrail\u001b[1C(3)\r\n\u001b[2C├─\u001b[1Crg\u001b[1C\"approval.request\"\u001b[1Cgateway/\u001b[1Ctui_gateway/\r\n\u001b[2C├─\u001b[1CReadFile\u001b[1Cgateway/run.py\r\n\u001b[2C└─\u001b[1CReadFile\u001b[1Cgateway/platforms/base.py\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2900,
|
||||
"id": "tool-trail",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 3200,
|
||||
"duration": 1700,
|
||||
"target": "tool-trail",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 3400,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "tool-trail",
|
||||
"text": "Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CFound\u001b[1Cthe\u001b[1Csplit\u001b[1Cguard.\u001b[1CBypass\u001b[1Cboth\u001b[1Cqueues\u001b[1Conly\u001b[1Cfor\u001b[1Capproval\u001b[1Ccommands;\r\n\u001b[3Cnormal\u001b[1Cchat\u001b[1Cordering\u001b[1Cstays\u001b[1Cintact.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 5400,
|
||||
"id": "assistant-result",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 6100,
|
||||
"duration": 1300,
|
||||
"target": "assistant-result",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 6300,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "assistant-result",
|
||||
"text": "Captions, spotlights, and fades layer on top of real ANSI. Best of both.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 8100,
|
||||
"duration": 600,
|
||||
"text": "/approve",
|
||||
"type": "compose"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Feature Tour",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
104
ui-tui/.showroom/workflows/interactive-prompts.json
Normal file
104
ui-tui/.showroom/workflows/interactive-prompts.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"composer": "deploy this to staging",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CRun\u001b[1Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[1Cin\u001b[1Cthe\u001b[1Cproject\u001b[1Croot.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 200,
|
||||
"id": "ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CI'll\u001b[1Cinstall\u001b[1Cexpress.\u001b[1CThe\u001b[1Cpackage\u001b[1Cmanager\u001b[1Cneeds\u001b[1Capproval\u001b[1C—\u001b[1Chere's\u001b[1Cthe\r\n\u001b[3Ccommand.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1200,
|
||||
"id": "explain",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔══════════════════════════════════════════════════════════════════════════════╗\r\n║\u001b[1C⚠\u001b[1Capproval\u001b[1Crequired\u001b[1C·\u001b[1Cinstall\u001b[1Cdependency\u001b[36C║\r\n║\u001b[2Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[57C║\r\n║\u001b[2Cadded\u001b[1C58\u001b[1Cpackages\u001b[1Cin\u001b[1C3.2s\u001b[51C║\r\n║\u001b[78C║\r\n║\u001b[2C+\u001b[1Cexpress@5.1.0\u001b[61C║\r\n║\u001b[1C▸\u001b[1C1.\u001b[1CAllow\u001b[1Conce\u001b[62C║\r\n║\u001b[3C2.\u001b[1CAllow\u001b[1Cthis\u001b[1Csession\u001b[54C║\r\n║\u001b[3C3.\u001b[1CAlways\u001b[1Callow\u001b[60C║\r\n║\u001b[3C4.\u001b[1CDeny\u001b[68C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CCtrl+C\u001b[1Cdeny\u001b[20C║\r\n╚══════════════════════════════════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2600,
|
||||
"id": "approval",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 2900,
|
||||
"duration": 1500,
|
||||
"target": "approval",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 3100,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "approval",
|
||||
"text": "Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 5400,
|
||||
"duration": 400,
|
||||
"text": "1",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"at": 5900,
|
||||
"duration": 500,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CDeploy\u001b[1Cthis\u001b[1Cto\u001b[1Cstaging.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6600,
|
||||
"id": "clarify-ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CWhich\u001b[1Cenvironment\u001b[1Cshould\u001b[1CI\u001b[1Ctarget?\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 7600,
|
||||
"id": "clarify-reply",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026hask\u001b[1CWhich\u001b[1Cregion?\r\n▸\u001b[1C1.\u001b[1Cstaging-us-east\r\n\u001b[2C2.\u001b[1Cstaging-eu-west\r\n\u001b[2C3.\u001b[1Cstaging-ap-south\r\n\u001b[2C4.\u001b[1COther\u001b[1C(type\u001b[1Cyour\u001b[1Canswer)\r\n↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CEsc\u001b[1Ccancel\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 8800,
|
||||
"id": "clarify",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 9100,
|
||||
"duration": 1500,
|
||||
"target": "clarify",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 9300,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "clarify",
|
||||
"text": "Clarify prompts handle ambiguous requests — numbered choices or free text.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 11600,
|
||||
"duration": 400,
|
||||
"text": "1",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[30Cdeployment\u001b[1Cqueued\u001b[31C│\r\n│\u001b[78C│\r\n│\u001b[2Ctarget\u001b[14Cstaging-us-east\u001b[41C│\r\n│\u001b[2Cbranch\u001b[14Cmain\u001b[52C│\r\n│\u001b[2Cpreview\u001b[13Chttps://pr-128.railway.app\u001b[30C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 12200,
|
||||
"id": "result",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 12500,
|
||||
"duration": 1300,
|
||||
"target": "result",
|
||||
"type": "highlight"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Interactive Prompts",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
100
ui-tui/.showroom/workflows/model-picker.json
Normal file
100
ui-tui/.showroom/workflows/model-picker.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"composer": "",
|
||||
"timeline": [
|
||||
{
|
||||
"at": 200,
|
||||
"duration": 500,
|
||||
"text": "/model",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CSwitch\u001b[1Cto\u001b[1CClaude.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 900,
|
||||
"id": "ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2COpening\u001b[1Cthe\u001b[1Cmodel\u001b[1Cpicker\u001b[1C—\u001b[1Cpick\u001b[1Ca\u001b[1Cprovider\u001b[1Cfirst,\u001b[1Cthen\u001b[1Ca\u001b[1Cmodel.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1800,
|
||||
"id": "reply",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CProvider\u001b[32C║\r\n║\u001b[1CCurrent\u001b[1Cmodel:\u001b[1Cgpt-5-codex\u001b[21C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1COpenAI\u001b[1C·\u001b[1C8\u001b[1Cmodels\u001b[25C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1CAnthropic\u001b[1C·\u001b[1C6\u001b[1Cmodels\u001b[22C║\r\n║\u001b[3C3.\u001b[1CGoogle\u001b[1C·\u001b[1C5\u001b[1Cmodels\u001b[25C║\r\n║\u001b[3C4.\u001b[1COpenRouter\u001b[1C·\u001b[1C42\u001b[1Cmodels\u001b[20C║\r\n║\u001b[3C5.\u001b[1CxAI\u001b[1C·\u001b[1C3\u001b[1Cmodels\u001b[28C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 3000,
|
||||
"id": "providers",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 3300,
|
||||
"duration": 1800,
|
||||
"target": "providers",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 3500,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "providers",
|
||||
"text": "Provider stage: pick from authenticated backends. Shows model count per provider.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 5600,
|
||||
"duration": 300,
|
||||
"text": "2",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CModel\u001b[35C║\r\n║\u001b[1CAnthropic\u001b[38C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1Cclaude-opus-4\u001b[29C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1Cclaude-sonnet-4\u001b[27C║\r\n║\u001b[3C3.\u001b[1Cclaude-sonnet-3.7\u001b[25C║\r\n║\u001b[3C4.\u001b[1Cclaude-haiku-3.5\u001b[26C║\r\n║\u001b[3C5.\u001b[1Cclaude-sonnet-3.5\u001b[25C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6200,
|
||||
"id": "models",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 6500,
|
||||
"duration": 1800,
|
||||
"target": "models",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 6700,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "models",
|
||||
"text": "Model stage: scrollable list with ▸ selection. Number keys for quick pick.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 9000,
|
||||
"duration": 300,
|
||||
"text": "2",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[32Cmodel\u001b[1Cswitched\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cfrom\u001b[16Cgpt-5-codex\u001b[45C│\r\n│\u001b[2Cto\u001b[18Cclaude-sonnet-4\u001b[41C│\r\n│\u001b[2Cscope\u001b[15Cthis\u001b[1Csession\u001b[44C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 9600,
|
||||
"id": "result",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 9900,
|
||||
"duration": 1300,
|
||||
"target": "result",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 10100,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "result",
|
||||
"text": "Model swap mid-session. Transcript and cache stay intact.",
|
||||
"type": "caption"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Model Picker",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
126
ui-tui/.showroom/workflows/slash-commands.json
Normal file
126
ui-tui/.showroom/workflows/slash-commands.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"composer": "",
|
||||
"timeline": [
|
||||
{
|
||||
"at": 200,
|
||||
"duration": 700,
|
||||
"text": "/skills search vibe",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2C/skills\u001b[1Csearch\u001b[1Cvibe\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1100,
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 1100,
|
||||
"duration": 200,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[29Cskills\u001b[1C·\u001b[1Csearch\u001b[1Cvibe\u001b[29C│\r\n│\u001b[78C│\r\n│\u001b[2Canthropics/skills/frontend-design★\u001b[1Ctrusted\u001b[34C│\r\n│\u001b[2Copenai/skills/skill-creator·\u001b[1Cofficial\u001b[39C│\r\n│\u001b[2Cskills.sh/community/vibe-coding⚙\u001b[1Ccommunity\u001b[33C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1400,
|
||||
"id": "skills",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 1700,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "skills",
|
||||
"text": "Typed /skills, hit return — same Panel the live TUI renders.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 4000,
|
||||
"duration": 700,
|
||||
"text": "/model claude-4.6-sonnet",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2C/model\u001b[1Cclaude-4.6-sonnet\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 4900,
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 4900,
|
||||
"duration": 200,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[32Cmodel\u001b[1Cswitched\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cfrom\u001b[16Cgpt-5-codex\u001b[45C│\r\n│\u001b[2Cto\u001b[18Cclaude-4.6-sonnet\u001b[39C│\r\n│\u001b[2Cscope\u001b[15Cthis\u001b[1Csession\u001b[44C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 5200,
|
||||
"id": "model",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 5500,
|
||||
"duration": 1900,
|
||||
"position": "right",
|
||||
"target": "model",
|
||||
"text": "/model swaps mid-session; transcript and cache stay intact.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 7600,
|
||||
"duration": 600,
|
||||
"text": "/agents pause",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2C/agents\u001b[1Cpause\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 8400,
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 8400,
|
||||
"duration": 200,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[31Cagents\u001b[1C·\u001b[1Cpaused\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cdelegation\u001b[10Cpaused\u001b[50C│\r\n│\u001b[2Cmax\u001b[1Cchildren\u001b[8C4\u001b[55C│\r\n│\u001b[2Crunning\u001b[1Ctasks\u001b[7Cqueued\u001b[1Cfor\u001b[1Cresume\u001b[39C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 8700,
|
||||
"id": "agents",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 9000,
|
||||
"duration": 1800,
|
||||
"position": "right",
|
||||
"target": "agents",
|
||||
"text": "Same registry powers TUI, gateway, Telegram, Discord — one truth.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 11000,
|
||||
"duration": 400,
|
||||
"text": "/help",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2C/help\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 11500,
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 11500,
|
||||
"duration": 200,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[31C(^_^)?\u001b[1CCommands\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2CTools\u001b[1C&\u001b[1CSkills\u001b[62C│\r\n│\u001b[2C/skills\u001b[4Csearch\u001b[1C·\u001b[1Cinstall\u001b[1C·\u001b[1Cinspect\u001b[39C│\r\n│\u001b[2C/model\u001b[5Cswitch\u001b[1Cmodel\u001b[1C·\u001b[1Cpop\u001b[1Cpicker\u001b[40C│\r\n│\u001b[78C│\r\n│\u001b[2CSession\u001b[69C│\r\n│\u001b[2C/agents\u001b[4Cspawn-tree\u001b[1Cdashboard\u001b[45C│\r\n│\u001b[2C/queue\u001b[5Cqueue\u001b[1Cprompt\u001b[1Cfor\u001b[1Cnext\u001b[1Cturn\u001b[39C│\r\n│\u001b[2C/steer\u001b[5Cinject\u001b[1Cafter\u001b[1Cnext\u001b[1Ctool\u001b[1Ccall\u001b[38C│\r\n│\u001b[78C│\r\n│\u001b[2CConfiguration\u001b[63C│\r\n│\u001b[2C/voice\u001b[5Ctoggle\u001b[1Cvoice\u001b[1Cmode\u001b[48C│\r\n│\u001b[2C/details\u001b[3Cthinking\u001b[1C·\u001b[1Ctools\u001b[1C·\u001b[1Csubagents\u001b[1C·\u001b[1Cactivity\u001b[26C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 11800,
|
||||
"id": "help",
|
||||
"type": "frame"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Slash Commands",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
82
ui-tui/.showroom/workflows/subagent-trail.json
Normal file
82
ui-tui/.showroom/workflows/subagent-trail.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"composer": "spawn the deploy fan-out",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CRun\u001b[1Ctests,\u001b[1Clint,\u001b[1Cand\u001b[1Ca\u001b[1CRailway\u001b[1Cpreview\u001b[1Cdeploy\u001b[1Cin\u001b[1Cparallel.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 200,
|
||||
"id": "ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CSpawning\u001b[1Cthree\u001b[1Csubagents\u001b[1Con\u001b[1Cthe\u001b[1Cfan-out\u001b[1Clane\u001b[1Cand\u001b[1Cwatching\u001b[1Ctheir\u001b[1Ctool\r\n\u001b[3Ccounts.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1100,
|
||||
"id": "plan",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C├─\u001b[1Ctests\u001b[3Crunning\u001b[3C12\u001b[1Ctools\u001b[3C⏱\u001b[1C14.2s\r\n\u001b[2C├─\u001b[1Clint\u001b[4Crunning\u001b[4C4\u001b[1Ctools\u001b[3C⏱\u001b[1C14.2s\r\n\u001b[2C└─\u001b[1Cdeploy\u001b[2Cqueued\u001b[5C0\u001b[1Ctools\u001b[3C⏱\u001b[2C0.0s\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2100,
|
||||
"id": "live",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 2300,
|
||||
"duration": 1500,
|
||||
"target": "live",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 2500,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "live",
|
||||
"text": "Each subagent gets its own depth and tool budget; the dashboard tracks them live.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C├─\u001b[1Ctests\u001b[3Ccomplete\u001b[2C18\u001b[1Ctools\u001b[3C⏱\u001b[1C22.7s\u001b[3C✓\r\n\u001b[2C├─\u001b[1Clint\u001b[4Ccomplete\u001b[3C6\u001b[1Ctools\u001b[3C⏱\u001b[1C18.1s\u001b[3C✓\r\n\u001b[2C└─\u001b[1Cdeploy\u001b[2Crunning\u001b[4C9\u001b[1Ctools\u001b[3C⏱\u001b[2C9.4s\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 4400,
|
||||
"id": "hot",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 4600,
|
||||
"duration": 1300,
|
||||
"target": "hot",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 4800,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "hot",
|
||||
"text": "Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CAll\u001b[1Cthree\u001b[1Clanded:\u001b[1C24\u001b[1Ctests\u001b[1Cpass,\u001b[1Clint\u001b[1Cclean,\u001b[1Cpreview\u001b[1Cat\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6800,
|
||||
"id": "summary",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 7000,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "summary",
|
||||
"text": "Subagent results stream back into the parent transcript as a single highlight.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 8800,
|
||||
"duration": 600,
|
||||
"text": "/agents",
|
||||
"type": "compose"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Subagent Trail",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
76
ui-tui/.showroom/workflows/voice-mode.json
Normal file
76
ui-tui/.showroom/workflows/voice-mode.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"composer": "ctrl+b to start recording",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1CVAD\u001b[1C·\u001b[1Ccapturing\u001b[1C(3)\r\n\u001b[2C├─\u001b[1C▮\u001b[1C▮▮\u001b[1C▮\u001b[1C▮▮▮▮\u001b[1C▮▮\u001b[1C▮▮▮▮▮▮\u001b[1C▮▮▮\u001b[1C▮\r\n\u001b[2C├─\u001b[1Crms\u001b[1C0.42\u001b[1C·\u001b[1C1.6s\u001b[1Ccaptured\r\n\u001b[2C└─\u001b[1Cauto-stop\u001b[1C·\u001b[1Csilence\u001b[1C380ms\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 250,
|
||||
"id": "vad",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 600,
|
||||
"duration": 1500,
|
||||
"target": "vad",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 800,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "vad",
|
||||
"text": "Continuous loop: VAD detects silence, transcribes, restarts — no key holds.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2Cwhat's\u001b[1Cin\u001b[1Cmy\u001b[1Cinbox\u001b[1Ctoday\u001b[1Cand\u001b[1Cwhat\u001b[1Cneeds\u001b[1Ca\u001b[1Creply\u001b[1Cbefore\u001b[1Cnoon?\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2700,
|
||||
"id": "transcript",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 3400,
|
||||
"duration": 1100,
|
||||
"target": "transcript",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 3600,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "transcript",
|
||||
"text": "Transcript flows straight into the composer with the standard ❯ user glyph.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CThree\u001b[1Cthreads\u001b[1Cneed\u001b[1Cyou\u001b[1Cbefore\u001b[1Cnoon:\u001b[1Cvendor\u001b[1Crenewal,\u001b[1Cpodcast\u001b[1Cintro\u001b[1Cfeedback,\r\n\u001b[4Cand\u001b[1Cthe\u001b[1Cdesign\u001b[1Creview\u001b[1Cat\u001b[1C11.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 5500,
|
||||
"id": "answer",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1Ctts\u001b[1C·\u001b[1Cplaying\u001b[1C(3)\r\n\u001b[2C├─\u001b[1Cvoice\u001b[1C11labs\u001b[1C·\u001b[1Cgrace_v3\r\n\u001b[2C├─\u001b[1Celapsed\u001b[1C4.6s\u001b[1C·\u001b[1C2\u001b[1Cchunks\u001b[1Cqueued\r\n\u001b[2C└─\u001b[1Cducking\u001b[1Cmic\u001b[1Cinput\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6700,
|
||||
"id": "tts",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 7000,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "tts",
|
||||
"text": "TTS auto-ducks the mic so the loop never echoes itself back.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 8800,
|
||||
"duration": 600,
|
||||
"text": "/voice off",
|
||||
"type": "compose"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Voice Mode",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"showroom": "tsx .showroom/server.ts",
|
||||
"showroom:build": "tsx .showroom/build.ts",
|
||||
"showroom:type-check": "tsc --noEmit -p .showroom/tsconfig.json",
|
||||
"showroom:record": "tsx .showroom/record.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
|
||||
Reference in New Issue
Block a user