diff --git a/ui-tui/.showroom/README.md b/ui-tui/.showroom/README.md index 9a986225a1..3c23df969a 100644 --- a/ui-tui/.showroom/README.md +++ b/ui-tui/.showroom/README.md @@ -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/.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=` 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/.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=` to deep-link a workflow. + +## Architecture + +``` +record.tsx ─┐ + ↳ MessageLine, │ Ink renders → custom Writable → ANSI string + Panel, Box, Text │ + ▼ +workflows/.json + │ served at /api/workflow/ + ▼ +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()` 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. diff --git a/ui-tui/.showroom/record.tsx b/ui-tui/.showroom/record.tsx new file mode 100644 index 0000000000..c851bbe417 --- /dev/null +++ b/ui-tui/.showroom/record.tsx @@ -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 => { + 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) => + +const ToolPanel = ({ items, title, theme }: { items: string[]; theme: Theme; title: string }) => ( + + + + + {title} + + + {' '} + ({items.length}) + + + {items.map((item, i) => ( + + {i === items.length - 1 ? '└─ ' : '├─ '} + {item} + + ))} + +) + +const Tree = ({ rows, theme }: { rows: { branch: 'mid' | 'last'; cols: string[]; tone?: 'amber' | 'dim' | 'gold' | 'ok' }[]; theme: Theme }) => ( + + {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 ( + + {stem} + {row.cols.join(' ')} + + ) + })} + +) + +const writeWorkflow = (name: string, workflow: Record) => { + 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( + + ) + + const assistantPlan = await snap( + + ) + + const toolTrail = await snap( + + ) + + const assistantResult = await snap( + + ) + + 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( + + ) + + const plan = await snap( + + ) + + const live = await snap( + + ) + + const hot = await snap( + + ) + + const summary = await snap( + + ) + + 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( + , + 180 + ) + + const modelSwitch = await snap( + , + 180 + ) + + const agentsStatus = await snap( + , + 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( + + ) + + const transcript = await snap( + + ) + + const answer = await snap( + + ) + + const tts = await snap( + + ) + + 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) +}) diff --git a/ui-tui/.showroom/server.ts b/ui-tui/.showroom/server.ts index 1a328c9903..6bb08e4cef 100644 --- a/ui-tui/.showroom/server.ts +++ b/ui-tui/.showroom/server.ts @@ -6,8 +6,8 @@ import { listWorkflows, readWorkflow, renderPage, - type WorkflowEntry, - workflowsDir + workflowsDir, + type WorkflowEntry } from './page.js' const FLAG_VALUES = new Set(['--port', '--workflow']) diff --git a/ui-tui/.showroom/src/showroom.css b/ui-tui/.showroom/src/showroom.css index 16af60e307..0f637833c3 100644 --- a/ui-tui/.showroom/src/showroom.css +++ b/ui-tui/.showroom/src/showroom.css @@ -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,") + rgba(205, 127, 50, 0.06) + url("data:image/svg+xml;utf8,") 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; } diff --git a/ui-tui/.showroom/src/showroom.js b/ui-tui/.showroom/src/showroom.js index 5de673d75b..4834f8815c 100644 --- a/ui-tui/.showroom/src/showroom.js +++ b/ui-tui/.showroom/src/showroom.js @@ -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 => - `` - ).join('') +const buildSegmented = (values, active) => + values + .map(value => ``) + .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 = `
${escapeHtml(state.workflow.title ?? 'Hermes TUI Showroom')} - showroom + ${state.frameMode ? 'real ink' : 'showroom'} - ${viewport.cols}x${viewport.rows} · ${viewport.scale}x + ${state.viewport.cols}x${state.viewport.rows} · ${state.scale}x ${catalog.length > 1 ? `` : ''}
- - + +
-
+
@@ -368,42 +503,72 @@ const mount = () => {
- ${buildSpeed()} + scale + ${buildSegmented(SCALES, state.scale)} + speed + ${buildSegmented(SPEEDS, state.speed)}
` - 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 = '
' + 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() diff --git a/ui-tui/.showroom/workflows/feature-tour.json b/ui-tui/.showroom/workflows/feature-tour.json index 1d5cf31d37..960ba13037 100644 --- a/ui-tui/.showroom/workflows/feature-tour.json +++ b/ui-tui/.showroom/workflows/feature-tour.json @@ -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 + } +} \ No newline at end of file diff --git a/ui-tui/.showroom/workflows/slash-commands.json b/ui-tui/.showroom/workflows/slash-commands.json index 341c1572ac..4a3c408af2 100644 --- a/ui-tui/.showroom/workflows/slash-commands.json +++ b/ui-tui/.showroom/workflows/slash-commands.json @@ -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 + } +} \ No newline at end of file diff --git a/ui-tui/.showroom/workflows/subagent-trail.json b/ui-tui/.showroom/workflows/subagent-trail.json index 8a534b1b5f..8d073c92d4 100644 --- a/ui-tui/.showroom/workflows/subagent-trail.json +++ b/ui-tui/.showroom/workflows/subagent-trail.json @@ -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 + } +} \ No newline at end of file diff --git a/ui-tui/.showroom/workflows/voice-mode.json b/ui-tui/.showroom/workflows/voice-mode.json index 264f698003..b82024925a 100644 --- a/ui-tui/.showroom/workflows/voice-mode.json +++ b/ui-tui/.showroom/workflows/voice-mode.json @@ -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 + } +} \ No newline at end of file diff --git a/ui-tui/package.json b/ui-tui/package.json index 3963008254..e0b2c26310 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -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",