Compare commits

...

26 Commits

Author SHA1 Message Date
Brooklyn Nicholson
648da6a8d1 feat(gui): make desktop setup flow real and testable
Add a GUI-first setup gate and runtime state API so desktop onboarding is safe, iterative, and works with isolated fresh-mode installs. Scaffold and wire the desktop shell/runtime pieces so this branch runs end-to-end without disturbing existing user installs.
2026-04-25 19:48:02 -05:00
brooklyn!
edc78e258c Merge pull request #15766 from NousResearch/bb/tui-ssh-copy
fix(tui): honor client copy shortcut over ssh
2026-04-25 15:33:17 -05:00
Brooklyn Nicholson
31d7f1951a fix(tui): clamp copied selection bounds
Clamp copied selection columns to the screen width before scanning rendered cells.
2026-04-25 15:32:45 -05:00
Brooklyn Nicholson
b1c18e5a41 refactor(tui): format screen imports
Keep screen.ts import ordering aligned with the ui-tui formatter.
2026-04-25 15:26:51 -05:00
Brooklyn Nicholson
bd66e55a02 fix(tui): track rendered spaces for selection copy
- add a written-cell bitmap so selection can distinguish rendered spaces from blank padding
- preserve code indentation without markdown-specific rendering hacks
2026-04-25 15:21:26 -05:00
Brooklyn Nicholson
1735ced93b fix(tui): preserve code block indentation in selection
Render code indentation spaces as selectable cells so copied fenced code keeps its leading whitespace.
2026-04-25 15:17:36 -05:00
Brooklyn Nicholson
bba16943f6 fix(tui): preserve rendered indentation in selections
- trim only empty edge rows instead of full selected text
- bound selection paint using unwritten cells so rendered indentation remains copyable
2026-04-25 15:14:26 -05:00
Brooklyn Nicholson
132620ba3d refactor(tui): simplify remote copy hotkey hints
Use an explicit conditional table instead of spread casting for SSH copy hint rows.
2026-04-25 15:09:12 -05:00
Brooklyn Nicholson
876bb60044 fix(tui): trim whitespace-only selection chrome
- clamp selection highlight to real row content so blank drag margins do not render or copy
- keep successful copy actions quiet while preserving usage and failure feedback
2026-04-25 15:07:29 -05:00
Brooklyn Nicholson
a68793b6c4 refactor(tui): share remote shell detection
Reuse the platform helper for SSH-aware copy hints so hotkey display and input handling cannot drift.
2026-04-25 14:55:28 -05:00
Brooklyn Nicholson
bcc5362432 fix(tui): honor client copy shortcut over ssh
- accept forwarded Cmd+C for selection copy in SSH sessions even when Hermes runs on Linux
- keep local Linux Alt+C from acting as copy and update TUI hotkey hints for remote shells
2026-04-25 14:44:39 -05:00
brooklyn!
283c8fd6e2 Merge pull request #15755 from NousResearch/bb/tui-model-flag
fix(tui): honor launch model overrides
2026-04-25 14:30:26 -05:00
Brooklyn Nicholson
919274b60e fix(tui): align overlay q shortcut casing
Keep shared overlay close behavior consistent with pager and agents overlays by binding lowercase q only.
2026-04-25 14:26:35 -05:00
Brooklyn Nicholson
6e83d90eb4 refactor(tui): tighten overlay helpers
- rename overlay help text component to match its role
- share picker window math across model, session, and skills overlays
2026-04-25 14:23:45 -05:00
Brooklyn Nicholson
c6fdf48b79 fix(tui): sync inference model after switches
- keep HERMES_INFERENCE_MODEL aligned with HERMES_MODEL after in-TUI model switches
- clarify static provider detection remapping docs
2026-04-25 14:17:57 -05:00
Brooklyn Nicholson
a046483e86 fix(tui): share overlay close controls
- add reusable overlay key and help-text helpers for picker-style overlays
- make model, session, skills, and pager hints consistently support Esc/q close behavior
2026-04-25 14:17:04 -05:00
Brooklyn Nicholson
fdcbd2257b fix(tui): resolve startup model aliases statically
- expand short model aliases like sonnet/opus via static catalogs during startup runtime resolution
- keep startup alias resolution network-free and add regression tests in models and tui gateway suites
2026-04-25 14:13:02 -05:00
Brooklyn Nicholson
48bdd2445e fix(tui): apply ui-tui fix pass and restore type-check
- run the requested ui-tui lint+format pass and include resulting formatting updates
- guard text-measure cache eviction key in hermes-ink so ui-tui type-check stays green
2026-04-25 14:08:54 -05:00
Brooklyn Nicholson
5e52011de3 fix(tui): bind provider as model alias 2026-04-25 13:58:59 -05:00
Brooklyn Nicholson
e48a497d16 fix(tui): share static model detection 2026-04-25 13:56:16 -05:00
Brooklyn Nicholson
2dfcc8087a fix(tui): avoid network lookup during startup 2026-04-25 13:47:18 -05:00
Brooklyn Nicholson
4db58d45d4 fix(tui): address startup provider review 2026-04-25 13:29:15 -05:00
Brooklyn Nicholson
57b43fdd4b fix(tui): preserve provider precedence on startup 2026-04-25 13:25:43 -05:00
Brooklyn Nicholson
e9c47c7042 fix(tui): honor launch model overrides 2026-04-25 13:21:59 -05:00
brooklyn!
ee0728c6c4 Merge pull request #15351 from helix4u/fix/tui-rebuild-missing-ink-bundle
fix(tui): rebuild when ink bundle is missing
2026-04-25 13:14:23 -05:00
helix4u
0738b80833 fix(tui): rebuild when ink bundle is missing 2026-04-24 15:51:38 -06:00
73 changed files with 15129 additions and 788 deletions

58
apps/README.md Normal file
View 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
View 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
View 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
View 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"
}
}

View 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}`);
}

View 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);
});

View 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
View File

@@ -0,0 +1 @@
/target/

5579
apps/gui/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View 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"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View 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"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default Hermes GUI permissions","local":true,"windows":["main"],"permissions":["core:default","notification:default","opener:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View File

@@ -0,0 +1 @@

View 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");
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermes_gui_lib::run();
}

View 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
View 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 {};

View 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"

View 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"

View 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.

View File

@@ -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),

View File

@@ -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.54GB 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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
}

View 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)
})
})

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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')

View File

@@ -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'

View File

@@ -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`)
}
},

View File

@@ -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')) {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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'

View File

@@ -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()
}

View File

@@ -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')

View File

@@ -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
}
>
{' │ '}

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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>
)
}

View 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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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')

View File

@@ -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)'],

View File

@@ -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'

View File

@@ -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>
);
}

View 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;
}

View 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>
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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
View 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>
);
}