Compare commits

...

26 Commits

Author SHA1 Message Date
Brooklyn Nicholson 036dd14425 feat(tui): add model picker and approval/clarify prompt workflows
Three new static prompt components for showroom capture (no useInput):

- ApprovalPromptStatic: double-bordered warning box with command preview,
  4 options (allow once/session/always/deny), ▸ selection, footer hints
- ClarifyPromptStatic: heading + numbered choices with 'Other' option
- ModelPickerStatic: double-bordered popup with provider/model lists,
  current model header, persist toggle, quick-pick numbers

New workflows:
- interactive-prompts: approval → clarify → deploy result
- model-picker: provider stage → model stage → switch result
2026-04-26 01:17:21 -05:00
Brooklyn Nicholson 1e499a7136 feat(tui): add interactive prompts workflow to showroom
Approval prompt and clarify prompt rendered as static mocks (no useInput)
so they work with the snap() capture pipeline. Shows:
- user asking for npm install
- approval prompt with double-bordered warning box, 4 options
- user asking to deploy
- clarify prompt with region selection
- deployment result panel

Static components match real prompt visuals: same borders, colors,
selection indicators, and footer hints.
2026-04-26 01:15:42 -05:00
Brooklyn Nicholson e58308c680 feat(tui): restore xterm.js via importmap, add frame fade animations
- xterm.js loads via importmap (CDN, cached by browser) instead of
  dynamic import — no async fetch latency after page load
- CSS link tag in head for parallel xterm.css download
- Terminal container fades in on init (300ms ease-out via .is-visible)
- Frame elements targetable by id for fade/highlight/spotlight overlays
- Proper viewport cellWidth (9px) matching real xterm rendering
- Clean CSS: removed dead .showroom-frame styles, added transitions
  for highlights, smooth overlay animations
2026-04-26 01:11:27 -05:00
Brooklyn Nicholson 6147a867cd perf(tui): drop xterm.js from showroom, use lightweight ANSI parser
The ANSI frames from Ink renders only use cursor-forward (ESC[NC) and
control sequences (cursor hide/show, bracketed paste). No color SGR.
Loading a full terminal emulator from CDN was pure overhead — ~200ms
network + heavy DOM reconciliation for a few <150 byte strings.

Replaced with a 30-line ANSI-to-HTML parser. Zero network deps,
instant render.
2026-04-26 01:02:32 -05:00
Brooklyn Nicholson 7603126c86 chore(tui): run formatter on showroom files 2026-04-26 00:44:36 -05:00
Brooklyn Nicholson 3eadf10047 chore(tui): strip showroom chrome and beef up slash demo
- drop title bar, "real ink" tag, meta line — terminal box is the surface
- single bottom controls row: ↻ · scale · speed · picker · progress
- slash workflow now types each command, echoes a slash msg, then renders the panel
- adds a /help scene (real Panel grouped by category)
- README minus the "real ink" marketing
2026-04-25 23:39:06 -05:00
Brooklyn Nicholson 72ca0809c4 feat(tui): showroom now renders real ui-tui frames via xterm.js
- record.tsx imports MessageLine, Panel, Box, Text and snapshots Ink output as ANSI
- frame action writes captured ANSI into xterm.js (jsDelivr CDN)
- captions, spotlights, fades, highlights still layer over frames by id
- dropped CSS-mock workflows; all 4 sample workflows now use real Ink output
- compact 80x16 viewport, 1x–4x scale picker, blink cursor, intro fade
2026-04-25 23:33:40 -05:00
Brooklyn Nicholson 70c43d5da1 feat(tui): showroom MVP with picker, speeds, sample workflows
- multi-workflow listing + browser picker, /api/workflows + /api/workflow/:name
- build emits dist/<name>.html for every workflow + dist/index.html
- player adds 0.5x/1x/2x speed control, blinking cursor, intro fade, progress bar
- new sample workflows: subagent trail, slash commands tour, voice mode
2026-04-25 23:08:01 -05:00
Brooklyn Nicholson 7d79dbc5ad feat(tui): add scripted showroom demos 2026-04-25 22:04:12 -05:00
Brooklyn Nicholson 14dd8e9a72 fix(tui): address Copilot review on editor handoff
- resolveEditor() now returns argv (string[]) so EDITOR='code --wait'
  and VISUAL='emacsclient -t' tokenize correctly into spawnSync's
  separate command + args. Previously the whole string was passed as
  argv[0] and would ENOENT.
- Skip the POSIX X_OK PATH walk on Windows; return ['notepad.exe']
  there since fs.constants.X_OK is not meaningful and PATHEXT-based
  resolution would need its own implementation.
- Surface openEditor() rejections via actions.sys instead of letting
  them become unhandled promise rejections in the useInput callback.
- Hotkey docs/comment now say Cmd/Ctrl+G to match isAction()'s
  platform-action-modifier behavior (Cmd on macOS, Ctrl elsewhere).
2026-04-25 20:34:24 -05:00
Brooklyn Nicholson 83129e72de refactor(tui): tighten editor handoff helpers
- editor.ts: collapse two private helpers into one flatMap-driven lookup,
  keep `isExecutable` as the only named primitive, document the fallback
  chain with prompt_toolkit parity
- editor.test.ts: hoist the `exe` helper out of `describe`, drop the
  empty afterEach + dead mkdir branch, materialize expected paths before
  the resolveEditor call so argument evaluation order doesn't bite
- useComposerState.openEditor: rmSync the mkdtemp dir (was leaking),
  early-return on bad exit / empty buffer, run cleanup in finally
- useInputHandlers: cheap `ch.toLowerCase() === 'g'` guard before the
  modifier check
- hermes-ink/screen.ts: pick up `npm run fix` import-sort cleanup so
  lint passes
2026-04-25 20:24:06 -05:00
Brooklyn Nicholson 7fd8dc0bfb fix: preserve prompt_toolkit editor picker and mirror it in TUI
Base CLI's editor UX was better because prompt_toolkit picks the system
editor first, then friendly terminal editors before vi. Do not override
that with a vim-first chain.

Keep the CLI on prompt_toolkit's picker and only set tempfile_suffix='.md'
to avoid the complex-tempfile EEXIST path. Update the TUI resolver to
match prompt_toolkit's fallback order: $VISUAL, $EDITOR, editor, nano,
pico, vi, emacs.
2026-04-25 20:20:05 -05:00
Brooklyn Nicholson d056b610b7 fix: avoid prompt_toolkit complex tempfile bug and prefer nvim first
Setting buffer.tempfile = 'prompt.md' pushed prompt_toolkit into its
complex-tempfile path, which creates a temp dir and then calls
os.makedirs() on that same path when no subdirectory is present. That
raises EEXIST before the editor can launch.

Keep prompt_toolkit on the simple tempfile path with .md suffix, and
make the editor fallback chain explicit on both surfaces:
$VISUAL -> $EDITOR -> nvim -> vim -> vi -> nano.
2026-04-25 20:16:50 -05:00
Brooklyn Nicholson db7c5735f0 fix: prefer vim over nano for $EDITOR fallback (CLI + TUI)
prompt_toolkit's default editor list is: $VISUAL, $EDITOR, /usr/bin/editor,
/usr/bin/nano, /usr/bin/pico, /usr/bin/vi, /usr/bin/emacs — so when
neither env var is set, the base CLI launched nano. The TUI fell back
to a literal 'vi'. Same Ctrl+G keystroke, two different editors.

Pick the same chain on both surfaces:
  $VISUAL → $EDITOR → vim → vi → nano

CLI: override input_area.buffer._open_file_in_editor on the TextArea
once at app build time. Local to that buffer; doesn't touch
os.environ or affect other subprocesses.

TUI: extract resolveEditor() into ui-tui/src/lib/editor.ts. PATH walk
with accessSync(X_OK), no shelling out. Six-line unit test verifies
the priority order and the multi-entry PATH walk.
2026-04-25 20:11:25 -05:00
Brooklyn Nicholson 5fac6c3440 fix(cli): write editor draft to prompt.md so syntax highlighting works
Base CLI was handing prompt_toolkit's Buffer.open_in_editor() a default
config — Buffer.tempfile_suffix and .tempfile both empty — so it
created /tmp/tmpXXXXXX with no extension. nano/vim/helix all key
syntax highlighting off the file extension, so the buffer rendered
plain.

The TUI already writes to <mkdtemp>/prompt.md and gets full markdown
highlighting + a sensible title bar. Set buffer.tempfile = 'prompt.md'
on the TextArea so prompt_toolkit's complex-tempfile path produces
<mkdtemp>/prompt.md to match. shutil.rmtree cleanup is built-in.
2026-04-25 20:04:04 -05:00
Brooklyn Nicholson 4c797bfae9 fix(cli): accept Alt+G as Ctrl+G fallback in VSCode/Cursor terminals
Same problem as the TUI: Cursor and VSCode bind Ctrl+G to "Find Next"
at the editor level, so the keystroke never reaches the terminal and
the prompt_toolkit-driven Hermes CLI sees nothing.

Register ('escape', 'g') alongside the existing 'c-g' on the same
handler so the editor handoff works inside Cursor/VSCode too. The
filter (no clarify/approval/sudo/secret prompt active) is unchanged.
2026-04-25 20:01:03 -05:00
Brooklyn Nicholson c58956a9a2 fix(tui): accept Alt+G as Ctrl+G fallback in VSCode/Cursor terminals
VSCode and Cursor bind Ctrl+G to "Find Next" at the editor level, so
the keystroke never reaches the embedded terminal — Ctrl+G to open
\$EDITOR was effectively dead inside those IDEs.

Alt+G is unbound in both editors and reaches the TUI cleanly as
`\x1bg` → `key.meta && ch === 'g'` after parse-keypress. Accept it
alongside the existing isAction(key, ch, 'g') check, and document the
fallback in README + the hotkeys panel.
2026-04-25 19:57:17 -05:00
Brooklyn Nicholson 3944b22506 fix(tui): suspend Ink properly when opening $EDITOR via Ctrl+G
The Ctrl+G handler was toggling the alt-screen by hand
(`\x1b[?1049l` ... `\x1b[?1049h`) without releasing stdin or kitty
keyboard mode, so the launched editor would lose keystrokes (Ink kept
swallowing them) and editors that don't speak CSI-u (e.g. nano) would
print "Unknown sequence" for every Ctrl-key.

Switch to `withInkSuspended` from @hermes/ink, the same helper
`/setup` already uses. It pauses Ink, removes stdin listeners, drops
raw mode, disables kitty/modifyOtherKeys + mouse + focus reporting,
runs the editor, then restores everything with a full repaint.
2026-04-25 19:54:06 -05:00
brooklyn! 489bed6f96 Merge pull request #15478 from yes999zc/fix-deepseek-reasoning-all-assistant-messages
fix: DeepSeek/Kimi thinking mode requires reasoning_content on ALL assistant messages
2026-04-25 19:19:33 -05:00
FocusFlow Dev ad0ac89478 fix: DeepSeek/Kimi thinking mode requires reasoning_content on ALL assistant messages
Previously _copy_reasoning_content_for_api only padded reasoning_content
when the assistant message had tool_calls. DeepSeek V4 thinking mode
requires the field on every assistant turn, including plain text replies
without tool_calls.

- Remove the 'source_msg.get("tool_calls") and' guard
- Update test: plain assistant turns now get padded for DeepSeek/Kimi

Fixes #15213
2026-04-26 07:47:13 +08:00
Teknium dc4d92f131 docs: embed tutorial videos on webhooks + auxiliary models pages (#15809)
- webhooks.md: adds a Video Tutorial section under the intro with a
  responsive YouTube iframe (WNYe5mD4fY8).
- configuration.md: adds a Video Tutorial subsection under Auxiliary
  Models with a responsive YouTube iframe (NoF-YajElIM).

Both use a 16:9 aspect-ratio wrapper so the embeds scale cleanly on
mobile. Verified with `npm run build` — MDX parses clean, no new
warnings or broken links introduced.
2026-04-25 16:44:53 -07:00
Teknium 47420a84b9 docs(obliteratus): link YouTube video guide in SKILL.md (#15808)
Adds a 'Video Guide' section pointing at the walkthrough of a Hermes agent
abliterating Gemma with OBLITERATUS, so the agent can surface it when the
user wants a visual overview before running the workflow.
2026-04-25 16:30:38 -07:00
brooklyn! f93d4624bf Merge pull request #15749 from Zjianru/fix/copy-reasoning-content-ordering-and-cross-provider-isolation
fix(agent): ordering fix in _copy_reasoning_content_for_api — cross-provider reasoning isolation
2026-04-25 17:21:49 -05:00
codez 5ae608152e fix: remove has_reasoning guard — inject empty reasoning_content for DeepSeek/Kimi tool_calls unconditionally 2026-04-26 06:08:54 +08:00
brooklyn! 88b65cc82a Update run_agent.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 05:49:38 +08:00
codez 9daa0620a6 fix(agent): ordering fix in _copy_reasoning_content_for_api — cross-provider reasoning isolation
Fix logic-ordering bug where normalized_reasoning promotion returns
before the DeepSeek/Kimi needs_empty_reasoning guard, causing
cross-provider reasoning content (MiniMax → DeepSeek) to leak into
reasoning_content and trigger HTTP 400.

Changes:
- Reorder branching: existing reasoning_content check first
- Add 'not has_reasoning' guard so poisoned histories (no reasoning)
  still get '' injected for DeepSeek/Kimi
- Healthy same-provider reasoning promotion path unchanged

Refs: #15250, #15213
2026-04-26 02:04:52 +08:00
29 changed files with 2881 additions and 41 deletions
+16 -7
View File
@@ -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
View File
@@ -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."""
+67
View File
@@ -0,0 +1,67 @@
# TUI Showroom
Scripted demos of `ui-tui`. Workflows snapshot real ui-tui components (`MessageLine`, `Panel`, `Box`, `Text`) into ANSI and replay them through xterm.js with cinematic overlays. Recorded once, played any number of times — built for screen capture.
```bash
npm run showroom # dev server at http://127.0.0.1:4317
npm run showroom:record # regenerate every workflow JSON
npm run showroom:build # dist/<name>.html for every workflow
npm run showroom:type-check
```
## Bundled workflows
| File | Shows |
| ------------------------------- | -------------------------------------------------------------- |
| `workflows/feature-tour.json` | Plan → tool trail → result highlight |
| `workflows/subagent-trail.json` | Parallel subagents, hot lanes, summary |
| `workflows/slash-commands.json` | `/skills`, `/model`, `/agents`, `/help` typed → echoed → panel |
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
Pick a workflow from the dropdown or deep-link with `?w=<name>`.
## Architecture
```
record.tsx ─┐
↳ MessageLine, │ Ink renders → Writable → ANSI string
Panel, Box, Text │
workflows/<name>.json
│ served at /api/workflow/<name>
showroom.js │ xterm.js renders ANSI; DOM overlays target frame ids
browser
```
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (CDN, cached) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
## Timeline actions
| Action | Required | Optional |
| ----------- | ---------------- | --------------------------------------------- |
| `frame` | `ansi` | `id` |
| `status` | `text` | `detail` |
| `compose` | `text` | `duration` (typewriter) |
| `caption` | `target`, `text` | `position` (`left`/`right`/`top`), `duration` |
| `spotlight` | `target` | `pad`, `duration` |
| `highlight` | `target` | `duration` |
| `fade` | `target` | `to` (default `0`), `duration` |
| `clear` | — | — |
`target` references the `id` of an earlier `frame`. `viewport.scale` (or the 1x4x picker) controls the upscale factor for capture.
## Player
- Restart (`R`), 1x4x scale, 0.5x/1x/2x speed (`1`/`2`/`3`).
- Progress bar reads `at + duration` from the slowest action.
## Adding a workflow
1. Add a scene fn to `record.tsx` returning `{ title, viewport, composer, timeline }`.
2. Compose Ink primitives or pull `MessageLine` / `Panel` from `../src`.
3. `await snap(<Component />)` for each frame.
4. `npm run showroom:record`.
Components must be state-free at first paint — `useEffect` hooks won't fire by the time the recorder unmounts. For accordions like the live `ToolTrail`, render a flat `Box` + `Text` scene instead.
+70
View File
@@ -0,0 +1,70 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { listWorkflows, readWorkflow, renderPage, showroomRoot } from './page.js'
const FLAG_VALUES = new Set<string>([])
const positionals = (() => {
const argv = process.argv.slice(2)
const out: string[] = []
for (let i = 0; i < argv.length; i++) {
const value = argv[i]!
if (FLAG_VALUES.has(value)) {
i += 1
continue
}
if (value.startsWith('-')) {
continue
}
out.push(value)
}
return out
})()
const explicitWorkflow = positionals[0]
const explicitOut = positionals[1]
const distDir = resolve(showroomRoot, 'dist')
const writeHtml = (path: string, html: string) => {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, html)
}
const buildAll = () => {
const catalog = listWorkflows()
for (const entry of catalog) {
const html = renderPage({ name: entry.name, workflow: readWorkflow(entry.path) }, catalog)
const out = join(distDir, `${entry.name}.html`)
writeHtml(out, html)
console.log(out)
}
if (catalog.length) {
const indexEntry = catalog.find(w => w.name === 'feature-tour') ?? catalog[0]!
const html = renderPage({ name: indexEntry.name, workflow: readWorkflow(indexEntry.path) }, catalog)
const out = join(distDir, 'index.html')
writeHtml(out, html)
console.log(out)
}
}
if (explicitWorkflow) {
const path = resolve(process.cwd(), explicitWorkflow)
const out = resolve(process.cwd(), explicitOut ?? join(distDir, 'index.html'))
const catalog = listWorkflows()
const html = renderPage({ name: 'override', workflow: readWorkflow(path) }, catalog)
writeHtml(out, html)
console.log(out)
} else {
buildAll()
}
+58
View File
@@ -0,0 +1,58 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, parse } from 'node:path'
import { fileURLToPath } from 'node:url'
export const showroomRoot = dirname(fileURLToPath(import.meta.url))
export const workflowsDir = join(showroomRoot, 'workflows')
export interface WorkflowEntry {
name: string
path: string
title: string
}
export const listWorkflows = (): WorkflowEntry[] =>
readdirSync(workflowsDir)
.filter(file => file.endsWith('.json') && statSync(join(workflowsDir, file)).isFile())
.map(file => {
const path = join(workflowsDir, file)
const data = JSON.parse(readFileSync(path, 'utf8'))
return { name: parse(file).name, path, title: String(data.title ?? parse(file).name) }
})
.sort((a, b) => a.name.localeCompare(b.name))
export const defaultWorkflowPath =
listWorkflows().find(w => w.name === 'feature-tour')?.path ?? listWorkflows()[0]?.path ?? ''
export const readWorkflow = (path = defaultWorkflowPath) => JSON.parse(readFileSync(path, 'utf8'))
export const renderPage = (initial: { name: string; workflow: unknown }, catalog: WorkflowEntry[]) => {
const css = readFileSync(join(showroomRoot, 'src', 'showroom.css'), 'utf8')
const js = readFileSync(join(showroomRoot, 'src', 'showroom.js'), 'utf8')
const safeCatalog = catalog.map(({ name, title }) => ({ name, title }))
const initialJson = JSON.stringify(initial).replace(/</g, '\\u003c')
const catalogJson = JSON.stringify(safeCatalog).replace(/</g, '\\u003c')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hermes TUI Showroom</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" />
<style>${css}</style>
</head>
<body>
<main id="showroom"></main>
<script>
window.__SHOWROOM_INITIAL__ = ${initialJson};
window.__SHOWROOM_CATALOG__ = ${catalogJson};
</script>
<script type="importmap">
{ "imports": { "@xterm/": "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/" } }
</script>
<script type="module">${js}</script>
</body>
</html>`
}
+802
View File
@@ -0,0 +1,802 @@
import { rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { Writable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import React from 'react'
import { Box, render, Text } from '@hermes/ink'
import { Panel } from '../src/components/branding.js'
import { MessageLine } from '../src/components/messageLine.js'
import type { Theme } from '../src/theme.js'
import { DEFAULT_THEME } from '../src/theme.js'
import type { Msg } from '../src/types.js'
const showroomRoot = dirname(fileURLToPath(import.meta.url))
class Capture extends Writable {
buffer = ''
isTTY = true
columns: number
rows: number
constructor(cols: number, rows: number) {
super()
this.columns = cols
this.rows = rows
}
override _write(chunk: any, _encoding: any, callback: any) {
this.buffer += chunk.toString()
callback()
}
}
const COLS = 80
const ROWS = 16
const t = DEFAULT_THEME
const snap = async (node: React.ReactElement, settle = 120): Promise<string> => {
const stdout = new Capture(COLS, ROWS) as unknown as NodeJS.WriteStream
const inst = await render(node, { stdout, exitOnCtrlC: false, patchConsole: false })
await new Promise(resolve => setTimeout(resolve, settle))
inst.unmount()
return (stdout as unknown as Capture).buffer
}
const Msg = (msg: Msg) => <MessageLine cols={COLS} msg={msg} t={t} />
const ToolPanel = ({ items, title, theme }: { items: string[]; theme: Theme; title: string }) => (
<Box flexDirection="column" marginLeft={2}>
<Box>
<Text color={theme.color.bronze}> </Text>
<Text bold color={theme.color.amber}>
{title}
</Text>
<Text color={theme.color.dim}> ({items.length})</Text>
</Box>
{items.map((item, i) => (
<Box key={i}>
<Text color={theme.color.bronze}>{i === items.length - 1 ? '└─ ' : '├─ '}</Text>
<Text color={theme.color.dim}>{item}</Text>
</Box>
))}
</Box>
)
const Tree = ({
rows,
theme
}: {
rows: { branch: 'mid' | 'last'; cols: string[]; tone?: 'amber' | 'dim' | 'gold' | 'ok' }[]
theme: Theme
}) => (
<Box flexDirection="column" marginLeft={2}>
{rows.map((row, i) => {
const stem = row.branch === 'last' ? '└─ ' : '├─ '
const tone =
row.tone === 'gold'
? theme.color.gold
: row.tone === 'amber'
? theme.color.amber
: row.tone === 'ok'
? theme.color.ok
: theme.color.dim
return (
<Box key={i}>
<Text color={theme.color.bronze}>{stem}</Text>
<Text color={tone}>{row.cols.join(' ')}</Text>
</Box>
)
})}
</Box>
)
const writeWorkflow = (name: string, workflow: Record<string, unknown>) => {
const out = join(showroomRoot, 'workflows', `${name}.json`)
writeFileSync(out, JSON.stringify(workflow, null, 2))
console.log(` wrote ${out}`)
}
const featureTour = async () => {
const userPrompt = await snap(<Msg role="user" text="Build a focused plan for a safer gateway approval flow." />)
const assistantPlan = await snap(
<Msg
role="assistant"
text="I'll trace the gateway guards first, then patch the smallest boundary that keeps approval commands live while an agent is blocked."
/>
)
const toolTrail = await snap(
<ToolPanel
items={[
'rg "approval.request" gateway/ tui_gateway/',
'ReadFile gateway/run.py',
'ReadFile gateway/platforms/base.py'
]}
theme={t}
title="tool trail"
/>
)
const assistantResult = await snap(
<Msg
role="assistant"
text="Found the split guard. Bypass both queues only for approval commands; normal chat ordering stays intact."
/>
)
return {
composer: 'ask hermes anything',
timeline: [
{ ansi: userPrompt, at: 200, id: 'user-row', type: 'frame' },
{ ansi: assistantPlan, at: 1500, id: 'assistant-plan', type: 'frame' },
{ ansi: toolTrail, at: 2900, id: 'tool-trail', type: 'frame' },
{ at: 3200, duration: 1700, target: 'tool-trail', type: 'spotlight' },
{
at: 3400,
duration: 1700,
position: 'right',
target: 'tool-trail',
text: 'Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.',
type: 'caption'
},
{ ansi: assistantResult, at: 5400, id: 'assistant-result', type: 'frame' },
{ at: 6100, duration: 1300, target: 'assistant-result', type: 'highlight' },
{
at: 6300,
duration: 1700,
position: 'right',
target: 'assistant-result',
text: 'Captions, spotlights, and fades layer on top of real ANSI. Best of both.',
type: 'caption'
},
{ at: 8100, duration: 600, text: '/approve', type: 'compose' }
],
title: 'Hermes TUI · Feature Tour',
viewport: { cols: COLS, rows: ROWS }
}
}
const subagentTrail = async () => {
const userPrompt = await snap(<Msg role="user" text="Run tests, lint, and a Railway preview deploy in parallel." />)
const plan = await snap(
<Msg role="assistant" text="Spawning three subagents on the fan-out lane and watching their tool counts." />
)
const live = await snap(
<Tree
rows={[
{ branch: 'mid', cols: ['tests running 12 tools ⏱ 14.2s'], tone: 'amber' },
{ branch: 'mid', cols: ['lint running 4 tools ⏱ 14.2s'], tone: 'amber' },
{ branch: 'last', cols: ['deploy queued 0 tools ⏱ 0.0s'], tone: 'dim' }
]}
theme={t}
/>
)
const hot = await snap(
<Tree
rows={[
{ branch: 'mid', cols: ['tests complete 18 tools ⏱ 22.7s ✓'], tone: 'ok' },
{ branch: 'mid', cols: ['lint complete 6 tools ⏱ 18.1s ✓'], tone: 'ok' },
{ branch: 'last', cols: ['deploy running 9 tools ⏱ 9.4s'], tone: 'gold' }
]}
theme={t}
/>
)
const summary = await snap(
<Msg role="assistant" text="All three landed: 24 tests pass, lint clean, preview at https://pr-128.railway.app." />
)
return {
composer: 'spawn the deploy fan-out',
timeline: [
{ ansi: userPrompt, at: 200, id: 'ask', type: 'frame' },
{ ansi: plan, at: 1100, id: 'plan', type: 'frame' },
{ ansi: live, at: 2100, id: 'live', type: 'frame' },
{ at: 2300, duration: 1500, target: 'live', type: 'spotlight' },
{
at: 2500,
duration: 1700,
position: 'right',
target: 'live',
text: 'Each subagent gets its own depth and tool budget; the dashboard tracks them live.',
type: 'caption'
},
{ ansi: hot, at: 4400, id: 'hot', type: 'frame' },
{ at: 4600, duration: 1300, target: 'hot', type: 'highlight' },
{
at: 4800,
duration: 1700,
position: 'right',
target: 'hot',
text: 'Completed runs collapse, hot lanes stay vivid — the eye tracks the live agent.',
type: 'caption'
},
{ ansi: summary, at: 6800, id: 'summary', type: 'frame' },
{
at: 7000,
duration: 1700,
position: 'right',
target: 'summary',
text: 'Subagent results stream back into the parent transcript as a single highlight.',
type: 'caption'
},
{ at: 8800, duration: 600, text: '/agents', type: 'compose' }
],
title: 'Hermes TUI · Subagent Trail',
viewport: { cols: COLS, rows: ROWS }
}
}
const slashCommands = async () => {
const slashEcho = (text: string) => snap(<Msg kind="slash" role="user" text={text} />)
const skillsEcho = await slashEcho('/skills search vibe')
const skillsResults = await snap(
<Panel
sections={[
{
rows: [
['anthropics/skills/frontend-design', '★ trusted'],
['openai/skills/skill-creator', '· official'],
['skills.sh/community/vibe-coding', '⚙ community']
]
}
]}
t={t}
title="skills · search vibe"
/>,
180
)
const modelEcho = await slashEcho('/model claude-4.6-sonnet')
const modelSwitch = await snap(
<Panel
sections={[
{
rows: [
['from', 'gpt-5-codex'],
['to', 'claude-4.6-sonnet'],
['scope', 'this session']
]
}
]}
t={t}
title="model switched"
/>,
180
)
const agentsEcho = await slashEcho('/agents pause')
const agentsStatus = await snap(
<Panel
sections={[
{
rows: [
['delegation', 'paused'],
['max children', '4'],
['running tasks', 'queued for resume']
]
}
]}
t={t}
title="agents · paused"
/>,
180
)
const helpEcho = await slashEcho('/help')
const helpPanel = await snap(
<Panel
sections={[
{
items: ['/skills search · install · inspect', '/model switch model · pop picker'],
title: 'Tools & Skills'
},
{
items: [
'/agents spawn-tree dashboard',
'/queue queue prompt for next turn',
'/steer inject after next tool call'
],
title: 'Session'
},
{
items: ['/voice toggle voice mode', '/details thinking · tools · subagents · activity'],
title: 'Configuration'
}
]}
t={t}
title="(^_^)? Commands"
/>,
220
)
return {
composer: '',
timeline: [
{ at: 200, duration: 700, text: '/skills search vibe', type: 'compose' },
{ ansi: skillsEcho, at: 1100, type: 'frame' },
{ at: 1100, duration: 200, text: '', type: 'compose' },
{ ansi: skillsResults, at: 1400, id: 'skills', type: 'frame' },
{
at: 1700,
duration: 2000,
position: 'right',
target: 'skills',
text: 'Typed /skills, hit return — same Panel the live TUI renders.',
type: 'caption'
},
{ at: 4000, duration: 700, text: '/model claude-4.6-sonnet', type: 'compose' },
{ ansi: modelEcho, at: 4900, type: 'frame' },
{ at: 4900, duration: 200, text: '', type: 'compose' },
{ ansi: modelSwitch, at: 5200, id: 'model', type: 'frame' },
{
at: 5500,
duration: 1900,
position: 'right',
target: 'model',
text: '/model swaps mid-session; transcript and cache stay intact.',
type: 'caption'
},
{ at: 7600, duration: 600, text: '/agents pause', type: 'compose' },
{ ansi: agentsEcho, at: 8400, type: 'frame' },
{ at: 8400, duration: 200, text: '', type: 'compose' },
{ ansi: agentsStatus, at: 8700, id: 'agents', type: 'frame' },
{
at: 9000,
duration: 1800,
position: 'right',
target: 'agents',
text: 'Same registry powers TUI, gateway, Telegram, Discord — one truth.',
type: 'caption'
},
{ at: 11000, duration: 400, text: '/help', type: 'compose' },
{ ansi: helpEcho, at: 11500, type: 'frame' },
{ at: 11500, duration: 200, text: '', type: 'compose' },
{ ansi: helpPanel, at: 11800, id: 'help', type: 'frame' }
],
title: 'Hermes TUI · Slash Commands',
viewport: { cols: COLS, rows: ROWS }
}
}
const voiceMode = async () => {
const vad = await snap(
<ToolPanel
items={['▮ ▮▮ ▮ ▮▮▮▮ ▮▮ ▮▮▮▮▮▮ ▮▮▮ ▮', 'rms 0.42 · 1.6s captured', 'auto-stop · silence 380ms']}
theme={t}
title="VAD · capturing"
/>
)
const transcript = await snap(<Msg role="user" text="what's in my inbox today and what needs a reply before noon?" />)
const answer = await snap(
<Msg
role="assistant"
text="Three threads need you before noon: vendor renewal, podcast intro feedback, and the design review at 11."
/>
)
const tts = await snap(
<ToolPanel
items={['voice 11labs · grace_v3', 'elapsed 4.6s · 2 chunks queued', 'ducking mic input']}
theme={t}
title="tts · playing"
/>
)
return {
composer: 'ctrl+b to start recording',
timeline: [
{ ansi: vad, at: 250, id: 'vad', type: 'frame' },
{ at: 600, duration: 1500, target: 'vad', type: 'spotlight' },
{
at: 800,
duration: 1700,
position: 'right',
target: 'vad',
text: 'Continuous loop: VAD detects silence, transcribes, restarts — no key holds.',
type: 'caption'
},
{ ansi: transcript, at: 2700, id: 'transcript', type: 'frame' },
{ at: 3400, duration: 1100, target: 'transcript', type: 'highlight' },
{
at: 3600,
duration: 1700,
position: 'right',
target: 'transcript',
text: 'Transcript flows straight into the composer with the standard user glyph.',
type: 'caption'
},
{ ansi: answer, at: 5500, id: 'answer', type: 'frame' },
{ ansi: tts, at: 6700, id: 'tts', type: 'frame' },
{
at: 7000,
duration: 1700,
position: 'right',
target: 'tts',
text: 'TTS auto-ducks the mic so the loop never echoes itself back.',
type: 'caption'
},
{ at: 8800, duration: 600, text: '/voice off', type: 'compose' }
],
title: 'Hermes TUI · Voice Mode',
viewport: { cols: COLS, rows: ROWS }
}
}
// --- Static prompt mocks (no useInput, safe for snap()) ---
const ApprovalPromptStatic = ({
command,
description,
selected = 0,
theme
}: {
command: string
description: string
selected?: number
theme: Theme
}) => {
const labels = ['Allow once', 'Allow this session', 'Always allow', 'Deny']
const lines = command.split('\n').slice(0, 5)
return (
<Box borderColor={theme.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={theme.color.warn}>
approval required · {description}
</Text>
<Box flexDirection="column" paddingLeft={1}>
{lines.map((line, i) => (
<Text color={theme.color.cornsilk} key={i}>
{line || ' '}
</Text>
))}
</Box>
<Text />
{labels.map((label, i) => (
<Text key={label}>
<Text bold={i === selected} color={i === selected ? theme.color.warn : theme.color.dim} inverse={i === selected}>
{i === selected ? '▸ ' : ' '}
{i + 1}. {label}
</Text>
</Text>
))}
<Text color={theme.color.dim}>/ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
</Box>
)
}
const ClarifyPromptStatic = ({
choices,
question,
selected = 0,
theme
}: {
choices: string[]
question: string
selected?: number
theme: Theme
}) => (
<Box flexDirection="column">
<Text bold>
<Text color={theme.color.amber}>ask</Text>
<Text color={theme.color.cornsilk}> {question}</Text>
</Text>
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text bold={i === selected} color={i === selected ? theme.color.label : theme.color.dim} inverse={i === selected}>
{i === selected ? '▸ ' : ' '}
{i + 1}. {c}
</Text>
</Text>
))}
<Text color={theme.color.dim}>
/ select · Enter confirm · 1-{choices.length + 1} quick pick · Esc cancel
</Text>
</Box>
)
const ModelPickerStatic = ({
currentModel,
items,
selected = 0,
stage,
theme
}: {
currentModel: string
items: string[]
selected?: number
stage: 'model' | 'provider'
theme: Theme
}) => (
<Box borderStyle="double" borderColor={theme.color.amber} flexDirection="column" paddingX={1} width={50}>
<Text bold color={theme.color.amber} wrap="truncate-end">
{stage === 'provider' ? 'Select Provider' : 'Select Model'}
</Text>
<Text color={theme.color.dim} wrap="truncate-end">
{stage === 'provider' ? `Current model: ${currentModel}` : currentModel}
</Text>
<Text color={theme.color.label} wrap="truncate-end">
{' '}
</Text>
<Text color={theme.color.dim}>{' '}</Text>
{items.map((item, i) => (
<Text
bold={i === selected}
color={i === selected ? theme.color.amber : theme.color.dim}
inverse={i === selected}
key={item}
wrap="truncate-end"
>
{i === selected ? '▸ ' : ' '}
{i + 1}. {item}
</Text>
))}
<Text color={theme.color.dim}>{' '}</Text>
<Text color={theme.color.dim}>persist: session · g toggle</Text>
<Text color={theme.color.dim}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</Text>
</Box>
)
const interactivePrompts = async () => {
// User asks for something that triggers approval
const userAsk = await snap(
<Msg role="user" text="Run npm install express in the project root." />
)
const assistantExplains = await snap(
<Msg
role="assistant"
text="I'll install express. The package manager needs approval — here's the command."
/>
)
// Approval prompt
const approval = await snap(
<ApprovalPromptStatic
command={'npm install express\nadded 58 packages in 3.2s\n\n+ express@5.1.0'}
description="install dependency"
theme={t}
/>,
180
)
// After approval, user asks something ambiguous
const userClarify = await snap(
<Msg role="user" text="Deploy this to staging." />
)
const assistantAsks = await snap(
<Msg role="assistant" text="Which environment should I target?" />
)
// Clarify prompt
const clarify = await snap(
<ClarifyPromptStatic
choices={['staging-us-east', 'staging-eu-west', 'staging-ap-south']}
question="Which region?"
theme={t}
/>,
180
)
const confirmResult = await snap(
<Panel
sections={[
{
rows: [
['target', 'staging-us-east'],
['branch', 'main'],
['preview', 'https://pr-128.railway.app']
]
}
]}
t={t}
title="deployment queued"
/>,
180
)
return {
composer: 'deploy this to staging',
timeline: [
{ ansi: userAsk, at: 200, id: 'ask', type: 'frame' },
{ ansi: assistantExplains, at: 1200, id: 'explain', type: 'frame' },
{ ansi: approval, at: 2600, id: 'approval', type: 'frame' },
{ at: 2900, duration: 1500, target: 'approval', type: 'spotlight' },
{
at: 3100,
duration: 2000,
position: 'right',
target: 'approval',
text: 'Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.',
type: 'caption'
},
{ at: 5400, duration: 400, text: '1', type: 'compose' },
{ at: 5900, duration: 500, text: '', type: 'compose' },
{ ansi: userClarify, at: 6600, id: 'clarify-ask', type: 'frame' },
{ ansi: assistantAsks, at: 7600, id: 'clarify-reply', type: 'frame' },
{ ansi: clarify, at: 8800, id: 'clarify', type: 'frame' },
{ at: 9100, duration: 1500, target: 'clarify', type: 'spotlight' },
{
at: 9300,
duration: 2000,
position: 'right',
target: 'clarify',
text: 'Clarify prompts handle ambiguous requests — numbered choices or free text.',
type: 'caption'
},
{ at: 11600, duration: 400, text: '1', type: 'compose' },
{ ansi: confirmResult, at: 12200, id: 'result', type: 'frame' },
{ at: 12500, duration: 1300, target: 'result', type: 'highlight' }
],
title: 'Hermes TUI · Interactive Prompts',
viewport: { cols: COLS, rows: ROWS }
}
}
const modelPicker = async () => {
const userAsk = await snap(
<Msg role="user" text="Switch to Claude." />
)
const assistantReply = await snap(
<Msg role="assistant" text="Opening the model picker — pick a provider first, then a model." />
)
// Provider selection stage
const providers = await snap(
<ModelPickerStatic
currentModel="gpt-5-codex"
items={[
'OpenAI · 8 models',
'Anthropic · 6 models',
'Google · 5 models',
'OpenRouter · 42 models',
'xAI · 3 models'
]}
selected={1}
stage="provider"
theme={t}
/>,
180
)
// Model selection stage
const models = await snap(
<ModelPickerStatic
currentModel="Anthropic"
items={[
'claude-opus-4',
'claude-sonnet-4',
'claude-sonnet-3.7',
'claude-haiku-3.5',
'claude-sonnet-3.5'
]}
selected={1}
stage="model"
theme={t}
/>,
180
)
const result = await snap(
<Panel
sections={[
{
rows: [
['from', 'gpt-5-codex'],
['to', 'claude-sonnet-4'],
['scope', 'this session']
]
}
]}
t={t}
title="model switched"
/>,
180
)
return {
composer: '',
timeline: [
{ at: 200, duration: 500, text: '/model', type: 'compose' },
{ ansi: userAsk, at: 900, id: 'ask', type: 'frame' },
{ ansi: assistantReply, at: 1800, id: 'reply', type: 'frame' },
{ ansi: providers, at: 3000, id: 'providers', type: 'frame' },
{ at: 3300, duration: 1800, target: 'providers', type: 'spotlight' },
{
at: 3500,
duration: 2000,
position: 'right',
target: 'providers',
text: 'Provider stage: pick from authenticated backends. Shows model count per provider.',
type: 'caption'
},
{ at: 5600, duration: 300, text: '2', type: 'compose' },
{ ansi: models, at: 6200, id: 'models', type: 'frame' },
{ at: 6500, duration: 1800, target: 'models', type: 'spotlight' },
{
at: 6700,
duration: 2000,
position: 'right',
target: 'models',
text: 'Model stage: scrollable list with ▸ selection. Number keys for quick pick.',
type: 'caption'
},
{ at: 9000, duration: 300, text: '2', type: 'compose' },
{ ansi: result, at: 9600, id: 'result', type: 'frame' },
{ at: 9900, duration: 1300, target: 'result', type: 'highlight' },
{
at: 10100,
duration: 1700,
position: 'right',
target: 'result',
text: 'Model swap mid-session. Transcript and cache stay intact.',
type: 'caption'
}
],
title: 'Hermes TUI · Model Picker',
viewport: { cols: COLS, rows: ROWS }
}
}
const main = async () => {
console.log('recording workflows…')
// Wipe the workflows dir so deleted/renamed scenes don't linger.
const workflowsDir = join(showroomRoot, 'workflows')
for (const file of [
'feature-tour.json',
'subagent-trail.json',
'slash-commands.json',
'voice-mode.json',
'interactive-prompts.json',
'model-picker.json',
'ink-frames.json'
]) {
try {
rmSync(join(workflowsDir, file))
} catch {
/* ignore */
}
}
writeWorkflow('feature-tour', await featureTour())
writeWorkflow('subagent-trail', await subagentTrail())
writeWorkflow('slash-commands', await slashCommands())
writeWorkflow('voice-mode', await voiceMode())
writeWorkflow('interactive-prompts', await interactivePrompts())
writeWorkflow('model-picker', await modelPicker())
console.log('done')
}
void main().catch(error => {
console.error(error)
process.exit(1)
})
+109
View File
@@ -0,0 +1,109 @@
import { createServer } from 'node:http'
import { resolve } from 'node:path'
import {
defaultWorkflowPath,
listWorkflows,
readWorkflow,
renderPage,
workflowsDir,
type WorkflowEntry
} from './page.js'
const FLAG_VALUES = new Set(['--port', '--workflow'])
const arg = (name: string) => {
const index = process.argv.indexOf(name)
return index === -1 ? undefined : process.argv[index + 1]
}
const positional = (() => {
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
const value = argv[i]!
if (FLAG_VALUES.has(value)) {
i += 1
continue
}
if (value.startsWith('-')) {
continue
}
return value
}
return undefined
})()
const port = Number(arg('--port') ?? process.env.PORT ?? 4317)
const overridePath = arg('--workflow') ?? positional
const pickInitial = (catalog: WorkflowEntry[], requested: null | string): WorkflowEntry => {
if (overridePath) {
const fullPath = resolve(process.cwd(), overridePath)
return { name: 'override', path: fullPath, title: requested ?? 'override' }
}
if (requested) {
const hit = catalog.find(w => w.name === requested)
if (hit) {
return hit
}
}
return catalog.find(w => w.path === defaultWorkflowPath) ?? catalog[0]!
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/healthz') {
res.writeHead(200).end('ok')
return
}
if (url.pathname === '/api/workflows') {
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(listWorkflows()))
return
}
if (url.pathname.startsWith('/api/workflow/')) {
const name = decodeURIComponent(url.pathname.slice('/api/workflow/'.length))
const hit = listWorkflows().find(w => w.name === name)
if (!hit) {
res.writeHead(404).end('not found')
return
}
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(readWorkflow(hit.path)))
return
}
try {
const catalog = listWorkflows()
const initial = pickInitial(catalog, url.searchParams.get('w'))
const page = renderPage({ name: initial.name, workflow: readWorkflow(initial.path) }, catalog)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(page)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end(message)
}
})
server.listen(port, '127.0.0.1', () => {
console.log(`showroom: http://127.0.0.1:${port}`)
console.log(`workflows dir: ${workflowsDir}`)
})
+422
View File
@@ -0,0 +1,422 @@
:root {
color-scheme: dark;
background: #050505;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
--gold: #ffd700;
--amber: #ffbf00;
--bronze: #cd7f32;
--cornsilk: #fff8dc;
--dim: #cc9b1f;
--label: #daa520;
--bg: #0a0a0a;
--bg-deep: #050505;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
overflow: auto;
background:
radial-gradient(circle at 18% 12%, rgba(205, 127, 50, 0.12), transparent 36rem),
radial-gradient(circle at 82% 14%, rgba(255, 215, 0, 0.05), transparent 30rem),
var(--bg-deep);
}
#showroom {
min-height: 100vh;
padding: 24px 24px 60px;
display: flex;
justify-content: center;
align-items: flex-start;
}
/* --- Shell --- */
.showroom-shell {
display: grid;
gap: 14px;
width: max-content;
max-width: 100%;
opacity: 0;
transform: translateY(12px);
transition:
opacity 600ms var(--ease-out),
transform 600ms var(--ease-out);
}
.showroom-shell.is-mounted {
opacity: 1;
transform: translateY(0);
}
/* --- Stage --- */
.showroom-stage {
position: relative;
width: var(--stage-w);
height: var(--stage-h);
overflow: hidden;
border: 1px solid rgba(205, 127, 50, 0.45);
border-radius: 14px;
background: var(--bg);
box-shadow:
0 32px 120px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.showroom-terminal {
position: absolute;
inset: 0 auto auto 0;
display: grid;
grid-template-rows: auto 1fr auto;
width: var(--term-w);
height: var(--term-h);
transform: scale(var(--scale));
transform-origin: top left;
overflow: hidden;
padding: 8px 10px;
background: var(--bg);
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
line-height: 18px;
}
/* --- Status bar --- */
.showroom-status {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
padding: 0 4px;
color: var(--dim);
font-size: 11px;
white-space: nowrap;
}
.showroom-status:empty,
.showroom-status-left:empty,
.showroom-status-right:empty {
display: none;
}
.showroom-status-left,
.showroom-status-right {
display: flex;
align-items: center;
gap: 8px;
}
/* --- Composer --- */
.showroom-composer {
display: flex;
align-items: center;
min-height: 22px;
padding: 6px 4px 0;
color: var(--cornsilk);
white-space: nowrap;
}
.showroom-composer:empty {
display: none;
}
.showroom-composer::before {
content: '';
color: var(--gold);
font-weight: 700;
margin-right: 8px;
}
.showroom-composer:not(:empty)::after {
content: '';
display: inline-block;
width: 7px;
height: 14px;
margin-left: 4px;
background: var(--gold);
vertical-align: middle;
animation: showroom-blink 1100ms steps(2) infinite;
}
@keyframes showroom-blink {
50% {
opacity: 0;
}
}
/* --- Body (DOM message mode) --- */
.showroom-body {
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
padding: 4px 0 6px;
}
/* --- xterm container (frame mode) --- */
.showroom-xterm {
width: 100%;
height: 100%;
overflow: hidden;
opacity: 0;
transition: opacity 300ms var(--ease-out);
}
.showroom-xterm.is-visible {
opacity: 1;
}
.showroom-xterm .xterm-viewport {
overflow: hidden !important;
background: transparent !important;
}
/* --- DOM-mode lines --- */
.showroom-line,
.showroom-tool {
opacity: 0;
transform: translateY(4px);
animation: showroom-enter 320ms var(--ease-out) forwards;
}
@keyframes showroom-enter {
to {
opacity: 1;
transform: translateY(0);
}
}
.showroom-line {
display: grid;
grid-template-columns: 22px 1fr;
gap: 4px;
}
.showroom-glyph {
color: var(--role);
font-weight: 700;
}
.showroom-copy {
color: var(--copy);
white-space: pre-wrap;
}
.showroom-line-user .showroom-copy {
color: var(--label);
font-weight: 600;
}
.showroom-line-assistant .showroom-copy {
color: var(--cornsilk);
}
.showroom-line-system .showroom-copy {
color: var(--dim);
}
/* --- Tool panel --- */
.showroom-tool {
margin-left: 22px;
border: 1px solid rgba(205, 127, 50, 0.32);
border-radius: 4px;
padding: 6px 10px;
background: rgba(205, 127, 50, 0.05);
}
.showroom-tool-title {
color: var(--gold);
font-weight: 700;
}
.showroom-tool-title::before {
content: '⚡ ';
color: var(--bronze);
}
.showroom-tool-items {
display: grid;
gap: 1px;
margin-top: 4px;
color: var(--dim);
font-size: 12px;
}
.showroom-tool-items div::before {
content: '┊ ';
color: var(--bronze);
}
/* --- Highlight --- */
.is-highlighted {
filter: brightness(1.4);
background: rgba(255, 215, 0, 0.1);
transform: translateX(3px);
transition:
filter 420ms var(--ease-in-out),
background 420ms var(--ease-in-out),
transform 420ms var(--ease-in-out);
}
/* --- Overlays (captions, spotlights) --- */
.showroom-overlays {
position: absolute;
inset: 0;
pointer-events: none;
}
.showroom-caption,
.showroom-spotlight {
position: absolute;
opacity: 0;
transition:
opacity 360ms var(--ease-out),
transform 360ms var(--ease-out);
}
.showroom-caption {
max-width: 360px;
border: 1px solid rgba(205, 127, 50, 0.5);
border-radius: 12px;
padding: 12px 14px;
background: rgba(10, 10, 10, 0.92);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
color: var(--cornsilk);
font-size: 14px;
line-height: 1.45;
transform: translateY(8px);
}
.showroom-spotlight {
border: 2px solid var(--gold);
border-radius: 8px;
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.42),
0 0 32px rgba(255, 215, 0, 0.32);
}
.showroom-caption.is-visible,
.showroom-spotlight.is-visible {
opacity: 1;
transform: translateY(0);
}
/* --- Picker --- */
.showroom-picker {
appearance: none;
border: 1px solid rgba(205, 127, 50, 0.4);
border-radius: 999px;
padding: 6px 30px 6px 14px;
background: rgba(205, 127, 50, 0.06)
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23cd7f32' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
no-repeat right 12px center / 10px;
color: var(--cornsilk);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
cursor: pointer;
}
.showroom-picker:focus {
outline: 1px solid var(--bronze);
}
/* --- Controls bar --- */
.showroom-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.showroom-controls button {
border: 1px solid rgba(205, 127, 50, 0.25);
border-radius: 999px;
padding: 4px 10px;
background: rgba(205, 127, 50, 0.04);
color: var(--dim);
cursor: pointer;
font: inherit;
}
.showroom-controls button:hover {
background: rgba(205, 127, 50, 0.12);
color: var(--cornsilk);
}
.showroom-controls button[data-action='restart'] {
width: 28px;
height: 28px;
padding: 0;
font-size: 14px;
line-height: 1;
}
.showroom-segmented {
display: inline-flex;
border: 1px solid rgba(205, 127, 50, 0.25);
border-radius: 999px;
padding: 2px;
background: rgba(205, 127, 50, 0.04);
}
.showroom-segmented button {
border: 0;
border-radius: 999px;
padding: 3px 10px;
background: transparent;
color: var(--dim);
cursor: pointer;
font: inherit;
}
.showroom-segmented button.is-active {
background: rgba(255, 215, 0, 0.18);
color: var(--cornsilk);
}
/* --- Progress --- */
.showroom-progress {
display: inline-flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 140px;
color: var(--dim);
}
.showroom-progress-track {
position: relative;
flex: 1;
height: 2px;
border-radius: 999px;
background: rgba(205, 127, 50, 0.1);
overflow: hidden;
}
.showroom-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, var(--bronze), var(--gold));
transition: width 80ms linear;
}
+541
View File
@@ -0,0 +1,541 @@
import { Terminal } from '@xterm/xterm'
const initial = window.__SHOWROOM_INITIAL__
const catalog = window.__SHOWROOM_CATALOG__ ?? []
const root = document.getElementById('showroom')
const SPEEDS = [0.5, 1, 2]
const SCALES = [1, 2, 3, 4]
const state = {
body: null,
composer: null,
frameTargets: new Map(),
overlays: null,
progressFill: null,
progressLabel: null,
raf: null,
scale: 2,
shell: null,
speed: 1,
startedAt: 0,
statusLeft: null,
statusRight: null,
term: null,
termContainer: null,
timers: [],
total: 0,
viewport: null,
workflow: initial?.workflow ?? { timeline: [] }
}
const clearTimers = () => {
while (state.timers.length) {
clearTimeout(state.timers.pop())
}
if (state.raf) {
cancelAnimationFrame(state.raf)
state.raf = null
}
}
const resolveTarget = id => {
if (!id) {
return null
}
return state.frameTargets.get(id) ?? document.querySelector(`[data-target="${CSS.escape(id)}"]`)
}
const setText = (node, text = '', duration = 0) => {
if (!duration || state.speed <= 0) {
node.textContent = text
return
}
const chars = [...text]
const adjusted = duration / state.speed
const started = performance.now()
const frame = now => {
const n = Math.min(chars.length, Math.ceil(((now - started) / adjusted) * chars.length))
node.textContent = chars.slice(0, n).join('')
if (n < chars.length) {
requestAnimationFrame(frame)
}
}
requestAnimationFrame(frame)
}
const removeAfter = (node, duration = 1400) => {
const wait = duration / state.speed
state.timers.push(
setTimeout(() => {
node.classList.remove('is-visible')
state.timers.push(setTimeout(() => node.remove(), 420 / state.speed))
}, wait)
)
}
const rectFor = (id, pad = 8) => {
const el = resolveTarget(id)
if (!el || !state.overlays) {
return null
}
const stage = state.overlays.getBoundingClientRect()
const rect = el.getBoundingClientRect()
return {
height: rect.height + pad * 2,
left: rect.left - stage.left - pad,
top: rect.top - stage.top - pad,
width: rect.width + pad * 2
}
}
const placeNear = (node, id, position = 'right') => {
const rect = rectFor(id, 0)
if (!rect) {
node.style.left = '24px'
node.style.top = '24px'
return
}
const gap = 18
const left = position === 'left' ? rect.left - node.offsetWidth - gap : rect.left + rect.width + gap
const top = position === 'top' ? rect.top - node.offsetHeight - gap : rect.top
node.style.left = `${Math.max(12, left)}px`
node.style.top = `${Math.max(12, top)}px`
}
// --- Actions ---
const message = action => {
const spec = {
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
user: { copy: '#daa520', glyph: '', tone: '#ffd700' }
}[action.role] ?? { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' }
const line = document.createElement('div')
const glyph = document.createElement('span')
const copy = document.createElement('div')
line.className = `showroom-line showroom-line-${action.role ?? 'assistant'}`
line.dataset.target = action.id ?? ''
line.style.setProperty('--role', spec.tone)
line.style.setProperty('--copy', spec.copy)
glyph.className = 'showroom-glyph'
glyph.textContent = spec.glyph
copy.className = 'showroom-copy'
line.append(glyph, copy)
state.body.append(line)
setText(copy, action.text, action.duration)
}
const tool = action => {
const box = document.createElement('div')
const title = document.createElement('div')
const items = document.createElement('div')
box.className = 'showroom-tool'
box.dataset.target = action.id ?? ''
title.className = 'showroom-tool-title'
title.textContent = action.title ?? 'tool activity'
items.className = 'showroom-tool-items'
for (const item of action.items ?? []) {
const row = document.createElement('div')
row.textContent = item
items.append(row)
}
box.append(title, items)
state.body.append(box)
}
const frame = action => {
if (!state.term || !action.ansi) {
return
}
state.term.write(action.ansi)
if (action.id) {
state.frameTargets.set(action.id, state.termContainer)
}
}
const fade = action => {
const el = resolveTarget(action.target)
if (!el) {
return
}
el.style.transition = `opacity ${(action.duration ?? 420) / state.speed}ms var(--ease-in-out)`
el.style.opacity = String(action.to ?? 0)
}
const highlight = action => {
const el = resolveTarget(action.target)
if (!el) {
return
}
el.classList.add('is-highlighted')
state.timers.push(setTimeout(() => el.classList.remove('is-highlighted'), (action.duration ?? 1200) / state.speed))
}
const caption = action => {
const node = document.createElement('div')
node.className = 'showroom-caption'
node.dataset.target = action.id ?? ''
node.textContent = action.text ?? ''
state.overlays.append(node)
placeNear(node, action.target, action.position)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration ?? 1600)
}
const spotlight = action => {
const rect = rectFor(action.target, action.pad ?? 6)
if (!rect) {
return
}
const node = document.createElement('div')
node.className = 'showroom-spotlight'
node.style.left = `${rect.left}px`
node.style.top = `${rect.top}px`
node.style.width = `${rect.width}px`
node.style.height = `${rect.height}px`
state.overlays.append(node)
requestAnimationFrame(() => node.classList.add('is-visible'))
removeAfter(node, action.duration ?? 1500)
}
const status = action => {
state.statusLeft.textContent = action.text ?? ''
state.statusRight.textContent = action.detail ?? ''
}
const compose = action => setText(state.composer, action.text ?? '', action.duration ?? 0)
const clearTranscript = () => {
state.overlays.textContent = ''
state.frameTargets.clear()
if (state.term) {
state.term.reset()
state.term.write('\x1b[?25l')
return
}
state.body.textContent = ''
}
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
// --- Progress ---
const fmtTime = ms => {
if (!Number.isFinite(ms)) {
return '0.0s'
}
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
}
const tickProgress = () => {
if (!state.startedAt) {
return
}
const elapsed = Math.min(state.total, (performance.now() - state.startedAt) * state.speed)
const ratio = state.total ? elapsed / state.total : 0
state.progressFill.style.width = `${(ratio * 100).toFixed(2)}%`
state.progressLabel.textContent = `${fmtTime(elapsed)} / ${fmtTime(state.total)}`
if (elapsed < state.total) {
state.raf = requestAnimationFrame(tickProgress)
}
}
// --- xterm ---
const initXterm = () => {
const hasFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
if (!hasFrames) {
state.term = null
state.termContainer = null
return
}
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
state.termContainer = state.body.querySelector('.showroom-xterm')
state.term = new Terminal({
cols: state.viewport.cols,
rows: state.viewport.rows,
fontFamily: 'JetBrains Mono, "SF Mono", Consolas, monospace',
fontSize: 13,
cursorBlink: false,
scrollback: 0,
convertEol: true,
allowProposedApi: true,
theme: {
background: '#0a0a0a',
foreground: '#fff8dc',
cursor: '#ffd700',
selectionBackground: '#3a3a55',
black: '#0a0a0a',
red: '#ef5350',
green: '#8fbc8f',
yellow: '#ffd700',
blue: '#5a82ff',
magenta: '#cd7f32',
cyan: '#daa520',
white: '#fff8dc',
brightBlack: '#cc9b1f',
brightRed: '#ef5350',
brightGreen: '#8fbc8f',
brightYellow: '#ffbf00',
brightBlue: '#5a82ff',
brightMagenta: '#cd7f32',
brightCyan: '#daa520',
brightWhite: '#fff8dc'
}
})
state.term.open(state.termContainer)
state.term.write('\x1b[?25l')
// Fade in
requestAnimationFrame(() => state.termContainer.classList.add('is-visible'))
}
// --- Playback ---
const play = () => {
clearTimers()
clearTranscript()
state.statusLeft.textContent = ''
state.statusRight.textContent = ''
state.composer.textContent = state.workflow.composer ?? ''
const timeline = [...(state.workflow.timeline ?? [])].sort((a, b) => a.at - b.at)
state.total = timeline.reduce((max, action) => Math.max(max, action.at + (action.duration ?? 0)), 0)
state.startedAt = performance.now()
state.progressFill.style.width = '0%'
state.progressLabel.textContent = `0.0s / ${fmtTime(state.total)}`
for (const action of timeline) {
state.timers.push(setTimeout(() => ACTIONS[action.type]?.(action), action.at / state.speed))
}
state.raf = requestAnimationFrame(tickProgress)
}
// --- Controls ---
const setSpeed = next => {
state.speed = next
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
button.classList.toggle('is-active', Number(button.dataset.value) === next)
}
}
const setScale = next => {
state.scale = next
state.shell.style.setProperty('--scale', `${next}`)
state.shell.style.setProperty('--stage-w', `${state.viewport.cols * state.viewport.cellWidth * next}px`)
state.shell.style.setProperty('--stage-h', `${state.viewport.rows * state.viewport.lineHeight * next}px`)
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
button.classList.toggle('is-active', Number(button.dataset.value) === next)
}
}
const fitScale = () => {
const margin = 96
const baseW = state.viewport.cols * state.viewport.cellWidth
const baseH = state.viewport.rows * state.viewport.lineHeight
const maxW = Math.max(1, window.innerWidth - margin)
const maxH = Math.max(1, window.innerHeight - 240)
const fit = Math.max(1, Math.floor(Math.min(maxW / baseW, maxH / baseH)))
return Math.max(1, Math.min(SCALES[SCALES.length - 1], fit))
}
const loadWorkflow = async name => {
const url = new URL(window.location.href)
url.searchParams.set('w', name)
window.history.replaceState(null, '', url)
try {
const response = await fetch(`/api/workflow/${encodeURIComponent(name)}`)
if (response.ok) {
state.workflow = await response.json()
}
} catch {
/* fall through */
}
await rebuild()
}
// --- DOM ---
const buildOptions = () => {
if (!catalog.length) {
return ''
}
return catalog
.map(({ name, title }) => {
const selected = name === initial?.name ? ' selected' : ''
return `<option value="${name}"${selected}>${title}</option>`
})
.join('')
}
const buildSegmented = (values, active) =>
values
.map(
value =>
`<button type="button" data-value="${value}" class="${value === active ? 'is-active' : ''}">${value}x</button>`
)
.join('')
const computeViewport = () => {
const fromWorkflow = state.workflow.viewport ?? {}
return {
cellWidth: 9,
cols: 80,
lineHeight: 19,
rows: 24,
scale: 2,
...fromWorkflow
}
}
const renderShell = () => {
state.viewport = computeViewport()
state.frameTargets.clear()
state.shell.style.setProperty('--cell-w', `${state.viewport.cellWidth}px`)
state.shell.style.setProperty('--cols', `${state.viewport.cols}`)
state.shell.style.setProperty('--line-h', `${state.viewport.lineHeight}px`)
state.shell.style.setProperty('--rows', `${state.viewport.rows}`)
state.shell.style.setProperty('--term-w', `${state.viewport.cols * state.viewport.cellWidth}px`)
state.shell.style.setProperty('--term-h', `${state.viewport.rows * state.viewport.lineHeight}px`)
state.shell.innerHTML = `
<div class="showroom-stage">
<div class="showroom-terminal">
<div class="showroom-status" data-target="status">
<span class="showroom-status-left"></span>
<span class="showroom-status-right"></span>
</div>
<div class="showroom-body"></div>
<div class="showroom-composer" data-target="composer"></div>
</div>
<div class="showroom-overlays"></div>
</div>
<footer class="showroom-controls">
<button type="button" data-action="restart" title="restart (R)">&#8635;</button>
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
<span class="showroom-progress">
<span data-role="time">0.0s / 0.0s</span>
<div class="showroom-progress-track"><div class="showroom-progress-fill"></div></div>
</span>
</footer>
`
state.body = state.shell.querySelector('.showroom-body')
state.composer = state.shell.querySelector('.showroom-composer')
state.overlays = state.shell.querySelector('.showroom-overlays')
state.statusLeft = state.shell.querySelector('.showroom-status-left')
state.statusRight = state.shell.querySelector('.showroom-status-right')
state.progressFill = state.shell.querySelector('.showroom-progress-fill')
state.progressLabel = state.shell.querySelector('[data-role="time"]')
state.shell.querySelector('[data-action="restart"]').addEventListener('click', play)
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
button.addEventListener('click', () => setSpeed(Number(button.dataset.value)))
}
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
button.addEventListener('click', () => setScale(Number(button.dataset.value)))
}
const picker = state.shell.querySelector('[data-action="picker"]')
if (picker) {
picker.addEventListener('change', event => {
void loadWorkflow(event.target.value)
})
}
}
const rebuild = async () => {
renderShell()
initXterm()
setScale(state.workflow.viewport?.scale ?? fitScale())
play()
}
const mount = () => {
state.shell = document.createElement('section')
state.shell.className = 'showroom-shell'
root.replaceChildren(state.shell)
void rebuild().then(() => {
requestAnimationFrame(() => state.shell.classList.add('is-mounted'))
})
window.addEventListener('keydown', event => {
const key = event.key.toLowerCase()
if (key === 'r') {
play()
} else if (key === '1' || key === '2' || key === '3') {
setSpeed(SPEEDS[Number(key) - 1])
}
})
}
mount()
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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'
+1 -1
View File
@@ -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>
+25 -14
View File
@@ -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(
+7 -2
View File
@@ -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)
+1 -1
View File
@@ -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'],
+74
View File
@@ -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'])
})
})
+47
View File
@@ -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']
}
+12
View File
@@ -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