Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 036dd14425 | |||
| 1e499a7136 | |||
| e58308c680 | |||
| 6147a867cd | |||
| 7603126c86 | |||
| 3eadf10047 | |||
| 72ca0809c4 | |||
| 70c43d5da1 | |||
| 7d79dbc5ad | |||
| 14dd8e9a72 | |||
| 83129e72de | |||
| 7fd8dc0bfb | |||
| d056b610b7 | |||
| db7c5735f0 | |||
| 5fac6c3440 | |||
| 4c797bfae9 | |||
| c58956a9a2 | |||
| 3944b22506 | |||
| 489bed6f96 | |||
| ad0ac89478 | |||
| dc4d92f131 | |||
| 47420a84b9 | |||
| f93d4624bf | |||
| 5ae608152e | |||
| 88b65cc82a | |||
| 9daa0620a6 |
@@ -4318,7 +4318,7 @@ class HermesCLI:
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
if _is_termux_environment():
|
||||
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||
else:
|
||||
@@ -9308,14 +9308,18 @@ class HermesCLI:
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@kb.add(
|
||||
'c-g',
|
||||
filter=Condition(
|
||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
||||
),
|
||||
# VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so
|
||||
# the keystroke never reaches the embedded terminal. Alt+G is unbound
|
||||
# in those IDEs and arrives here as ('escape', 'g') — register it as
|
||||
# a fallback so the editor handoff works inside Cursor/VSCode too.
|
||||
_editor_filter = Condition(
|
||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
||||
)
|
||||
|
||||
@kb.add('c-g', filter=_editor_filter)
|
||||
@kb.add('escape', 'g', filter=_editor_filter)
|
||||
def handle_open_in_editor(event):
|
||||
"""Ctrl+G opens the current draft in an external editor."""
|
||||
"""Ctrl+G (or Alt+G in VSCode/Cursor) opens the current draft in an external editor."""
|
||||
cli_ref._open_external_editor(event.current_buffer)
|
||||
|
||||
@kb.add('tab', eager=True)
|
||||
@@ -9779,6 +9783,11 @@ class HermesCLI:
|
||||
completer=_completer,
|
||||
),
|
||||
)
|
||||
# Keep prompt_toolkit on its simple tempfile path. Setting
|
||||
# buffer.tempfile = "prompt.md" triggers its complex-tempfile branch,
|
||||
# which tries to mkdir() the mkdtemp() directory again and raises
|
||||
# EEXIST. The suffix keeps markdown highlighting without that bug.
|
||||
input_area.buffer.tempfile_suffix = '.md'
|
||||
|
||||
# Dynamic height: accounts for both explicit newlines AND visual
|
||||
# wrapping of long lines so the input area always fits its content.
|
||||
|
||||
+33
-8
@@ -7754,25 +7754,50 @@ class AIAgent:
|
||||
if source_msg.get("role") != "assistant":
|
||||
return
|
||||
|
||||
explicit_reasoning = source_msg.get("reasoning_content")
|
||||
if isinstance(explicit_reasoning, str):
|
||||
api_msg["reasoning_content"] = explicit_reasoning
|
||||
# 1. Explicit reasoning_content already set — preserve it verbatim
|
||||
# (includes DeepSeek/Kimi's own empty-string placeholder written at
|
||||
# creation time, and any valid reasoning content from the same provider).
|
||||
existing = source_msg.get("reasoning_content")
|
||||
if isinstance(existing, str):
|
||||
api_msg["reasoning_content"] = existing
|
||||
return
|
||||
|
||||
# 2. DeepSeek / Kimi thinking mode: tool-call turns that lack
|
||||
# reasoning_content are "poisoned history" — a prior provider (MiniMax,
|
||||
# etc.) left them empty. DeepSeek returns HTTP 400 if reasoning_content
|
||||
# is absent on replay; inject "" to satisfy the provider's requirement
|
||||
# without forwarding any cross-provider reasoning content.
|
||||
needs_empty_reasoning = (
|
||||
source_msg.get("tool_calls")
|
||||
and (
|
||||
self._needs_kimi_tool_reasoning()
|
||||
or self._needs_deepseek_tool_reasoning()
|
||||
)
|
||||
)
|
||||
if needs_empty_reasoning:
|
||||
api_msg["reasoning_content"] = ""
|
||||
return
|
||||
|
||||
# 3. Healthy session: promote 'reasoning' field to 'reasoning_content'
|
||||
# for providers that use the internal 'reasoning' key.
|
||||
normalized_reasoning = source_msg.get("reasoning")
|
||||
if isinstance(normalized_reasoning, str) and normalized_reasoning:
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
return
|
||||
|
||||
# Providers that require an echoed reasoning_content on every
|
||||
# assistant tool-call turn. Detection logic lives in the per-provider
|
||||
# helpers so both the creation path (_build_assistant_message) and
|
||||
# this replay path stay in sync.
|
||||
if source_msg.get("tool_calls") and (
|
||||
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
|
||||
# reasoning_content. Inject "" to satisfy the provider's requirement
|
||||
# when no explicit reasoning content is present.
|
||||
if (
|
||||
self._needs_kimi_tool_reasoning()
|
||||
or self._needs_deepseek_tool_reasoning()
|
||||
):
|
||||
api_msg["reasoning_content"] = ""
|
||||
return
|
||||
|
||||
# 5. reasoning_content was present but not a string (e.g. None after
|
||||
# context compaction). Don't pass null to the API.
|
||||
api_msg.pop("reasoning_content", None)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict:
|
||||
|
||||
@@ -17,6 +17,13 @@ Remove refusal behaviors (guardrails) from open-weight LLMs without retraining o
|
||||
|
||||
**License warning:** OBLITERATUS is AGPL-3.0. NEVER import it as a Python library. Always invoke via CLI (`obliteratus` command) or subprocess. This keeps Hermes Agent's MIT license clean.
|
||||
|
||||
## Video Guide
|
||||
|
||||
Walkthrough of OBLITERATUS used by a Hermes agent to abliterate Gemma:
|
||||
https://www.youtube.com/watch?v=8fG9BrNTeHs ("OBLITERATUS: An AI Agent Removed Gemma 4's Safety Guardrails")
|
||||
|
||||
Useful when the user wants a visual overview of the end-to-end workflow before running it themselves.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Trigger when the user:
|
||||
|
||||
@@ -88,13 +88,13 @@ class TestCopyReasoningContentForApi:
|
||||
agent._copy_reasoning_content_for_api(source, api_msg)
|
||||
assert api_msg.get("reasoning_content") == ""
|
||||
|
||||
def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
|
||||
"""Plain assistant turns without tool_calls don't get padded."""
|
||||
def test_deepseek_assistant_no_tool_call_gets_padded(self) -> None:
|
||||
"""DeepSeek thinking mode pads ALL assistant turns, even without tool_calls."""
|
||||
agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
|
||||
source = {"role": "assistant", "content": "hello"}
|
||||
api_msg: dict = {}
|
||||
agent._copy_reasoning_content_for_api(source, api_msg)
|
||||
assert "reasoning_content" not in api_msg
|
||||
assert api_msg.get("reasoning_content") == ""
|
||||
|
||||
def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
|
||||
"""When reasoning_content is already set, it's copied verbatim."""
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -110,7 +110,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an
|
||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Ctrl+G` | Open `$EDITOR` with the current draft |
|
||||
| `Cmd/Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind the primary keystroke to Find Next) |
|
||||
| `Ctrl+L` | New session (same as `/clear`) |
|
||||
| `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable |
|
||||
| `Tab` | Apply the active completion |
|
||||
@@ -169,7 +169,7 @@ Notes:
|
||||
- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
|
||||
- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`.
|
||||
- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
|
||||
- `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
||||
- `Cmd/Ctrl+G` (or `Alt+G` in VSCode/Cursor, which intercept the primary keystroke for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
||||
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
||||
|
||||
## Rendering
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ansiCodesToString, diffAnsiCodes, type AnsiCode } from '@alcalzone/ansi-tokenize'
|
||||
import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
import { unionRect, type Point, type Rectangle, type Size } from './layout/geometry.js'
|
||||
import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js'
|
||||
import { BEL, ESC, SEP } from './termio/ansi.js'
|
||||
import * as warn from './warn.js'
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface ComposerActions {
|
||||
dequeue: () => string | undefined
|
||||
enqueue: (text: string) => void
|
||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||
openEditor: () => void
|
||||
openEditor: () => Promise<void>
|
||||
pushHistory: (text: string) => void
|
||||
replaceQueue: (index: number, text: string) => void
|
||||
setCompIdx: StateSetter<number>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { useStdin } from '@hermes/ink'
|
||||
import { useStdin, withInkSuspended } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCompletion } from '../hooks/useCompletion.js'
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js'
|
||||
import { useQueue } from '../hooks/useQueue.js'
|
||||
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
|
||||
import { resolveEditor } from '../lib/editor.js'
|
||||
import { readOsc52Clipboard } from '../lib/osc52.js'
|
||||
import { isRemoteShellSession } from '../lib/terminalSetup.js'
|
||||
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
|
||||
@@ -253,26 +254,36 @@ export function useComposerState({
|
||||
[handleResolvedPaste, onClipboardPaste, querier]
|
||||
)
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
|
||||
const openEditor = useCallback(async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
|
||||
const file = join(dir, 'prompt.md')
|
||||
const [cmd, ...args] = resolveEditor()
|
||||
|
||||
writeFileSync(file, [...inputBuf, input].join('\n'))
|
||||
process.stdout.write('\x1b[?1049l')
|
||||
const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' })
|
||||
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
|
||||
|
||||
if (code === 0) {
|
||||
let exitCode: null | number = null
|
||||
|
||||
await withInkSuspended(async () => {
|
||||
exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
|
||||
})
|
||||
|
||||
try {
|
||||
if (exitCode !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = readFileSync(file, 'utf8').trimEnd()
|
||||
|
||||
if (text) {
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
submitRef.current(text)
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(file, { force: true })
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
submitRef.current(text)
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
}, [input, inputBuf, submitRef])
|
||||
|
||||
const actions = useMemo(
|
||||
|
||||
@@ -366,8 +366,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return voiceRecordToggle()
|
||||
}
|
||||
|
||||
if (isAction(key, ch, 'g')) {
|
||||
return cActions.openEditor()
|
||||
// Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
|
||||
// primary keystroke to "Find Next" before the TUI sees it; Alt+G
|
||||
// arrives as meta+g across platforms).
|
||||
if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
|
||||
return void cActions.openEditor().catch((err: unknown) => {
|
||||
actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor')
|
||||
})
|
||||
}
|
||||
|
||||
// shift-tab flips yolo without spending a turn (claude-code parity)
|
||||
|
||||
@@ -18,7 +18,7 @@ const copyHotkeys: [string, string][] = isMac
|
||||
export const HOTKEYS: [string, string][] = [
|
||||
...copyHotkeys,
|
||||
[action + '+D', 'exit'],
|
||||
[action + '+G', 'open $EDITOR for prompt'],
|
||||
[action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'],
|
||||
[action + '+L', 'new session (clear)'],
|
||||
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
||||
['Tab', 'apply completion'],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveEditor } from './editor.js'
|
||||
|
||||
const exe = (dir: string, name: string): string => {
|
||||
const path = join(dir, name)
|
||||
|
||||
writeFileSync(path, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(path, 0o755)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
describe('resolveEditor', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'editor-test-'))
|
||||
})
|
||||
|
||||
it('honors $VISUAL above all else', () => {
|
||||
expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toEqual(['helix'])
|
||||
})
|
||||
|
||||
it('falls back to $EDITOR when $VISUAL is unset', () => {
|
||||
expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toEqual(['nvim'])
|
||||
})
|
||||
|
||||
it('shell-tokenizes editors with arguments', () => {
|
||||
expect(resolveEditor({ EDITOR: 'code --wait', PATH: dir })).toEqual(['code', '--wait'])
|
||||
expect(resolveEditor({ PATH: dir, VISUAL: 'emacsclient -t' })).toEqual(['emacsclient', '-t'])
|
||||
})
|
||||
|
||||
it('ignores whitespace-only env vars', () => {
|
||||
const expected = exe(dir, 'editor')
|
||||
|
||||
expect(resolveEditor({ EDITOR: ' ', PATH: dir, VISUAL: '' })).toEqual([expected])
|
||||
})
|
||||
|
||||
it('prefers `editor` over nano over vi on $PATH', () => {
|
||||
exe(dir, 'nano')
|
||||
exe(dir, 'vi')
|
||||
const expected = exe(dir, 'editor')
|
||||
|
||||
expect(resolveEditor({ PATH: dir })).toEqual([expected])
|
||||
})
|
||||
|
||||
it('falls back to nano before vi when both exist', () => {
|
||||
exe(dir, 'vi')
|
||||
const expected = exe(dir, 'nano')
|
||||
|
||||
expect(resolveEditor({ PATH: dir })).toEqual([expected])
|
||||
})
|
||||
|
||||
it('returns ["vi"] when $PATH is empty', () => {
|
||||
expect(resolveEditor({ PATH: '' })).toEqual(['vi'])
|
||||
})
|
||||
|
||||
it('walks multi-entry $PATH', () => {
|
||||
const a = mkdtempSync(join(tmpdir(), 'editor-a-'))
|
||||
const b = mkdtempSync(join(tmpdir(), 'editor-b-'))
|
||||
const expected = exe(b, 'editor')
|
||||
|
||||
expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toEqual([expected])
|
||||
})
|
||||
|
||||
it('uses notepad.exe on Windows when no env override', () => {
|
||||
expect(resolveEditor({ PATH: dir }, 'win32')).toEqual(['notepad.exe'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors
|
||||
* prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and
|
||||
* the TUI launch the same editor on a given box.
|
||||
*/
|
||||
const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs']
|
||||
|
||||
const isExecutable = (path: string): boolean => {
|
||||
try {
|
||||
accessSync(path, constants.X_OK)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the editor invocation argv (without the file argument).
|
||||
*
|
||||
* 1. $VISUAL / $EDITOR, shell-tokenized so `EDITOR="code --wait"` works
|
||||
* 2. on POSIX: first FALLBACKS entry resolvable on $PATH
|
||||
* 3. on Windows: `notepad.exe`
|
||||
* 4. literal `['vi']` as the last-resort POSIX floor
|
||||
*/
|
||||
export const resolveEditor = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string[] => {
|
||||
const explicit = env.VISUAL ?? env.EDITOR
|
||||
|
||||
if (explicit?.trim()) {
|
||||
return explicit.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return ['notepad.exe']
|
||||
}
|
||||
|
||||
const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean)
|
||||
const found = FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable)
|
||||
|
||||
return [found ?? 'vi']
|
||||
}
|
||||
@@ -645,6 +645,18 @@ Options: `fill_first` (default), `round_robin`, `least_used`, `random`. See [Cre
|
||||
|
||||
Hermes uses lightweight "auxiliary" models for side tasks like image analysis, web page summarization, and browser screenshot analysis. By default, these use **Gemini Flash** via auto-detection — you don't need to configure anything.
|
||||
|
||||
### Video Tutorial
|
||||
|
||||
<div style={{position: 'relative', width: '100%', aspectRatio: '16 / 9', marginBottom: '1.5rem'}}>
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/NoF-YajElIM"
|
||||
title="Hermes Agent — Auxiliary Models Tutorial"
|
||||
style={{position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
### The universal config pattern
|
||||
|
||||
Every model slot in Hermes — auxiliary tasks, compression, fallback — uses the same three knobs:
|
||||
|
||||
@@ -10,6 +10,18 @@ Receive events from external services (GitHub, GitLab, JIRA, Stripe, etc.) and t
|
||||
|
||||
The agent processes the event and can respond by posting comments on PRs, sending messages to Telegram/Discord, or logging the result.
|
||||
|
||||
## Video Tutorial
|
||||
|
||||
<div style={{position: 'relative', width: '100%', aspectRatio: '16 / 9', marginBottom: '1.5rem'}}>
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/WNYe5mD4fY8"
|
||||
title="Hermes Agent — Webhooks Tutorial"
|
||||
style={{position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Reference in New Issue
Block a user