mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 19:26:56 +08:00
Compare commits
26 Commits
investigat
...
bb/base-gu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
648da6a8d1 | ||
|
|
edc78e258c | ||
|
|
31d7f1951a | ||
|
|
b1c18e5a41 | ||
|
|
bd66e55a02 | ||
|
|
1735ced93b | ||
|
|
bba16943f6 | ||
|
|
132620ba3d | ||
|
|
876bb60044 | ||
|
|
a68793b6c4 | ||
|
|
bcc5362432 | ||
|
|
283c8fd6e2 | ||
|
|
919274b60e | ||
|
|
6e83d90eb4 | ||
|
|
c6fdf48b79 | ||
|
|
a046483e86 | ||
|
|
fdcbd2257b | ||
|
|
48bdd2445e | ||
|
|
5e52011de3 | ||
|
|
e48a497d16 | ||
|
|
2dfcc8087a | ||
|
|
4db58d45d4 | ||
|
|
57b43fdd4b | ||
|
|
e9c47c7042 | ||
|
|
ee0728c6c4 | ||
|
|
0738b80833 |
58
apps/README.md
Normal file
58
apps/README.md
Normal file
@@ -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.
|
||||
102
apps/gui/README.md
Normal file
102
apps/gui/README.md
Normal file
@@ -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
|
||||
57
apps/gui/dev-tauri.ps1
Normal file
57
apps/gui/dev-tauri.ps1
Normal file
@@ -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
|
||||
}
|
||||
13
apps/gui/package.json
Normal file
13
apps/gui/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
156
apps/gui/scripts/dev-shell.mjs
Normal file
156
apps/gui/scripts/dev-shell.mjs
Normal file
@@ -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}`);
|
||||
}
|
||||
95
apps/gui/scripts/start-dashboard.mjs
Normal file
95
apps/gui/scripts/start-dashboard.mjs
Normal file
@@ -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);
|
||||
});
|
||||
90
apps/gui/scripts/tauri.mjs
Normal file
90
apps/gui/scripts/tauri.mjs
Normal file
@@ -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]);
|
||||
1
apps/gui/src-tauri/.gitignore
vendored
Normal file
1
apps/gui/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target/
|
||||
5579
apps/gui/src-tauri/Cargo.lock
generated
Normal file
5579
apps/gui/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
apps/gui/src-tauri/Cargo.toml
Normal file
17
apps/gui/src-tauri/Cargo.toml
Normal file
@@ -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"
|
||||
3
apps/gui/src-tauri/build.rs
Normal file
3
apps/gui/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
7
apps/gui/src-tauri/capabilities/default.json
Normal file
7
apps/gui/src-tauri/capabilities/default.json
Normal file
@@ -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"]
|
||||
}
|
||||
1
apps/gui/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
apps/gui/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/gui/src-tauri/gen/schemas/capabilities.json
Normal file
1
apps/gui/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default Hermes GUI permissions","local":true,"windows":["main"],"permissions":["core:default","notification:default","opener:default"]}}
|
||||
2675
apps/gui/src-tauri/gen/schemas/desktop-schema.json
Normal file
2675
apps/gui/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2675
apps/gui/src-tauri/gen/schemas/windows-schema.json
Normal file
2675
apps/gui/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/gui/src-tauri/icons/32x32.png
Normal file
BIN
apps/gui/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 B |
BIN
apps/gui/src-tauri/icons/icon.ico
Normal file
BIN
apps/gui/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
4
apps/gui/src-tauri/icons/icon.svg
Normal file
4
apps/gui/src-tauri/icons/icon.svg
Normal file
@@ -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 |
1
apps/gui/src-tauri/sidecars/.gitkeep
Normal file
1
apps/gui/src-tauri/sidecars/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
433
apps/gui/src-tauri/src/lib.rs
Normal file
433
apps/gui/src-tauri/src/lib.rs
Normal file
@@ -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");
|
||||
}
|
||||
5
apps/gui/src-tauri/src/main.rs
Normal file
5
apps/gui/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_gui_lib::run();
|
||||
}
|
||||
38
apps/gui/src-tauri/tauri.conf.json
Normal file
38
apps/gui/src-tauri/tauri.conf.json
Normal file
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
apps/gui/src/main.ts
Normal file
5
apps/gui/src/main.ts
Normal file
@@ -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 {};
|
||||
44
apps/shared/bundle-runtime.ps1
Normal file
44
apps/shared/bundle-runtime.ps1
Normal file
@@ -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"
|
||||
41
apps/shared/bundle-runtime.sh
Normal file
41
apps/shared/bundle-runtime.sh
Normal file
@@ -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"
|
||||
33
apps/shared/desktop-env.md
Normal file
33
apps/shared/desktop-env.md
Normal file
@@ -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.
|
||||
@@ -103,7 +103,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"),
|
||||
CommandDef("model", "Switch model for this session", "Configuration",
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
|
||||
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
|
||||
cli_only=True),
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -839,6 +841,8 @@ def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
|
||||
|
||||
|
||||
def _tui_build_needed(tui_dir: Path) -> bool:
|
||||
if _hermes_ink_bundle_stale(tui_dir):
|
||||
return True
|
||||
entry = tui_dir / "dist" / "entry.js"
|
||||
if not entry.exists():
|
||||
return True
|
||||
@@ -1026,7 +1030,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [node, str(root / "dist" / "entry.js")], root
|
||||
|
||||
|
||||
def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False):
|
||||
def _launch_tui(
|
||||
resume_session_id: Optional[str] = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
):
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
|
||||
@@ -1036,6 +1045,12 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False):
|
||||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
if model:
|
||||
env["HERMES_MODEL"] = model
|
||||
env["HERMES_INFERENCE_MODEL"] = model
|
||||
if provider:
|
||||
env["HERMES_TUI_PROVIDER"] = provider
|
||||
env["HERMES_INFERENCE_PROVIDER"] = provider
|
||||
# Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is
|
||||
# ~1.5–4GB depending on version and can fatal-OOM on long sessions with
|
||||
# large transcripts / reasoning blobs. Token-level merge: respect any
|
||||
@@ -1174,6 +1189,8 @@ def cmd_chat(args):
|
||||
_launch_tui(
|
||||
getattr(args, "resume", None),
|
||||
tui_dev=getattr(args, "tui_dev", False),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
)
|
||||
|
||||
# Import and run the CLI
|
||||
@@ -1325,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")
|
||||
@@ -1701,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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1807,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.")
|
||||
@@ -1818,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
|
||||
@@ -1874,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:
|
||||
@@ -1883,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)
|
||||
@@ -1951,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:
|
||||
@@ -1978,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()
|
||||
@@ -1988,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 ""))
|
||||
@@ -2117,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:
|
||||
@@ -2917,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,
|
||||
)
|
||||
|
||||
@@ -3588,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"
|
||||
@@ -3607,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()
|
||||
@@ -3632,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 = []
|
||||
@@ -4475,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)
|
||||
|
||||
|
||||
@@ -6046,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.
|
||||
|
||||
@@ -6060,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
|
||||
@@ -6071,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.
|
||||
|
||||
@@ -6083,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
|
||||
@@ -6129,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
|
||||
@@ -6199,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
|
||||
@@ -6215,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:
|
||||
@@ -6228,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)
|
||||
@@ -6263,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:
|
||||
@@ -6280,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")
|
||||
@@ -6799,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -6911,7 +7006,7 @@ For more help on a command:
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -6919,7 +7014,7 @@ For more help on a command:
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -7499,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)
|
||||
|
||||
# =========================================================================
|
||||
@@ -7719,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",
|
||||
)
|
||||
|
||||
@@ -7732,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"
|
||||
@@ -7747,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(
|
||||
@@ -9033,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)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9175,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(
|
||||
@@ -9207,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:
|
||||
|
||||
@@ -1379,27 +1379,93 @@ def curated_models_for_provider(
|
||||
return [(m, "") for m in models]
|
||||
|
||||
|
||||
def detect_provider_for_model(
|
||||
def _provider_keys(provider: str) -> set[str]:
|
||||
key = (provider or "").strip().lower()
|
||||
normalized = normalize_provider(provider)
|
||||
return {k for k in (key, normalized) if k}
|
||||
|
||||
|
||||
def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
|
||||
return any(
|
||||
name_lower == model.lower()
|
||||
for provider in providers
|
||||
for model in _PROVIDER_MODELS.get(provider, [])
|
||||
)
|
||||
|
||||
|
||||
_AGGREGATOR_PROVIDERS = frozenset(
|
||||
{"nous", "openrouter", "ai-gateway", "copilot", "kilocode"}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_static_model_alias(
|
||||
name_lower: str,
|
||||
current_keys: set[str],
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Resolve short aliases (e.g. sonnet/opus) using static catalogs only."""
|
||||
try:
|
||||
from hermes_cli.model_switch import MODEL_ALIASES
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
identity = MODEL_ALIASES.get(name_lower)
|
||||
if identity is None:
|
||||
return None
|
||||
|
||||
vendor = identity.vendor
|
||||
family = identity.family
|
||||
|
||||
def _match(provider: str) -> Optional[str]:
|
||||
models = _PROVIDER_MODELS.get(provider, [])
|
||||
if not models:
|
||||
return None
|
||||
prefix = (
|
||||
f"{vendor}/{family}"
|
||||
if provider in _AGGREGATOR_PROVIDERS
|
||||
else family
|
||||
).lower()
|
||||
for model in models:
|
||||
if model.lower().startswith(prefix):
|
||||
return model
|
||||
return None
|
||||
|
||||
for provider in current_keys:
|
||||
if matched := _match(provider):
|
||||
return provider, matched
|
||||
|
||||
for provider in _PROVIDER_MODELS:
|
||||
if provider in current_keys or provider in _AGGREGATOR_PROVIDERS:
|
||||
continue
|
||||
if matched := _match(provider):
|
||||
return provider, matched
|
||||
|
||||
for provider in _AGGREGATOR_PROVIDERS:
|
||||
if provider in current_keys and (matched := _match(provider)):
|
||||
return provider, matched
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_static_provider_for_model(
|
||||
model_name: str,
|
||||
current_provider: str,
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Auto-detect the best provider for a model name.
|
||||
"""Auto-detect a provider from static catalogs only.
|
||||
|
||||
Returns ``(provider_id, model_name)`` — the model name may be remapped
|
||||
(e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter).
|
||||
Returns ``(provider_id, model_name)``. The model name may be remapped
|
||||
when a static alias or bare provider name resolves to a catalog default.
|
||||
Returns ``None`` when no confident match is found.
|
||||
|
||||
Priority:
|
||||
0. Bare provider name → switch to that provider's default model
|
||||
1. Direct provider with credentials (highest)
|
||||
2. Direct provider without credentials → remap to OpenRouter slug
|
||||
3. OpenRouter catalog match
|
||||
"""
|
||||
name = (model_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
name_lower = name.lower()
|
||||
current_keys = _provider_keys(current_provider)
|
||||
|
||||
alias_match = _resolve_static_model_alias(name_lower, current_keys)
|
||||
if alias_match:
|
||||
return alias_match
|
||||
|
||||
# --- Step 0: bare provider name typed as model ---
|
||||
# If someone types `/model nous` or `/model anthropic`, treat it as a
|
||||
@@ -1412,64 +1478,49 @@ def detect_provider_for_model(
|
||||
if (
|
||||
resolved_provider in _PROVIDER_LABELS
|
||||
and default_models
|
||||
and resolved_provider != normalize_provider(current_provider)
|
||||
and resolved_provider not in current_keys
|
||||
):
|
||||
return (resolved_provider, default_models[0])
|
||||
|
||||
# Aggregators list other providers' models — never auto-switch TO them
|
||||
_AGGREGATORS = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"}
|
||||
|
||||
# If the model belongs to the current provider's catalog, don't suggest switching
|
||||
current_models = _PROVIDER_MODELS.get(current_provider, [])
|
||||
if any(name_lower == m.lower() for m in current_models):
|
||||
if _model_in_provider_catalog(name_lower, current_keys):
|
||||
return None
|
||||
|
||||
# --- Step 1: check static provider catalogs for a direct match ---
|
||||
direct_match: Optional[str] = None
|
||||
for pid, models in _PROVIDER_MODELS.items():
|
||||
if pid == current_provider or pid in _AGGREGATORS:
|
||||
if pid in current_keys or pid in _AGGREGATOR_PROVIDERS:
|
||||
continue
|
||||
if any(name_lower == m.lower() for m in models):
|
||||
direct_match = pid
|
||||
break
|
||||
return (pid, name)
|
||||
|
||||
if direct_match:
|
||||
# Check if we have credentials for this provider — env vars,
|
||||
# credential pool, or auth store entries.
|
||||
has_creds = False
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY.get(direct_match)
|
||||
if pconfig:
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
if os.getenv(env_var, "").strip():
|
||||
has_creds = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
# Also check credential pool and auth store — covers OAuth,
|
||||
# Claude Code tokens, and other non-env-var credentials (#10300).
|
||||
if not has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(direct_match)
|
||||
if pool.has_credentials():
|
||||
has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
if not has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
if direct_match in store.get("providers", {}) or direct_match in store.get("credential_pool", {}):
|
||||
has_creds = True
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Always return the direct provider match. If credentials are
|
||||
# missing, the client init will give a clear error rather than
|
||||
# silently routing through the wrong provider (#10300).
|
||||
return (direct_match, name)
|
||||
|
||||
def detect_provider_for_model(
|
||||
model_name: str,
|
||||
current_provider: str,
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Auto-detect the best provider for a model name.
|
||||
|
||||
Returns ``(provider_id, model_name)`` — the model name may be remapped
|
||||
(e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter).
|
||||
Returns ``None`` when no confident match is found.
|
||||
|
||||
Priority:
|
||||
0. Bare provider name → switch to that provider's default model
|
||||
1. Direct provider static catalog match
|
||||
2. OpenRouter catalog match
|
||||
"""
|
||||
name = (model_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
static_match = detect_static_provider_for_model(name, current_provider)
|
||||
if static_match:
|
||||
return static_match
|
||||
if _model_in_provider_catalog(name.lower(), _provider_keys(current_provider)):
|
||||
return None
|
||||
|
||||
# --- Step 2: check OpenRouter catalog ---
|
||||
# First try exact match (handles provider/model format)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -256,6 +256,17 @@ class TestDetectProviderForModel:
|
||||
"""Models belonging to the current provider should not trigger a switch."""
|
||||
assert detect_provider_for_model("gpt-5.3-codex", "openai-codex") is None
|
||||
|
||||
def test_short_alias_resolves_to_static_model(self):
|
||||
"""Short aliases (e.g. sonnet) should resolve without network lookups."""
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_openrouter_models",
|
||||
side_effect=AssertionError("network lookup should not run"),
|
||||
):
|
||||
result = detect_provider_for_model("sonnet", "auto")
|
||||
assert result is not None
|
||||
assert result[0] == "anthropic"
|
||||
assert result[1].startswith("claude-sonnet")
|
||||
|
||||
def test_openrouter_slug_match(self):
|
||||
"""Models in the OpenRouter catalog should be found."""
|
||||
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
||||
|
||||
@@ -19,6 +19,18 @@ def _touch_ink(root: Path) -> None:
|
||||
ink.write_text("{}")
|
||||
|
||||
|
||||
def _touch_tui_entry(root: Path) -> None:
|
||||
entry = root / "dist" / "entry.js"
|
||||
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry.write_text("console.log('tui')")
|
||||
|
||||
|
||||
def _touch_ink_bundle(root: Path) -> None:
|
||||
bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js"
|
||||
bundle.parent.mkdir(parents=True, exist_ok=True)
|
||||
bundle.write_text("export {}")
|
||||
|
||||
|
||||
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
@@ -51,3 +63,19 @@ def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
|
||||
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
_touch_ink(tmp_path)
|
||||
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
assert main_mod._tui_build_needed(tmp_path) is True
|
||||
|
||||
|
||||
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
_touch_ink(tmp_path)
|
||||
_touch_ink_bundle(tmp_path)
|
||||
|
||||
assert main_mod._tui_build_needed(tmp_path) is False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
@@ -8,8 +9,11 @@ import pytest
|
||||
def _args(**overrides):
|
||||
base = {
|
||||
"continue_last": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"resume": None,
|
||||
"tui": True,
|
||||
"tui_dev": False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
@@ -31,7 +35,7 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
||||
calls.append(source)
|
||||
return "20260408_235959_a1b2c3" if source == "tui" else None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -58,7 +62,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
||||
return "20260408_235959_d4e5f6"
|
||||
return None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -76,7 +80,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
||||
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -89,6 +93,60 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
|
||||
assert captured["resume"] == "20260409_000000_aa11bb"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
captured.update(
|
||||
{
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"resume": resume_session_id,
|
||||
"tui_dev": tui_dev,
|
||||
}
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(
|
||||
_args(model="anthropic/claude-sonnet-4.6", provider="anthropic")
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"resume": None,
|
||||
"tui_dev": False,
|
||||
}
|
||||
|
||||
|
||||
def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_make_tui_argv",
|
||||
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
|
||||
)
|
||||
|
||||
def fake_call(argv, cwd=None, env=None):
|
||||
captured.update({"argv": argv, "cwd": cwd, "env": env})
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod._launch_tui(model="nous/hermes-test", provider="nous")
|
||||
|
||||
env = captured["env"]
|
||||
assert env["HERMES_MODEL"] == "nous/hermes-test"
|
||||
assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test"
|
||||
assert env["HERMES_TUI_PROVIDER"] == "nous"
|
||||
assert env["HERMES_INFERENCE_PROVIDER"] == "nous"
|
||||
|
||||
|
||||
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,100 @@ def test_status_callback_accepts_single_message_argument():
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_model_uses_inference_model_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.setenv("HERMES_INFERENCE_MODEL", " anthropic/claude-sonnet-4.6\n")
|
||||
|
||||
assert server._resolve_model() == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
def test_resolve_model_strips_config_model(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
|
||||
monkeypatch.setattr(
|
||||
server, "_load_cfg", lambda: {"model": {"default": " nous/hermes-test "}}
|
||||
)
|
||||
|
||||
assert server._resolve_model() == "nous/hermes-test"
|
||||
|
||||
|
||||
def test_startup_runtime_uses_tui_provider_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
|
||||
monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous")
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
|
||||
assert server._resolve_startup_runtime() == ("nous/hermes-test", "nous")
|
||||
|
||||
|
||||
def test_startup_runtime_does_not_treat_inference_provider_as_explicit(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
|
||||
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
|
||||
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.detect_static_provider_for_model",
|
||||
lambda model, provider: None,
|
||||
)
|
||||
|
||||
assert server._resolve_startup_runtime() == ("nous/hermes-test", None)
|
||||
|
||||
|
||||
def test_startup_runtime_detects_provider_for_model_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "sonnet")
|
||||
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})
|
||||
|
||||
def fake_detect(model, current_provider):
|
||||
assert model == "sonnet"
|
||||
assert current_provider == "auto"
|
||||
return "anthropic", "anthropic/claude-sonnet-4.6"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.detect_static_provider_for_model", fake_detect
|
||||
)
|
||||
|
||||
assert server._resolve_startup_runtime() == (
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic",
|
||||
)
|
||||
|
||||
|
||||
def test_startup_runtime_resolves_short_alias_without_network(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "sonnet")
|
||||
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_openrouter_models",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("network lookup should not run")
|
||||
),
|
||||
)
|
||||
|
||||
model, provider = server._resolve_startup_runtime()
|
||||
|
||||
assert provider == "anthropic"
|
||||
assert model.startswith("claude-sonnet")
|
||||
|
||||
|
||||
def test_startup_runtime_does_not_call_network_detector(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "sonnet")
|
||||
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.detect_provider_for_model",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("network detector called")
|
||||
),
|
||||
)
|
||||
|
||||
model, provider = server._resolve_startup_runtime()
|
||||
|
||||
assert model
|
||||
assert provider in {None, "anthropic"}
|
||||
|
||||
|
||||
def _session(agent=None, **extra):
|
||||
return {
|
||||
"agent": agent if agent is not None else types.SimpleNamespace(),
|
||||
@@ -245,6 +339,14 @@ def test_setup_status_reports_provider_config(monkeypatch):
|
||||
assert resp["result"]["provider_configured"] is False
|
||||
|
||||
|
||||
def test_complete_slash_includes_provider_alias():
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
|
||||
)
|
||||
|
||||
assert any(item["text"] == "provider" for item in resp["result"]["items"])
|
||||
|
||||
|
||||
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(reasoning_config=None)
|
||||
@@ -415,6 +517,57 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch):
|
||||
assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic"
|
||||
|
||||
|
||||
def test_config_set_model_syncs_tui_provider_env(monkeypatch):
|
||||
class Agent:
|
||||
model = "gpt-5.3-codex"
|
||||
provider = "openai-codex"
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
|
||||
def switch_model(self, **kwargs):
|
||||
self.model = kwargs["new_model"]
|
||||
self.provider = kwargs["new_provider"]
|
||||
|
||||
agent = Agent()
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
monkeypatch.setenv("HERMES_TUI_PROVIDER", "openai-codex")
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
|
||||
def fake_switch_model(**kwargs):
|
||||
return types.SimpleNamespace(
|
||||
success=True,
|
||||
new_model="anthropic/claude-sonnet-4.6",
|
||||
target_provider="anthropic",
|
||||
api_key="key",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
warning_message="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", fake_switch_model)
|
||||
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "anthropic/claude-sonnet-4.6 --provider anthropic",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
|
||||
assert os.environ["HERMES_TUI_PROVIDER"] == "anthropic"
|
||||
assert os.environ["HERMES_MODEL"] == "anthropic/claude-sonnet-4.6"
|
||||
assert os.environ["HERMES_INFERENCE_MODEL"] == "anthropic/claude-sonnet-4.6"
|
||||
finally:
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
|
||||
@@ -560,17 +560,55 @@ def resolve_skin() -> dict:
|
||||
|
||||
|
||||
def _resolve_model() -> str:
|
||||
env = os.environ.get("HERMES_MODEL", "")
|
||||
env = (
|
||||
os.environ.get("HERMES_MODEL", "")
|
||||
or os.environ.get("HERMES_INFERENCE_MODEL", "")
|
||||
).strip()
|
||||
if env:
|
||||
return env
|
||||
m = _load_cfg().get("model", "")
|
||||
if isinstance(m, dict):
|
||||
return m.get("default", "")
|
||||
return str(m.get("default", "") or "").strip()
|
||||
if isinstance(m, str) and m:
|
||||
return m
|
||||
return m.strip()
|
||||
return "anthropic/claude-sonnet-4"
|
||||
|
||||
|
||||
def _resolve_startup_runtime() -> tuple[str, str | None]:
|
||||
model = _resolve_model()
|
||||
explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
|
||||
if explicit_provider:
|
||||
return model, explicit_provider
|
||||
|
||||
explicit_model = (
|
||||
os.environ.get("HERMES_MODEL", "")
|
||||
or os.environ.get("HERMES_INFERENCE_MODEL", "")
|
||||
).strip()
|
||||
if not explicit_model:
|
||||
return model, None
|
||||
|
||||
try:
|
||||
from hermes_cli.models import detect_static_provider_for_model
|
||||
|
||||
cfg = _load_cfg().get("model") or {}
|
||||
current_provider = (
|
||||
(
|
||||
str(cfg.get("provider") or "").strip().lower()
|
||||
if isinstance(cfg, dict)
|
||||
else ""
|
||||
)
|
||||
or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
or "auto"
|
||||
)
|
||||
detected = detect_static_provider_for_model(explicit_model, current_provider)
|
||||
if detected:
|
||||
provider, detected_model = detected
|
||||
return detected_model, provider
|
||||
except Exception:
|
||||
pass
|
||||
return model, None
|
||||
|
||||
|
||||
def _write_config_key(key_path: str, value):
|
||||
cfg = _load_cfg()
|
||||
current = cfg
|
||||
@@ -736,12 +774,15 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
||||
_emit("session.info", sid, _session_info(agent))
|
||||
|
||||
os.environ["HERMES_MODEL"] = result.new_model
|
||||
os.environ["HERMES_INFERENCE_MODEL"] = result.new_model
|
||||
# Keep the process-level provider env var in sync with the user's explicit
|
||||
# choice so any ambient re-resolution (credential pool refresh, compressor
|
||||
# rebuild, aux clients) resolves to the new provider instead of the
|
||||
# original one persisted in config or env.
|
||||
if result.target_provider:
|
||||
os.environ["HERMES_INFERENCE_PROVIDER"] = result.target_provider
|
||||
if os.environ.get("HERMES_TUI_PROVIDER"):
|
||||
os.environ["HERMES_TUI_PROVIDER"] = result.target_provider
|
||||
if persist_global:
|
||||
_persist_model_switch(result)
|
||||
return {"value": result.new_model, "warning": result.warning_message or ""}
|
||||
@@ -1277,9 +1318,13 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
||||
|
||||
cfg = _load_cfg()
|
||||
system_prompt = ((cfg.get("agent") or {}).get("system_prompt", "") or "").strip()
|
||||
runtime = resolve_runtime_provider(requested=None)
|
||||
model, requested_provider = _resolve_startup_runtime()
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=requested_provider,
|
||||
target_model=model or None,
|
||||
)
|
||||
return AIAgent(
|
||||
model=_resolve_model(),
|
||||
model=model,
|
||||
provider=runtime.get("provider"),
|
||||
base_url=runtime.get("base_url"),
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
@@ -53,7 +53,11 @@ export function AlternateScreen(t0: Props) {
|
||||
}
|
||||
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
|
||||
ENTER_ALT_SCREEN +
|
||||
ERASE_SCROLLBACK +
|
||||
ERASE_SCREEN +
|
||||
CURSOR_HOME +
|
||||
(mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
|
||||
|
||||
@@ -323,27 +323,39 @@ const measureTextNode = function (
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode
|
||||
|
||||
if (elem && elem.nodeName === 'ink-text') {
|
||||
let cache = elem._textMeasureCache
|
||||
|
||||
if (!cache) {
|
||||
cache = { gen: 0, entries: new Map() }
|
||||
elem._textMeasureCache = cache
|
||||
}
|
||||
|
||||
const key = `${width}|${widthMode}`
|
||||
const hit = cache.entries.get(key)
|
||||
|
||||
if (hit && hit._gen === cache.gen) {
|
||||
return hit.result
|
||||
}
|
||||
|
||||
const result = computeTextMeasure(node, width, widthMode)
|
||||
|
||||
// Enforce cap with FIFO eviction to avoid unbounded growth during
|
||||
// pathological frames where yoga probes many widths.
|
||||
if (cache.entries.size >= MEASURE_CACHE_CAP) {
|
||||
const firstKey = cache.entries.keys().next().value
|
||||
cache.entries.delete(firstKey)
|
||||
|
||||
if (firstKey !== undefined) {
|
||||
cache.entries.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
cache.entries.set(key, { _gen: cache.gen, result })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return computeTextMeasure(node, width, widthMode)
|
||||
}
|
||||
|
||||
@@ -475,6 +487,7 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||
for (const child of node.childNodes) {
|
||||
clearYogaNodeReferences(child)
|
||||
}
|
||||
|
||||
node._textMeasureCache = undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -436,6 +436,13 @@ export type Screen = Size & {
|
||||
*/
|
||||
noSelect: Uint8Array
|
||||
|
||||
/**
|
||||
* Per-cell written bitmap. A written plain space and never-written padding
|
||||
* share the same packed cell value, so selection needs this side channel to
|
||||
* preserve code indentation without selecting blank UI margins.
|
||||
*/
|
||||
written: Uint8Array
|
||||
|
||||
/**
|
||||
* Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
|
||||
* is a word-wrap continuation of row r-1 (the `\n` before it was
|
||||
@@ -475,6 +482,14 @@ export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean {
|
||||
return isEmptyCellByIndex(screen, y * screen.width + x)
|
||||
}
|
||||
|
||||
export function isWrittenCellAt(screen: Screen, x: number, y: number): boolean {
|
||||
if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
|
||||
return false
|
||||
}
|
||||
|
||||
return screen.written[y * screen.width + x] === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Cell (view object) represents an empty cell.
|
||||
*/
|
||||
@@ -533,6 +548,7 @@ export function createScreen(
|
||||
emptyStyleId: styles.none,
|
||||
damage: undefined,
|
||||
noSelect: new Uint8Array(size),
|
||||
written: new Uint8Array(size),
|
||||
softWrap: new Int32Array(height)
|
||||
}
|
||||
}
|
||||
@@ -566,6 +582,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void
|
||||
screen.cells = new Int32Array(buf)
|
||||
screen.cells64 = new BigInt64Array(buf)
|
||||
screen.noSelect = new Uint8Array(size)
|
||||
screen.written = new Uint8Array(size)
|
||||
}
|
||||
|
||||
if (screen.softWrap.length < height) {
|
||||
@@ -575,6 +592,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void
|
||||
// Reset all cells — single fill call, no loop
|
||||
screen.cells64.fill(EMPTY_CELL_VALUE, 0, size)
|
||||
screen.noSelect.fill(0, 0, size)
|
||||
screen.written.fill(0, 0, size)
|
||||
screen.softWrap.fill(0, 0, height)
|
||||
|
||||
// Update dimensions
|
||||
@@ -770,6 +788,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
|
||||
if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
|
||||
cells[spacerCI] = EMPTY_CHAR_INDEX
|
||||
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
|
||||
screen.written[y * screen.width + spacerX] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,6 +806,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
|
||||
if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
|
||||
cells[wideCI] = EMPTY_CHAR_INDEX
|
||||
cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
|
||||
screen.written[y * screen.width + x - 1] = 0
|
||||
clearedWideX = x - 1
|
||||
}
|
||||
}
|
||||
@@ -795,6 +815,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
|
||||
// Pack cell data into cells array
|
||||
cells[ci] = internCharString(screen, cell.char)
|
||||
cells[ci + 1] = packWord1(cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width)
|
||||
screen.written[y * screen.width + x] = 1
|
||||
|
||||
// Track damage - expand bounds in place instead of allocating new objects
|
||||
// Include the main cell position and any cleared orphan cells
|
||||
@@ -841,11 +862,13 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
|
||||
if (spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
|
||||
cells[orphanCI] = EMPTY_CHAR_INDEX
|
||||
cells[orphanCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
|
||||
screen.written[y * screen.width + spacerX + 1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
cells[spacerCI] = SPACER_CHAR_INDEX
|
||||
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail)
|
||||
screen.written[y * screen.width + spacerX] = 1
|
||||
|
||||
// Expand damage to include SpacerTail so diff() scans it
|
||||
const d = screen.damage
|
||||
@@ -929,6 +952,8 @@ export function blitRegion(
|
||||
const dstCells = dst.cells
|
||||
const srcNoSel = src.noSelect
|
||||
const dstNoSel = dst.noSelect
|
||||
const srcWritten = src.written
|
||||
const dstWritten = dst.written
|
||||
|
||||
// softWrap is per-row — copy the row range regardless of stride/width.
|
||||
// Partial-width blits still carry the row's wrap provenance since the
|
||||
@@ -947,6 +972,7 @@ export function blitRegion(
|
||||
const nsStart = regionY * src.width
|
||||
const nsLen = (maxY - regionY) * src.width
|
||||
dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart)
|
||||
dstWritten.set(srcWritten.subarray(nsStart, nsStart + nsLen), nsStart)
|
||||
} else {
|
||||
// Per-row copy for partial-width or mismatched-stride regions
|
||||
let srcRowCI = regionY * srcStride + (regionX << 1)
|
||||
@@ -957,6 +983,7 @@ export function blitRegion(
|
||||
for (let y = regionY; y < maxY; y++) {
|
||||
dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI)
|
||||
dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
|
||||
dstWritten.set(srcWritten.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
|
||||
srcRowCI += srcStride
|
||||
dstRowCI += dstStride
|
||||
srcRowNS += src.width
|
||||
@@ -989,6 +1016,7 @@ export function blitRegion(
|
||||
if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
|
||||
dstCells[dstSpacerCI] = SPACER_CHAR_INDEX
|
||||
dstCells[dstSpacerCI + 1] = packWord1(dst.emptyStyleId, 0, CellWidth.SpacerTail)
|
||||
dstWritten[y * dst.width + maxX] = 1
|
||||
wroteSpacerOutsideRegion = true
|
||||
}
|
||||
|
||||
@@ -1030,6 +1058,7 @@ export function clearRegion(
|
||||
|
||||
const cells = screen.cells
|
||||
const cells64 = screen.cells64
|
||||
const written = screen.written
|
||||
const screenWidth = screen.width
|
||||
const rowBase = startY * screenWidth
|
||||
let damageMinX = startX
|
||||
@@ -1040,6 +1069,7 @@ export function clearRegion(
|
||||
if (startX === 0 && maxX === screenWidth) {
|
||||
// Full-width: single fill, no boundary checks needed
|
||||
cells64.fill(EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth)
|
||||
written.fill(0, rowBase, rowBase + (maxY - startY) * screenWidth)
|
||||
} else {
|
||||
// Partial-width: single loop handles boundary cleanup and fill per row.
|
||||
const stride = screenWidth << 1 // 2 Int32s per cell
|
||||
@@ -1062,6 +1092,7 @@ export function clearRegion(
|
||||
if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) {
|
||||
cells[prevW1 - 1] = EMPTY_CHAR_INDEX
|
||||
cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
|
||||
written[y * screenWidth + startX - 1] = 0
|
||||
damageMinX = startX - 1
|
||||
}
|
||||
}
|
||||
@@ -1078,12 +1109,14 @@ export function clearRegion(
|
||||
if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
|
||||
cells[nextW1 - 1] = EMPTY_CHAR_INDEX
|
||||
cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
|
||||
written[y * screenWidth + maxX] = 0
|
||||
damageMaxX = maxX + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen)
|
||||
written.fill(0, fillStart, fillStart + rowLen)
|
||||
leftEdge += stride
|
||||
rightEdge += stride
|
||||
fillStart += screenWidth
|
||||
@@ -1120,12 +1153,14 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number
|
||||
const w = screen.width
|
||||
const cells64 = screen.cells64
|
||||
const noSel = screen.noSelect
|
||||
const written = screen.written
|
||||
const sw = screen.softWrap
|
||||
const absN = Math.abs(n)
|
||||
|
||||
if (absN > bottom - top) {
|
||||
cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w)
|
||||
noSel.fill(0, top * w, (bottom + 1) * w)
|
||||
written.fill(0, top * w, (bottom + 1) * w)
|
||||
sw.fill(0, top, bottom + 1)
|
||||
|
||||
return
|
||||
@@ -1135,17 +1170,21 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number
|
||||
// SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom
|
||||
cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
|
||||
noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
|
||||
written.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
|
||||
sw.copyWithin(top, top + n, bottom + 1)
|
||||
cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w)
|
||||
noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
|
||||
written.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
|
||||
sw.fill(0, bottom - n + 1, bottom + 1)
|
||||
} else {
|
||||
// SD: row top..bottom+n → top-n..bottom; clear top..top-n-1
|
||||
cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
|
||||
noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
|
||||
written.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
|
||||
sw.copyWithin(top - n, top, bottom + n + 1)
|
||||
cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w)
|
||||
noSel.fill(0, top * w, (top - n) * w)
|
||||
written.fill(0, top * w, (top - n) * w)
|
||||
sw.fill(0, top, top - n)
|
||||
}
|
||||
}
|
||||
|
||||
82
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
82
ui-tui/packages/hermes-ink/src/ink/selection.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cellAt, CellWidth, CharPool, createScreen, HyperlinkPool, setCellAt, StylePool } from './screen.js'
|
||||
import {
|
||||
applySelectionOverlay,
|
||||
createSelectionState,
|
||||
getSelectedText,
|
||||
startSelection,
|
||||
updateSelection
|
||||
} from './selection.js'
|
||||
|
||||
const screenWithText = () => {
|
||||
const styles = new StylePool()
|
||||
const screen = createScreen(10, 3, styles, new CharPool(), new HyperlinkPool())
|
||||
|
||||
setCellAt(screen, 2, 1, { char: 'h', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
setCellAt(screen, 3, 1, { char: 'i', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
|
||||
return { screen, styles }
|
||||
}
|
||||
|
||||
describe('selection whitespace handling', () => {
|
||||
it('does not copy whitespace-only selections', () => {
|
||||
const { screen } = screenWithText()
|
||||
const selection = createSelectionState()
|
||||
|
||||
startSelection(selection, 0, 0)
|
||||
updateSelection(selection, 9, 0)
|
||||
|
||||
expect(getSelectedText(selection, screen)).toBe('')
|
||||
})
|
||||
|
||||
it('trims outer drag padding while preserving selected content', () => {
|
||||
const { screen } = screenWithText()
|
||||
const selection = createSelectionState()
|
||||
|
||||
startSelection(selection, 0, 1)
|
||||
updateSelection(selection, 9, 1)
|
||||
|
||||
expect(getSelectedText(selection, screen)).toBe('hi')
|
||||
})
|
||||
|
||||
it('preserves selected indentation when spaces are rendered content', () => {
|
||||
const styles = new StylePool()
|
||||
const screen = createScreen(10, 1, styles, new CharPool(), new HyperlinkPool())
|
||||
const selection = createSelectionState()
|
||||
|
||||
setCellAt(screen, 0, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
setCellAt(screen, 1, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
setCellAt(screen, 2, 0, { char: 'x', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
|
||||
|
||||
startSelection(selection, 0, 0)
|
||||
updateSelection(selection, 9, 0)
|
||||
|
||||
expect(getSelectedText(selection, screen)).toBe(' x')
|
||||
})
|
||||
|
||||
it('clamps copied selection bounds to screen width', () => {
|
||||
const { screen } = screenWithText()
|
||||
const selection = createSelectionState()
|
||||
|
||||
startSelection(selection, 0, 1)
|
||||
updateSelection(selection, 99, 1)
|
||||
|
||||
expect(getSelectedText(selection, screen)).toBe('hi')
|
||||
})
|
||||
|
||||
it('does not paint selection background on leading/trailing empty cells or empty rows', () => {
|
||||
const { screen, styles } = screenWithText()
|
||||
const selection = createSelectionState()
|
||||
|
||||
startSelection(selection, 0, 0)
|
||||
updateSelection(selection, 9, 2)
|
||||
applySelectionOverlay(screen, selection, styles)
|
||||
|
||||
expect(cellAt(screen, 0, 0)?.styleId).toBe(screen.emptyStyleId)
|
||||
expect(cellAt(screen, 0, 1)?.styleId).toBe(screen.emptyStyleId)
|
||||
expect(cellAt(screen, 2, 1)?.styleId).not.toBe(screen.emptyStyleId)
|
||||
expect(cellAt(screen, 4, 1)?.styleId).toBe(screen.emptyStyleId)
|
||||
expect(cellAt(screen, 0, 2)?.styleId).toBe(screen.emptyStyleId)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { clamp } from './layout/geometry.js'
|
||||
import type { Screen, StylePool } from './screen.js'
|
||||
import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js'
|
||||
import { cellAt, cellAtIndex, CellWidth, isWrittenCellAt, setCellStyleId } from './screen.js'
|
||||
|
||||
type Point = { col: number; row: number }
|
||||
|
||||
@@ -842,6 +842,43 @@ export function isCellSelected(s: SelectionState, col: number, row: number): boo
|
||||
return true
|
||||
}
|
||||
|
||||
function selectableCell(screen: Screen, row: number, col: number): boolean {
|
||||
const cell = cellAt(screen, col, row)
|
||||
|
||||
return (
|
||||
screen.noSelect[row * screen.width + col] !== 1 &&
|
||||
isWrittenCellAt(screen, col, row) &&
|
||||
!!cell &&
|
||||
cell.width !== CellWidth.SpacerTail &&
|
||||
cell.width !== CellWidth.SpacerHead
|
||||
)
|
||||
}
|
||||
|
||||
function selectionContentBounds(
|
||||
screen: Screen,
|
||||
row: number,
|
||||
start: number,
|
||||
end: number
|
||||
): { first: number; last: number } | null {
|
||||
let first = start
|
||||
|
||||
while (first <= end && !selectableCell(screen, row, first)) {
|
||||
first++
|
||||
}
|
||||
|
||||
if (first > end) {
|
||||
return null
|
||||
}
|
||||
|
||||
let last = end
|
||||
|
||||
while (last >= first && !selectableCell(screen, row, last)) {
|
||||
last--
|
||||
}
|
||||
|
||||
return { first, last }
|
||||
}
|
||||
|
||||
/** Extract text from one screen row. When the next row is a soft-wrap
|
||||
* continuation (screen.softWrap[row+1]>0), clamp to that content-end
|
||||
* column and skip the trailing trim so the word-separator space survives
|
||||
@@ -890,6 +927,21 @@ function joinRows(lines: string[], text: string, sw: boolean | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
function trimEmptyEdgeRows(lines: string[]): string[] {
|
||||
let start = 0
|
||||
let end = lines.length
|
||||
|
||||
while (start < end && !lines[start]!.trim()) {
|
||||
start++
|
||||
}
|
||||
|
||||
while (end > start && !lines[end - 1]!.trim()) {
|
||||
end--
|
||||
}
|
||||
|
||||
return lines.slice(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from the screen buffer within the selection range.
|
||||
* Rows are joined with newlines unless the screen's softWrap bitmap
|
||||
@@ -917,16 +969,18 @@ export function getSelectedText(s: SelectionState, screen: Screen): string {
|
||||
}
|
||||
|
||||
for (let row = start.row; row <= end.row; row++) {
|
||||
const rowStart = row === start.row ? start.col : 0
|
||||
const rowEnd = row === end.row ? end.col : screen.width - 1
|
||||
joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0)
|
||||
const rowStart = Math.max(0, row === start.row ? start.col : 0)
|
||||
const rowEnd = Math.min(row === end.row ? end.col : screen.width - 1, screen.width - 1)
|
||||
const bounds = selectionContentBounds(screen, row, rowStart, rowEnd)
|
||||
|
||||
joinRows(lines, bounds ? extractRowText(screen, row, bounds.first, bounds.last) : '', sw[row]! > 0)
|
||||
}
|
||||
|
||||
for (let i = 0; i < s.scrolledOffBelow.length; i++) {
|
||||
joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i])
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
return trimEmptyEdgeRows(lines).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1051,9 +1105,14 @@ export function applySelectionOverlay(screen: Screen, selection: SelectionState,
|
||||
for (let row = start.row; row <= end.row && row < screen.height; row++) {
|
||||
const colStart = row === start.row ? start.col : 0
|
||||
const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
|
||||
const bounds = selectionContentBounds(screen, row, colStart, colEnd)
|
||||
const rowOff = row * width
|
||||
|
||||
for (let col = colStart; col <= colEnd; col++) {
|
||||
if (!bounds) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let col = bounds.first; col <= bounds.last; col++) {
|
||||
const idx = rowOff + col
|
||||
|
||||
// Skip noSelect cells — gutters stay visually unchanged so it's
|
||||
|
||||
@@ -9,18 +9,21 @@ describe('shouldEmitClipboardSequence', () => {
|
||||
})
|
||||
|
||||
it('keeps OSC enabled for remote or plain local terminals', () => {
|
||||
expect(shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
shouldEmitClipboardSequence({ SSH_CONNECTION: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
expect(shouldEmitClipboardSequence({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('honors explicit env override', () => {
|
||||
expect(shouldEmitClipboardSequence({ HERMES_TUI_CLIPBOARD_OSC52: '1', TMUX: '/tmp/tmux-1/default,1,0' } as NodeJS.ProcessEnv)).toBe(
|
||||
true
|
||||
)
|
||||
expect(shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
shouldEmitClipboardSequence({
|
||||
HERMES_TUI_CLIPBOARD_OSC52: '1',
|
||||
TMUX: '/tmp/tmux-1/default,1,0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +226,10 @@ describe('createGatewayEventHandler', () => {
|
||||
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
|
||||
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
|
||||
|
||||
onEvent({ payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
|
||||
onEvent({
|
||||
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
|
||||
type: 'tool.complete'
|
||||
} as any)
|
||||
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
|
||||
|
||||
expect(appended).toHaveLength(1)
|
||||
|
||||
@@ -17,6 +17,14 @@ describe('createSlashHandler', () => {
|
||||
expect(getOverlayState().picker).toBe(true)
|
||||
})
|
||||
|
||||
it('treats /provider as a local /model alias', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/provider')).toBe(true)
|
||||
expect(getOverlayState().modelPicker).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the skills hub locally for bare /skills', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
@@ -118,9 +126,7 @@ describe('createSlashHandler', () => {
|
||||
const ctx = buildCtx()
|
||||
createSlashHandler(ctx)('/details tools blink')
|
||||
expect(getUiState().sections.tools).toBeUndefined()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(
|
||||
'usage: /details <section> [hidden|collapsed|expanded|reset]'
|
||||
)
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details <section> [hidden|collapsed|expanded|reset]')
|
||||
})
|
||||
|
||||
it('shows tool enable usage when names are missing', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSectionName, parseDetailsMode, resolveSections, sectionMode, SECTION_NAMES } from '../domain/details.js'
|
||||
import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
|
||||
describe('parseDetailsMode', () => {
|
||||
it('accepts the canonical modes case-insensitively', () => {
|
||||
|
||||
@@ -31,6 +31,28 @@ describe('platform action modifier', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCopyShortcut', () => {
|
||||
it('keeps Ctrl+C as the local non-macOS copy chord', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts client Cmd+C over SSH even when running on Linux', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv
|
||||
|
||||
expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true)
|
||||
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat local Linux Alt+C as copy', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVoiceToggleKey', () => {
|
||||
it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { GatewayProvider } from './app/gatewayContext.js'
|
||||
import { useMainApp } from './app/useMainApp.js'
|
||||
import { $uiState } from './app/uiStore.js'
|
||||
import { useMainApp } from './app/useMainApp.js'
|
||||
import { AppLayout } from './components/appLayout.js'
|
||||
import type { GatewayClient } from './gatewayClient.js'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
|
||||
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
||||
import { HOTKEYS } from '../../../content/hotkeys.js'
|
||||
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
||||
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
|
||||
import type {
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
@@ -40,8 +40,10 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => {
|
||||
|
||||
const RESET_WORDS = new Set(['reset', 'clear', 'default'])
|
||||
const CYCLE_WORDS = new Set(['cycle', 'toggle'])
|
||||
|
||||
const DETAILS_USAGE =
|
||||
'usage: /details [hidden|collapsed|expanded|cycle] or /details <section> [hidden|collapsed|expanded|reset]'
|
||||
|
||||
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
|
||||
|
||||
export const coreCommands: SlashCommand[] = [
|
||||
@@ -97,9 +99,7 @@ export const coreCommands: SlashCommand[] = [
|
||||
}
|
||||
|
||||
patchUiState({ mouseTracking: next })
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' })
|
||||
.catch(() => {})
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
|
||||
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
|
||||
}
|
||||
@@ -178,7 +178,9 @@ export const coreCommands: SlashCommand[] = [
|
||||
gateway
|
||||
.rpc<ConfigGetValueResponse>('config.get', { key: 'details_mode' })
|
||||
.then(r => {
|
||||
if (ctx.stale()) return
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
|
||||
patchUiState({ detailsMode: mode })
|
||||
@@ -267,7 +269,6 @@ export const coreCommands: SlashCommand[] = [
|
||||
}
|
||||
|
||||
writeOsc52Clipboard(target.text)
|
||||
sys(`copied ${target.text.length} chars`)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
|
||||
{
|
||||
help: 'change or show model',
|
||||
aliases: ['provider'],
|
||||
name: 'model',
|
||||
run: (arg, ctx) => {
|
||||
if (ctx.session.guardBusySessionSwitch('change models')) {
|
||||
|
||||
@@ -5,18 +5,6 @@ import { runExternalSetup } from '../../setupHandoff.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
export const setupCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'configure LLM provider + model (launches `hermes model`)',
|
||||
name: 'provider',
|
||||
run: (_arg, ctx) =>
|
||||
void runExternalSetup({
|
||||
args: ['model'],
|
||||
ctx,
|
||||
done: 'provider updated — starting session…',
|
||||
launcher: launchHermesCommand,
|
||||
suspend: withInkSuspended
|
||||
})
|
||||
},
|
||||
{
|
||||
help: 'run full setup wizard (launches `hermes setup`)',
|
||||
name: 'setup',
|
||||
|
||||
@@ -300,6 +300,7 @@ class TurnController {
|
||||
|
||||
const hasDiffSegment = segments.some(msg => msg.kind === 'diff')
|
||||
const detailsBelongBeforeDiff = hasDiffSegment && (tools.length > 0 || Boolean(savedReasoning))
|
||||
|
||||
const finalMessages = detailsBelongBeforeDiff
|
||||
? insertBeforeFirstDiff(segments, {
|
||||
kind: 'trail',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { MOUSE_TRACKING } from '../config/env.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
import { MOUSE_TRACKING } from '../config/env.js'
|
||||
|
||||
import type { UiState } from './interfaces.js'
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
SudoRespondResponse,
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { isAction, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||
@@ -30,11 +30,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const copySelection = () => {
|
||||
// ink's copySelection() already calls setClipboard() which handles
|
||||
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
|
||||
const text = terminal.selection.copySelection()
|
||||
|
||||
if (text) {
|
||||
actions.sys(`copied ${text.length} chars`)
|
||||
}
|
||||
terminal.selection.copySelection()
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
@@ -159,16 +155,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
voice.setProcessing(false)
|
||||
}
|
||||
|
||||
gateway
|
||||
.rpc<VoiceRecordResponse>('voice.record', { action })
|
||||
.catch((e: Error) => {
|
||||
// Revert optimistic UI on failure.
|
||||
if (starting) {
|
||||
voice.setRecording(false)
|
||||
}
|
||||
gateway.rpc<VoiceRecordResponse>('voice.record', { action }).catch((e: Error) => {
|
||||
// Revert optimistic UI on failure.
|
||||
if (starting) {
|
||||
voice.setRecording(false)
|
||||
}
|
||||
|
||||
actions.sys(`voice error: ${e.message}`)
|
||||
})
|
||||
actions.sys(`voice error: ${e.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
@@ -317,7 +311,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (isAction(key, ch, 'c')) {
|
||||
if (isCopyShortcut(key, ch)) {
|
||||
if (terminal.hasSelection) {
|
||||
return copySelection()
|
||||
}
|
||||
|
||||
@@ -640,14 +640,14 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const showProgressArea = anyPanelVisible
|
||||
? Boolean(
|
||||
ui.busy ||
|
||||
turn.outcome ||
|
||||
turn.streamPendingTools.length ||
|
||||
turn.streamSegments.length ||
|
||||
turn.subagents.length ||
|
||||
turn.tools.length ||
|
||||
turn.turnTrail.length ||
|
||||
hasReasoning ||
|
||||
turn.activity.length
|
||||
turn.outcome ||
|
||||
turn.streamPendingTools.length ||
|
||||
turn.streamSegments.length ||
|
||||
turn.subagents.length ||
|
||||
turn.tools.length ||
|
||||
turn.turnTrail.length ||
|
||||
hasReasoning ||
|
||||
turn.activity.length
|
||||
)
|
||||
: turn.activity.some(item => item.tone !== 'info')
|
||||
|
||||
|
||||
@@ -218,11 +218,7 @@ export function StatusRule({
|
||||
{voiceLabel ? (
|
||||
<Text
|
||||
color={
|
||||
voiceLabel.startsWith('●')
|
||||
? t.color.error
|
||||
: voiceLabel.startsWith('◉')
|
||||
? t.color.warn
|
||||
: t.color.dim
|
||||
voiceLabel.startsWith('●') ? t.color.error : voiceLabel.startsWith('◉') ? t.color.warn : t.color.dim
|
||||
}
|
||||
>
|
||||
{' │ '}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { $uiState } from '../app/uiStore.js'
|
||||
import { FloatBox } from './appChrome.js'
|
||||
import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { OverlayHint } from './overlayControls.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
|
||||
import { SessionPicker } from './sessionPicker.js'
|
||||
import { SkillsHub } from './skillsHub.js'
|
||||
@@ -162,11 +163,11 @@ export function FloatingOverlays({
|
||||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={ui.theme.color.dim}>
|
||||
<OverlayHint t={ui.theme}>
|
||||
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
|
||||
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
|
||||
: `end · ↑↓/jk · b/PgUp back · g top · q close (${overlay.pager.lines.length} lines)`}
|
||||
</Text>
|
||||
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
|
||||
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
|
||||
</OverlayHint>
|
||||
</Box>
|
||||
</Box>
|
||||
</FloatBox>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
||||
@@ -72,8 +72,7 @@ export const MessageLine = memo(function MessageLine({
|
||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||
|
||||
const showDetails =
|
||||
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) ||
|
||||
(thinkingMode !== 'hidden' && Boolean(thinking))
|
||||
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') {
|
||||
|
||||
@@ -7,18 +7,12 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 12
|
||||
const MIN_WIDTH = 40
|
||||
const MAX_WIDTH = 90
|
||||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [currentModel, setCurrentModel] = useState('')
|
||||
@@ -71,20 +65,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
const models = provider?.models ?? []
|
||||
const names = useMemo(() => providerDisplayNames(providers), [providers])
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === 'model') {
|
||||
setStage('provider')
|
||||
setModelIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onCancel()
|
||||
const back = () => {
|
||||
if (stage === 'model') {
|
||||
setStage('provider')
|
||||
setModelIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onCancel()
|
||||
}
|
||||
|
||||
useOverlayKeys({ onBack: back, onClose: onCancel })
|
||||
|
||||
useInput((ch, key) => {
|
||||
const count = stage === 'provider' ? providers.length : models.length
|
||||
const sel = stage === 'provider' ? providerIdx : modelIdx
|
||||
const setSel = stage === 'provider' ? setProviderIdx : setModelIdx
|
||||
@@ -133,16 +127,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
const n = ch === '0' ? 10 : parseInt(ch, 10)
|
||||
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
|
||||
const off = pageOffset(count, sel)
|
||||
const offset = windowOffset(count, sel, VISIBLE)
|
||||
|
||||
if (stage === 'provider') {
|
||||
const next = off + n - 1
|
||||
const next = offset + n - 1
|
||||
|
||||
if (providers[next]) {
|
||||
setProviderIdx(next)
|
||||
}
|
||||
} else if (provider && models[off + n - 1]) {
|
||||
onSelect(`${models[off + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`)
|
||||
} else if (provider && models[offset + n - 1]) {
|
||||
onSelect(`${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -155,7 +149,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -164,7 +158,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no authenticated providers</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -174,7 +168,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
(p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models`
|
||||
)
|
||||
|
||||
const { items, off } = visibleItems(rows, providerIdx)
|
||||
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@@ -189,12 +183,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{off > 0 ? ` ↑ ${off} more` : ' '}
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
{Array.from({ length: VISIBLE }, (_, i) => {
|
||||
const row = items[i]
|
||||
const idx = off + i
|
||||
const idx = offset + i
|
||||
|
||||
return row ? (
|
||||
<Text
|
||||
@@ -215,20 +209,18 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '}
|
||||
{offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel
|
||||
</Text>
|
||||
<OverlayHint t={t}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items, off } = visibleItems(models, modelIdx)
|
||||
const { items, offset } = windowItems(models, modelIdx, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@@ -243,12 +235,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
{provider?.warning ? `warning: ${provider.warning}` : ' '}
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{off > 0 ? ` ↑ ${off} more` : ' '}
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
{Array.from({ length: VISIBLE }, (_, i) => {
|
||||
const row = items[i]
|
||||
const idx = off + i
|
||||
const idx = offset + i
|
||||
|
||||
if (!row) {
|
||||
return !models.length && i === 0 ? (
|
||||
@@ -277,15 +269,15 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||
})}
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{off + VISIBLE < models.length ? ` ↓ ${models.length - off - VISIBLE} more` : ' '}
|
||||
{offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
persist: {persistGlobal ? 'global' : 'session'} · g toggle
|
||||
</Text>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'}
|
||||
</Text>
|
||||
<OverlayHint t={t}>
|
||||
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'}
|
||||
</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
50
ui-tui/src/components/overlayControls.tsx
Normal file
50
ui-tui/src/components/overlayControls.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Text, useInput } from '@hermes/ink'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) {
|
||||
useInput((ch, key) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ch === 'q') {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
return onBack ? onBack() : onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function OverlayHint({ children, t }: OverlayHintProps) {
|
||||
return (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const windowOffset = (count: number, selected: number, visible: number) =>
|
||||
Math.max(0, Math.min(selected - Math.floor(visible / 2), count - visible))
|
||||
|
||||
export function windowItems<T>(items: T[], selected: number, visible: number) {
|
||||
const offset = windowOffset(items.length, selected, visible)
|
||||
|
||||
return {
|
||||
items: items.slice(offset, offset + visible),
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
interface OverlayHintProps {
|
||||
children: string
|
||||
t: Theme
|
||||
}
|
||||
|
||||
interface OverlayKeysOptions {
|
||||
disabled?: boolean
|
||||
onBack?: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 15
|
||||
const MIN_WIDTH = 60
|
||||
const MAX_WIDTH = 120
|
||||
@@ -33,6 +35,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
const { stdout } = useStdout()
|
||||
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
|
||||
|
||||
useOverlayKeys({ onClose: onCancel })
|
||||
|
||||
useEffect(() => {
|
||||
gw.request<SessionListResponse>('session.list', { limit: 20 })
|
||||
.then(raw => {
|
||||
@@ -56,10 +60,6 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
}, [gw])
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
return onCancel()
|
||||
}
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(s => s - 1)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -96,12 +96,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no previous sessions</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
||||
const offset = windowOffset(items.length, sel, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@@ -109,10 +109,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
Resume Session
|
||||
</Text>
|
||||
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.slice(off, off + VISIBLE).map((s, vi) => {
|
||||
const i = off + vi
|
||||
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
|
||||
const i = offset + vi
|
||||
const selected = sel === i
|
||||
|
||||
return (
|
||||
@@ -140,8 +140,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
||||
{offset + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,18 +5,12 @@ import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 12
|
||||
const MIN_WIDTH = 40
|
||||
const MAX_WIDTH = 90
|
||||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
|
||||
const [selectedCat, setSelectedCat] = useState('')
|
||||
@@ -48,6 +42,27 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : []
|
||||
const skillName = skills[skillIdx] ?? ''
|
||||
|
||||
const back = () => {
|
||||
if (stage === 'actions') {
|
||||
setStage('skill')
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'skill') {
|
||||
setStage('category')
|
||||
setSkillIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
useOverlayKeys({ disabled: installing, onBack: back, onClose })
|
||||
|
||||
const inspect = (name: string) => {
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
@@ -72,27 +87,6 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (stage === 'actions') {
|
||||
setStage('skill')
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'skill') {
|
||||
setStage('category')
|
||||
setSkillIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'actions') {
|
||||
if (key.return) {
|
||||
setStage('skill')
|
||||
@@ -159,8 +153,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
const n = ch === '0' ? 10 : parseInt(ch, 10)
|
||||
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
|
||||
const off = pageOffset(count, sel)
|
||||
const next = off + n - 1
|
||||
const next = windowOffset(count, sel, VISIBLE) + n - 1
|
||||
|
||||
if (stage === 'category') {
|
||||
const cat = cats[next]
|
||||
@@ -193,7 +186,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -202,14 +195,14 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.dim}>no skills available</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'category') {
|
||||
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`)
|
||||
const { items, off } = visibleItems(rows, catIdx)
|
||||
const { items, offset } = windowItems(rows, catIdx, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@@ -218,10 +211,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>select a category</Text>
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
const idx = offset + i
|
||||
|
||||
return (
|
||||
<Text
|
||||
@@ -237,14 +230,14 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter open · 1-9,0 quick · Esc cancel</Text>
|
||||
{offset + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - offset - VISIBLE} more</Text>}
|
||||
<OverlayHint t={t}>↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'skill') {
|
||||
const { items, off } = visibleItems(skills, skillIdx)
|
||||
const { items, offset } = windowItems(skills, skillIdx, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@@ -254,10 +247,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
|
||||
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
{offset > 0 && <Text color={t.color.dim}> ↑ {offset} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
const idx = offset + i
|
||||
|
||||
return (
|
||||
<Text
|
||||
@@ -273,10 +266,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < skills.length && <Text color={t.color.dim}> ↓ {skills.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>
|
||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'}
|
||||
</Text>
|
||||
{offset + VISIBLE < skills.length && (
|
||||
<Text color={t.color.dim}> ↓ {skills.length - offset - VISIBLE} more</Text>
|
||||
)}
|
||||
<OverlayHint t={t}>
|
||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
|
||||
</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -294,7 +289,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{installing ? <Text color={t.color.amber}>installing…</Text> : null}
|
||||
|
||||
<Text color={t.color.dim}>i reinspect · x reinstall · Enter/Esc back</Text>
|
||||
<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
@@ -919,13 +919,22 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
// hidden sections stay hidden so the override is honoured.
|
||||
|
||||
const expandAll = () => {
|
||||
if (visible.thinking !== 'hidden') setOpenThinking(true)
|
||||
if (visible.tools !== 'hidden') setOpenTools(true)
|
||||
if (visible.thinking !== 'hidden') {
|
||||
setOpenThinking(true)
|
||||
}
|
||||
|
||||
if (visible.tools !== 'hidden') {
|
||||
setOpenTools(true)
|
||||
}
|
||||
|
||||
if (visible.subagents !== 'hidden') {
|
||||
setOpenSubagents(true)
|
||||
setDeepSubagents(true)
|
||||
}
|
||||
if (visible.activity !== 'hidden') setOpenMeta(true)
|
||||
|
||||
if (visible.activity !== 'hidden') {
|
||||
setOpenMeta(true)
|
||||
}
|
||||
}
|
||||
|
||||
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error')
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { isMac } from '../lib/platform.js'
|
||||
import { isMac, isRemoteShell } from '../lib/platform.js'
|
||||
|
||||
const action = isMac ? 'Cmd' : 'Ctrl'
|
||||
const paste = isMac ? 'Cmd' : 'Alt'
|
||||
|
||||
const copyHotkeys: [string, string][] = isMac
|
||||
? [
|
||||
['Cmd+C', 'copy selection'],
|
||||
['Ctrl+C', 'interrupt / clear draft / exit']
|
||||
]
|
||||
: isRemoteShell()
|
||||
? [
|
||||
['Cmd+C', 'copy selection when forwarded by the terminal'],
|
||||
['Ctrl+C', 'copy selection / interrupt / clear draft / exit']
|
||||
]
|
||||
: [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']]
|
||||
|
||||
export const HOTKEYS: [string, string][] = [
|
||||
...(isMac
|
||||
? ([
|
||||
['Cmd+C', 'copy selection'],
|
||||
['Ctrl+C', 'interrupt / clear draft / exit']
|
||||
] as [string, string][])
|
||||
: ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])),
|
||||
...copyHotkeys,
|
||||
[action + '+D', 'exit'],
|
||||
[action + '+G', 'open $EDITOR for prompt'],
|
||||
[action + '+L', 'new session (clear)'],
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace
|
||||
* into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them.
|
||||
* On other platforms the action modifier is Ctrl.
|
||||
* Ctrl+C is ALWAYS the interrupt key regardless of platform — it must never be
|
||||
* remapped to copy.
|
||||
* Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also
|
||||
* copy an active TUI selection, matching common terminal selection behavior.
|
||||
*/
|
||||
|
||||
export const isMac = process.platform === 'darwin'
|
||||
@@ -34,6 +34,16 @@ export const isMacActionFallback = (
|
||||
export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean =>
|
||||
isActionMod(key) && ch.toLowerCase() === target
|
||||
|
||||
export const isRemoteShell = (env: NodeJS.ProcessEnv = process.env): boolean =>
|
||||
Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
|
||||
|
||||
export const isCopyShortcut = (
|
||||
key: { ctrl: boolean; meta: boolean; super?: boolean },
|
||||
ch: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): boolean =>
|
||||
isAction(key, ch, 'c') || (isRemoteShell(env) && (key.meta || key.super === true) && ch.toLowerCase() === 'c')
|
||||
|
||||
/**
|
||||
* Voice recording toggle key (Ctrl+B).
|
||||
*
|
||||
@@ -43,7 +53,5 @@ export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean },
|
||||
* accept Cmd+B (the platform action modifier) so existing macOS muscle memory
|
||||
* keeps working.
|
||||
*/
|
||||
export const isVoiceToggleKey = (
|
||||
key: { ctrl: boolean; meta: boolean; super?: boolean },
|
||||
ch: string
|
||||
): boolean => (key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'
|
||||
export const isVoiceToggleKey = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string): boolean =>
|
||||
(key.ctrl || isActionMod(key)) && ch.toLowerCase() === 'b'
|
||||
|
||||
143
web/src/App.tsx
143
web/src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
web/src/components/DesktopBridge.tsx
Normal file
47
web/src/components/DesktopBridge.tsx
Normal file
@@ -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;
|
||||
}
|
||||
117
web/src/components/RuntimeOverlay.tsx
Normal file
117
web/src/components/RuntimeOverlay.tsx
Normal file
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
434
web/src/pages/SetupPage.tsx
Normal file
434
web/src/pages/SetupPage.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user