Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 648da6a8d1 |
@@ -0,0 +1,58 @@
|
||||
# Hermes Apps
|
||||
|
||||
Platform apps live here. The first app is a cross-platform GUI shell around the
|
||||
existing Hermes dashboard; it should not fork chat, config, logs, or session UI.
|
||||
|
||||
## Shape
|
||||
|
||||
```text
|
||||
apps/
|
||||
gui/ # cross-platform app shell: dev Chrome shell now, Tauri native next
|
||||
shared/ # runtime bundle notes/scripts used by Windows + macOS packaging
|
||||
```
|
||||
|
||||
## Desktop Dev
|
||||
|
||||
The backend-only GUI mode is:
|
||||
|
||||
```bash
|
||||
hermes dashboard --gui
|
||||
```
|
||||
|
||||
The fast GUI shell is:
|
||||
|
||||
```powershell
|
||||
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The native Tauri shell is:
|
||||
|
||||
```powershell
|
||||
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
`--gui` implies the embedded TUI; do not pass `--tui` separately for GUI mode.
|
||||
|
||||
## MVP Boundary
|
||||
|
||||
Included:
|
||||
|
||||
- bundled Python runtime
|
||||
- bundled Node/TUI runtime
|
||||
- CLI install to PATH
|
||||
- profile picker and first-run setup
|
||||
- dashboard health/reconnect state
|
||||
- tray controls
|
||||
- desktop notifications
|
||||
- Windows installer
|
||||
|
||||
Deferred:
|
||||
|
||||
- code signing
|
||||
- native self-updater
|
||||
- store distribution
|
||||
|
||||
For MVP updates, the desktop UI should run the existing `hermes update` flow and
|
||||
surface progress/finish notifications.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Hermes GUI
|
||||
|
||||
Cross-platform GUI shell for the Hermes dashboard.
|
||||
|
||||
## Fast Dev Shell
|
||||
|
||||
This gets a GUI window on Windows/WSL today by launching Chrome in app mode:
|
||||
|
||||
```bash
|
||||
cd apps/gui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
It starts `hermes dashboard --gui --no-open --port 9120`, waits for
|
||||
`/api/health`, then opens a standalone app window at `http://127.0.0.1:9120`.
|
||||
|
||||
## Native Shell
|
||||
|
||||
The native Tauri shell is still scaffolded:
|
||||
|
||||
```bash
|
||||
cd apps/gui
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
From Windows PowerShell on a `\\wsl$` path, use PowerShell `npm`, not
|
||||
`npm.cmd`:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
|
||||
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
`npm.cmd` goes through `cmd.exe`, and `cmd.exe` cannot use UNC paths as the
|
||||
current directory.
|
||||
|
||||
If `npm run` still falls through `cmd.exe`, bypass npm entirely:
|
||||
|
||||
```powershell
|
||||
\\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1
|
||||
```
|
||||
|
||||
The launcher builds into `%LOCALAPPDATA%\Hermes\cargo-target\gui` instead of
|
||||
`\\wsl$` because Windows Cargo incremental locks do not work reliably on UNC
|
||||
WSL filesystems.
|
||||
|
||||
In dev, either start Hermes yourself:
|
||||
|
||||
```bash
|
||||
hermes dashboard --gui --no-open --port 9120
|
||||
```
|
||||
|
||||
or let the native shell start it. The tray menu owns:
|
||||
|
||||
- Open Hermes
|
||||
- Open in Browser
|
||||
- Restart Hermes Runtime
|
||||
- Quit Hermes
|
||||
|
||||
The native shell reuses a healthy GUI runtime when one is already running.
|
||||
Otherwise it picks the first free port from `9120..9139`, passes that port into
|
||||
the WSL/backend process, and navigates the Tauri window there. Set
|
||||
`HERMES_GUI_PORT` to force a starting port.
|
||||
|
||||
## Fresh Install Emulation
|
||||
|
||||
Use an isolated Hermes home without touching your real `~/.hermes`:
|
||||
|
||||
```powershell
|
||||
powershell.exe -ExecutionPolicy Bypass -File \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1 -Fresh
|
||||
```
|
||||
|
||||
Reset that disposable home and run again:
|
||||
|
||||
```powershell
|
||||
powershell.exe -ExecutionPolicy Bypass -File \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1 -Fresh -ResetFresh
|
||||
```
|
||||
|
||||
Fresh mode stores state in `%LOCALAPPDATA%\Hermes\fresh-install-home` and starts
|
||||
from port `9140` so it does not collide with your normal GUI dev session.
|
||||
|
||||
Set `HERMES_GUI_MIN_SPLASH_MS` only when debugging the startup screen; default
|
||||
startup is instant once the backend is healthy.
|
||||
|
||||
## Boundary
|
||||
|
||||
GUI owns:
|
||||
|
||||
- app shell/window
|
||||
- startup state
|
||||
- sidecar process lifecycle
|
||||
- future tray/notifications/installers
|
||||
|
||||
Hermes owns:
|
||||
|
||||
- dashboard UI
|
||||
- auth/session token
|
||||
- profiles/config/env
|
||||
- TUI/PTT chat bridge
|
||||
- tools/skills/gateway
|
||||
- update flow
|
||||
@@ -0,0 +1,57 @@
|
||||
param(
|
||||
[string]$Command = "dev",
|
||||
[switch]$Fresh,
|
||||
[switch]$ResetFresh
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
|
||||
|
||||
$AppRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Script = Join-Path $AppRoot "scripts\tauri.mjs"
|
||||
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
throw "Windows Node.js was not found. Install it with: winget install OpenJS.NodeJS.LTS"
|
||||
}
|
||||
|
||||
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
|
||||
throw "Windows Rust was not found. Install it with: winget install Rustlang.Rustup"
|
||||
}
|
||||
|
||||
$Tauri = Get-Command tauri -ErrorAction SilentlyContinue
|
||||
$CargoTauri = Get-Command cargo-tauri -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $Tauri -and -not $CargoTauri) {
|
||||
throw "Tauri CLI not found. Install it with: npm install -g @tauri-apps/cli (run from a normal Windows path, not \\wsl$)"
|
||||
}
|
||||
|
||||
$env:CARGO_INCREMENTAL = "0"
|
||||
$env:CARGO_TARGET_DIR = Join-Path $env:LOCALAPPDATA "Hermes\cargo-target\gui"
|
||||
New-Item -ItemType Directory -Force -Path $env:CARGO_TARGET_DIR | Out-Null
|
||||
|
||||
if ($Fresh) {
|
||||
$FreshHome = Join-Path $env:LOCALAPPDATA "Hermes\fresh-install-home"
|
||||
if ($ResetFresh -and (Test-Path $FreshHome)) {
|
||||
Remove-Item -Recurse -Force $FreshHome
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $FreshHome | Out-Null
|
||||
$env:HERMES_HOME = $FreshHome
|
||||
$env:HERMES_GUI_PORT = "9140"
|
||||
$env:HERMES_GUI_FRESH = "1"
|
||||
Write-Host "Fresh GUI mode"
|
||||
Write-Host " HERMES_HOME=$FreshHome"
|
||||
Write-Host " HERMES_GUI_PORT=$env:HERMES_GUI_PORT"
|
||||
}
|
||||
|
||||
Push-Location $AppRoot
|
||||
try {
|
||||
if ($Tauri) {
|
||||
& tauri $Command
|
||||
}
|
||||
else {
|
||||
& cargo tauri $Command
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@hermes/gui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-shell.mjs",
|
||||
"dev:tauri": "node scripts/tauri.mjs dev",
|
||||
"build": "node scripts/tauri.mjs build",
|
||||
"dashboard": "node scripts/start-dashboard.mjs",
|
||||
"tauri": "node scripts/tauri.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "../../..");
|
||||
const python = process.env.HERMES_PYTHON || "python";
|
||||
let port = process.env.HERMES_GUI_PORT || "9120";
|
||||
let url = `http://127.0.0.1:${port}`;
|
||||
|
||||
let dashboard = null;
|
||||
|
||||
function stop() {
|
||||
if (dashboard && !dashboard.killed) dashboard.kill();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
stop();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
stop();
|
||||
process.exit(143);
|
||||
});
|
||||
process.on("exit", stop);
|
||||
|
||||
async function waitForHealth() {
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
if (await isHealthy()) return true;
|
||||
await delay(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isHealthy() {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
const data = await res.json();
|
||||
return res.ok && data.status === "ok";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canBind(candidate) {
|
||||
return new Promise((resolveBind) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => resolveBind(false));
|
||||
server.listen(Number(candidate), "127.0.0.1", () => {
|
||||
server.close(() => resolveBind(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function choosePort() {
|
||||
if (process.env.HERMES_GUI_PORT) return;
|
||||
|
||||
let candidate = Number(port);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (await canBind(candidate)) {
|
||||
port = String(candidate);
|
||||
url = `http://127.0.0.1:${port}`;
|
||||
return;
|
||||
}
|
||||
candidate += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function startDashboard() {
|
||||
dashboard = spawn(
|
||||
python,
|
||||
[
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"dashboard",
|
||||
"--gui",
|
||||
"--no-open",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
port,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_GUI: "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
dashboard.on("exit", (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
return (
|
||||
spawnSync(command, args, {
|
||||
shell: process.platform === "win32",
|
||||
stdio: "ignore",
|
||||
}).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
function openGuiWindow() {
|
||||
if (process.platform === "win32") {
|
||||
return (
|
||||
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
|
||||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
|
||||
run("cmd.exe", ["/C", "start", "", url])
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.WSL_DISTRO_NAME) {
|
||||
return (
|
||||
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
|
||||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
|
||||
run("cmd.exe", ["/C", "start", "", url])
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return (
|
||||
run("open", ["-na", "Google Chrome", "--args", `--app=${url}`]) ||
|
||||
run("open", [url])
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
run("google-chrome", [`--app=${url}`]) ||
|
||||
run("chromium", [`--app=${url}`]) ||
|
||||
run("xdg-open", [url])
|
||||
);
|
||||
}
|
||||
|
||||
if (await isHealthy()) {
|
||||
console.log(`Hermes GUI already running -> ${url}`);
|
||||
openGuiWindow();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await choosePort();
|
||||
startDashboard();
|
||||
|
||||
if (await waitForHealth()) {
|
||||
console.log(`Hermes GUI -> ${url}`);
|
||||
openGuiWindow();
|
||||
} else {
|
||||
console.error(`Hermes GUI did not become healthy at ${url}`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "../../..");
|
||||
const python = process.env.HERMES_PYTHON || "python";
|
||||
const port = process.env.HERMES_GUI_PORT || "9120";
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
async function isHealthy() {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
const data = await res.json();
|
||||
return res.ok && data.status === "ok";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function wslRepoRoot() {
|
||||
const normalized = repoRoot.replaceAll("\\", "/");
|
||||
const parts = normalized.split("/");
|
||||
const host = parts[2]?.toLowerCase();
|
||||
if (process.platform !== "win32") return null;
|
||||
if (host !== "wsl$" && host !== "wsl.localhost") return null;
|
||||
const distro = parts[3];
|
||||
const path = `/${parts.slice(4).join("/")}`;
|
||||
return distro && path !== "/" ? { distro, path } : null;
|
||||
}
|
||||
|
||||
function spawnDashboard() {
|
||||
const wsl = wslRepoRoot();
|
||||
if (wsl) {
|
||||
return spawn(
|
||||
"wsl.exe",
|
||||
[
|
||||
"-d",
|
||||
wsl.distro,
|
||||
"--cd",
|
||||
wsl.path,
|
||||
"env",
|
||||
"HERMES_GUI=1",
|
||||
process.env.HERMES_WSL_PYTHON || "python",
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"dashboard",
|
||||
"--gui",
|
||||
"--no-open",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
port,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
|
||||
return spawn(
|
||||
python,
|
||||
[
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"dashboard",
|
||||
"--gui",
|
||||
"--no-open",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
port,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_GUI: "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (await isHealthy()) {
|
||||
console.log(`Hermes GUI already running -> ${url}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const child = spawnDashboard();
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = resolve(here, "..");
|
||||
const bin = process.platform === "win32" ? "tauri.cmd" : "tauri";
|
||||
const localTauri = resolve(appRoot, "node_modules", ".bin", bin);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function isWsl() {
|
||||
return process.platform === "linux" && !!process.env.WSL_DISTRO_NAME;
|
||||
}
|
||||
|
||||
function quotePs(value) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function dispatchToWindows() {
|
||||
const pathResult = spawnSync("wslpath", ["-w", appRoot], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const windowsPath = pathResult.stdout.trim();
|
||||
if (!windowsPath) return false;
|
||||
|
||||
const command = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
|
||||
"if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {",
|
||||
' Write-Error "Windows npm was not found. Install Windows Node.js first: winget install OpenJS.NodeJS.LTS"',
|
||||
"}",
|
||||
"if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {",
|
||||
' Write-Error "Windows Rust was not found. Install Rust first: winget install Rustlang.Rustup"',
|
||||
"}",
|
||||
`Set-Location -LiteralPath ${quotePs(windowsPath)}`,
|
||||
"& npm run dev:tauri",
|
||||
].join("; ");
|
||||
const result = spawnSync(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function run(command, commandArgs, { exit = true } = {}) {
|
||||
if (process.platform === "win32") {
|
||||
const psCommand = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
|
||||
`Set-Location -LiteralPath ${quotePs(appRoot)}`,
|
||||
`& ${quotePs(command)} ${commandArgs.map(quotePs).join(" ")}`,
|
||||
].join("; ");
|
||||
const result = spawnSync(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psCommand],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.error && result.error.code === "ENOENT") return false;
|
||||
if (exit) process.exit(result.status ?? 1);
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: appRoot,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error && result.error.code === "ENOENT") return false;
|
||||
if (exit) process.exit(result.status ?? 1);
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
if (isWsl() && process.env.HERMES_GUI_TAURI_WSL !== "1") {
|
||||
console.log("Launching native Windows Tauri from WSL...");
|
||||
dispatchToWindows();
|
||||
console.error(
|
||||
"Could not hand off to Windows PowerShell. Run this from Windows PowerShell instead:",
|
||||
);
|
||||
console.error(" cd \\\\wsl$\\Ubuntu\\home\\bb\\hermes-agent\\apps\\gui");
|
||||
console.error(" npm run dev:tauri");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(localTauri)) run(localTauri, args);
|
||||
if (run("tauri", args, { exit: false })) process.exit(0);
|
||||
if (run("cargo", ["tauri", ...args], { exit: false })) process.exit(0);
|
||||
run("npx", ["--yes", "@tauri-apps/cli@latest", ...args]);
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+5579
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "hermes-gui"
|
||||
version = "0.0.0"
|
||||
description = "Hermes GUI shell"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "hermes_gui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default Hermes GUI permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "notification:default", "opener:default"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default Hermes GUI permissions","local":true,"windows":["main"],"permissions":["core:default","notification:default","opener:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 135 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#071313"/>
|
||||
<text x="50" y="70" text-anchor="middle" font-size="68" fill="#f0e6d2">⚕</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 212 B |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
App, AppHandle, Manager, WebviewWindow,
|
||||
};
|
||||
|
||||
const GUI_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_GUI_PORT: u16 = 9120;
|
||||
const MIN_SPLASH_MS: u64 = 0;
|
||||
const SPLASH_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Cmeta%20charset%3Dutf-8%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bheight%3A100vh%3Bdisplay%3Agrid%3Bplace-items%3Acenter%3Bbackground%3A%23071313%3Bcolor%3A%23f0e6d2%3Bfont%3A14px%20monospace%3Bletter-spacing%3A.08em%3Btext-transform%3Auppercase%7D%3C%2Fstyle%3E%3Cbody%3EStarting%20Hermes%E2%80%A6%3C%2Fbody%3E";
|
||||
|
||||
struct GuiState {
|
||||
child: Mutex<Option<Child>>,
|
||||
port: Mutex<u16>,
|
||||
}
|
||||
|
||||
fn gui_url(port: u16) -> String {
|
||||
format!("http://{GUI_HOST}:{port}")
|
||||
}
|
||||
|
||||
fn check_health(port: u16) -> bool {
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(
|
||||
&format!("{GUI_HOST}:{port}").parse().unwrap(),
|
||||
Duration::from_secs(1),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||
let request =
|
||||
format!("GET /api/health HTTP/1.1\r\nHost: {GUI_HOST}:{port}\r\nConnection: close\r\n\r\n");
|
||||
|
||||
if stream.write_all(request.as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut response = String::new();
|
||||
let _ = stream.read_to_string(&mut response);
|
||||
response.contains("200 OK")
|
||||
&& response.contains("\"status\":\"ok\"")
|
||||
&& response.contains("\"mode\":\"gui\"")
|
||||
}
|
||||
|
||||
fn can_bind(port: u16) -> bool {
|
||||
TcpListener::bind((GUI_HOST, port)).is_ok()
|
||||
}
|
||||
|
||||
fn base_port() -> u16 {
|
||||
std::env::var("HERMES_GUI_PORT")
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse().ok())
|
||||
.unwrap_or(DEFAULT_GUI_PORT)
|
||||
}
|
||||
|
||||
fn select_port() -> u16 {
|
||||
let start = base_port();
|
||||
for port in start..start.saturating_add(20) {
|
||||
if check_health(port) || can_bind(port) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
fn repo_root() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn runtime_dir() -> Option<std::path::PathBuf> {
|
||||
std::env::var_os("HERMES_GUI_RUNTIME_DIR").map(std::path::PathBuf::from)
|
||||
}
|
||||
|
||||
fn runtime_python(runtime: &std::path::Path) -> std::path::PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
runtime.join("venv").join("Scripts").join("python.exe")
|
||||
} else {
|
||||
runtime.join("venv").join("bin").join("python")
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_path(root: &std::path::Path) -> Option<(String, String)> {
|
||||
let raw = root.to_string_lossy().replace('\\', "/");
|
||||
let parts: Vec<&str> = raw.split('/').collect();
|
||||
let host = parts.get(2)?.to_ascii_lowercase();
|
||||
if host != "wsl$" && host != "wsl.localhost" {
|
||||
return None;
|
||||
}
|
||||
let distro = parts.get(3)?.to_string();
|
||||
let path = format!("/{}", parts.get(4..)?.join("/"));
|
||||
Some((distro, path))
|
||||
}
|
||||
|
||||
fn start_dashboard(port: u16) -> std::io::Result<Child> {
|
||||
if let Some(runtime) = runtime_dir() {
|
||||
let python = runtime_python(&runtime);
|
||||
let web_dist = runtime.join("web_dist");
|
||||
let tui_dir = runtime.join("ui-tui");
|
||||
let port = port.to_string();
|
||||
return Command::new(python)
|
||||
.args([
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"dashboard",
|
||||
"--gui",
|
||||
"--no-open",
|
||||
"--host",
|
||||
GUI_HOST,
|
||||
"--port",
|
||||
&port,
|
||||
])
|
||||
.env("HERMES_GUI", "1")
|
||||
.env("HERMES_GUI_PORT", &port)
|
||||
.env("HERMES_WEB_DIST", web_dist)
|
||||
.env("HERMES_TUI_DIR", tui_dir)
|
||||
.envs(
|
||||
std::env::vars()
|
||||
.filter(|(key, _)| matches!(key.as_str(), "HERMES_HOME" | "HERMES_GUI_FRESH")),
|
||||
)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
let root = repo_root();
|
||||
let port = port.to_string();
|
||||
|
||||
if let Some((distro, path)) = wsl_path(&root) {
|
||||
let port_env = format!("HERMES_GUI_PORT={port}");
|
||||
let mut env_args = vec!["HERMES_GUI=1".to_string(), port_env];
|
||||
if let Ok(home) = std::env::var("HERMES_HOME") {
|
||||
env_args.push(format!("HERMES_HOME={home}"));
|
||||
}
|
||||
if let Ok(fresh) = std::env::var("HERMES_GUI_FRESH") {
|
||||
env_args.push(format!("HERMES_GUI_FRESH={fresh}"));
|
||||
}
|
||||
let mut args = vec![
|
||||
"-d".to_string(),
|
||||
distro,
|
||||
"--cd".to_string(),
|
||||
path,
|
||||
"env".to_string(),
|
||||
];
|
||||
args.extend(env_args);
|
||||
args.extend([
|
||||
"python".to_string(),
|
||||
"-m".to_string(),
|
||||
"hermes_cli.main".to_string(),
|
||||
"dashboard".to_string(),
|
||||
"--gui".to_string(),
|
||||
"--no-open".to_string(),
|
||||
"--host".to_string(),
|
||||
GUI_HOST.to_string(),
|
||||
"--port".to_string(),
|
||||
port.clone(),
|
||||
]);
|
||||
return Command::new("wsl.exe")
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
Command::new("python")
|
||||
.args([
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"dashboard",
|
||||
"--gui",
|
||||
"--no-open",
|
||||
"--host",
|
||||
GUI_HOST,
|
||||
"--port",
|
||||
&port,
|
||||
])
|
||||
.current_dir(root)
|
||||
.env("HERMES_GUI", "1")
|
||||
.env("HERMES_GUI_PORT", &port)
|
||||
.envs(
|
||||
std::env::vars()
|
||||
.filter(|(key, _)| matches!(key.as_str(), "HERMES_HOME" | "HERMES_GUI_FRESH")),
|
||||
)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
}
|
||||
|
||||
fn stop_owned_dashboard(state: &GuiState) {
|
||||
let Some(mut child) = state.child.lock().expect("gui child lock poisoned").take() else {
|
||||
return;
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn current_port(state: &GuiState) -> u16 {
|
||||
*state.port.lock().expect("gui port lock poisoned")
|
||||
}
|
||||
|
||||
fn ensure_dashboard(state: &GuiState) -> Result<(), String> {
|
||||
let current = current_port(state);
|
||||
if check_health(current) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let port = select_port();
|
||||
*state.port.lock().expect("gui port lock poisoned") = port;
|
||||
|
||||
if check_health(port) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let child = start_dashboard(port).map_err(|err| {
|
||||
format!(
|
||||
"Could not auto-start Hermes dashboard ({err}). Start it manually with: hermes dashboard --gui --no-open --port {port}"
|
||||
)
|
||||
})?;
|
||||
*state.child.lock().expect("gui child lock poisoned") = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn navigate_when_ready(window: WebviewWindow, port: u16) {
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < Duration::from_secs(60) {
|
||||
if check_health(port) {
|
||||
let min_splash = std::env::var("HERMES_GUI_MIN_SPLASH_MS")
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<u64>().ok())
|
||||
.unwrap_or(MIN_SPLASH_MS);
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed < Duration::from_millis(min_splash) {
|
||||
std::thread::sleep(Duration::from_millis(min_splash) - elapsed);
|
||||
}
|
||||
if let Ok(url) = tauri::Url::parse(&gui_url(port)) {
|
||||
let _ = window.navigate(url);
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_browser(port: u16) {
|
||||
let url = gui_url(port);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = Command::new("cmd")
|
||||
.args(["/C", "start", "", &url])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new("open").arg(&url).spawn();
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let _ = Command::new("xdg-open").arg(&url).spawn();
|
||||
}
|
||||
|
||||
fn tray_icon() -> Image<'static> {
|
||||
let width = 32;
|
||||
let height = 32;
|
||||
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let mark = (14..=17).contains(&x) && (5..=26).contains(&y)
|
||||
|| (8..=23).contains(&x) && (13..=16).contains(&y)
|
||||
|| (10..=21).contains(&x) && (y == 5 || y == 26);
|
||||
if mark {
|
||||
rgba.extend_from_slice(&[0xF0, 0xE6, 0xD2, 0xFF]);
|
||||
} else {
|
||||
rgba.extend_from_slice(&[0x07, 0x13, 0x13, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image::new_owned(rgba, width as u32, height as u32)
|
||||
}
|
||||
|
||||
fn restart_runtime(app: &AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<GuiState>();
|
||||
stop_owned_dashboard(&state);
|
||||
ensure_dashboard(&state)?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Ok(url) = tauri::Url::parse(SPLASH_URL) {
|
||||
let _ = window.navigate(url);
|
||||
}
|
||||
let port = current_port(&state);
|
||||
navigate_when_ready(window, port);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_tray(app: &App) -> tauri::Result<()> {
|
||||
let open_item = MenuItem::with_id(app, "open", "Open Hermes", true, None::<&str>)?;
|
||||
let browser_item = MenuItem::with_id(app, "browser", "Open in Browser", true, None::<&str>)?;
|
||||
let restart_item =
|
||||
MenuItem::with_id(app, "restart", "Restart Hermes Runtime", true, None::<&str>)?;
|
||||
let status_item = MenuItem::with_id(app, "status", "Local runtime", false, None::<&str>)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
let separator2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit Hermes", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open_item,
|
||||
&browser_item,
|
||||
&restart_item,
|
||||
&separator,
|
||||
&status_item,
|
||||
&separator2,
|
||||
&quit_item,
|
||||
],
|
||||
)?;
|
||||
|
||||
let icon = tray_icon();
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("Hermes")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main_window(app),
|
||||
"browser" => {
|
||||
let state = app.state::<GuiState>();
|
||||
open_browser(current_port(&state));
|
||||
}
|
||||
"restart" => {
|
||||
if let Err(err) = restart_runtime(app) {
|
||||
eprintln!("Failed to restart Hermes runtime: {err}");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
let state = app.state::<GuiState>();
|
||||
stop_owned_dashboard(&state);
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(&tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn runtime_running(app: AppHandle) -> bool {
|
||||
let state = app.state::<GuiState>();
|
||||
check_health(current_port(&state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_runtime_command(app: AppHandle) -> Result<(), String> {
|
||||
restart_runtime(&app)
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(GuiState {
|
||||
child: Mutex::new(None),
|
||||
port: Mutex::new(base_port()),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
runtime_running,
|
||||
restart_runtime_command
|
||||
])
|
||||
.setup(|app| {
|
||||
setup_tray(app)?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Ok(url) = tauri::Url::parse(SPLASH_URL) {
|
||||
let _ = window.navigate(url);
|
||||
}
|
||||
|
||||
let state = app.state::<GuiState>();
|
||||
if let Err(err) = ensure_dashboard(&state) {
|
||||
eprintln!("{err}");
|
||||
}
|
||||
|
||||
let port = current_port(&state);
|
||||
navigate_when_ready(window, port);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run Hermes GUI");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_gui_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes",
|
||||
"version": "0.0.0",
|
||||
"identifier": "ai.nous.hermes.gui",
|
||||
"build": {
|
||||
"beforeDevCommand": "",
|
||||
"beforeBuildCommand": "",
|
||||
"devUrl": "http://127.0.0.1:9120",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; style-src 'self' 'unsafe-inline' http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:* http://localhost:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"icon": ["icons/32x32.png", "icons/icon.ico", "icons/icon.svg"],
|
||||
"targets": ["nsis", "dmg", "app"],
|
||||
"resources": {
|
||||
"sidecars": "sidecars/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Browser-side GUI bridge entry.
|
||||
//
|
||||
// The dashboard remains in `web/`; this file is reserved for future shell-only
|
||||
// glue if we need pre-navigation scripts or native event wiring.
|
||||
export {};
|
||||
@@ -0,0 +1,44 @@
|
||||
param(
|
||||
[string]$Out = "$PSScriptRoot\..\gui\src-tauri\sidecars\hermes-runtime",
|
||||
[string]$Python = "python"
|
||||
)
|
||||
|
||||
$Root = Resolve-Path "$PSScriptRoot\..\.."
|
||||
|
||||
Write-Host "Bundling Hermes GUI runtime"
|
||||
Write-Host "repo: $Root"
|
||||
Write-Host "out: $Out"
|
||||
|
||||
if (Test-Path $Out) {
|
||||
Remove-Item -Recurse -Force $Out
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||
|
||||
Write-Host "-> Building dashboard"
|
||||
npm --prefix "$Root\web" ci
|
||||
npm --prefix "$Root\web" run build
|
||||
Copy-Item -Recurse "$Root\web\dist" "$Out\web_dist"
|
||||
|
||||
Write-Host "-> Building TUI"
|
||||
npm --prefix "$Root\ui-tui" ci
|
||||
npm --prefix "$Root\ui-tui" run build
|
||||
New-Item -ItemType Directory -Force -Path "$Out\ui-tui" | Out-Null
|
||||
Copy-Item -Recurse "$Root\ui-tui\dist" "$Out\ui-tui\dist"
|
||||
Copy-Item "$Root\ui-tui\package.json" "$Out\ui-tui\package.json"
|
||||
Copy-Item "$Root\ui-tui\package-lock.json" "$Out\ui-tui\package-lock.json"
|
||||
Copy-Item -Recurse "$Root\ui-tui\node_modules" "$Out\ui-tui\node_modules"
|
||||
|
||||
Write-Host "-> Creating Python runtime"
|
||||
& $Python -m venv "$Out\venv"
|
||||
& "$Out\venv\Scripts\python.exe" -m pip install --upgrade pip
|
||||
& "$Out\venv\Scripts\python.exe" -m pip install -e "$Root[web,pty]"
|
||||
|
||||
@"
|
||||
# Hermes GUI Runtime
|
||||
|
||||
Generated by apps/shared/bundle-runtime.ps1.
|
||||
|
||||
Set HERMES_GUI_RUNTIME_DIR to this directory before launching the Tauri shell.
|
||||
"@ | Set-Content "$Out\README.md"
|
||||
|
||||
Write-Host "Runtime bundle ready: $Out"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT="${1:-"$ROOT/apps/gui/src-tauri/sidecars/hermes-runtime"}"
|
||||
PYTHON="${PYTHON:-python}"
|
||||
|
||||
echo "Bundling Hermes GUI runtime"
|
||||
echo "repo: $ROOT"
|
||||
echo "out: $OUT"
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
echo "→ Building dashboard"
|
||||
npm --prefix "$ROOT/web" ci
|
||||
npm --prefix "$ROOT/web" run build
|
||||
cp -a "$ROOT/web/dist" "$OUT/web_dist"
|
||||
|
||||
echo "→ Building TUI"
|
||||
npm --prefix "$ROOT/ui-tui" ci
|
||||
npm --prefix "$ROOT/ui-tui" run build
|
||||
mkdir -p "$OUT/ui-tui"
|
||||
cp -a "$ROOT/ui-tui/dist" "$OUT/ui-tui/dist"
|
||||
cp -a "$ROOT/ui-tui/package.json" "$ROOT/ui-tui/package-lock.json" "$OUT/ui-tui/"
|
||||
cp -a "$ROOT/ui-tui/node_modules" "$OUT/ui-tui/node_modules"
|
||||
|
||||
echo "→ Creating Python runtime"
|
||||
"$PYTHON" -m venv "$OUT/venv"
|
||||
"$OUT/venv/bin/python" -m pip install --upgrade pip
|
||||
"$OUT/venv/bin/python" -m pip install -e "$ROOT[web,pty]"
|
||||
|
||||
cat > "$OUT/README.md" <<EOF
|
||||
# Hermes GUI Runtime
|
||||
|
||||
Generated by apps/shared/bundle-runtime.sh.
|
||||
|
||||
Set HERMES_GUI_RUNTIME_DIR to this directory before launching the Tauri shell.
|
||||
EOF
|
||||
|
||||
echo "✓ Runtime bundle ready: $OUT"
|
||||
@@ -0,0 +1,33 @@
|
||||
# GUI Runtime Contract
|
||||
|
||||
The GUI shell starts Hermes with a small, explicit environment.
|
||||
|
||||
## Environment
|
||||
|
||||
```text
|
||||
HERMES_GUI=1
|
||||
HERMES_WEB_DIST=<bundled web dist>
|
||||
HERMES_TUI_DIR=<bundled ui-tui dir>
|
||||
```
|
||||
|
||||
The native shell uses `127.0.0.1:9120` as its initial GUI port during dev.
|
||||
Bundled builds should keep the port private to the local machine and expose it
|
||||
through `/api/health` and `/api/runtime`.
|
||||
|
||||
The shell should also pass the selected profile through the normal Hermes CLI
|
||||
profile mechanism once the profile picker is wired.
|
||||
|
||||
## Ports
|
||||
|
||||
Use `127.0.0.1` only. Start with the GUI default port, then fall back to a
|
||||
free port if occupied. Show the chosen port in the tray menu.
|
||||
|
||||
## User Data
|
||||
|
||||
The installer owns app files. Hermes owns user state under `HERMES_HOME`.
|
||||
Uninstallers must not delete user state unless the user explicitly asks.
|
||||
|
||||
## Update Model
|
||||
|
||||
MVP does not use Tauri's native updater. GUI runs `hermes update`, tails the
|
||||
action log, notifies completion, then offers to restart the runtime.
|
||||
@@ -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 (Alt+G in VSCode/Cursor){_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G{_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,18 +9308,14 @@ class HermesCLI:
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
# 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=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 (or Alt+G in VSCode/Cursor) opens the current draft in an external editor."""
|
||||
"""Ctrl+G opens the current draft in an external editor."""
|
||||
cli_ref._open_external_editor(event.current_buffer)
|
||||
|
||||
@kb.add('tab', eager=True)
|
||||
@@ -9783,11 +9779,6 @@ 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.
|
||||
|
||||
+187
-70
@@ -51,6 +51,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _add_accept_hooks_flag(parser) -> None:
|
||||
"""Attach the ``--accept-hooks`` flag. Shared across every agent
|
||||
subparser so the flag works regardless of CLI position."""
|
||||
@@ -174,6 +175,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
|
||||
try:
|
||||
if "HERMES_REDACT_SECRETS" not in os.environ:
|
||||
import yaml as _yaml_early
|
||||
|
||||
_cfg_path = get_hermes_home() / "config.yaml"
|
||||
if _cfg_path.exists():
|
||||
with open(_cfg_path, encoding="utf-8") as _f:
|
||||
@@ -1340,7 +1342,9 @@ def cmd_whatsapp(args):
|
||||
return
|
||||
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...")
|
||||
print(
|
||||
"\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..."
|
||||
)
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
print(" ✗ npm not found on PATH — install Node.js first")
|
||||
@@ -1716,14 +1720,14 @@ def _clear_stale_openai_base_url():
|
||||
|
||||
# (task_key, display_name, short_description)
|
||||
_AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("vision", "Vision", "image/screenshot analysis"),
|
||||
("compression", "Compression", "context summarization"),
|
||||
("web_extract", "Web extract", "web page summarization"),
|
||||
("session_search", "Session search", "past-conversation recall"),
|
||||
("approval", "Approval", "smart command approval"),
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("vision", "Vision", "image/screenshot analysis"),
|
||||
("compression", "Compression", "context summarization"),
|
||||
("web_extract", "Web extract", "web page summarization"),
|
||||
("session_search", "Session search", "past-conversation recall"),
|
||||
("approval", "Approval", "smart command approval"),
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1822,7 +1826,7 @@ def _aux_config_menu() -> None:
|
||||
print(" Auxiliary models — side-task routing")
|
||||
print()
|
||||
print(" Side tasks (vision, compression, web extraction, etc.) default")
|
||||
print(" to your main chat model. \"auto\" means \"use my main model\" —")
|
||||
print(' to your main chat model. "auto" means "use my main model" —')
|
||||
print(" Hermes only falls back to a lightweight backend (OpenRouter,")
|
||||
print(" Nous Portal) if the main model is unavailable. Override a")
|
||||
print(" task below if you want it pinned to a specific provider/model.")
|
||||
@@ -1833,15 +1837,20 @@ def _aux_config_menu() -> None:
|
||||
desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4
|
||||
entries: list[tuple[str, str]] = []
|
||||
for task_key, name, desc in _AUX_TASKS:
|
||||
task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
|
||||
task_cfg = (
|
||||
aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
|
||||
)
|
||||
current = _format_aux_current(task_cfg)
|
||||
label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
|
||||
label = (
|
||||
f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
|
||||
)
|
||||
entries.append((task_key, label))
|
||||
entries.append(("__reset__", "Reset all to auto"))
|
||||
entries.append(("__back__", "Back"))
|
||||
entries.append(("__back__", "Back"))
|
||||
|
||||
idx = _prompt_provider_choice(
|
||||
[label for _, label in entries], default=0,
|
||||
[label for _, label in entries],
|
||||
default=0,
|
||||
)
|
||||
if idx is None:
|
||||
return
|
||||
@@ -1889,7 +1898,9 @@ def _aux_select_for_task(task: str) -> None:
|
||||
|
||||
entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models)
|
||||
# "auto" always first
|
||||
auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
|
||||
auto_marker = (
|
||||
" ← current" if current_provider == "auto" and not current_base_url else ""
|
||||
)
|
||||
entries.append(("__auto__", f"auto (recommended){auto_marker}", []))
|
||||
|
||||
for p in providers:
|
||||
@@ -1898,7 +1909,9 @@ def _aux_select_for_task(task: str) -> None:
|
||||
total = p.get("total_models", 0)
|
||||
models = p.get("models") or []
|
||||
model_hint = f" — {total} models" if total else ""
|
||||
marker = " ← current" if slug == current_provider and not current_base_url else ""
|
||||
marker = (
|
||||
" ← current" if slug == current_provider and not current_base_url else ""
|
||||
)
|
||||
entries.append((slug, f"{name}{model_hint}{marker}", list(models)))
|
||||
|
||||
# Custom endpoint (raw base_url)
|
||||
@@ -1966,14 +1979,17 @@ def _aux_flow_provider_model(
|
||||
selected = val or ""
|
||||
else:
|
||||
selected = _prompt_model_selection(
|
||||
model_list, current_model=current_model, pricing=pricing,
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
)
|
||||
if selected is None:
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
_save_aux_choice(task, provider=provider_slug, model=selected or "",
|
||||
base_url="", api_key="")
|
||||
_save_aux_choice(
|
||||
task, provider=provider_slug, model=selected or "", base_url="", api_key=""
|
||||
)
|
||||
if selected:
|
||||
print(f"{display_name}: {provider_slug} · {selected}")
|
||||
else:
|
||||
@@ -1993,7 +2009,9 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
||||
print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)")
|
||||
print()
|
||||
try:
|
||||
url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
|
||||
url_prompt = (
|
||||
f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
|
||||
)
|
||||
url = input(url_prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
@@ -2003,20 +2021,30 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
||||
print("No URL provided. No change.")
|
||||
return
|
||||
try:
|
||||
model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): "
|
||||
model_prompt = (
|
||||
f"Model slug (optional) [{current_model}]: "
|
||||
if current_model
|
||||
else "Model slug (optional): "
|
||||
)
|
||||
model = input(model_prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
model = model or current_model
|
||||
try:
|
||||
api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip()
|
||||
api_key = getpass.getpass(
|
||||
"API key (optional, blank = use OPENAI_API_KEY): "
|
||||
).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
|
||||
_save_aux_choice(
|
||||
task, provider="custom", model=model, base_url=url, api_key=api_key,
|
||||
task,
|
||||
provider="custom",
|
||||
model=model,
|
||||
base_url=url,
|
||||
api_key=api_key,
|
||||
)
|
||||
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
|
||||
@@ -2132,7 +2160,9 @@ def _model_flow_ai_gateway(config, current_model=""):
|
||||
api_key = get_env_value("AI_GATEWAY_API_KEY")
|
||||
if not api_key:
|
||||
print("No Vercel AI Gateway API key configured.")
|
||||
print("Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway")
|
||||
print(
|
||||
"Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway"
|
||||
)
|
||||
print("Add a payment method to get $5 in free credits.")
|
||||
print()
|
||||
try:
|
||||
@@ -2932,7 +2962,9 @@ def _model_flow_named_custom(config, provider_info):
|
||||
|
||||
print("Fetching available models...")
|
||||
models = fetch_api_models(
|
||||
api_key, base_url, timeout=8.0,
|
||||
api_key,
|
||||
base_url,
|
||||
timeout=8.0,
|
||||
api_mode=api_mode or None,
|
||||
)
|
||||
|
||||
@@ -3603,7 +3635,12 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
_save_model_choice,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||
from hermes_cli.config import (
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
provider_id = "stepfun"
|
||||
@@ -3622,6 +3659,7 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
@@ -3647,7 +3685,10 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url)
|
||||
|
||||
region_choices = [
|
||||
("international", f"International ({_stepfun_base_url_for_region('international')})"),
|
||||
(
|
||||
"international",
|
||||
f"International ({_stepfun_base_url_for_region('international')})",
|
||||
),
|
||||
("china", f"China ({_stepfun_base_url_for_region('china')})"),
|
||||
]
|
||||
ordered_regions = []
|
||||
@@ -4490,6 +4531,7 @@ def cmd_webhook(args):
|
||||
def cmd_hooks(args):
|
||||
"""Shell-hook inspection and management."""
|
||||
from hermes_cli.hooks import hooks_command
|
||||
|
||||
hooks_command(args)
|
||||
|
||||
|
||||
@@ -6061,7 +6103,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
import signal as _signal
|
||||
|
||||
def _wait_for_service_active(
|
||||
scope_cmd_: list, svc_name_: str, timeout: float = 10.0,
|
||||
scope_cmd_: list,
|
||||
svc_name_: str,
|
||||
timeout: float = 10.0,
|
||||
) -> bool:
|
||||
"""Poll ``systemctl is-active`` until the unit reports active.
|
||||
|
||||
@@ -6075,7 +6119,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
try:
|
||||
_verify = subprocess.run(
|
||||
scope_cmd_ + ["is-active", svc_name_],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if _verify.stdout.strip() == "active":
|
||||
return True
|
||||
@@ -6086,7 +6132,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_time.sleep(0.5)
|
||||
|
||||
def _service_restart_sec(
|
||||
scope_cmd_: list, svc_name_: str, default: float = 0.0,
|
||||
scope_cmd_: list,
|
||||
svc_name_: str,
|
||||
default: float = 0.0,
|
||||
) -> float:
|
||||
"""Read the unit's ``RestartUSec`` (RestartSec) in seconds.
|
||||
|
||||
@@ -6098,11 +6146,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""
|
||||
try:
|
||||
_show = subprocess.run(
|
||||
scope_cmd_ + [
|
||||
"show", svc_name_,
|
||||
"--property=RestartUSec", "--value",
|
||||
scope_cmd_
|
||||
+ [
|
||||
"show",
|
||||
svc_name_,
|
||||
"--property=RestartUSec",
|
||||
"--value",
|
||||
],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return default
|
||||
@@ -6144,12 +6197,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_cfg_drain = None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg_agent = (load_config().get("agent") or {})
|
||||
|
||||
_cfg_agent = load_config().get("agent") or {}
|
||||
_cfg_drain = _cfg_agent.get("restart_drain_timeout")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_drain_budget = float(_cfg_drain) if _cfg_drain is not None else float(_DEFAULT_DRAIN)
|
||||
_drain_budget = (
|
||||
float(_cfg_drain)
|
||||
if _cfg_drain is not None
|
||||
else float(_DEFAULT_DRAIN)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_drain_budget = float(_DEFAULT_DRAIN)
|
||||
# Add a 15s margin so the drain loop + final exit finish before
|
||||
@@ -6214,14 +6272,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_main_pid = 0
|
||||
try:
|
||||
_show = subprocess.run(
|
||||
scope_cmd + [
|
||||
"show", svc_name,
|
||||
"--property=MainPID", "--value",
|
||||
scope_cmd
|
||||
+ [
|
||||
"show",
|
||||
svc_name,
|
||||
"--property=MainPID",
|
||||
"--value",
|
||||
],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
_main_pid = int((_show.stdout or "").strip() or 0)
|
||||
except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
except (
|
||||
ValueError,
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
):
|
||||
_main_pid = 0
|
||||
|
||||
_graceful_ok = False
|
||||
@@ -6230,7 +6297,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
f" → {svc_name}: draining (up to {int(_drain_budget)}s)..."
|
||||
)
|
||||
_graceful_ok = _graceful_restart_via_sigusr1(
|
||||
_main_pid, drain_timeout=_drain_budget,
|
||||
_main_pid,
|
||||
drain_timeout=_drain_budget,
|
||||
)
|
||||
|
||||
if _graceful_ok:
|
||||
@@ -6243,13 +6311,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# units without RestartSec set we fall back
|
||||
# to the original 10s budget.
|
||||
_restart_sec = _service_restart_sec(
|
||||
scope_cmd, svc_name, default=0.0,
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
default=0.0,
|
||||
)
|
||||
_post_drain_timeout = max(
|
||||
10.0, _restart_sec + 10.0,
|
||||
10.0,
|
||||
_restart_sec + 10.0,
|
||||
)
|
||||
if _wait_for_service_active(
|
||||
scope_cmd, svc_name,
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
timeout=_post_drain_timeout,
|
||||
):
|
||||
restarted_services.append(svc_name)
|
||||
@@ -6278,7 +6350,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# restart. systemctl restart returns 0 even
|
||||
# if the new process crashes immediately.
|
||||
if _wait_for_service_active(
|
||||
scope_cmd, svc_name, timeout=10.0,
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
timeout=10.0,
|
||||
):
|
||||
restarted_services.append(svc_name)
|
||||
else:
|
||||
@@ -6295,7 +6369,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
timeout=15,
|
||||
)
|
||||
if _wait_for_service_active(
|
||||
scope_cmd, svc_name, timeout=10.0,
|
||||
scope_cmd,
|
||||
svc_name,
|
||||
timeout=10.0,
|
||||
):
|
||||
restarted_services.append(svc_name)
|
||||
print(f" ✓ {svc_name} recovered on retry")
|
||||
@@ -6814,13 +6890,17 @@ def cmd_dashboard(args):
|
||||
|
||||
from hermes_cli.web_server import start_server
|
||||
|
||||
embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
|
||||
gui_mode = getattr(args, "gui", False)
|
||||
embedded_chat = (
|
||||
gui_mode or args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
|
||||
)
|
||||
start_server(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
embedded_chat=embedded_chat,
|
||||
gui_mode=gui_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -7514,17 +7594,39 @@ For more help on a command:
|
||||
"reset", help="Clear exhaustion status for all credentials for a provider"
|
||||
)
|
||||
auth_reset.add_argument("provider", help="Provider id")
|
||||
auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider")
|
||||
auth_status = auth_subparsers.add_parser(
|
||||
"status", help="Show auth status for a provider"
|
||||
)
|
||||
auth_status.add_argument("provider", help="Provider id")
|
||||
auth_logout = auth_subparsers.add_parser("logout", help="Log out a provider and clear stored auth state")
|
||||
auth_logout = auth_subparsers.add_parser(
|
||||
"logout", help="Log out a provider and clear stored auth state"
|
||||
)
|
||||
auth_logout.add_argument("provider", help="Provider id")
|
||||
auth_spotify = auth_subparsers.add_parser("spotify", help="Authenticate Hermes with Spotify via PKCE")
|
||||
auth_spotify.add_argument("spotify_action", nargs="?", choices=["login", "status", "logout"], default="login")
|
||||
auth_spotify.add_argument("--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)")
|
||||
auth_spotify.add_argument("--redirect-uri", help="Allow-listed localhost redirect URI for your Spotify app")
|
||||
auth_spotify = auth_subparsers.add_parser(
|
||||
"spotify", help="Authenticate Hermes with Spotify via PKCE"
|
||||
)
|
||||
auth_spotify.add_argument(
|
||||
"spotify_action",
|
||||
nargs="?",
|
||||
choices=["login", "status", "logout"],
|
||||
default="login",
|
||||
)
|
||||
auth_spotify.add_argument(
|
||||
"--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)"
|
||||
)
|
||||
auth_spotify.add_argument(
|
||||
"--redirect-uri",
|
||||
help="Allow-listed localhost redirect URI for your Spotify app",
|
||||
)
|
||||
auth_spotify.add_argument("--scope", help="Override requested Spotify scopes")
|
||||
auth_spotify.add_argument("--no-browser", action="store_true", help="Do not attempt to open the browser automatically")
|
||||
auth_spotify.add_argument("--timeout", type=float, help="Callback/token exchange timeout in seconds")
|
||||
auth_spotify.add_argument(
|
||||
"--no-browser",
|
||||
action="store_true",
|
||||
help="Do not attempt to open the browser automatically",
|
||||
)
|
||||
auth_spotify.add_argument(
|
||||
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
|
||||
)
|
||||
auth_parser.set_defaults(func=cmd_auth)
|
||||
|
||||
# =========================================================================
|
||||
@@ -7734,7 +7836,8 @@ For more help on a command:
|
||||
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
|
||||
|
||||
hooks_subparsers.add_parser(
|
||||
"list", aliases=["ls"],
|
||||
"list",
|
||||
aliases=["ls"],
|
||||
help="List configured hooks with matcher, timeout, and consent status",
|
||||
)
|
||||
|
||||
@@ -7747,14 +7850,18 @@ For more help on a command:
|
||||
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
|
||||
)
|
||||
_hk_test.add_argument(
|
||||
"--for-tool", dest="for_tool", default=None,
|
||||
"--for-tool",
|
||||
dest="for_tool",
|
||||
default=None,
|
||||
help=(
|
||||
"Only fire hooks whose matcher matches this tool name "
|
||||
"(used for pre_tool_call / post_tool_call)"
|
||||
),
|
||||
)
|
||||
_hk_test.add_argument(
|
||||
"--payload-file", dest="payload_file", default=None,
|
||||
"--payload-file",
|
||||
dest="payload_file",
|
||||
default=None,
|
||||
help=(
|
||||
"Path to a JSON file whose contents are merged into the "
|
||||
"synthetic payload before execution"
|
||||
@@ -7762,7 +7869,8 @@ For more help on a command:
|
||||
)
|
||||
|
||||
_hk_revoke = hooks_subparsers.add_parser(
|
||||
"revoke", aliases=["remove", "rm"],
|
||||
"revoke",
|
||||
aliases=["remove", "rm"],
|
||||
help="Remove a command's allowlist entries (takes effect on next restart)",
|
||||
)
|
||||
_hk_revoke.add_argument(
|
||||
@@ -9048,6 +9156,11 @@ Examples:
|
||||
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--gui",
|
||||
action="store_true",
|
||||
help="Run dashboard in GUI-shell mode; implies --tui",
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9190,26 +9303,28 @@ Examples:
|
||||
# the nested subcommand (dest varies by parser).
|
||||
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
|
||||
_AGENT_SUBCOMMANDS = {
|
||||
"cron": ("cron_command", {"run", "tick"}),
|
||||
"cron": ("cron_command", {"run", "tick"}),
|
||||
"gateway": ("gateway_command", {"run"}),
|
||||
"mcp": ("mcp_action", {"serve"}),
|
||||
"mcp": ("mcp_action", {"serve"}),
|
||||
}
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
if (
|
||||
args.command in _AGENT_COMMANDS
|
||||
or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
|
||||
if args.command in _AGENT_COMMANDS or (
|
||||
_sub_attr and getattr(args, _sub_attr, None) in _sub_set
|
||||
):
|
||||
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"plugin discovery failed at CLI startup", exc_info=True,
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
|
||||
register_from_config(load_config(), accept_hooks=_accept_hooks)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
@@ -9222,11 +9337,13 @@ Examples:
|
||||
if getattr(args, "oneshot", None):
|
||||
from hermes_cli.oneshot import run_oneshot
|
||||
|
||||
sys.exit(run_oneshot(
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
))
|
||||
sys.exit(
|
||||
run_oneshot(
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
)
|
||||
)
|
||||
|
||||
# Handle top-level --resume / --continue as shortcut to chat
|
||||
if (args.resume or args.continue_last) and args.command is None:
|
||||
|
||||
+547
-146
File diff suppressed because it is too large
Load Diff
+8
-33
@@ -7754,50 +7754,25 @@ class AIAgent:
|
||||
if source_msg.get("role") != "assistant":
|
||||
return
|
||||
|
||||
# 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
|
||||
explicit_reasoning = source_msg.get("reasoning_content")
|
||||
if isinstance(explicit_reasoning, str):
|
||||
api_msg["reasoning_content"] = explicit_reasoning
|
||||
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
|
||||
|
||||
# 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 (
|
||||
# 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 (
|
||||
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,13 +17,6 @@ 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:
|
||||
|
||||
+543
-252
File diff suppressed because it is too large
Load Diff
@@ -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_gets_padded(self) -> None:
|
||||
"""DeepSeek thinking mode pads ALL assistant turns, even without tool_calls."""
|
||||
def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
|
||||
"""Plain assistant turns without tool_calls don't get padded."""
|
||||
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 api_msg.get("reasoning_content") == ""
|
||||
assert "reasoning_content" not in api_msg
|
||||
|
||||
def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
|
||||
"""When reasoning_content is already set, it's copied verbatim."""
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# TUI Showroom
|
||||
|
||||
Scripted demos of `ui-tui`. Workflows snapshot real ui-tui components (`MessageLine`, `Panel`, `Box`, `Text`) into ANSI and replay them through xterm.js with cinematic overlays. Recorded once, played any number of times — built for screen capture.
|
||||
|
||||
```bash
|
||||
npm run showroom # dev server at http://127.0.0.1:4317
|
||||
npm run showroom:record # regenerate every workflow JSON
|
||||
npm run showroom:build # dist/<name>.html for every workflow
|
||||
npm run showroom:type-check
|
||||
```
|
||||
|
||||
## Bundled workflows
|
||||
|
||||
| File | Shows |
|
||||
| ------------------------------- | -------------------------------------------------------------- |
|
||||
| `workflows/feature-tour.json` | Plan → tool trail → result highlight |
|
||||
| `workflows/subagent-trail.json` | Parallel subagents, hot lanes, summary |
|
||||
| `workflows/slash-commands.json` | `/skills`, `/model`, `/agents`, `/help` typed → echoed → panel |
|
||||
| `workflows/voice-mode.json` | VAD capture, transcript, TTS ducking |
|
||||
|
||||
Pick a workflow from the dropdown or deep-link with `?w=<name>`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
record.tsx ─┐
|
||||
↳ MessageLine, │ Ink renders → Writable → ANSI string
|
||||
Panel, Box, Text │
|
||||
▼
|
||||
workflows/<name>.json
|
||||
│ served at /api/workflow/<name>
|
||||
▼
|
||||
showroom.js │ xterm.js renders ANSI; DOM overlays target frame ids
|
||||
▼
|
||||
browser
|
||||
```
|
||||
|
||||
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (CDN, cached) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
|
||||
|
||||
## Timeline actions
|
||||
|
||||
| Action | Required | Optional |
|
||||
| ----------- | ---------------- | --------------------------------------------- |
|
||||
| `frame` | `ansi` | `id` |
|
||||
| `status` | `text` | `detail` |
|
||||
| `compose` | `text` | `duration` (typewriter) |
|
||||
| `caption` | `target`, `text` | `position` (`left`/`right`/`top`), `duration` |
|
||||
| `spotlight` | `target` | `pad`, `duration` |
|
||||
| `highlight` | `target` | `duration` |
|
||||
| `fade` | `target` | `to` (default `0`), `duration` |
|
||||
| `clear` | — | — |
|
||||
|
||||
`target` references the `id` of an earlier `frame`. `viewport.scale` (or the 1x–4x picker) controls the upscale factor for capture.
|
||||
|
||||
## Player
|
||||
|
||||
- Restart (`R`), 1x–4x scale, 0.5x/1x/2x speed (`1`/`2`/`3`).
|
||||
- Progress bar reads `at + duration` from the slowest action.
|
||||
|
||||
## Adding a workflow
|
||||
|
||||
1. Add a scene fn to `record.tsx` returning `{ title, viewport, composer, timeline }`.
|
||||
2. Compose Ink primitives or pull `MessageLine` / `Panel` from `../src`.
|
||||
3. `await snap(<Component />)` for each frame.
|
||||
4. `npm run showroom:record`.
|
||||
|
||||
Components must be state-free at first paint — `useEffect` hooks won't fire by the time the recorder unmounts. For accordions like the live `ToolTrail`, render a flat `Box` + `Text` scene instead.
|
||||
@@ -1,70 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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>`
|
||||
}
|
||||
@@ -1,802 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
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}`)
|
||||
})
|
||||
@@ -1,422 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
|
||||
const initial = window.__SHOWROOM_INITIAL__
|
||||
const catalog = window.__SHOWROOM_CATALOG__ ?? []
|
||||
const root = document.getElementById('showroom')
|
||||
const SPEEDS = [0.5, 1, 2]
|
||||
const SCALES = [1, 2, 3, 4]
|
||||
|
||||
const state = {
|
||||
body: null,
|
||||
composer: null,
|
||||
frameTargets: new Map(),
|
||||
overlays: null,
|
||||
progressFill: null,
|
||||
progressLabel: null,
|
||||
raf: null,
|
||||
scale: 2,
|
||||
shell: null,
|
||||
speed: 1,
|
||||
startedAt: 0,
|
||||
statusLeft: null,
|
||||
statusRight: null,
|
||||
term: null,
|
||||
termContainer: null,
|
||||
timers: [],
|
||||
total: 0,
|
||||
viewport: null,
|
||||
workflow: initial?.workflow ?? { timeline: [] }
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
while (state.timers.length) {
|
||||
clearTimeout(state.timers.pop())
|
||||
}
|
||||
|
||||
if (state.raf) {
|
||||
cancelAnimationFrame(state.raf)
|
||||
state.raf = null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTarget = id => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.frameTargets.get(id) ?? document.querySelector(`[data-target="${CSS.escape(id)}"]`)
|
||||
}
|
||||
|
||||
const setText = (node, text = '', duration = 0) => {
|
||||
if (!duration || state.speed <= 0) {
|
||||
node.textContent = text
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const chars = [...text]
|
||||
const adjusted = duration / state.speed
|
||||
const started = performance.now()
|
||||
|
||||
const frame = now => {
|
||||
const n = Math.min(chars.length, Math.ceil(((now - started) / adjusted) * chars.length))
|
||||
node.textContent = chars.slice(0, n).join('')
|
||||
|
||||
if (n < chars.length) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
const removeAfter = (node, duration = 1400) => {
|
||||
const wait = duration / state.speed
|
||||
|
||||
state.timers.push(
|
||||
setTimeout(() => {
|
||||
node.classList.remove('is-visible')
|
||||
state.timers.push(setTimeout(() => node.remove(), 420 / state.speed))
|
||||
}, wait)
|
||||
)
|
||||
}
|
||||
|
||||
const rectFor = (id, pad = 8) => {
|
||||
const el = resolveTarget(id)
|
||||
|
||||
if (!el || !state.overlays) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stage = state.overlays.getBoundingClientRect()
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
height: rect.height + pad * 2,
|
||||
left: rect.left - stage.left - pad,
|
||||
top: rect.top - stage.top - pad,
|
||||
width: rect.width + pad * 2
|
||||
}
|
||||
}
|
||||
|
||||
const placeNear = (node, id, position = 'right') => {
|
||||
const rect = rectFor(id, 0)
|
||||
|
||||
if (!rect) {
|
||||
node.style.left = '24px'
|
||||
node.style.top = '24px'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const gap = 18
|
||||
const left = position === 'left' ? rect.left - node.offsetWidth - gap : rect.left + rect.width + gap
|
||||
const top = position === 'top' ? rect.top - node.offsetHeight - gap : rect.top
|
||||
|
||||
node.style.left = `${Math.max(12, left)}px`
|
||||
node.style.top = `${Math.max(12, top)}px`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const message = action => {
|
||||
const spec = {
|
||||
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
|
||||
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
|
||||
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
|
||||
user: { copy: '#daa520', glyph: '❯', tone: '#ffd700' }
|
||||
}[action.role] ?? { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' }
|
||||
|
||||
const line = document.createElement('div')
|
||||
const glyph = document.createElement('span')
|
||||
const copy = document.createElement('div')
|
||||
|
||||
line.className = `showroom-line showroom-line-${action.role ?? 'assistant'}`
|
||||
line.dataset.target = action.id ?? ''
|
||||
line.style.setProperty('--role', spec.tone)
|
||||
line.style.setProperty('--copy', spec.copy)
|
||||
|
||||
glyph.className = 'showroom-glyph'
|
||||
glyph.textContent = spec.glyph
|
||||
|
||||
copy.className = 'showroom-copy'
|
||||
|
||||
line.append(glyph, copy)
|
||||
state.body.append(line)
|
||||
setText(copy, action.text, action.duration)
|
||||
}
|
||||
|
||||
const tool = action => {
|
||||
const box = document.createElement('div')
|
||||
const title = document.createElement('div')
|
||||
const items = document.createElement('div')
|
||||
|
||||
box.className = 'showroom-tool'
|
||||
box.dataset.target = action.id ?? ''
|
||||
|
||||
title.className = 'showroom-tool-title'
|
||||
title.textContent = action.title ?? 'tool activity'
|
||||
|
||||
items.className = 'showroom-tool-items'
|
||||
|
||||
for (const item of action.items ?? []) {
|
||||
const row = document.createElement('div')
|
||||
|
||||
row.textContent = item
|
||||
items.append(row)
|
||||
}
|
||||
|
||||
box.append(title, items)
|
||||
state.body.append(box)
|
||||
}
|
||||
|
||||
const frame = action => {
|
||||
if (!state.term || !action.ansi) {
|
||||
return
|
||||
}
|
||||
|
||||
state.term.write(action.ansi)
|
||||
|
||||
if (action.id) {
|
||||
state.frameTargets.set(action.id, state.termContainer)
|
||||
}
|
||||
}
|
||||
|
||||
const fade = action => {
|
||||
const el = resolveTarget(action.target)
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.style.transition = `opacity ${(action.duration ?? 420) / state.speed}ms var(--ease-in-out)`
|
||||
el.style.opacity = String(action.to ?? 0)
|
||||
}
|
||||
|
||||
const highlight = action => {
|
||||
const el = resolveTarget(action.target)
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.classList.add('is-highlighted')
|
||||
state.timers.push(setTimeout(() => el.classList.remove('is-highlighted'), (action.duration ?? 1200) / state.speed))
|
||||
}
|
||||
|
||||
const caption = action => {
|
||||
const node = document.createElement('div')
|
||||
|
||||
node.className = 'showroom-caption'
|
||||
node.dataset.target = action.id ?? ''
|
||||
node.textContent = action.text ?? ''
|
||||
state.overlays.append(node)
|
||||
placeNear(node, action.target, action.position)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
removeAfter(node, action.duration ?? 1600)
|
||||
}
|
||||
|
||||
const spotlight = action => {
|
||||
const rect = rectFor(action.target, action.pad ?? 6)
|
||||
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
|
||||
const node = document.createElement('div')
|
||||
|
||||
node.className = 'showroom-spotlight'
|
||||
node.style.left = `${rect.left}px`
|
||||
node.style.top = `${rect.top}px`
|
||||
node.style.width = `${rect.width}px`
|
||||
node.style.height = `${rect.height}px`
|
||||
state.overlays.append(node)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
removeAfter(node, action.duration ?? 1500)
|
||||
}
|
||||
|
||||
const status = action => {
|
||||
state.statusLeft.textContent = action.text ?? ''
|
||||
state.statusRight.textContent = action.detail ?? ''
|
||||
}
|
||||
|
||||
const compose = action => setText(state.composer, action.text ?? '', action.duration ?? 0)
|
||||
|
||||
const clearTranscript = () => {
|
||||
state.overlays.textContent = ''
|
||||
state.frameTargets.clear()
|
||||
|
||||
if (state.term) {
|
||||
state.term.reset()
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state.body.textContent = ''
|
||||
}
|
||||
|
||||
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
|
||||
|
||||
// --- Progress ---
|
||||
|
||||
const fmtTime = ms => {
|
||||
if (!Number.isFinite(ms)) {
|
||||
return '0.0s'
|
||||
}
|
||||
|
||||
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const tickProgress = () => {
|
||||
if (!state.startedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Math.min(state.total, (performance.now() - state.startedAt) * state.speed)
|
||||
const ratio = state.total ? elapsed / state.total : 0
|
||||
|
||||
state.progressFill.style.width = `${(ratio * 100).toFixed(2)}%`
|
||||
state.progressLabel.textContent = `${fmtTime(elapsed)} / ${fmtTime(state.total)}`
|
||||
|
||||
if (elapsed < state.total) {
|
||||
state.raf = requestAnimationFrame(tickProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// --- xterm ---
|
||||
|
||||
const initXterm = () => {
|
||||
const hasFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
|
||||
|
||||
if (!hasFrames) {
|
||||
state.term = null
|
||||
state.termContainer = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
|
||||
state.termContainer = state.body.querySelector('.showroom-xterm')
|
||||
|
||||
state.term = new Terminal({
|
||||
cols: state.viewport.cols,
|
||||
rows: state.viewport.rows,
|
||||
fontFamily: 'JetBrains Mono, "SF Mono", Consolas, monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: false,
|
||||
scrollback: 0,
|
||||
convertEol: true,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#fff8dc',
|
||||
cursor: '#ffd700',
|
||||
selectionBackground: '#3a3a55',
|
||||
black: '#0a0a0a',
|
||||
red: '#ef5350',
|
||||
green: '#8fbc8f',
|
||||
yellow: '#ffd700',
|
||||
blue: '#5a82ff',
|
||||
magenta: '#cd7f32',
|
||||
cyan: '#daa520',
|
||||
white: '#fff8dc',
|
||||
brightBlack: '#cc9b1f',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#8fbc8f',
|
||||
brightYellow: '#ffbf00',
|
||||
brightBlue: '#5a82ff',
|
||||
brightMagenta: '#cd7f32',
|
||||
brightCyan: '#daa520',
|
||||
brightWhite: '#fff8dc'
|
||||
}
|
||||
})
|
||||
|
||||
state.term.open(state.termContainer)
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
// Fade in
|
||||
requestAnimationFrame(() => state.termContainer.classList.add('is-visible'))
|
||||
}
|
||||
|
||||
// --- Playback ---
|
||||
|
||||
const play = () => {
|
||||
clearTimers()
|
||||
clearTranscript()
|
||||
state.statusLeft.textContent = ''
|
||||
state.statusRight.textContent = ''
|
||||
state.composer.textContent = state.workflow.composer ?? ''
|
||||
|
||||
const timeline = [...(state.workflow.timeline ?? [])].sort((a, b) => a.at - b.at)
|
||||
|
||||
state.total = timeline.reduce((max, action) => Math.max(max, action.at + (action.duration ?? 0)), 0)
|
||||
state.startedAt = performance.now()
|
||||
state.progressFill.style.width = '0%'
|
||||
state.progressLabel.textContent = `0.0s / ${fmtTime(state.total)}`
|
||||
|
||||
for (const action of timeline) {
|
||||
state.timers.push(setTimeout(() => ACTIONS[action.type]?.(action), action.at / state.speed))
|
||||
}
|
||||
|
||||
state.raf = requestAnimationFrame(tickProgress)
|
||||
}
|
||||
|
||||
// --- Controls ---
|
||||
|
||||
const setSpeed = next => {
|
||||
state.speed = next
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
|
||||
button.classList.toggle('is-active', Number(button.dataset.value) === next)
|
||||
}
|
||||
}
|
||||
|
||||
const setScale = next => {
|
||||
state.scale = next
|
||||
state.shell.style.setProperty('--scale', `${next}`)
|
||||
state.shell.style.setProperty('--stage-w', `${state.viewport.cols * state.viewport.cellWidth * next}px`)
|
||||
state.shell.style.setProperty('--stage-h', `${state.viewport.rows * state.viewport.lineHeight * next}px`)
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
|
||||
button.classList.toggle('is-active', Number(button.dataset.value) === next)
|
||||
}
|
||||
}
|
||||
|
||||
const fitScale = () => {
|
||||
const margin = 96
|
||||
const baseW = state.viewport.cols * state.viewport.cellWidth
|
||||
const baseH = state.viewport.rows * state.viewport.lineHeight
|
||||
const maxW = Math.max(1, window.innerWidth - margin)
|
||||
const maxH = Math.max(1, window.innerHeight - 240)
|
||||
const fit = Math.max(1, Math.floor(Math.min(maxW / baseW, maxH / baseH)))
|
||||
|
||||
return Math.max(1, Math.min(SCALES[SCALES.length - 1], fit))
|
||||
}
|
||||
|
||||
const loadWorkflow = async name => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('w', name)
|
||||
window.history.replaceState(null, '', url)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflow/${encodeURIComponent(name)}`)
|
||||
|
||||
if (response.ok) {
|
||||
state.workflow = await response.json()
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
await rebuild()
|
||||
}
|
||||
|
||||
// --- DOM ---
|
||||
|
||||
const buildOptions = () => {
|
||||
if (!catalog.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return catalog
|
||||
.map(({ name, title }) => {
|
||||
const selected = name === initial?.name ? ' selected' : ''
|
||||
|
||||
return `<option value="${name}"${selected}>${title}</option>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const buildSegmented = (values, active) =>
|
||||
values
|
||||
.map(
|
||||
value =>
|
||||
`<button type="button" data-value="${value}" class="${value === active ? 'is-active' : ''}">${value}x</button>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const computeViewport = () => {
|
||||
const fromWorkflow = state.workflow.viewport ?? {}
|
||||
|
||||
return {
|
||||
cellWidth: 9,
|
||||
cols: 80,
|
||||
lineHeight: 19,
|
||||
rows: 24,
|
||||
scale: 2,
|
||||
...fromWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
const renderShell = () => {
|
||||
state.viewport = computeViewport()
|
||||
state.frameTargets.clear()
|
||||
|
||||
state.shell.style.setProperty('--cell-w', `${state.viewport.cellWidth}px`)
|
||||
state.shell.style.setProperty('--cols', `${state.viewport.cols}`)
|
||||
state.shell.style.setProperty('--line-h', `${state.viewport.lineHeight}px`)
|
||||
state.shell.style.setProperty('--rows', `${state.viewport.rows}`)
|
||||
state.shell.style.setProperty('--term-w', `${state.viewport.cols * state.viewport.cellWidth}px`)
|
||||
state.shell.style.setProperty('--term-h', `${state.viewport.rows * state.viewport.lineHeight}px`)
|
||||
|
||||
state.shell.innerHTML = `
|
||||
<div class="showroom-stage">
|
||||
<div class="showroom-terminal">
|
||||
<div class="showroom-status" data-target="status">
|
||||
<span class="showroom-status-left"></span>
|
||||
<span class="showroom-status-right"></span>
|
||||
</div>
|
||||
<div class="showroom-body"></div>
|
||||
<div class="showroom-composer" data-target="composer"></div>
|
||||
</div>
|
||||
<div class="showroom-overlays"></div>
|
||||
</div>
|
||||
<footer class="showroom-controls">
|
||||
<button type="button" data-action="restart" title="restart (R)">↻</button>
|
||||
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
|
||||
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
|
||||
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
|
||||
<span class="showroom-progress">
|
||||
<span data-role="time">0.0s / 0.0s</span>
|
||||
<div class="showroom-progress-track"><div class="showroom-progress-fill"></div></div>
|
||||
</span>
|
||||
</footer>
|
||||
`
|
||||
|
||||
state.body = state.shell.querySelector('.showroom-body')
|
||||
state.composer = state.shell.querySelector('.showroom-composer')
|
||||
state.overlays = state.shell.querySelector('.showroom-overlays')
|
||||
state.statusLeft = state.shell.querySelector('.showroom-status-left')
|
||||
state.statusRight = state.shell.querySelector('.showroom-status-right')
|
||||
state.progressFill = state.shell.querySelector('.showroom-progress-fill')
|
||||
state.progressLabel = state.shell.querySelector('[data-role="time"]')
|
||||
|
||||
state.shell.querySelector('[data-action="restart"]').addEventListener('click', play)
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="speed"] button')) {
|
||||
button.addEventListener('click', () => setSpeed(Number(button.dataset.value)))
|
||||
}
|
||||
|
||||
for (const button of state.shell.querySelectorAll('[data-segment="scale"] button')) {
|
||||
button.addEventListener('click', () => setScale(Number(button.dataset.value)))
|
||||
}
|
||||
|
||||
const picker = state.shell.querySelector('[data-action="picker"]')
|
||||
|
||||
if (picker) {
|
||||
picker.addEventListener('change', event => {
|
||||
void loadWorkflow(event.target.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rebuild = async () => {
|
||||
renderShell()
|
||||
initXterm()
|
||||
setScale(state.workflow.viewport?.scale ?? fitScale())
|
||||
play()
|
||||
}
|
||||
|
||||
const mount = () => {
|
||||
state.shell = document.createElement('section')
|
||||
state.shell.className = 'showroom-shell'
|
||||
root.replaceChildren(state.shell)
|
||||
|
||||
void rebuild().then(() => {
|
||||
requestAnimationFrame(() => state.shell.classList.add('is-mounted'))
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'r') {
|
||||
play()
|
||||
} else if (key === '1' || key === '2' || key === '3') {
|
||||
setSpeed(SPEEDS[Number(key) - 1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mount()
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"composer": "ctrl+b to start recording",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1CVAD\u001b[1C·\u001b[1Ccapturing\u001b[1C(3)\r\n\u001b[2C├─\u001b[1C▮\u001b[1C▮▮\u001b[1C▮\u001b[1C▮▮▮▮\u001b[1C▮▮\u001b[1C▮▮▮▮▮▮\u001b[1C▮▮▮\u001b[1C▮\r\n\u001b[2C├─\u001b[1Crms\u001b[1C0.42\u001b[1C·\u001b[1C1.6s\u001b[1Ccaptured\r\n\u001b[2C└─\u001b[1Cauto-stop\u001b[1C·\u001b[1Csilence\u001b[1C380ms\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 250,
|
||||
"id": "vad",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 600,
|
||||
"duration": 1500,
|
||||
"target": "vad",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 800,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "vad",
|
||||
"text": "Continuous loop: VAD detects silence, transcribes, restarts — no key holds.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2Cwhat's\u001b[1Cin\u001b[1Cmy\u001b[1Cinbox\u001b[1Ctoday\u001b[1Cand\u001b[1Cwhat\u001b[1Cneeds\u001b[1Ca\u001b[1Creply\u001b[1Cbefore\u001b[1Cnoon?\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2700,
|
||||
"id": "transcript",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 3400,
|
||||
"duration": 1100,
|
||||
"target": "transcript",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 3600,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "transcript",
|
||||
"text": "Transcript flows straight into the composer with the standard ❯ user glyph.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CThree\u001b[1Cthreads\u001b[1Cneed\u001b[1Cyou\u001b[1Cbefore\u001b[1Cnoon:\u001b[1Cvendor\u001b[1Crenewal,\u001b[1Cpodcast\u001b[1Cintro\u001b[1Cfeedback,\r\n\u001b[4Cand\u001b[1Cthe\u001b[1Cdesign\u001b[1Creview\u001b[1Cat\u001b[1C11.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 5500,
|
||||
"id": "answer",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\u001b[2C⚡\u001b[1Ctts\u001b[1C·\u001b[1Cplaying\u001b[1C(3)\r\n\u001b[2C├─\u001b[1Cvoice\u001b[1C11labs\u001b[1C·\u001b[1Cgrace_v3\r\n\u001b[2C├─\u001b[1Celapsed\u001b[1C4.6s\u001b[1C·\u001b[1C2\u001b[1Cchunks\u001b[1Cqueued\r\n\u001b[2C└─\u001b[1Cducking\u001b[1Cmic\u001b[1Cinput\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6700,
|
||||
"id": "tts",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 7000,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "tts",
|
||||
"text": "TTS auto-ducks the mic so the loop never echoes itself back.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 8800,
|
||||
"duration": 600,
|
||||
"text": "/voice off",
|
||||
"type": "compose"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Voice Mode",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -110,7 +110,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an
|
||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `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+G` | Open `$EDITOR` with the current draft |
|
||||
| `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
||||
|
||||
## Rendering
|
||||
|
||||
+1
-5
@@ -13,11 +13,7 @@
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test": "vitest run",
|
||||
"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"
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
import { ansiCodesToString, diffAnsiCodes, type AnsiCode } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js'
|
||||
import { unionRect, type Point, type Rectangle, type Size } from './layout/geometry.js'
|
||||
import { BEL, ESC, SEP } from './termio/ansi.js'
|
||||
import * as warn from './warn.js'
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface ComposerActions {
|
||||
dequeue: () => string | undefined
|
||||
enqueue: (text: string) => void
|
||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||
openEditor: () => Promise<void>
|
||||
openEditor: () => void
|
||||
pushHistory: (text: string) => void
|
||||
replaceQueue: (index: number, text: string) => void
|
||||
setCompIdx: StateSetter<number>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { useStdin, withInkSuspended } from '@hermes/ink'
|
||||
import { useStdin } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
@@ -14,7 +14,6 @@ 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'
|
||||
@@ -254,36 +253,26 @@ export function useComposerState({
|
||||
[handleResolvedPaste, onClipboardPaste, querier]
|
||||
)
|
||||
|
||||
const openEditor = useCallback(async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
|
||||
const file = join(dir, 'prompt.md')
|
||||
const [cmd, ...args] = resolveEditor()
|
||||
const openEditor = useCallback(() => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
|
||||
|
||||
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')
|
||||
|
||||
let exitCode: null | number = null
|
||||
|
||||
await withInkSuspended(async () => {
|
||||
exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
|
||||
})
|
||||
|
||||
try {
|
||||
if (exitCode !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
const text = readFileSync(file, 'utf8').trimEnd()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
if (text) {
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
submitRef.current(text)
|
||||
}
|
||||
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
submitRef.current(text)
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
rmSync(file, { force: true })
|
||||
}, [input, inputBuf, submitRef])
|
||||
|
||||
const actions = useMemo(
|
||||
|
||||
@@ -366,13 +366,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return voiceRecordToggle()
|
||||
}
|
||||
|
||||
// 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')
|
||||
})
|
||||
if (isAction(key, ch, 'g')) {
|
||||
return cActions.openEditor()
|
||||
}
|
||||
|
||||
// shift-tab flips yolo without spending a turn (claude-code parity)
|
||||
|
||||
@@ -18,7 +18,7 @@ const copyHotkeys: [string, string][] = isMac
|
||||
export const HOTKEYS: [string, string][] = [
|
||||
...copyHotkeys,
|
||||
[action + '+D', 'exit'],
|
||||
[action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'],
|
||||
[action + '+G', 'open $EDITOR for prompt'],
|
||||
[action + '+L', 'new session (clear)'],
|
||||
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
||||
['Tab', 'apply completion'],
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
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']
|
||||
}
|
||||
+101
-42
@@ -1,19 +1,34 @@
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { DesktopBridge } from "@/components/DesktopBridge";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { RuntimeOverlay } from "@/components/RuntimeOverlay";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { api, type SetupStateResponse } from "@/lib/api";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
NavLink,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
isDashboardEmbeddedChatEnabled,
|
||||
isDashboardGuiEnabled,
|
||||
} from "@/lib/dashboard-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import ChatPage from "@/pages/ChatPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import DocsPage from "@/pages/DocsPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import SetupPage from "@/pages/SetupPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -42,30 +57,22 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import DocsPage from "@/pages/DocsPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import ChatPage from "@/pages/ChatPage";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
NavLink,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
@@ -144,7 +151,10 @@ function resolveIcon(name: string): ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[name] ?? Puzzle;
|
||||
}
|
||||
|
||||
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
|
||||
function buildNavItems(
|
||||
builtIn: NavItem[],
|
||||
manifests: PluginManifest[],
|
||||
): NavItem[] {
|
||||
const items = [...builtIn];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
@@ -240,21 +250,25 @@ function buildRoutes(
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { manifests } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [setupState, setSetupState] = useState<SetupStateResponse | null>(null);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
const guiMode = isDashboardGuiEnabled();
|
||||
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
const builtinRoutes = useMemo(
|
||||
() => ({
|
||||
...BUILTIN_ROUTES_CORE,
|
||||
...(guiMode ? { "/setup": SetupPage } : {}),
|
||||
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
||||
}),
|
||||
[embeddedChat],
|
||||
[embeddedChat, guiMode],
|
||||
);
|
||||
|
||||
const builtinNav = useMemo(
|
||||
@@ -284,6 +298,48 @@ export default function App() {
|
||||
|
||||
const layoutVariant = theme.layoutVariant ?? "standard";
|
||||
|
||||
useEffect(() => {
|
||||
if (!guiMode) return;
|
||||
let cancelled = false;
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const state = await api.getSetupState();
|
||||
if (!cancelled) {
|
||||
setSetupState(state);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSetupState(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
window.addEventListener("hermes:setup-refresh", onRefresh);
|
||||
const id = window.setInterval(refresh, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("hermes:setup-refresh", onRefresh);
|
||||
};
|
||||
}, [guiMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guiMode || !setupState) return;
|
||||
if (setupState.needs_setup && normalizedPath !== "/setup") {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!setupState.needs_setup && normalizedPath === "/setup") {
|
||||
navigate("/sessions", { replace: true });
|
||||
}
|
||||
}, [guiMode, navigate, normalizedPath, setupState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -507,7 +563,8 @@ export default function App() {
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
|
||||
(isDocsRoute || isChatRoute) &&
|
||||
"min-h-0 flex flex-1 flex-col",
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
@@ -527,6 +584,8 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<PluginSlot name="overlay" />
|
||||
<DesktopBridge />
|
||||
<RuntimeOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { isDashboardGuiEnabled } from "@/lib/dashboard-flags";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__?: {
|
||||
notification?: {
|
||||
isPermissionGranted: () => Promise<boolean>;
|
||||
requestPermission: () => Promise<"default" | "denied" | "granted">;
|
||||
sendNotification: (notification: {
|
||||
body?: string;
|
||||
title: string;
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function DesktopBridge() {
|
||||
useEffect(() => {
|
||||
if (!isDashboardGuiEnabled()) return;
|
||||
|
||||
const notify = async (title: string, body?: string) => {
|
||||
const api = window.__TAURI__?.notification;
|
||||
if (!api) return;
|
||||
|
||||
let granted = await api.isPermissionGranted();
|
||||
if (!granted) {
|
||||
granted = (await api.requestPermission()) === "granted";
|
||||
}
|
||||
if (granted) api.sendNotification({ body, title });
|
||||
};
|
||||
|
||||
const onNotify = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ body?: string; title?: string }>)
|
||||
.detail;
|
||||
if (!detail?.title) return;
|
||||
void notify(detail.title, detail.body);
|
||||
};
|
||||
|
||||
window.addEventListener("hermes:desktop-notify", onNotify);
|
||||
return () => window.removeEventListener("hermes:desktop-notify", onNotify);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { isDashboardGuiEnabled } from "@/lib/dashboard-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RuntimeState = "checking" | "healthy" | "reconnecting";
|
||||
|
||||
const POLL_MS = 2_500;
|
||||
|
||||
export function RuntimeOverlay() {
|
||||
const [state, setState] = useState<RuntimeState>("checking");
|
||||
const [isGui, setIsGui] = useState(() => isDashboardGuiEnabled());
|
||||
const [lastOkAt, setLastOkAt] = useState<number | null>(null);
|
||||
const [notifiedDown, setNotifiedDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const runtime = await api.getRuntime();
|
||||
if (cancelled) return;
|
||||
setIsGui(runtime.gui);
|
||||
setLastOkAt(Date.now());
|
||||
if (notifiedDown) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hermes:desktop-notify", {
|
||||
detail: {
|
||||
body: "The dashboard runtime is healthy again.",
|
||||
title: "Hermes Reconnected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
setNotifiedDown(false);
|
||||
}
|
||||
setState("healthy");
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setNotifiedDown((already) => {
|
||||
if (!already && isGui) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hermes:desktop-notify", {
|
||||
detail: {
|
||||
body: "Trying to reconnect to the local Hermes runtime.",
|
||||
title: "Hermes Runtime Disconnected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setState((prev) => (prev === "checking" ? "checking" : "reconnecting"));
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const id = setInterval(poll, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [isGui, notifiedDown]);
|
||||
|
||||
const detail = useMemo(() => {
|
||||
if (state === "checking") return "Checking local Hermes runtime...";
|
||||
if (!lastOkAt) return "Trying to reconnect to the local Hermes runtime.";
|
||||
return `Runtime connection dropped. Last healthy ${Math.max(
|
||||
1,
|
||||
Math.round((Date.now() - lastOkAt) / 1000),
|
||||
)}s ago.`;
|
||||
}, [lastOkAt, state]);
|
||||
|
||||
if (!isGui || state === "healthy") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-80 flex items-center justify-center",
|
||||
"bg-black/70 backdrop-blur-sm",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-[min(92vw,28rem)] border border-current/20 bg-background-base/95",
|
||||
"px-6 py-5 text-midground shadow-2xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<RotateCw className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-mondwest text-sm tracking-[0.16em]">
|
||||
Hermes GUI Runtime
|
||||
</p>
|
||||
<p className="mt-2 text-xs normal-case leading-5 text-muted-foreground">
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-5 h-8 text-xs"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Window
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
|
||||
export function SidebarFooter() {
|
||||
const status = useSidebarStatus();
|
||||
@@ -19,7 +19,9 @@ export function SidebarFooter() {
|
||||
mondwest
|
||||
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70"
|
||||
>
|
||||
{status?.version != null ? `v${status.version}` : "—"}
|
||||
{status?.version != null
|
||||
? `v${status.version}${status.gui ? " · GUI" : ""}`
|
||||
: "—"}
|
||||
</Typography>
|
||||
|
||||
<a
|
||||
|
||||
+109
-15
@@ -18,7 +18,10 @@ function setSessionHeader(headers: Headers, token: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
export async function fetchJSON<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
@@ -40,32 +43,50 @@ async function getSessionToken(): Promise<string> {
|
||||
_sessionToken = injected;
|
||||
return _sessionToken;
|
||||
}
|
||||
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard server",
|
||||
);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getHealth: () => fetchJSON<HealthResponse>("/api/health"),
|
||||
getRuntime: () => fetchJSON<RuntimeResponse>("/api/runtime"),
|
||||
getSetupState: () => fetchJSON<SetupStateResponse>("/api/setup/state"),
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
fetchJSON<PaginatedSessions>(
|
||||
`/api/sessions?limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
fetchJSON<SessionMessagesResponse>(
|
||||
`/api/sessions/${encodeURIComponent(id)}/messages`,
|
||||
),
|
||||
deleteSession: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||
getLogs: (params: {
|
||||
file?: string;
|
||||
lines?: number;
|
||||
level?: string;
|
||||
component?: string;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.file) qs.set("file", params.file);
|
||||
if (params.lines) qs.set("lines", String(params.lines));
|
||||
if (params.level && params.level !== "ALL") qs.set("level", params.level);
|
||||
if (params.component && params.component !== "all") qs.set("component", params.component);
|
||||
if (params.component && params.component !== "all")
|
||||
qs.set("component", params.component);
|
||||
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
||||
},
|
||||
getAnalytics: (days: number) =>
|
||||
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||
getSchema: () =>
|
||||
fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>(
|
||||
"/api/config/schema",
|
||||
),
|
||||
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||
saveConfig: (config: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||
@@ -107,18 +128,29 @@ export const api = {
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
||||
createCronJob: (job: {
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
name?: string;
|
||||
deliver?: string;
|
||||
}) =>
|
||||
fetchJSON<CronJob>("/api/cron/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job),
|
||||
}),
|
||||
pauseCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, {
|
||||
method: "POST",
|
||||
}),
|
||||
resumeCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, {
|
||||
method: "POST",
|
||||
}),
|
||||
triggerCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, {
|
||||
method: "POST",
|
||||
}),
|
||||
deleteCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
||||
|
||||
@@ -134,7 +166,9 @@ export const api = {
|
||||
|
||||
// Session search (FTS5)
|
||||
searchSessions: (q: string) =>
|
||||
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||
fetchJSON<SessionSearchResponse>(
|
||||
`/api/sessions/search?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
|
||||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
@@ -163,7 +197,11 @@ export const api = {
|
||||
},
|
||||
);
|
||||
},
|
||||
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
||||
submitOAuthCode: async (
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
code: string,
|
||||
) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthSubmitResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
@@ -209,8 +247,7 @@ export const api = {
|
||||
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
||||
|
||||
// Dashboard themes
|
||||
getThemes: () =>
|
||||
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
||||
getThemes: () => fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
||||
setTheme: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
|
||||
method: "PUT",
|
||||
@@ -244,6 +281,7 @@ export interface StatusResponse {
|
||||
active_sessions: number;
|
||||
config_path: string;
|
||||
config_version: number;
|
||||
embedded_chat: boolean;
|
||||
env_path: string;
|
||||
gateway_exit_reason: string | null;
|
||||
gateway_health_url: string | null;
|
||||
@@ -252,12 +290,68 @@ export interface StatusResponse {
|
||||
gateway_running: boolean;
|
||||
gateway_state: string | null;
|
||||
gateway_updated_at: string | null;
|
||||
gui: boolean;
|
||||
hermes_home: string;
|
||||
latest_config_version: number;
|
||||
release_date: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
embedded_chat: boolean;
|
||||
mode: "browser" | "gui";
|
||||
profile: string;
|
||||
status: "ok";
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RuntimeResponse {
|
||||
dashboard: {
|
||||
embedded_chat: boolean;
|
||||
};
|
||||
gateway: {
|
||||
pid: number | null;
|
||||
platforms: Record<string, PlatformStatus>;
|
||||
running: boolean;
|
||||
state: string | null;
|
||||
};
|
||||
gui: boolean;
|
||||
hermes_home: string;
|
||||
profile: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SetupStateResponse {
|
||||
checklist: {
|
||||
model: boolean;
|
||||
provider: boolean;
|
||||
terminal: boolean;
|
||||
};
|
||||
gui: boolean;
|
||||
hermes_home: string;
|
||||
is_fresh_mode: boolean;
|
||||
model: {
|
||||
configured: boolean;
|
||||
value: string;
|
||||
};
|
||||
needs_setup: boolean;
|
||||
profile: string;
|
||||
provider: {
|
||||
active_provider: string | null;
|
||||
configured_env_keys: string[];
|
||||
recommended_keys: Array<{
|
||||
description: string;
|
||||
is_set: boolean;
|
||||
name: string;
|
||||
url: string | null;
|
||||
}>;
|
||||
};
|
||||
terminal: {
|
||||
backend: string;
|
||||
configured: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
source: string | null;
|
||||
|
||||
@@ -2,6 +2,8 @@ declare global {
|
||||
interface Window {
|
||||
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
|
||||
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
|
||||
/** Set true by the server for `hermes dashboard --gui`. */
|
||||
__HERMES_DASHBOARD_GUI__?: boolean;
|
||||
/** @deprecated Older injected name; treated as on when true. */
|
||||
__HERMES_DASHBOARD_TUI__?: boolean;
|
||||
}
|
||||
@@ -13,3 +15,8 @@ export function isDashboardEmbeddedChatEnabled(): boolean {
|
||||
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
|
||||
return window.__HERMES_DASHBOARD_TUI__ === true;
|
||||
}
|
||||
|
||||
export function isDashboardGuiEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.__HERMES_DASHBOARD_GUI__ === true;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export function resolvePageTitle(
|
||||
pluginTabs: { path: string; label: string }[],
|
||||
): string {
|
||||
const normalized = pathname.replace(/\/$/, "") || "/";
|
||||
if (normalized === "/setup") {
|
||||
return "Setup";
|
||||
}
|
||||
if (normalized === "/") {
|
||||
return t.app.nav.sessions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { api, type EnvVarInfo, type SetupStateResponse } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"openai/gpt-4.1",
|
||||
"google/gemini-2.5-pro",
|
||||
"deepseek/deepseek-reasoner",
|
||||
];
|
||||
|
||||
const FALLBACK_PROVIDER_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"NOUS_API_KEY",
|
||||
];
|
||||
|
||||
function readModelValue(config: Record<string, unknown> | null): string {
|
||||
if (!config) return "";
|
||||
const modelValue = config.model;
|
||||
if (typeof modelValue === "string") return modelValue;
|
||||
if (
|
||||
modelValue &&
|
||||
typeof modelValue === "object" &&
|
||||
!Array.isArray(modelValue)
|
||||
) {
|
||||
const defaultModel = (modelValue as Record<string, unknown>).default;
|
||||
if (typeof defaultModel === "string") return defaultModel;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, showToast } = useToast();
|
||||
const [setupState, setSetupState] = useState<SetupStateResponse | null>(null);
|
||||
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(
|
||||
null,
|
||||
);
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [providerKey, setProviderKey] = useState("");
|
||||
const [providerValue, setProviderValue] = useState("");
|
||||
const [modelValue, setModelValue] = useState("");
|
||||
const [terminalBackend, setTerminalBackend] = useState("local");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [state, vars, cfg] = await Promise.all([
|
||||
api.getSetupState(),
|
||||
api.getEnvVars(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
setSetupState(state);
|
||||
setEnvVars(vars);
|
||||
setConfig(cfg);
|
||||
setModelValue(state.model.value || readModelValue(cfg));
|
||||
setTerminalBackend(state.terminal.backend || "local");
|
||||
|
||||
const preferredKeys = [
|
||||
...state.provider.recommended_keys.map((k) => k.name),
|
||||
...FALLBACK_PROVIDER_KEYS,
|
||||
];
|
||||
const availableKeys = preferredKeys.filter((key) => vars[key] != null);
|
||||
const firstUnset = availableKeys.find((key) => !vars[key]?.is_set);
|
||||
const nextKey = firstUnset || availableKeys[0] || "";
|
||||
setProviderKey((prev) => (prev && vars[prev] ? prev : nextKey));
|
||||
} catch (error) {
|
||||
showToast(`Failed to load setup state: ${error}`, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
if (!envVars || !setupState) return [];
|
||||
const keySet = new Set<string>();
|
||||
const options: Array<{
|
||||
key: string;
|
||||
description: string;
|
||||
isSet: boolean;
|
||||
url: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const item of setupState.provider.recommended_keys) {
|
||||
if (!envVars[item.name]) continue;
|
||||
keySet.add(item.name);
|
||||
options.push({
|
||||
key: item.name,
|
||||
description: item.description,
|
||||
isSet: envVars[item.name]?.is_set ?? item.is_set,
|
||||
url: item.url,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, info] of Object.entries(envVars)) {
|
||||
if (keySet.has(key)) continue;
|
||||
if (info.category !== "provider") continue;
|
||||
if (!(key.endsWith("_API_KEY") || key.endsWith("_TOKEN"))) continue;
|
||||
options.push({
|
||||
key,
|
||||
description: info.description,
|
||||
isSet: info.is_set,
|
||||
url: info.url,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [envVars, setupState]);
|
||||
|
||||
const selectedProviderMeta = providerOptions.find(
|
||||
(o) => o.key === providerKey,
|
||||
);
|
||||
const checklist = setupState?.checklist;
|
||||
const ready = !!checklist?.provider && !!checklist?.model;
|
||||
const completeCount =
|
||||
Number(!!checklist?.provider) +
|
||||
Number(!!checklist?.model) +
|
||||
Number(!!checklist?.terminal);
|
||||
|
||||
const saveProviderKey = async () => {
|
||||
if (!providerKey || !providerValue.trim()) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await api.setEnvVar(providerKey, providerValue.trim());
|
||||
setProviderValue("");
|
||||
showToast(`Saved ${providerKey}`, "success");
|
||||
window.dispatchEvent(new Event("hermes:setup-refresh"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
showToast(`Failed to save ${providerKey}: ${error}`, "error");
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveModelAndDefaults = async () => {
|
||||
if (!config) return;
|
||||
const trimmedModel = modelValue.trim();
|
||||
if (!trimmedModel) {
|
||||
showToast("Model is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfig = structuredClone(config);
|
||||
nextConfig.model = trimmedModel;
|
||||
const rawTerminal = nextConfig.terminal;
|
||||
const terminal =
|
||||
rawTerminal &&
|
||||
typeof rawTerminal === "object" &&
|
||||
!Array.isArray(rawTerminal)
|
||||
? { ...(rawTerminal as Record<string, unknown>) }
|
||||
: {};
|
||||
terminal.backend = terminalBackend || "local";
|
||||
nextConfig.terminal = terminal;
|
||||
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
await api.saveConfig(nextConfig);
|
||||
setConfig(nextConfig);
|
||||
showToast("Saved model and runtime defaults.", "success");
|
||||
window.dispatchEvent(new Event("hermes:setup-refresh"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
showToast(`Failed to save setup config: ${error}`, "error");
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!setupState) {
|
||||
return (
|
||||
<div className="border border-destructive/30 bg-destructive/6 p-4 text-sm text-destructive">
|
||||
Setup state unavailable. Reload the dashboard.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="setup:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Hermes GUI Setup</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={ready ? "success" : "outline"}>
|
||||
{ready ? "Ready" : "Setup Required"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{completeCount}/3 checks complete
|
||||
</span>
|
||||
</div>
|
||||
{setupState.is_fresh_mode && (
|
||||
<p className="text-xs text-success">
|
||||
Fresh mode active. This GUI run is isolated from your default
|
||||
install.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Profile: <code>{setupState.profile}</code> · Home:{" "}
|
||||
<code>{setupState.hermes_home}</code>
|
||||
</p>
|
||||
<div className="grid gap-1 text-xs">
|
||||
<ChecklistItem
|
||||
done={setupState.checklist.provider}
|
||||
label="Provider credential connected"
|
||||
/>
|
||||
<ChecklistItem
|
||||
done={setupState.checklist.model}
|
||||
label="Model selected"
|
||||
/>
|
||||
<ChecklistItem
|
||||
done={setupState.checklist.terminal}
|
||||
label="Terminal backend configured"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>1) Connect a provider</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 border border-border p-3">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
API Key (manual)
|
||||
</Label>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
||||
<select
|
||||
value={providerKey}
|
||||
onChange={(e) => setProviderKey(e.target.value)}
|
||||
className="h-9 border border-border bg-background px-2 text-xs"
|
||||
>
|
||||
{providerOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.key}
|
||||
{option.isSet ? " (set)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="password"
|
||||
value={providerValue}
|
||||
onChange={(e) => setProviderValue(e.target.value)}
|
||||
placeholder="Paste API key"
|
||||
className="h-9 font-mono-ui text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={saveProviderKey}
|
||||
disabled={savingKey || !providerKey || !providerValue.trim()}
|
||||
>
|
||||
{savingKey ? "Saving..." : "Save key"}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedProviderMeta?.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedProviderMeta.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedProviderMeta?.url && (
|
||||
<a
|
||||
href={selectedProviderMeta.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Get key
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>2) Choose model + runtime defaults</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Input
|
||||
value={modelValue}
|
||||
onChange={(e) => setModelValue(e.target.value)}
|
||||
placeholder="anthropic/claude-sonnet-4.6"
|
||||
className="font-mono-ui text-xs"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MODEL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setModelValue(preset)}
|
||||
className="border border-border px-2 py-1 text-[11px] hover:bg-secondary/40"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Terminal backend
|
||||
</Label>
|
||||
<select
|
||||
value={terminalBackend}
|
||||
onChange={(e) => setTerminalBackend(e.target.value)}
|
||||
className="h-9 border border-border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="local">local</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="modal">modal</option>
|
||||
<option value="daytona">daytona</option>
|
||||
<option value="singularity">singularity</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveModelAndDefaults}
|
||||
disabled={savingConfig || !modelValue.trim()}
|
||||
className="w-fit"
|
||||
>
|
||||
{savingConfig ? "Saving..." : "Save setup defaults"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>3) Continue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate("/sessions", { replace: true })}
|
||||
disabled={!ready}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Enter Hermes
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/env")}
|
||||
>
|
||||
Advanced keys
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/config")}
|
||||
>
|
||||
Advanced config
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PluginSlot name="setup:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistItem({ done, label }: { done: boolean; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{done ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
)}
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -645,18 +645,6 @@ 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,18 +10,6 @@ Receive events from external services (GitHub, GitLab, JIRA, Stripe, etc.) and t
|
||||
|
||||
The agent processes the event and can respond by posting comments on PRs, sending messages to Telegram/Discord, or logging the result.
|
||||
|
||||
## Video Tutorial
|
||||
|
||||
<div style={{position: 'relative', width: '100%', aspectRatio: '16 / 9', marginBottom: '1.5rem'}}>
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/WNYe5mD4fY8"
|
||||
title="Hermes Agent — Webhooks Tutorial"
|
||||
style={{position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Reference in New Issue
Block a user