Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 7c8d8f80ac Point desktop downloads to website
Route Desktop download links to the hosted Desktop page instead of GitHub Releases, keeping source-build docs separate from user-facing downloads.
2026-06-02 10:45:08 -05:00
Brooklyn Nicholson 957056181a Clarify desktop local build path
Make the desktop command and docs explicit that hermes desktop builds an unpacked Electron app from the checkout, while release installers remain the consumer download path.
2026-06-02 10:38:36 -05:00
203 changed files with 40210 additions and 15520 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file ui-tui/package-lock.json ui-tui/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
use flake
+8 -8
View File
@@ -4,10 +4,10 @@ on:
push:
branches: [main]
paths:
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package-lock.json'
- 'ui-tui/package.json'
- 'apps/desktop/package.json'
- 'apps/dashboard/package-lock.json'
- 'apps/dashboard/package.json'
workflow_dispatch:
inputs:
pr_number:
@@ -27,9 +27,9 @@ concurrency:
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
# Fires when a push to main touches package.json or package-lock.json
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
# update commit directly to main so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
@@ -109,8 +109,8 @@ jobs:
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
'ui-tui/package-lock.json' 'ui-tui/package.json' \
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
+20 -8
View File
@@ -37,16 +37,23 @@ jobs:
- name: Check flake
id: flake
if: runner.os == 'Linux'
continue-on-error: true
run: nix flake check --print-build-logs
# When the flake check fails, run a targeted diagnostic to see if
- name: Build package
id: build
if: runner.os == 'Linux'
continue-on-error: true
run: nix build --print-build-logs
# When the real Nix build fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
@@ -81,25 +88,30 @@ jobs:
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the flake check passed outright (no
# Clear the sticky comment when either the build passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (check failed for a non-hash reason).
# (build failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
runner.os == 'Linux' &&
(steps.hash_check.outputs.stale == 'false' ||
steps.flake.outcome == 'success')
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if flake check failed
if: steps.flake.outcome == 'failure'
- name: Final fail if build or flake failed
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix flake check failed. See logs above."
echo "::error::Nix build/flake check failed. See logs above."
fi
exit 1
- name: Evaluate flake (macOS)
if: runner.os == 'macOS'
run: nix flake show --json > /dev/null
+3 -1
View File
@@ -28,6 +28,7 @@ on:
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'ui-tui/package-lock.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
@@ -38,6 +39,7 @@ on:
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package-lock.json'
- 'website/package-lock.json'
schedule:
# Weekly scan against main — catches CVEs published after merge for
@@ -60,6 +62,6 @@ jobs:
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=ui-tui/package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false
+2 -2
View File
@@ -49,8 +49,8 @@ hermes-agent/
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
│ # strike-freedom-cockpit, ...
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
+4 -19
View File
@@ -113,8 +113,8 @@ WORKDIR /opt/hermes
# ui-tui/package.json. Copying the tree up front lets npm resolve the
# workspace to real content instead of stopping at a bare package.json.
COPY package.json package-lock.json ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY web/package.json web/package-lock.json web/
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as
@@ -131,6 +131,8 @@ ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
(cd web && npm install --prefer-offline --no-audit) && \
(cd ui-tui && npm install --prefer-offline --no-audit) && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
@@ -243,23 +245,6 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
# nix/packaged-release path the launcher was designed for.
#
# Why this is required (not just an optimization): the root package-lock.json
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
# never `npm install`ed here). So the actualized node_modules permanently
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
# every launch, and the runtime `npm install` it triggers (a) can never
# converge against the partial monorepo and (b) races itself across concurrent
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
# `docker exec` privilege-drop shim. When operators run
-132
View File
@@ -1621,47 +1621,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
)
def _refresh_nous_recommended_model(
*, vision: bool, stale_model: Optional[str]
) -> Optional[str]:
"""Re-fetch the Nous Portal's recommended model after a stale-model 404.
Long-lived processes (gateway, watchers) cache the Portal's
``recommended-models`` payload for 10 minutes and, in practice, can pin a
model for the whole process lifetime. When that model is later dropped from
the Nous → OpenRouter catalog, every auxiliary call 404s with
"model does not exist". This forces a fresh Portal fetch and returns a
model name to retry with:
* the Portal's current recommendation for the task, if it differs from
the model that just failed; otherwise
* ``_NOUS_MODEL`` (google/gemini-3-flash-preview), the known-good default,
if it too differs from the failed model.
Returns ``None`` when no usable alternative is available (e.g. the Portal
still recommends the exact model that just 404'd and the default also
matches it) — callers should then let the original error propagate.
"""
stale = (stale_model or "").strip().lower()
fresh: Optional[str] = None
try:
from hermes_cli.models import get_nous_recommended_aux_model
fresh = get_nous_recommended_aux_model(vision=vision, force_refresh=True)
except Exception as exc:
logger.debug(
"Nous recommended-model refresh failed (%s); using default %s",
exc, _NOUS_MODEL,
)
if fresh and fresh.strip().lower() != stale:
return fresh
# Portal recommendation unchanged or unavailable — fall back to the
# hardcoded known-good default, but only if it's actually different.
if _NOUS_MODEL.strip().lower() != stale:
return _NOUS_MODEL
return None
def _read_main_model() -> str:
"""Read the user's configured main model from config.yaml.
@@ -2492,46 +2451,6 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
return _is_unsupported_parameter_error(exc, "temperature")
def _is_model_not_found_error(exc: Exception) -> bool:
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
This fires when a resolved model name is no longer served by the endpoint
— most commonly when a long-lived process pinned a Portal-recommended model
that has since been dropped from the Nous → OpenRouter catalog. The Nous
proxy returns 404 with a body like::
Model 'gpt-5.4-mini' not found. The requested model does not exist
in our configuration or OpenRouter catalog.
Distinct from :func:`_is_payment_error` (which also matches some 404s for
free-tier/credit language) — this one keys on "does not exist / not found /
not a valid model" phrasing, and explicitly excludes the billing keywords
that the payment path already owns so the two predicates don't overlap.
"""
status = getattr(exc, "status_code", None)
err_lower = str(exc).lower()
# Billing/quota 404s belong to _is_payment_error — don't claim them here.
if any(kw in err_lower for kw in (
"credits", "insufficient funds", "billing", "out of funds",
"balance_depleted", "no usable credits", "free tier", "free-tier",
"not available on the free tier",
)):
return False
if status not in {404, 400, None}:
return False
return any(kw in err_lower for kw in (
"model does not exist",
"does not exist in our configuration",
"openrouter catalog",
"is not a valid model",
"no such model",
"model not found",
"the model `", # OpenAI-style: "The model `X` does not exist"
"model_not_found",
"unknown model",
))
def _evict_cached_clients(provider: str) -> None:
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
normalized = _normalize_aux_provider(provider)
@@ -5108,32 +5027,6 @@ def call_llm(
raise
first_err = retry_err
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
# A long-lived process can pin a Portal-recommended model that has
# since been dropped from the Nous → OpenRouter catalog, so every
# auxiliary call 404s with "model does not exist". Force a fresh
# Portal fetch and retry once with the current recommendation (or the
# known-good default). Only applies to Nous-routed calls.
_heal_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
)
if _is_model_not_found_error(first_err) and _heal_is_nous:
healed_model = _refresh_nous_recommended_model(
vision=(task == "vision"), stale_model=kwargs.get("model"))
if healed_model and healed_model != kwargs.get("model"):
logger.warning(
"Auxiliary %s: model %r no longer in Nous catalog; "
"retrying with refreshed recommendation %r",
task or "call", kwargs.get("model"), healed_model,
)
kwargs["model"] = healed_model
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as retry_err:
first_err = retry_err
# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
@@ -5571,31 +5464,6 @@ async def async_call_llm(
raise
first_err = retry_err
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
# See the sync call_llm() path for the rationale: a long-lived process
# can pin a Portal-recommended model that has since been dropped from
# the Nous → OpenRouter catalog, 404'ing every auxiliary call. Force a
# fresh Portal fetch and retry once with the current recommendation.
_heal_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
)
if _is_model_not_found_error(first_err) and _heal_is_nous:
healed_model = _refresh_nous_recommended_model(
vision=(task == "vision"), stale_model=kwargs.get("model"))
if healed_model and healed_model != kwargs.get("model"):
logger.warning(
"Auxiliary %s (async): model %r no longer in Nous catalog; "
"retrying with refreshed recommendation %r",
task or "call", kwargs.get("model"), healed_model,
)
kwargs["model"] = healed_model
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as retry_err:
first_err = retry_err
# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
+1 -2
View File
@@ -1891,7 +1891,6 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
# via `hermes auth openai-codex`.
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("device_code")
custom_label = str(state.get("label") or "").strip()
changed |= _upsert_entry(
entries,
provider,
@@ -1903,7 +1902,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
"refresh_token": tokens.get("refresh_token"),
"base_url": "https://chatgpt.com/backend-api/codex",
"last_refresh": state.get("last_refresh"),
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
},
)
+5 -34
View File
@@ -6,42 +6,16 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and
relies on the launch dir. Reading it in one place keeps the system prompt, the
tool surfaces, and context-file discovery agreeing on where the agent lives.
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
The #29531 per-session extension point is this function: a future PR adds a
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
`set_session_vars` seam — by design, not a reopening hazard.
"""
import os
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Any
_UNSET: Any = object()
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
def set_session_cwd(cwd: str | None) -> Token:
"""Pin the logical cwd for the current context."""
return _SESSION_CWD.set((cwd or "").strip())
def clear_session_cwd() -> None:
_SESSION_CWD.set("")
def _session_cwd_override() -> str:
value = _SESSION_CWD.get()
if value is _UNSET:
return ""
return str(value).strip()
def resolve_agent_cwd() -> Path:
override = _session_cwd_override()
if override:
p = Path(override).expanduser()
if p.is_dir():
return p
raw = os.environ.get("TERMINAL_CWD", "").strip()
if raw:
p = Path(raw).expanduser()
@@ -53,10 +27,7 @@ def resolve_agent_cwd() -> Path:
def resolve_context_cwd() -> Path | None:
# None means "no configured cwd": build_context_files_prompt then falls back
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
# or, per session, the _SESSION_CWD contextvar above.
override = _session_cwd_override()
if override:
return Path(override).expanduser()
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
raw = os.environ.get("TERMINAL_CWD", "").strip()
return Path(raw).expanduser() if raw else None
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes</title>
<title>Hermes Setup</title>
</head>
<body class="h-full antialiased">
<div id="root" class="h-full"></div>
@@ -208,7 +208,7 @@ pub async fn launch_hermes_desktop(
/// Walks the well-known electron-builder unpacked-app paths under
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
/// <os>-unpacked/<exe>).
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
let release_dir = install_root.join("apps").join("desktop").join("release");
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[
@@ -232,35 +232,6 @@ pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Opti
None
}
/// True when a prior install completed (bootstrap-complete marker present) AND a
/// launchable desktop app exists on disk. Used by the installer's launcher fast
/// path so a bare re-open just opens Hermes instead of re-running setup.
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
install_root.join(".hermes-bootstrap-complete").exists()
&& resolve_hermes_desktop_exe(install_root).is_some()
}
/// Spawn the already-built desktop app, detached. Returns Err if no built app
/// exists or the spawn fails, so the caller can fall back to showing the
/// installer UI.
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
})?;
let mut cmd = std::process::Command::new(&exe);
cmd.current_dir(exe.parent().unwrap_or(install_root));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
// installer exits, mirroring launch_hermes_desktop. Kept correct here
// even though the only caller is macOS-gated today, so future reuse on
// Windows doesn't reintroduce the relaunch race.
cmd.creation_flags(0x0000_0008);
}
cmd.spawn().map(|_child| ())
}
// ---------------------------------------------------------------------------
// Bootstrap implementation
// ---------------------------------------------------------------------------
+2 -100
View File
@@ -50,20 +50,6 @@ impl AppMode {
}
}
/// Returns true when the args request a forced installer UI (repair/reinstall)
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
/// these flags never flip Install<->Update.
pub fn force_setup_from_args<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
}
/// Process-wide install state, shared across Tauri commands.
///
/// The bootstrap is a one-shot, single-tenant process — we only need one
@@ -99,11 +85,7 @@ pub fn run() {
let _guard = paths::init_logging();
let mode = AppMode::from_args(std::env::args().skip(1));
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
// Hermes is already installed, so users can re-run setup to repair a broken
// install instead of the launcher fast path silently relaunching the app.
let force_setup = force_setup_from_args(std::env::args().skip(1));
tracing::info!(?mode, force_setup, "Hermes installer starting");
tracing::info!(?mode, "Hermes Setup starting");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -111,60 +93,6 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.manage(Arc::new(AppState::new(mode)))
.setup(move |app| {
use tauri::Manager;
// Launcher fast path (macOS only): a bare ("Install") launch when
// Hermes is already installed should NOT show the installer or
// rebuild — it should just open the app, so the /Applications
// "Hermes" doubles as a normal launcher (first run installs, every
// later run launches instantly). The window is kept hidden until
// here via `"visible": false` so this path never flashes a window.
//
// Gated to macOS deliberately: on Windows/Linux the installer keeps
// its existing behavior (Windows users relaunch via the Start
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
// reliable detached relaunch there needs the DETACHED_PROCESS +
// startup-grace handling used by launch_hermes_desktop — out of
// scope here). So this is a pure no-op on non-macOS.
//
// `--reinstall`/`--repair` opts out so a broken install can be
// repaired by re-running setup instead of launching the bad app.
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
let install_root = paths::hermes_home().join("hermes-agent");
if bootstrap::hermes_is_installed(&install_root) {
match bootstrap::spawn_installed_desktop(&install_root) {
Ok(()) => {
// Brief grace so the spawned app is registered
// before we exit (mirrors launch_hermes_desktop).
std::thread::sleep(std::time::Duration::from_millis(200));
tracing::info!(
"hermes already installed — relaunched desktop; exiting installer"
);
app.handle().exit(0);
return Ok(());
}
Err(err) => {
tracing::warn!(
?err,
"relaunch of installed desktop failed; showing installer UI"
);
}
}
}
}
// First run / repair install, or Update mode: reveal the UI.
match app.get_webview_window("main") {
Some(win) => {
if let Err(err) = win.show() {
tracing::error!(?err, "failed to show main installer window");
}
}
None => {
tracing::error!("main installer window not found; installer UI will not appear");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Mode (install vs update)
get_mode,
@@ -187,7 +115,7 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::{force_setup_from_args, AppMode};
use super::AppMode;
#[test]
fn bare_args_are_install() {
@@ -203,30 +131,4 @@ mod tests {
AppMode::Update
);
}
#[test]
fn reinstall_and_repair_flags_force_setup() {
assert!(force_setup_from_args(["--reinstall"]));
assert!(force_setup_from_args(["--repair"]));
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
}
#[test]
fn bare_or_unrelated_args_do_not_force_setup() {
assert!(!force_setup_from_args(Vec::<String>::new()));
assert!(!force_setup_from_args(["--foo", "bar"]));
// --update must not be mistaken for a force-setup flag.
assert!(!force_setup_from_args(["--update"]));
}
#[test]
fn force_setup_flags_do_not_affect_mode_selection() {
// The repair flags must never flip Install<->Update.
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
assert_eq!(
AppMode::from_args(["--update", "--reinstall"]),
AppMode::Update
);
}
}
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes",
"productName": "Hermes Setup",
"version": "0.0.1",
"identifier": "com.nousresearch.hermes.setup",
"build": {
@@ -13,7 +13,7 @@
"windows": [
{
"label": "main",
"title": "Hermes",
"title": "Hermes Setup",
"width": 880,
"height": 620,
"minWidth": 720,
@@ -22,8 +22,7 @@
"fullscreen": false,
"decorations": true,
"transparent": false,
"center": true,
"visible": false
"center": true
}
],
"security": {
@@ -34,7 +33,7 @@
"bundle": {
"active": true,
"category": "DeveloperTool",
"shortDescription": "Hermes",
"shortDescription": "Hermes Setup",
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
"publisher": "Nous Research",
"copyright": "Copyright © 2026 Nous Research",
+9 -20
View File
@@ -1,7 +1,7 @@
# Hermes Desktop ☤
<p align="center">
<a href="https://github.com/NousResearch/hermes-agent/releases"><img src="https://img.shields.io/badge/Download-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-FFD700?style=for-the-badge" alt="Download"></a>
<a href="https://hermes-agent.nousresearch.com/desktop"><img src="https://img.shields.io/badge/Download-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-FFD700?style=for-the-badge" alt="Download"></a>
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
@@ -38,9 +38,11 @@ hermes desktop
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
### Prebuilt installers
This is the source-checkout path: `hermes desktop` builds a local unpacked Electron app with `npm run pack`. It does not download Hermes Desktop.
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
### Desktop Download
For the user-facing desktop download, go to [hermes-agent.nousresearch.com/desktop](https://hermes-agent.nousresearch.com/desktop).
---
@@ -90,7 +92,7 @@ npm run dist:linux # AppImage + deb + rpm
npm run pack # unpacked app under release/ (no installer)
```
Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
These commands build local desktop artifacts from this checkout. macOS/Windows signing and notarization happen when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
### How it works
@@ -111,28 +113,15 @@ npm run test:desktop:all
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
**macOS / Linux:**
```bash
# Force a clean first-launch setup
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
# Rebuild a broken Python venv
rm -rf "$HOME/.hermes/hermes-agent/venv"
# Reset a stuck macOS microphone prompt (macOS only)
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
# Reset a stuck macOS microphone prompt
tccutil reset Microphone com.nousresearch.hermes
```
**Windows (PowerShell):**
```powershell
# Force a clean first-launch setup
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
# Rebuild a broken Python venv
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
```
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
---
## Community
@@ -32,58 +32,8 @@ function bundledRuntimeImportCheck(platform = process.platform) {
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
}
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
/**
* Decide whether the app is being shown over a remote/forwarded display, where
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
* present accelerated layers cleanly over the wire). Native local Windows/macOS
* sessions composite locally and never hit this, so we only fall back to
* software rendering when a remote display is detected.
*
* Returns a short reason string when GPU acceleration should be disabled, or
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
// local X server is ":0"/":1" with no host part before the colon.
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
}
if (platform === 'win32') {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
}
return null
}
module.exports = {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
@@ -3,12 +3,7 @@ const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@@ -33,53 +28,6 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () =>
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
})
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
})
test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags RDP sessions', () => {
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
})
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
// Force-on even on a local display.
assert.match(
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
/override/
)
// Force-off even over SSH (escape hatch when a remote display has working accel).
assert.equal(
detectRemoteDisplay({
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
platform: 'linux'
}),
null
)
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
@@ -8,7 +8,5 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+11 -463
View File
@@ -23,7 +23,7 @@ const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const {
@@ -73,26 +73,6 @@ const IS_MAC = process.platform === 'darwin'
const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
// GPU and never see it. Fall back to software rendering when a remote display
// is detected; it's rock-steady over the wire and the CPU cost is negligible
// next to the connection's latency. Must run before app `ready` — these
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
// (1/true → always disable, 0/false → keep GPU on).
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
if (REMOTE_DISPLAY_REASON) {
app.disableHardwareAcceleration()
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
// with only --disable-gpu: force compositing onto the CPU too.
app.commandLine.appendSwitch('disable-gpu-compositing')
console.log(
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
)
}
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Build-time install stamp -- the git ref this .exe was built against.
@@ -449,13 +429,6 @@ function registerMediaProtocol() {
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
// Auto-reload budget for renderer crashes. A deterministic startup crash would
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
// logs. Allow a few reloads per rolling window, then stop and leave the dead
// window so the user can read the error / quit.
const RENDERER_RELOAD_WINDOW_MS = 60_000
const RENDERER_RELOAD_MAX = 3
let rendererReloadTimes = []
// Latched bootstrap failure: when the first-launch install fails, we hold
// onto the error so subsequent startHermes() calls (e.g. the renderer's
// ensureGatewayOpen retrying after the WS won't open) return the same error
@@ -465,10 +438,6 @@ let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
// existing-install adopt branch (3b) so repair re-drives the installer instead
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@@ -559,39 +528,6 @@ function openExternalUrl(rawUrl) {
return false
}
// `file://` URLs come from the artifacts panel (the renderer can't open
// them itself because Chromium blocks file:// navigation from the app
// origin). Hand them to `shell.openPath`, which dispatches to the OS
// file association. If the OS can't open it (`error` is a non-empty
// string), fall back to revealing the file in the system file manager.
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = fileURLToPath(parsed.toString())
} catch {
return false
}
void shell
.openPath(localPath)
.then(error => {
if (!error) {
return
}
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
try {
shell.showItemInFolder(localPath)
} catch (revealError) {
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
}
})
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
return true
}
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
return false
}
@@ -1530,12 +1466,8 @@ function readJson(filePath) {
// Marker schema (version 1):
// {
// schemaVersion: 1,
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
// // may be null for adopted installs
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
// pinnedBranch: "<branch name>" | null,
// adopted: <bool>, // true when we adopted a pre-existing
// // install rather than bootstrapping it;
// // treated as authoritative even sans commit
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
@@ -1543,25 +1475,11 @@ function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
// ever having written the bootstrap marker -- so we must be able to recognise
// "already installed" off the filesystem alone, not just the marker.
function isActiveRuntimeUsable() {
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function isBootstrapComplete() {
const marker = readBootstrapMarker()
if (!marker || typeof marker !== 'object') return false
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
// Adopted markers (an existing install we detected and took ownership of,
// possibly without a resolvable commit) are still authoritative -- they
// attest a runnable install we deliberately decided to forward to.
if (marker.adopted !== true) return false
}
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
// We DELIBERATELY do NOT verify that the checkout is currently at the
// pinned commit -- users update via the in-app update path or `hermes
// update`, which moves HEAD legitimately. The marker just attests "we
@@ -1569,22 +1487,7 @@ function isBootstrapComplete() {
// a runnable venv: an interrupted or split-home install can leave the marker
// + checkout without a venv, and trusting that spawns a dead backend
// ("gateway offline") instead of re-running bootstrap to repair it.
return isActiveRuntimeUsable()
}
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
// provenance a freshly-bootstrapped one would. null when git is unavailable or
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
function readActiveHeadCommit() {
try {
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
} catch {
return null
}
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function writeBootstrapMarker(payload) {
@@ -1593,7 +1496,6 @@ function writeBootstrapMarker(payload) {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
pinnedBranch: payload.pinnedBranch || null,
adopted: Boolean(payload.adopted),
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
@@ -1617,18 +1519,10 @@ function resolveRendererIndex() {
}
function resolveHermesCwd() {
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
// on macOS). Sessions spawned there leave files inside the app bundle
// and bewilder users when "where did my files go?" is the install dir.
// The user-configurable default project directory wins over everything,
// followed by env hints (only honored when packaged if they point at a
// real directory), then the home dir.
const candidates = [
readDefaultProjectDir(),
process.env.HERMES_DESKTOP_CWD,
process.env.INIT_CWD,
IS_PACKAGED ? null : process.cwd(),
process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
]
@@ -1642,48 +1536,6 @@ function resolveHermesCwd() {
return app.getPath('home')
}
// Persisted "Default project directory" — surfaced as a setting in the
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
// userData so it survives self-updates without bleeding into the new
// install. `null` means "no preference, fall back to the usual chain".
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
function defaultProjectDirConfigPath() {
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
}
function readDefaultProjectDir() {
try {
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
const resolved = path.resolve(parsed.dir)
if (directoryExists(resolved)) {
return resolved
}
}
} catch {
// Missing / unreadable / malformed → fall through to the rest of the
// candidate chain.
}
return null
}
function writeDefaultProjectDir(dir) {
const target = defaultProjectDirConfigPath()
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
try {
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, payload, 'utf8')
} catch (error) {
rememberLog(`[settings] write default project dir failed: ${error.message}`)
}
}
function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
@@ -1751,24 +1603,6 @@ function resolveHermesBackend(dashboardArgs) {
return createActiveBackend(dashboardArgs)
}
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
// written only by OUR bootstrap, so a runtime from `install.sh
// --include-desktop` (or a DMG launch over a prior CLI install) is
// runnable yet markerless -- without this we'd fall to step 6 and re-run
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
// canonical location (unlike a random `hermes` on PATH), so adopt it:
// stamp the marker once and forward straight to the app. Repair skips
// this so a broken-but-present venv still gets rebuilt.
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
try {
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
} catch (err) {
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
}
return createActiveBackend(dashboardArgs)
}
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
// a previous tool-only setup, or pip-installed system-wide. Use it but
// do NOT write a bootstrap marker; the user did this themselves and we
@@ -1959,9 +1793,6 @@ async function ensureRuntime(backend) {
}
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
// below SHOULD land on the fresh marker fast-path rather than skip it.
forceBootstrapRepair = false
// Re-resolve now that the install exists. The new resolution lands in
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
return ensureRuntime(resolveHermesBackend(backend.args))
@@ -2751,31 +2582,9 @@ function buildApplicationMenu() {
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
},
{
label: 'Zoom In',
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
{
label: 'Zoom Out',
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
@@ -2834,32 +2643,6 @@ function installPreviewShortcut(window) {
})
}
function installZoomShortcuts(window) {
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
// The menu items handle this on macOS (where the menu is always present),
// but on Linux/Windows the menu is null and Chromium's default handler
// would use the full 0.2 step, so we intercept here for consistency.
const ZOOM_STEP = 0.1
window.webContents.on('before-input-event', (event, input) => {
const mod = IS_MAC ? input.meta : input.control
if (!mod || input.alt || input.shift) return
const key = input.key
if (key === '0') {
event.preventDefault()
window.webContents.setZoomLevel(0)
} else if (key === '=' || key === '+') {
event.preventDefault()
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
window.webContents.setZoomLevel(next)
} else if (key === '-') {
event.preventDefault()
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
window.webContents.setZoomLevel(next)
}
})
}
function installContextMenu(window) {
window.webContents.on('context-menu', (_event, params) => {
const template = []
@@ -2912,28 +2695,6 @@ function installContextMenu(window) {
)
}
// Spell-check suggestions for the misspelled word under the caret.
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
// top 5 plus a "Add to dictionary" affordance.
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
if (isEditable && params.misspelledWord && suggestions.length > 0) {
if (template.length) template.push({ type: 'separator' })
for (const suggestion of suggestions.slice(0, 5)) {
template.push({
label: suggestion,
click: () => window.webContents.replaceMisspelling(suggestion)
})
}
template.push({ type: 'separator' })
template.push({
label: 'Add to dictionary',
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
})
}
if (hasSelection || isEditable) {
if (template.length) template.push({ type: 'separator' })
if (isEditable) {
@@ -3446,7 +3207,6 @@ function createWindow() {
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installZoomShortcuts(mainWindow)
installContextMenu(mainWindow)
mainWindow.webContents.setWindowOpenHandler(details => {
openExternalUrl(details.url)
@@ -3462,51 +3222,6 @@ function createWindow() {
openExternalUrl(url)
})
mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
if (details?.reason === 'crashed' || details?.reason === 'oom') {
const now = Date.now()
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
rememberLog(
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
)
return
}
rendererReloadTimes.push(now)
setImmediate(() => {
if (!mainWindow || mainWindow.isDestroyed()) return
try {
mainWindow.webContents.reload()
} catch (err) {
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
}
})
}
})
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
// Electron always passes the event first. The canonical (Electron 36+) shape
// is (event, messageDetails); the deprecated positional shape is
// (event, level, message, line, sourceId). Handle both. `level` is numeric
// (0..3), where 3 === error.
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
const level = details ? details.level : detailsOrLevel
if (level !== 3) return
const text = details ? details.message : message
const src = details ? details.sourceUrl : sourceId
const lineNo = details ? details.lineNumber : line
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
})
if (DEV_SERVER) {
mainWindow.loadURL(DEV_SERVER)
} else {
@@ -3527,7 +3242,6 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
bootstrapFailure = null
forceBootstrapRepair = false
connectionPromise = null
bootstrapState = {
active: false,
@@ -3555,9 +3269,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
// Force the next resolution past both the marker fast-path and the adopt
// branch so the installer actually re-runs (the whole point of repair).
forceBootstrapRepair = true
resetHermesConnection()
return { ok: true }
})
@@ -3661,21 +3372,13 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
})
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
const properties = options?.directories ? ['openDirectory'] : ['openFile']
const properties = ['openFile']
if (options?.directories) properties.push('openDirectory')
if (options?.multiple !== false) properties.push('multiSelections')
let resolvedDefaultPath
if (options?.defaultPath) {
try {
resolvedDefaultPath = path.resolve(String(options.defaultPath))
} catch {
resolvedDefaultPath = undefined
}
}
const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || 'Add context',
defaultPath: resolvedDefaultPath,
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
properties,
filters: Array.isArray(options?.filters) ? options.filters : undefined
})
@@ -3734,45 +3437,6 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
}
})
// User-configurable default project directory. The renderer reads this on
// settings mount and seeds the value into the picker; writing back persists
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
// session spawn (no app restart needed).
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
}))
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
if (next) {
try {
fs.mkdirSync(next, { recursive: true })
} catch (error) {
throw new Error(`Could not create directory: ${error.message}`)
}
}
writeDefaultProjectDir(next)
return { dir: next }
})
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
const result = await dialog.showOpenDialog({
title: 'Choose default project directory',
properties: ['openDirectory', 'createDirectory'],
defaultPath: readDefaultProjectDir() || app.getPath('home')
})
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true, dir: null }
}
return { canceled: false, dir: result.filePaths[0] }
})
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
ipcMain.handle('hermes:logs:reveal', async () => {
@@ -4073,99 +3737,7 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ---------------------------------------------------------------------------
// macOS first-launch placement: move into /Applications and pin to the Dock
// ---------------------------------------------------------------------------
//
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
// no Dock tile, and "which icon do I click?" confusion. On first packaged
// launch we relocate into /Applications (Electron relaunches from there) and,
// once we're that canonical copy, pin to the Dock. Both macOS-only,
// packaged-only, best-effort, run at most once.
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
// is underway (caller must stop init). No-op in dev, off macOS, or already in
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
// it. `exists` -> stale copy; replace it so there's exactly one current app.
function maybeRelocateToApplications() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
try {
if (app.isInApplicationsFolder()) return false
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
return moved
} catch (err) {
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
return false
}
}
const DOCK_PINNED_MARKER = 'dock-pinned.json'
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
// Guarded by a userData marker + membership check so we never duplicate the tile.
function maybePinToDock() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
if (fileExists(marker)) return
let bundle
try {
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
bundle = runningAppBundle()
} catch {
return
}
if (!bundle) return
// The Dock stores tiles as file-reference URLs (type 15), e.g.
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
const done = (note = {}) => {
try {
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
} catch {
// best-effort; we re-check next launch (membership guard dedupes)
}
}
try {
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (apps.includes(url)) return done({ alreadyPresent: true })
} catch {
// persistent-apps may not exist yet; -array-add creates it
}
const tile =
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
'</dict></dict></dict>'
try {
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
// Flush the write through cfprefsd before restarting the Dock, otherwise the
// Dock reloads stale prefs and our tile is lost in the race.
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
done()
rememberLog(`[install] pinned to Dock: ${url}`)
} catch (err) {
rememberLog(`[install] Dock pin skipped: ${err.message}`)
}
}
app.whenReady().then(() => {
// macOS: relocate into /Applications before anything else so setup + state
// land in the final location; on success this relaunches, so bail here.
if (maybeRelocateToApplications()) return
maybePinToDock()
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {
@@ -4174,7 +3746,6 @@ app.whenReady().then(() => {
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
createWindow()
app.on('activate', () => {
@@ -4182,29 +3753,6 @@ app.whenReady().then(() => {
})
})
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
// On macOS Electron uses the native spellchecker which ignores this list, but
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
// won't enable any without an explicit language.
function configureSpellChecker() {
try {
const defaultSession = session.defaultSession
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
return
}
const available = defaultSession.availableSpellCheckerLanguages || []
const locale = (app.getLocale && app.getLocale()) || 'en-US'
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
defaultSession.setSpellCheckerLanguages([chosen])
} catch (error) {
rememberLog(`Spellchecker setup failed: ${error.message}`)
}
}
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
-5
View File
@@ -31,11 +31,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
+2 -5
View File
@@ -3,11 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="shortcut icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Hermes</title>
</head>
<body>
+18363
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -50,7 +50,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "^13.13.0",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
-229
View File
@@ -1,229 +0,0 @@
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
//
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
// older content randomly yanks the view to a different position, so you have to
// fight the scrollbar. Mac users on trackpads don't see it.
//
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
// auto`, and the thread renders items in natural document flow (padding
// spacers, NOT transforms). When an item above the viewport is measured by
// @tanstack/react-virtual (its real height differs a lot from the 220px
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
// the browser's native scroll anchoring. The double-correction lurches the
// view. A mouse wheel's coarse, discrete notches mount/measure several
// under-estimated turns per tick, so the over-correction is large and visible;
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
//
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
// measures how much a tracked on-screen turn jumps, first with
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
//
// Prereq: a running desktop app with remote debugging on 9222, on a thread
// with enough history to scroll (the longer / more code+tool blocks, the
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
const NOTCHES = 14 // wheel-up ticks per sweep
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
if (!tgt) {
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
process.exit(1)
}
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const evalP = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
return r.result.result.value
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
// force inline so we A/B the exact same thread regardless of any CSS fix.
// Starts from ~45% down the thread so there's room to scroll up into
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
async function arm(mode) {
await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!v) throw new Error('thread viewport not found')
// Force the overflow-anchor behavior under test (inline beats CSS).
v.style.overflowAnchor = ${JSON.stringify(mode)}
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
// turns above the fold (where the measurement correction fires).
v.scrollTop = Math.round(v.scrollHeight * 0.45)
// Tag the turn closest to viewport center; we track its on-screen top.
const vr = v.getBoundingClientRect()
const center = vr.top + v.clientHeight / 2
let best = null, bestD = Infinity
for (const el of v.querySelectorAll('[data-index]')) {
const r = el.getBoundingClientRect()
const d = Math.abs((r.top + r.height / 2) - center)
if (d < bestD) { bestD = d; best = el }
}
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
if (best) best.setAttribute('data-se-anchor', '1')
const anchorIndex = best ? best.getAttribute('data-index') : null
const samples = []
const writes = []
const ros = []
const t0 = performance.now()
// Intercept scrollTop writes → these are JS (TanStack) corrections.
// Native browser scroll anchoring does NOT go through this setter, so a
// scrollTop change with no write in the same frame is a native adjust.
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
Object.defineProperty(v, 'scrollTop', {
configurable: true,
get() { return desc.get.call(this) },
set(val) {
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
desc.set.call(this, val)
}
})
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
const ro = new ResizeObserver(entries => {
for (const e of entries) {
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
}
})
ro.observe(v)
if (v.firstElementChild) ro.observe(v.firstElementChild)
let running = true
const tick = () => {
if (!running) return
const a = v.querySelector('[data-se-anchor]')
const ar = a ? a.getBoundingClientRect() : null
samples.push({
t: performance.now() - t0,
st: Math.round(v.scrollTop * 100) / 100,
sh: v.scrollHeight,
ch: v.clientHeight,
atop: ar ? Math.round(ar.top * 100) / 100 : null,
aconn: !!a
})
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
return true
})()`)
}
async function wheelUpSweep() {
const { x, y } = await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
const r = v.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
})()`)
for (let i = 0; i < NOTCHES; i++) {
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
await sleep(NOTCH_GAP_MS)
}
await sleep(400)
}
async function collect() {
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
return data
}
function analyze(label, data) {
const { samples, writes, ros, anchorIndex, dpr } = data
let reverseJumps = 0
let reverseSum = 0
let lurches = 0
let maxJump = 0
let nativeMoves = 0
let prev = null
for (const s of samples) {
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
const dSt = s.st - prev.st
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
if (dTop < -REVERSE_JUMP_PX) {
reverseJumps++
reverseSum += -dTop
}
if (Math.abs(dTop) > LURCH_PX) lurches++
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
}
prev = s
}
console.log(`\n── ${label} ──`)
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
console.log(` tracked turn index: ${anchorIndex}`)
console.log(` rAF frames: ${samples.length}`)
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
console.log(` ResizeObserver hits: ${ros.length}`)
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
}
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
await arm('auto')
await sleep(150)
await wheelUpSweep()
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
await sleep(300)
await arm('none')
await sleep(150)
await wheelUpSweep()
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
// Clean up our tag.
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
console.log('\n══ verdict ══')
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
} else if (a.reverseJumps + a.lurches === 0) {
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
}
ws.close()
@@ -1,20 +1,14 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
@@ -23,24 +17,6 @@ import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
{
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
description: 'Outline an approach before touching code so the diff stays focused.',
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
description: 'Walk through how the selected code works and link to the key files.',
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
export function ContextMenu({
state,
onInsertText,
@@ -49,114 +25,81 @@ export function ContextMenu({
onPickFiles,
onPickFolders,
onPickImages
}: ContextMenuProps) {
// Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog —
// easier to grow with search / descriptions, and no positioning math.
const [snippetsOpen, setSnippetsOpen] = useState(false)
}: {
state: ChatBarState
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
}) {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets
</ContextMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MessageSquareText />
<span>Prompt snippets</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{[
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
].map(snippet => (
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
{snippet.label}
</ContextMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
</>
)
}
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
<DialogHeader>
<DialogTitle>Prompt snippets</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{snippets.map(snippet => (
<li key={snippet.label}>
<button
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span>
</button>
</li>
))}
</ul>
</DialogContent>
</Dialog>
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -165,7 +108,12 @@ export function ContextMenuItem({
disabled,
icon: Icon,
onSelect
}: ContextMenuItemProps) {
}: {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />
@@ -173,33 +121,3 @@ export function ContextMenuItem({
</DropdownMenuItem>
)
}
interface ContextMenuItemProps {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
state: ChatBarState
}
interface PromptSnippet {
description: string
label: string
text: string
}
interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void
open: boolean
snippets: readonly PromptSnippet[]
}
+16 -77
View File
@@ -31,7 +31,6 @@ import {
enqueueQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $messages } from '@/store/session'
@@ -125,12 +124,6 @@ export function ChatBar({
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
// Set when the user explicitly interrupts the running turn via the Stop
// button (busy + empty composer). It suppresses the next busy→false
// auto-drain so an explicit Stop actually halts instead of immediately
// firing the head of the queue. The queue is preserved; the user resumes
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
const userInterruptedRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -421,14 +414,6 @@ export function ChatBar({
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// Set synchronously in keydown when the open trigger popover consumes a
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
// NOT run refreshTrigger for that keypress: it never edits text, and for
// Escape the keydown has already set trigger=null, so a keyup refresh would
// re-detect the still-present `/` and instantly reopen the menu. A ref is
// used instead of reading `trigger` in keyup because by keyup time React has
// re-rendered and the handler closure sees the post-keydown state.
const triggerKeyConsumedRef = useRef(false)
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
@@ -457,14 +442,7 @@ export function ChatBar({
const detected = detectTrigger(before ?? composerPlainText(editor))
setTrigger(detected)
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
setTriggerActive(0)
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
@@ -580,7 +558,6 @@ export function ChatBar({
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
@@ -588,7 +565,6 @@ export function ChatBar({
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
@@ -596,7 +572,6 @@ export function ChatBar({
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
if (item) {
@@ -608,7 +583,6 @@ export function ChatBar({
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
@@ -629,18 +603,6 @@ export function ChatBar({
}
const handleEditorKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
@@ -882,42 +844,26 @@ export function ChatBar({
[queueEdit, runDrain]
)
// Auto-drain on busy → false (turn settled). An explicit user interrupt
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
// the user asked to halt, so we must not immediately re-send the queue.
// The queued turns stay intact and the user resumes them on demand.
const interruptAndSendNextQueued = useCallback(async () => {
if (queuedPrompts.length === 0) {
return false
}
await Promise.resolve(onCancel())
return drainNextQueued()
}, [drainNextQueued, onCancel, queuedPrompts.length])
// Auto-drain on busy → false (turn settled).
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
// Clear the interrupt latch when a new turn starts (false → true). This
// guards the sub-frame race where a Stop click lands after busy already
// flipped false (button not yet unmounted): the stale latch can no longer
// survive into the next turn and wrongly suppress its natural auto-drain.
if (busy && !wasBusy) {
userInterruptedRef.current = false
if (busy || !wasBusy || queuedPrompts.length === 0) {
return
}
const interrupted = userInterruptedRef.current
// Consume the interrupt latch on any settle so a later natural completion
// is not wrongly suppressed.
if (!busy && wasBusy && interrupted) {
userInterruptedRef.current = false
}
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
userInterrupted: interrupted,
wasBusy
})
) {
void drainNextQueued()
}
void drainNextQueued()
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
@@ -940,13 +886,9 @@ export function ChatBar({
} else if (busy) {
if (hasComposerPayload) {
queueCurrentDraft()
} else if (queuedPrompts.length > 0) {
void interruptAndSendNextQueued()
} else {
// Stop button: an explicit interrupt must actually halt the running
// turn. Mark the interrupt so the busy→false auto-drain effect skips
// re-sending the queue — otherwise a queued follow-up would fire the
// instant we cancel and Stop would appear to "never work". Queued
// turns are preserved; the user sends them on demand.
userInterruptedRef.current = true
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -1082,8 +1024,6 @@ export function ChatBar({
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
autoCorrect="off"
autoCapitalize="off"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
@@ -1105,7 +1045,6 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck="true"
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -1,183 +0,0 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { act, fireEvent, render } from '@testing-library/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
import { detectTrigger, type TriggerState } from './text-utils'
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
// keydown+keyup events on a contentEditable. Exercises the parts a direct
// reducer-call repro misses: the keyup -> refreshTrigger path, the
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
function Harness({
onState
}: {
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const triggerKeyConsumedRef = useRef(false)
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
const { adapter } = useLiveCompletionAdapter({
enabled: true,
debounceMs: 0,
fetcher: async (query: string) => ({
query,
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
}),
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
})
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {
if (trigger) {
setTrigger(null)
setTriggerActive(0)
}
return
}
const detected = detectTrigger(raw)
setTrigger(detected)
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
return
}
setTriggerItems(triggerAdapter.search(trigger.query))
}, [trigger, triggerAdapter])
useEffect(() => {
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
}
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
// ref that the keyup consults.
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
}
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
}
}
}
const handleKeyUp = () => {
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
// here so the test deterministically observes the keyup-driven refresh.
refreshTrigger()
}
return (
<div
contentEditable
data-testid="editor"
onInput={() => refreshTrigger()}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
async function flush() {
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
}
describe('slash menu navigation — real DOM keydown+keyup', () => {
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
vi.useRealTimers()
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
const editor = getByTestId('editor')
// Simulate typing '/'.
await act(async () => {
editor.textContent = '/'
fireEvent.input(editor)
})
await flush()
expect(latest.open).toBe(true)
expect(latest.items.length).toBe(5)
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
const seen: number[] = [latest.active]
for (let i = 0; i < 6; i++) {
await act(async () => {
fireEvent.keyDown(editor, { key: 'ArrowDown' })
fireEvent.keyUp(editor, { key: 'ArrowDown' })
await Promise.resolve()
})
seen.push(latest.active)
}
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
await act(async () => {
fireEvent.keyDown(editor, { key: 'Escape' })
fireEvent.keyUp(editor, { key: 'Escape' })
await Promise.resolve()
})
await flush()
expect(latest.open).toBe(false)
})
})
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest'
import { detectTrigger } from './text-utils'
describe('detectTrigger', () => {
it('detects a bare slash trigger with an empty query', () => {
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
})
it('detects a slash command query', () => {
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
})
it('detects a bare at-mention trigger with an empty query', () => {
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
})
it('detects an at-mention query', () => {
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
})
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
})
@@ -97,17 +97,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) return
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) event.preventDefault()
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
+19 -30
View File
@@ -67,12 +67,6 @@ import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
@@ -444,7 +438,7 @@ export function ChatSidebar({
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
)}
</>
)}
@@ -546,28 +540,23 @@ export function ChatSidebar({
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
// Grouping operates on unpinned recents; if everything is
// pinned the toggle does nothing visible, so hide it to avoid
// a phantom click target.
agentSessions.length > 0 ? (
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
) : null
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
@@ -644,7 +633,7 @@ function SidebarPinnedEmptyState() {
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift-click a chat to pin · drag to reorder</span>
<span>Shift click to pin a chat</span>
</div>
)
}
+10 -17
View File
@@ -428,6 +428,14 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
return (
<PageSearchShell
{...props}
filters={
<div className="flex flex-wrap items-center justify-center gap-2">
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
@@ -449,10 +457,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
@@ -465,19 +469,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
@@ -493,6 +484,8 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
</div>
</div>
)}
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
+7 -42
View File
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
@@ -33,8 +33,6 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
mergeWorkingSessions,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -61,11 +59,10 @@ import { ChatSidebar } from './chat/sidebar'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -80,7 +77,6 @@ import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from './shell/model-menu-panel'
import type { StatusbarItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
@@ -208,12 +204,7 @@ export function DesktopController() {
const result = await listSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
// Don't hard-replace: a session whose first turn is still in flight has
// message_count 0 in the DB, so min_messages=1 omits it. Since every
// message.complete refreshes the list, a plain replace would drop the
// other still-running new chats the moment one of them finishes. Keep
// any working session the server hasn't surfaced yet.
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
setSessions(result.sessions)
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
}
} finally {
@@ -283,22 +274,6 @@ export function DesktopController() {
requestGateway
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=keys`)
}, [navigate])
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
onSelectModel={selectModel}
requestGateway={requestGateway}
/>
) : null,
[gatewayRef, gatewayState, requestGateway, selectModel]
)
useContextSuggestions({
activeSessionId,
activeSessionIdRef,
@@ -397,22 +372,14 @@ export function DesktopController() {
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
return
}
// Two accelerators for "new session":
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
// - Shift+N (single-key, only when no input is focused)
const accelerator = event.metaKey || event.ctrlKey
const singleKey = !accelerator && !editing && event.shiftKey
if (!accelerator && !singleKey) {
return
if (event.shiftKey && event.code === 'KeyN') {
event.preventDefault()
startFreshSessionDraft()
}
event.preventDefault()
startFreshSessionDraft()
}
window.addEventListener('keydown', onKeyDown)
@@ -522,7 +489,6 @@ export function DesktopController() {
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
statusSnapshot,
@@ -557,7 +523,6 @@ export function DesktopController() {
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
+36 -2
View File
@@ -21,8 +21,6 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
@@ -41,6 +39,29 @@ const STATE_LABELS: Record<string, string> = {
startup_failed: 'Startup failed'
}
const PLATFORM_TINTS: Record<string, string> = {
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
}
const PILL_TONE: Record<StatusTone, string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
@@ -421,6 +442,19 @@ function PlatformRow({
)
}
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
return (
<span
className={cn(
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
)}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
}
function PlatformDetail({
edits,
onClear,
@@ -1,97 +0,0 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
SiDiscord,
SiGmail,
SiHomeassistant,
SiMatrix,
SiMattermost,
SiQq,
SiSignal,
SiTelegram,
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
// We render simpleicons.org brand glyphs for platforms whose owners publish a
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
// request, so we fall back to a colored letter monogram for those.
//
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
// glyph in its native color on top of a soft tint. The fallback monogram uses
// the same hex to keep visual consistency.
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
}
interface PlatformAvatarProps {
platformId: string
platformName: string
className?: string
}
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
const spec = PLATFORM_ICONS[platformId]
const baseClass = cn(
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
className
)
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
}
const { Icon, color } = spec
return (
<span
aria-hidden="true"
className={baseClass}
style={{
// 16% tint of the brand color so the glyph reads against any surface
// without the avatar dominating the row.
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
color
}}
>
<Icon className="size-3.5" />
</span>
)
}
@@ -1,31 +0,0 @@
import { useStore } from '@nanostores/react'
import { ModelVisibilityDialog } from '@/components/model-visibility-dialog'
import type { HermesGateway } from '@/hermes'
import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility'
import { $activeSessionId, $gatewayState } from '@/store/session'
interface ModelVisibilityOverlayProps {
gateway?: HermesGateway
onOpenProviders: () => void
}
export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) {
const activeSessionId = useStore($activeSessionId)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelVisibilityOpen)
if (!gatewayOpen) {
return null
}
return (
<ModelVisibilityDialog
gw={gateway}
onOpenChange={setModelVisibilityOpen}
onOpenProviders={onOpenProviders}
open={open}
sessionId={activeSessionId}
/>
)
}
+3 -9
View File
@@ -28,16 +28,10 @@ export function PageSearchShell({
{...props}
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
{/*
This header sits in the titlebar row, so it overlaps the OS window-drag
region painted by the shell. Without `-webkit-app-region: no-drag` on
the search row, mousedown on the input gets intercepted as a window-
drag start and the input never receives focus (visible as "I can't
click the search box" on the messaging/cron/etc pages).
*/}
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them. */}
footprint so the full-width search input never slides under them
(this header sits in the titlebar row at the window top). */}
<div
style={{
paddingRight:
@@ -11,8 +11,6 @@ const ROW_HEIGHT = 22
const INDENT = 10
interface ProjectTreeProps {
collapseNonce: number
cwd: string
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
@@ -23,8 +21,6 @@ interface ProjectTreeProps {
}
export function ProjectTree({
collapseNonce,
cwd,
data,
onActivateFile,
onActivateFolder,
@@ -67,7 +63,7 @@ export function ProjectTree({
onNodeOpenChange(id, node.isOpen)
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
if (node.isOpen && node.data.children === undefined) {
void onLoadChildren(id)
}
},
@@ -76,7 +72,7 @@ export function ProjectTree({
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (node.data && !node.data.isDirectory) {
if (!node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
@@ -87,7 +83,7 @@ export function ProjectTree({
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
@@ -95,7 +91,6 @@ export function ProjectTree({
height={size.height}
indent={INDENT}
initialOpenState={openState}
key={`${cwd}:${collapseNonce}`}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
@@ -140,10 +135,6 @@ function ProjectTreeRow({
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
}) {
if (!node.data) {
return <div style={style} />
}
const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')
@@ -47,20 +47,16 @@ function placeholderChild(parentId: string): TreeNode {
}
export interface UseProjectTreeResult {
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
collapseNonce: number
data: TreeNode[]
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
collapseAll: () => void
loadChildren: (id: string) => Promise<void>
refreshRoot: () => Promise<void>
setNodeOpen: (id: string, open: boolean) => void
}
interface ProjectTreeState {
collapseNonce: number
cwd: string
data: TreeNode[]
loaded: boolean
@@ -71,7 +67,6 @@ interface ProjectTreeState {
}
const initialState: ProjectTreeState = {
collapseNonce: 0,
cwd: '',
data: [],
loaded: false,
@@ -117,7 +112,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
$projectTree.set({
collapseNonce: current.collapseNonce,
cwd,
data: [],
loaded: false,
@@ -180,19 +174,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[cwd]
)
// Clears the recorded open state and bumps the nonce; the tree is keyed on
// the nonce so it remounts with everything collapsed (loaded children stay
// cached in `data`, just hidden).
const collapseAll = useCallback(() => {
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
})
}, [cwd])
const loadChildren = useCallback(
async (id: string) => {
if (!cwd || inflight.has(id)) {
@@ -241,8 +222,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
@@ -252,12 +231,10 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
setNodeOpen
}),
[
collapseAll,
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.collapseNonce,
state.cwd,
state.data,
state.openState,
+12 -72
View File
@@ -1,7 +1,6 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -53,10 +52,7 @@ export function RightSidebarPane({
.pop() ?? currentCwd)
: 'No folder selected'
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
@@ -101,8 +97,6 @@ export function RightSidebarPane({
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
@@ -112,7 +106,6 @@ export function RightSidebarPane({
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
@@ -167,22 +160,13 @@ function RightSidebarChrome({
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
onCollapseAll: () => void
onRefresh: () => void
}
const HEADER_ACTION_CLASS =
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
collapseNonce,
cwd,
cwdName,
data,
@@ -192,7 +176,6 @@ function FilesystemTab({
onActivateFile,
onActivateFolder,
onChangeFolder,
onCollapseAll,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
@@ -205,35 +188,14 @@ function FilesystemTab({
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
title={hasCwd ? cwd : 'No folder selected'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
<Button
aria-label="Collapse all folders"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_REVEAL_CLASS}
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
@@ -244,7 +206,6 @@ function FilesystemTab({
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
cwd={cwd}
data={data}
error={error}
@@ -265,7 +226,6 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
}
interface FileTreeBodyProps {
collapseNonce: number
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
@@ -279,7 +239,6 @@ interface FileTreeBodyProps {
}
function FileTreeBody({
collapseNonce,
cwd,
data,
error,
@@ -308,34 +267,15 @@ function FileTreeBody({
}
return (
<ErrorBoundary
fallback={({ reset }) => (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
<button
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
onClick={reset}
type="button"
>
Try again
</button>
</div>
)}
key={cwd}
label="file-tree"
>
<ProjectTree
collapseNonce={collapseNonce}
cwd={cwd}
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
</ErrorBoundary>
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
)
}
@@ -50,23 +50,16 @@ export function useCwdActions({
}
if (!activeSessionId) {
setCurrentCwd(trimmed)
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: trimmed
})
// Adopt the backend's normalized cwd so the persisted workspace and
// branch stay consistent with what the agent will use.
if (info.cwd) {
setCurrentCwd(info.cwd)
}
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} catch {
setCurrentBranch('')
} catch (err) {
notifyError(err, 'Working directory change failed')
}
return
@@ -3,7 +3,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
@@ -48,53 +48,38 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
}
}, [])
// Returns whether the switch succeeded so callers can await it before
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
// on the right active model — bail rather than write to the previous one).
const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected.
const prevModel = $currentModel.get()
const prevProvider = $currentProvider.get()
(selection: ModelSelection) => {
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
void (async () => {
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return true
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} catch (err) {
notifyError(err, 'Model switch failed')
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
return true
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
notifyError(err, 'Model switch failed')
return false
}
})()
},
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
@@ -65,7 +65,7 @@ interface PromptActionsOptions {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
createBackendSessionForSend: () => Promise<string | null>
handleSkinCommand: (arg: string) => string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
@@ -296,7 +296,7 @@ export function usePromptActions({
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend(visibleText)
sessionId = await createBackendSessionForSend()
} catch (err) {
dropOptimistic(null)
releaseBusy()
@@ -15,7 +15,6 @@ import {
$currentCwd,
$messages,
$sessions,
getRememberedWorkspaceCwd,
setActiveSessionId,
setAwaitingResponse,
setBusy,
@@ -33,7 +32,6 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionsTotal,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
@@ -293,8 +291,7 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentCwd('')
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
@@ -303,7 +300,7 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
@@ -311,7 +308,7 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const cwd = $currentCwd.get().trim()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
@@ -330,11 +327,7 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
upsertOptimisticSession(created, stored)
navigate(sessionRoute(stored), { replace: true })
}
@@ -694,9 +687,6 @@ export function useSessionActions({
const previousPinned = $pinnedSessionIds.get()
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
// Tear down before awaiting so the route effect can't resume the
@@ -719,7 +709,6 @@ export function useSessionActions({
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
setSessionsTotal(prev => prev + 1)
}
$pinnedSessionIds.set(previousPinned)
@@ -772,10 +761,6 @@ export function useSessionActions({
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
if (wasSelected) {
@@ -788,7 +773,6 @@ export function useSessionActions({
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessionsTotal(prev => prev + 1)
}
$pinnedSessionIds.set(previousPinned)
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import { $busy, $messages, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -95,19 +95,6 @@ export function useSessionStateCache({
const syncSessionStateToView = useCallback(
(sessionId: string, state: ClientSessionState) => {
// Only the currently-viewed session may stage into the shared `$messages`
// view. A background session (e.g. one still busy and emitting stream /
// error updates after the user toggled away) must update its own cache
// entry but never the view — otherwise its messages clobber the
// foreground transcript and appear to "bleed" into every other session.
// The flush below also re-checks the active id, but staging here is what
// prevents a background write from overwriting an already-pending
// foreground write within the same animation frame (only one RAF is
// scheduled, so the last `pendingViewStateRef` writer would otherwise win).
if (sessionId !== activeSessionIdRef.current) {
return
}
pendingViewStateRef.current = { sessionId, state }
if (viewSyncRafRef.current !== null) {
@@ -153,13 +140,6 @@ export function useSessionStateCache({
}
setSessionWorking(next.storedSessionId, next.busy)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
// the stream goes silent.
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@@ -10,8 +10,7 @@ import {
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow,
refreshDesktopVersion
openUpdatesWindow
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
@@ -47,14 +46,6 @@ export function AboutSettings() {
const checking = useStore($updateChecking)
const [justChecked, setJustChecked] = useState(false)
// The version atom is loaded once at app boot, which makes About show a
// stale number after a self-update (the running binary is current, the
// displayed string is not). Re-read on mount so opening About always
// reflects the running build.
useEffect(() => {
void refreshDesktopVersion()
}, [])
const behind = status?.behind ?? 0
const supported = status?.supported !== false
const applying = apply.applying || apply.stage === 'restart'
+46 -24
View File
@@ -22,6 +22,8 @@ import {
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
@@ -184,11 +186,8 @@ function EnvProviderGroup({
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
}) {
const [expanded, setExpanded] = useState(false)
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0)
return (
<div className="overflow-hidden rounded-xl bg-background/60">
@@ -223,17 +222,27 @@ export function KeysSettings({ query }: SearchProps) {
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
// default-collapsed-unless-set keep the surface manageable.
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
try {
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
if (stored === null) {
return false
}
return stored === 'true'
} catch {
return false
}
})
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
} catch {
// Ignore — old key cleanup is best-effort.
// Ignore persistence failures and keep in-memory preference.
}
}, [])
}, [showAdvanced])
useEffect(() => {
let cancelled = false
@@ -253,21 +262,28 @@ export function KeysSettings({ query }: SearchProps) {
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
const filterEnv = useCallback(
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
if (!showAdvanced && Boolean(info.advanced)) {
return false
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}, [])
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
},
[showAdvanced]
)
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
@@ -399,6 +415,12 @@ export function KeysSettings({ query }: SearchProps) {
return (
<SettingsContent>
<div className="mb-4 flex justify-end">
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
</Button>
</div>
<div className="mb-6">
<SectionHeading
icon={Zap}
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@@ -105,8 +105,6 @@ export function SessionsSettings({ query }: SearchProps) {
return (
<SettingsContent>
<DefaultProjectDirSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@@ -168,104 +166,3 @@ export function SessionsSettings({ query }: SearchProps) {
</SettingsContent>
)
}
// Lets the user pin the default cwd for new sessions. Without this, packaged
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.
function DefaultProjectDirSetting() {
const [dir, setDir] = useState<null | string>(null)
const [fallback, setFallback] = useState<string>('')
const [busy, setBusy] = useState(false)
useEffect(() => {
// The bridge is only present when running inside Electron. In a Vitest
// / Storybook / non-Electron context `window.hermesDesktop` is
// undefined, so guard the WHOLE call chain rather than chaining
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
// short-circuit to `undefined.then(...)` and throw at runtime).
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) return
setDir(result.dir)
setFallback(result.defaultLabel)
})
return () => {
alive = false
}
}, [])
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
setBusy(true)
try {
const picked = await settings.pickDefaultProjectDir()
if (picked.canceled || !picked.dir) {
return
}
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
} catch (err) {
notifyError(err, 'Could not update default directory')
} finally {
setBusy(false)
}
}, [])
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
setBusy(true)
try {
await settings.setDefaultProjectDir(null)
setDir(null)
} catch (err) {
notifyError(err, 'Could not clear default directory')
} finally {
setBusy(false)
}
}, [])
return (
<div className="mb-6">
<SectionHeading icon={FolderOpen} title="Default project directory" />
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
</p>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
Clear
</Button>
)}
</div>
}
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
title={dir ? dir : 'Not set'}
/>
</div>
)
}
@@ -1,11 +1,9 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -13,10 +11,8 @@ import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$busy,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
@@ -38,7 +34,6 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
statusSnapshot: StatusResponse | null
@@ -53,17 +48,14 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
statusSnapshot,
toggleCommandCenter
}: StatusbarItemsOptions) {
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@@ -277,51 +269,17 @@ export function useStatusbarItems({
variant: 'text'
},
{
detail: currentProvider || '',
icon: <Cpu className="size-3" />,
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? `Model · ${currentProvider}: ${currentModel || 'none'}`
: 'Switch model',
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? `${currentProvider} · ${currentModel || 'no model'}`
: 'Open model picker',
variant: 'action' as const
})
label: currentModel || 'No model selected',
onSelect: () => setModelPickerOpen(true),
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
variant: 'action'
},
versionItem
],
[
busy,
contextBar,
contextUsage,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
turnStartedAt,
versionItem
]
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
)
const leftStatusbarItems = useMemo(
@@ -1,248 +0,0 @@
import { useStore } from '@nanostores/react'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
dropdownMenuRow,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Max' }
] as const
/** How "fast" is achieved for a given model two different mechanisms:
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
* - `variant`: a separate `…-fast` sibling model selected via the model field.
*/
export type FastControl =
| { kind: 'none' }
| { kind: 'param'; on: boolean }
| { kind: 'variant'; baseId: string; fastId: string; on: boolean }
/** Resolve the fast mechanism for a model: prefer the speed=fast parameter
* when the backend supports it, else fall back to a `…-fast` sibling model. */
export function resolveFastControl(
model: string,
providerModels: readonly string[],
paramSupported: boolean,
currentFastMode: boolean
): FastControl {
if (paramSupported) {
return { kind: 'param', on: currentFastMode }
}
if (/-fast$/i.test(model)) {
const baseId = model.replace(/-fast$/i, '')
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
}
const fastId = `${model}-fast`
if (providerModels.includes(fastId)) {
return { kind: 'variant', baseId: model, fastId, on: false }
}
// Fast isn't natively offered here, but if the session still has the speed
// param on (carried over from a previous model), expose the toggle so it can
// be turned off rather than stranded.
if (currentFastMode) {
return { kind: 'param', on: true }
}
return { kind: 'none' }
}
interface ModelEditSubmenuProps {
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying
* edits when not active so a failed switch doesn't write to the old model. */
onActivate: () => Promise<boolean> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
fastControl,
isActive,
onActivate,
onSelectModel,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it. Returns false if the
// switch failed, so callers skip applying to the wrong (previous) model.
const ensureActive = async (): Promise<boolean> => {
if (isActive) {
return true
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
try {
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
} catch (err) {
setCurrentReasoningEffort(rollback)
notifyError(err, 'Model option update failed')
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base).
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
return
}
if (fastControl.kind === 'param') {
setCurrentFastMode(enabled)
void (async () => {
try {
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(!enabled)
notifyError(err, 'Fast mode update failed')
}
})()
}
}
const hasFast = fastControl.kind !== 'none'
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
return (
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
{!hasFast && !reasoning ? (
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
) : (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto cursor-pointer"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
</DropdownMenuItem>
) : null}
{reasoning ? (
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
key={option.value}
onSelect={event => event.preventDefault()}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
) : null}
</>
)}
</DropdownMenuSubContent>
)
}
function isThinkingEnabled(effort: string): boolean {
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
return (effort || 'medium').trim().toLowerCase() !== 'none'
}
function normalizeEffort(effort: string): string {
const value = (effort || 'medium').trim().toLowerCase()
// Thinking off → no effort selected in the radio group.
if (value === 'none') {
return ''
}
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
}
@@ -1,289 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort
} from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
const activeSessionId = useStore($activeSessionId)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gateway && activeSessionId) {
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
}
return getGlobalModelOptions()
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const providers = modelOptions.data?.providers
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
)
return (
<>
<DropdownMenuSearch
aria-label="Search models"
onValueChange={setSearch}
placeholder="Search models"
value={search}
/>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
No models found
</DropdownMenuItem>
) : (
<div className="max-h-80 overflow-y-auto py-0.5">
{groups.map(group => (
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
{group.families.map(family => {
// The active id may be the base or its -fast sibling; either
// way this one family row represents both.
const activeId =
group.provider.slug === optionsProvider &&
(optionsModel === family.id || optionsModel === family.fastId)
? optionsModel
: null
const isCurrent = activeId !== null
const name = modelDisplayParts(family.id).name
// Capabilities are looked up against the active/base id; the
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps
// the row label in lock-step with the submenu's Fast toggle and
// handles the standalone `-fast` id case.
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
currentFastMode
)
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId
? 'Fast'
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
// the Fast toggle inside swaps to the -fast sibling (or flips
// the speed param). The sub-trigger has no `onSelect`, so wire
// both click and Enter/Space for keyboard parity.
const activate = () => {
if (!isCurrent) {
void switchTo(family.id, group.provider.slug)
}
}
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={cn(dropdownMenuRow, 'cursor-pointer')}
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
>
<span className="min-w-0 flex-1 truncate">
{name}
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
fastControl={fastControl}
isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
))}
</div>
)}
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
Edit Models
</DropdownMenuItem>
</>
)
}
// Collapsed we show the user's chosen models (or the curated default); typing
// spans every available model so anything is reachable past the cut.
const PER_PROVIDER_SEARCH = 12
function groupModels(
providers: ModelOptionProvider[],
search: string,
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = search.trim().toLowerCase()
const groups: ProviderGroup[] = []
for (const provider of providers) {
const allFamilies = collapseModelFamilies(provider.models ?? [])
if (allFamilies.length === 0) {
continue
}
const matches = (family: ModelFamily) =>
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
.toLowerCase()
.includes(q)
// Which model ids to show (the active one is always added on top of this).
let shown: Set<string>
if (q) {
// Search spans every family, regardless of visibility.
shown = new Set(allFamilies.filter(matches).map(family => family.id))
} else if (visible) {
// User has customized which models show — honor their selection exactly.
shown = new Set(
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
)
} else {
// Default: curated top-N families per provider.
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
}
// Always include the active model — but keep every row in the provider's
// stable curated order (filter `allFamilies`, never reorder), so selecting
// a model can't shuffle the list.
const activeId =
provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
if (q) {
families = families.slice(0, PER_PROVIDER_SEARCH)
}
if (families.length > 0) {
groups.push({ families, provider })
}
}
// Stable, logical group order: alphabetical by provider name. (The backend
// floats the current provider first, which would reshuffle on every switch.)
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
return groups
}
@@ -26,7 +26,6 @@ export interface StatusbarItem {
disabled?: boolean
hidden?: boolean
href?: string
menuAlign?: 'center' | 'end' | 'start'
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
@@ -55,18 +54,14 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
)}
{...props}
>
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item for
example "Connecting…" on a fresh/untitled session can't paint a
horizontal scrollbar across the bottom of the window. Items already
`truncate` their labels, so clipping is the right behavior. */}
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{leftItems
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{items
.filter(item => !item.hidden)
.map(item => (
@@ -105,7 +100,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={item.menuAlign ?? 'start'}
align="start"
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
side="top"
sideOffset={8}
+1 -1
View File
@@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
@@ -1,34 +0,0 @@
import type { FC } from 'react'
import { useMemo } from 'react'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
import { cn } from '@/lib/utils'
interface AnsiTextProps {
text: string
className?: string
}
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
* back to a plain string node when no codes are present so the parser cost
* is paid only when there's something to colorize. */
export const AnsiText: FC<AnsiTextProps> = ({ className, text }) => {
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
if (!segments) {
return <span className={className}>{text}</span>
}
return (
<span className={className}>
{segments.map((segment, index) => (
<span
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
key={`ansi-${index}`}
>
{segment.text}
</span>
))}
</span>
)
}
@@ -48,8 +48,7 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
@@ -74,7 +73,6 @@ import {
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
@@ -638,7 +636,7 @@ function messageAttachmentRefs(value: unknown): string[] {
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
return (
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
>
@@ -686,32 +684,6 @@ const UserMessage: FC<{
return messageAttachmentRefs(custom.attachmentRefs)
})
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
// doesn't dominate the viewport while the response streams underneath; the
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
// inner wrapper so the ResizeObserver only fires on real content / width
// changes, not on every frame while the outer max-height animates open.
const clampInnerRef = useRef<HTMLDivElement | null>(null)
const [bodyClamped, setBodyClamped] = useState(false)
const measureClamp = useCallback(() => {
const inner = clampInnerRef.current
const outer = inner?.parentElement
if (!inner || !outer) {
return
}
const styles = getComputedStyle(inner)
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
const fullHeight = inner.scrollHeight
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
setBodyClamped(fullHeight > lineHeight * 2 + 1)
}, [])
useResizeObserver(measureClamp, clampInnerRef)
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
@@ -731,14 +703,9 @@ const UserMessage: FC<{
</span>
)}
{hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
<span className="wrap-anywhere block whitespace-pre-line">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</span>
)}
</>
)
@@ -873,10 +840,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// See index.tsx: set in keydown when the open popover consumes a nav/control
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
// `trigger`, which keyup sees as already-null after Escape).
const triggerKeyConsumedRef = useRef(false)
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
@@ -1001,15 +964,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
setTrigger(detected)
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
setTriggerActive(0)
}, [])
const closeTrigger = useCallback(() => {
setTrigger(null)
@@ -1242,7 +1198,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
@@ -1250,7 +1205,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
@@ -1258,7 +1212,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
if (item) {
@@ -1270,7 +1223,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
@@ -1290,22 +1242,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
}
const handleKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
return (
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
<StickyHumanMessageContainer>
@@ -1356,7 +1292,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
onFocus={() => markActiveComposer('edit')}
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
onMouseUp={refreshTrigger}
onPaste={handlePaste}
ref={editorRef}
@@ -35,18 +35,7 @@ export interface ToolView {
previewTarget?: string
rawArgs: string
rawResult: string
/** Set for tools whose output naturally contains ANSI escape codes
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout distinct from an error tone. */
stderr?: string
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
status: ToolStatus
subtitle: string
title: string
@@ -1013,10 +1002,6 @@ function toolDetailText(
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
// Streams are split out into ToolView.stdout / ToolView.stderr by
// buildToolView so the renderer can label them separately. The merged
// fallback here is only used when the backend doesn't expose either
// stream individually.
const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
const lines = Array.isArray(resultRecord.lines)
@@ -1224,18 +1209,6 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
// For shell/code tools we surface stdout and stderr as separate labeled
// streams in the renderer. Many CLIs use stderr for informational
// messages (npm progress, git hints), so we deliberately don't paint
// stderr destructively even though it's tagged.
const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
// Only attach stderr when the backend actually returned it as its own
// field — otherwise the merged `detail` already covers it and double-
// rendering would duplicate output.
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
return {
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
detail,
@@ -1247,10 +1220,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rawArgs: prettyJson(part.args),
rawResult: prettyJson(part.result),
rendersAnsi: rendersAnsi || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
stdout: hasSplitStreams ? stdout || undefined : undefined,
status,
subtitle,
title,
@@ -5,7 +5,6 @@ import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { useShallow } from 'zustand/shallow'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
@@ -345,41 +344,11 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
</div>
) : null
) : view.stdout || view.stderr ? (
// Stdout + stderr split: render both as labeled blocks. stderr
// is intentionally NOT painted destructive — many CLIs log
// informational output there.
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{view.stdout && (
<div className="space-y-0.5">
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
</pre>
</div>
)}
{view.stderr && (
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
<pre
className={cn(
TOOL_SECTION_PRE_CLASS,
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
)}
>
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
</pre>
</div>
)}
</div>
) : (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{renderDetailAsCode ? (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
</pre>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
) : (
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
@@ -1,150 +0,0 @@
import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { cn } from '@/lib/utils'
// User messages should render the bare-minimum of markdown: backtick `code`
// spans and ``` fenced blocks. We deliberately don't pull in the full
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
// because user input rarely contains structured docs and the heavy pipeline
// adds a lot of runtime cost per bubble.
//
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
// inside the plain-text segments.
interface FenceSegment {
kind: 'fence'
code: string
lang: string | null
}
interface InlineSegment {
kind: 'inline'
text: string
}
interface InlineCodeSegment {
kind: 'inline-code'
code: string
}
interface InlineTextSegment {
kind: 'inline-text'
text: string
}
type TopSegment = FenceSegment | InlineSegment
type InlineNode = InlineCodeSegment | InlineTextSegment
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
// Greedy backtick run length so ``code with `backticks` inside`` works.
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
function splitFences(text: string): TopSegment[] {
const segments: TopSegment[] = []
let cursor = 0
for (const match of text.matchAll(FENCE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
}
segments.push({
kind: 'fence',
lang: (match[1] || '').trim() || null,
code: match[2] ?? ''
})
cursor = start + match[0].length
}
if (cursor < text.length) {
segments.push({ kind: 'inline', text: text.slice(cursor) })
}
return segments
}
function splitInlineCode(text: string): InlineNode[] {
const nodes: InlineNode[] = []
let cursor = 0
for (const match of text.matchAll(INLINE_CODE_RE)) {
const start = match.index ?? 0
if (start > cursor) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
}
nodes.push({ kind: 'inline-code', code: match[2] })
cursor = start + match[0].length
}
if (cursor < text.length) {
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
}
return nodes
}
interface UserMessageTextProps {
text: string
className?: string
}
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
const top = useMemo(() => splitFences(text), [text])
return (
<span className={cn('block', className)} data-slot="aui_user-message-text">
{top.map((segment, segmentIndex) => {
if (segment.kind === 'fence') {
return (
<pre
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
data-slot="aui_user-fence"
key={`fence-${segmentIndex}`}
>
<code className="block whitespace-pre">{segment.code}</code>
</pre>
)
}
return (
<Fragment key={`inline-${segmentIndex}`}>
<InlineSegmentView text={segment.text} />
</Fragment>
)
})}
</span>
)
}
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
<span className="wrap-anywhere block whitespace-pre-line">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
data-slot="aui_user-inline-code"
key={`code-${nodeIndex}`}
>
{node.code}
</code>
) : (
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
// still render. DirectiveContent already preserves whitespace.
<Fragment key={`text-${nodeIndex}`}>
<DirectiveContent text={node.text} />
</Fragment>
)
)}
</span>
)
}
@@ -348,7 +348,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
@@ -107,9 +107,8 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
anthropic: { order: 1, title: 'Anthropic Claude' },
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
'minimax-oauth': { order: 3, title: 'MiniMax' },
'xai-oauth': { order: 4, title: 'xAI Grok' },
'claude-code': { order: 5, title: 'Claude Code' },
'qwen-oauth': { order: 6, title: 'Qwen Code' }
'claude-code': { order: 4, title: 'Claude Code' },
'qwen-oauth': { order: 5, title: 'Qwen Code' }
}
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
@@ -117,7 +116,6 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
loopback: 'Opens your browser to sign in — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
}
@@ -567,24 +565,6 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
)
}
if (flow.status === 'awaiting_browser') {
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected
automatically nothing to copy or paste.
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}
if (flow.status === 'external_pending') {
return (
<Step title={`Sign in with ${title}`}>
@@ -1,91 +0,0 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, RefreshCw } from '@/lib/icons'
export interface ErrorBoundaryFallbackProps {
error: Error
reset: () => void
}
interface ErrorBoundaryProps {
children: ReactNode
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode
label?: string
onError?: (error: Error, info: ErrorInfo) => void
}
interface ErrorBoundaryState {
error: Error | null
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
this.props.onError?.(error, info)
}
reset = () => {
this.setState({ error: null })
}
render() {
const { error } = this.state
if (!error) {
return this.props.children
}
if (this.props.fallback) {
return this.props.fallback({ error, reset: this.reset })
}
return <RootErrorFallback error={error} reset={this.reset} />
}
}
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
return (
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
</p>
</div>
</div>
<div className="grid gap-4 p-5">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
{error.message || String(error)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>
<RefreshCw className="size-4" />
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Reload window
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
Open logs
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -1,148 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
interface ModelVisibilityDialogProps {
gw?: HermesGateway
onOpenChange: (open: boolean) => void
onOpenProviders: () => void
open: boolean
sessionId?: string | null
}
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
}
return getGlobalModelOptions()
},
enabled: open
})
const providers = useMemo(
() => (modelOptions.data?.providers ?? []).filter(provider => (provider.models ?? []).length > 0),
[modelOptions.data]
)
const visible = effectiveVisibleKeys(stored, providers)
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
setVisibleModels(next)
}
const q = search.trim().toLowerCase()
const matches = (provider: ModelOptionProvider, model: string) =>
!q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q)
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
<DialogHeader className="px-3 pb-1 pt-3">
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
</DialogHeader>
<div className="px-3 py-1.5">
<input
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearch(event.target.value)}
placeholder="Search models"
type="text"
value={search}
/>
</div>
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
</div>
) : (
providers.map(provider => {
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
matches(provider, family.id)
)
if (models.length === 0) {
return null
}
return (
<div className="py-0.5" key={provider.slug}>
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
{provider.name}
</div>
{models.map(family => {
const { name, tag } = modelDisplayParts(family.id)
const key = modelVisibilityKey(provider.slug, family.id)
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch
checked={visible.has(key)}
className="cursor-pointer"
onCheckedChange={() => toggle(provider, family.id)}
/>
</label>
)
})}
</div>
)
})
)}
</div>
<div className="px-3 py-2">
<button
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
onClick={() => {
onOpenChange(false)
onOpenProviders()
}}
type="button"
>
Add provider
</button>
</div>
</DialogContent>
</Dialog>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+1 -4
View File
@@ -46,10 +46,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
@@ -4,17 +4,6 @@ import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
// full-width, square, and compact so the highlight spans the whole surface.
// Reuse these instead of re-deriving per menu so every searchable/compact menu
// reads identically.
export const dropdownMenuRow = 'gap-2 rounded-none px-2.5 py-1 text-xs'
export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide'
// Keys that must reach Radix's menu handler (navigation/close). Everything else
// is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it.
const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab'])
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
@@ -27,65 +16,18 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
/**
* Borderless filter input for a searchable dropdown. Autofocuses, keeps the
* menu's typeahead from eating keystrokes, and still lets arrow/enter/escape
* drive the list. Drop it in as the first child of a `DropdownMenuContent`.
*/
function DropdownMenuSearch({
className,
onChange,
onKeyDown,
onValueChange,
...props
}: Omit<React.ComponentProps<'input'>, 'type'> & {
onValueChange?: (value: string) => void
}) {
return (
<div className="px-2.5 py-1.5" data-slot="dropdown-menu-search">
<input
autoFocus
className={cn(
'h-4 w-full bg-transparent text-xs leading-none text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none',
className
)}
onChange={event => {
onChange?.(event)
onValueChange?.(event.target.value)
}}
onKeyDown={event => {
if (!DROPDOWN_NAV_KEYS.has(event.key)) {
event.stopPropagation()
}
onKeyDown?.(event)
}}
type="text"
{...props}
/>
</div>
)
}
function DropdownMenuContent({
className,
collisionPadding = 8,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
// `.scrollbar-dt` for portaled overlays (Radix renders this under
// document.body, outside #root's scope). See styles.css.
className={cn(
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Keep the menu inside the viewport: Radix flips/shifts away from edges
// (avoidCollisions defaults on); the padding stops it kissing the edge.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
@@ -131,16 +73,18 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Codicon name="check" size="1rem" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
<Codicon name="check" size="0.75rem" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.CheckboxItem>
)
}
@@ -157,16 +101,18 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Codicon name="primitive-dot" size="0.5rem" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
<Codicon name="check" size="0.75rem" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.RadioItem>
)
}
@@ -215,13 +161,10 @@ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuP
function DropdownMenuSubTrigger({
className,
inset,
hideChevron = false,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
/** Suppress the trailing caret — for triggers that own their right-side affordance. */
hideChevron?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -234,40 +177,24 @@ function DropdownMenuSubTrigger({
{...props}
>
{children}
{!hideChevron && <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />}
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
// Portal the submenu out of the parent Content so it escapes that Content's
// `overflow` clip. Without this, a submenu opening from a scrollable menu
// gets visually cut off at the parent's edges. Radix Popper still anchors
// it to the SubTrigger and handles collision/flip, so portaling is safe.
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
// `dt-portal-scrollbar` reproduces the themed scrollbar for portaled
// overlays (rendered under document.body). Use a fixed `max-h-80`
// rather than the Radix available-height variable: that variable is
// only published on Content, NOT SubContent — using it here collapses
// the submenu to 0px height.
className={cn(
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Flip to the other side / shift vertically when near a viewport edge
// (e.g. the status bar menu opening from the bottom-right corner) so
// the submenu never gets clipped.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-sub-content"
{...props}
/>
</DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
className={cn(
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
)
}
@@ -281,7 +208,6 @@ export {
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSearch,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
-5
View File
@@ -27,11 +27,6 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
-123
View File
@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
const ESC = '\x1b'
describe('parseAnsi', () => {
it('returns a single default segment for plain text', () => {
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
})
it('returns nothing for an empty string', () => {
expect(parseAnsi('')).toEqual([])
})
it('parses a basic foreground color sequence and resets', () => {
const input = `${ESC}[31merror${ESC}[0m ok`
expect(parseAnsi(input)).toEqual([
{ bold: false, fg: 'red', text: 'error' },
{ bold: false, fg: null, text: ' ok' }
])
})
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
const input = `${ESC}[1mloud${ESC}[22m quiet`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: null, text: 'loud' },
{ bold: false, fg: null, text: ' quiet' }
])
})
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'both' },
{ bold: true, fg: null, text: 'bold-only' }
])
})
it('handles bright colors via the 90-97 range', () => {
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
})
it('coalesces adjacent runs with the same style', () => {
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
})
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
// they never bleed into the visible segment text.
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('orange')
})
it('skips truecolor (38;2;r;g;b) trailing args', () => {
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('rgb')
})
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
})
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
const input = `${ESC}[1;31mfoo${ESC}[mbar`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'foo' },
{ bold: false, fg: null, text: 'bar' }
])
})
})
describe('hasAnsiCodes', () => {
it('returns false for plain text', () => {
expect(hasAnsiCodes('hello world')).toBe(false)
})
it('returns true when any CSI introducer is present', () => {
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
})
})
describe('ansiColorClass', () => {
it('returns a non-empty Tailwind class string for every supported color', () => {
const colors = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'bright-black',
'bright-red',
'bright-green',
'bright-yellow',
'bright-blue',
'bright-magenta',
'bright-cyan',
'bright-white'
] as const
for (const color of colors) {
expect(ansiColorClass(color)).toMatch(/\S/)
}
})
})
-175
View File
@@ -1,175 +0,0 @@
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
// cards. Only handles the SGR codes that show up in practice (color, bold,
// reset); cursor motions and other CSI sequences are dropped silently.
//
// Returns a flat array of styled segments so callers can render them as
// React spans without each consumer having to re-implement the parser.
export interface AnsiSegment {
bold: boolean
/** Tailwind text-color class or null for the default foreground. */
fg: AnsiColor | null
text: string
}
export type AnsiColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'bright-black'
| 'bright-red'
| 'bright-green'
| 'bright-yellow'
| 'bright-blue'
| 'bright-magenta'
| 'bright-cyan'
| 'bright-white'
const FG_BY_CODE: Record<number, AnsiColor> = {
30: 'black',
31: 'red',
32: 'green',
33: 'yellow',
34: 'blue',
35: 'magenta',
36: 'cyan',
37: 'white',
90: 'bright-black',
91: 'bright-red',
92: 'bright-green',
93: 'bright-yellow',
94: 'bright-blue',
95: 'bright-magenta',
96: 'bright-cyan',
97: 'bright-white'
}
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
// final bytes are matched and consumed so they don't leak into the rendered
// text. Range covers the common CSI command set (A-Z / a-z / @).
// eslint-disable-next-line no-control-regex
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
// eslint-disable-next-line no-control-regex
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
export function parseAnsi(input: string): AnsiSegment[] {
if (!input) {
return []
}
// Strip non-CSI escapes upfront — none of them carry text we want to keep
// and CSI_RE wouldn't match them.
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
const segments: AnsiSegment[] = []
let cursor = 0
let bold = false
let fg: AnsiColor | null = null
const pushText = (text: string) => {
if (!text) {
return
}
const last = segments.at(-1)
if (last && last.bold === bold && last.fg === fg) {
last.text += text
return
}
segments.push({ bold, fg, text })
}
CSI_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = CSI_RE.exec(cleaned)) !== null) {
const start = match.index
if (start > cursor) {
pushText(cleaned.slice(cursor, start))
}
if (match[2] === 'm') {
const codes = match[1]
.split(';')
.map(part => (part === '' ? 0 : Number(part)))
.filter(value => Number.isFinite(value))
for (let i = 0; i < codes.length; i += 1) {
const code = codes[i]
if (code === 0) {
bold = false
fg = null
} else if (code === 1) {
bold = true
} else if (code === 22) {
bold = false
} else if (code === 39) {
fg = null
} else if (code in FG_BY_CODE) {
fg = FG_BY_CODE[code]
} else if (code === 38) {
// 256-color / truecolor — skip the trailing args we don't render.
if (codes[i + 1] === 5) {
i += 2
} else if (codes[i + 1] === 2) {
i += 4
}
}
// Background colors (40-47, 100-107) and effects we don't render are
// intentionally ignored — the segment keeps the prior bold/fg state.
}
}
cursor = CSI_RE.lastIndex
}
if (cursor < cleaned.length) {
pushText(cleaned.slice(cursor))
}
return segments
}
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
// disappear into the surface.
'black': 'text-zinc-700 dark:text-zinc-300',
'red': 'text-red-700 dark:text-red-300',
'green': 'text-emerald-700 dark:text-emerald-300',
'yellow': 'text-amber-700 dark:text-amber-300',
'blue': 'text-blue-700 dark:text-blue-300',
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
'cyan': 'text-cyan-700 dark:text-cyan-300',
'white': 'text-zinc-600 dark:text-zinc-200',
'bright-black': 'text-zinc-500 dark:text-zinc-400',
'bright-red': 'text-rose-600 dark:text-rose-300',
'bright-green': 'text-emerald-600 dark:text-emerald-200',
'bright-yellow': 'text-amber-600 dark:text-amber-200',
'bright-blue': 'text-sky-600 dark:text-sky-300',
'bright-magenta': 'text-pink-600 dark:text-pink-300',
'bright-cyan': 'text-teal-600 dark:text-teal-200',
'bright-white': 'text-zinc-500 dark:text-zinc-100'
}
export function ansiColorClass(color: AnsiColor): string {
return TAILWIND_BY_COLOR[color]
}
/** Returns true if the input contains at least one CSI sequence. Cheap check
* so callers can skip the parser for plain-ASCII output. */
export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
it('maps reasoning effort to compact labels', () => {
expect(reasoningEffortLabel('high')).toBe('High')
expect(reasoningEffortLabel('xhigh')).toBe('Max')
expect(reasoningEffortLabel('')).toBe('')
})
it('appends fast + effort session state to the status label', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
'GPT-5.5 · Fast High'
)
})
it('always surfaces the effort (default medium) so the level is visible', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
})
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
})
-103
View File
@@ -1,103 +0,0 @@
const REASONING_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'Max'
}
export function reasoningEffortLabel(effort: string): string {
const key = effort.trim().toLowerCase()
if (!key) {
return ''
}
return REASONING_LABELS[key] ?? effort
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()
const slash = trimmed.lastIndexOf('/')
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
}
// Trailing model-id variants that should render as a grayed tag beside the
// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to
// the same display name.
const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
[/-fast$/i, 'Fast'],
[/-thinking$/i, 'Thinking'],
[/-preview$/i, 'Preview'],
[/-latest$/i, 'Latest']
]
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()
function prettifyBase(base: string): string {
if (/^claude-/i.test(base)) {
return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' '))
}
if (/^gpt-/i.test(base)) {
return base.replace(/^gpt-/i, 'GPT-')
}
if (/^gemini-/i.test(base)) {
return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ')
}
return titleCase(base.replace(/-/g, ' '))
}
/** Split a model id into a clean display name plus an optional grayed variant
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
export function modelDisplayParts(model: string): { name: string; tag: string } {
let base = modelBaseId(model)
let tag = ''
for (const [pattern, label] of VARIANT_TAGS) {
if (pattern.test(base)) {
tag = label
base = base.replace(pattern, '')
break
}
}
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
}
/** Friendly one-line model name for menus and the status bar. */
export function displayModelName(model: string): string {
return modelDisplayParts(model).name
}
/** Status bar trigger label — model name plus the live session state (effort/fast). */
export function formatModelStatusLabel(
model: string,
options?: { fastMode?: boolean; reasoningEffort?: string }
): string {
const name = displayModelName(model)
if (!model.trim()) {
return name
}
const parts: string[] = []
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
// active model is a `…-fast` variant (fast via a separate model id).
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
parts.push('Fast')
}
// Always surface the effort (empty = Hermes default of medium) so the
// current reasoning level is visible at a glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
return `${name} · ${parts.join(' ')}`
}
+1 -5
View File
@@ -20,11 +20,7 @@ const PRIORITY_KEYS = [
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
// 'stderr' deliberately excluded: many CLIs emit informational lines on
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
// aren't errors. Treating those as error signal flipped tool cards into
// destructive styling for healthy commands.
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>
+9 -12
View File
@@ -6,7 +6,6 @@ import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { installClipboardShim } from './lib/clipboard'
import { ThemeProvider } from './themes/context'
@@ -33,16 +32,14 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
)
@@ -8,7 +8,6 @@ import {
enqueueQueuedPrompt,
getQueuedPrompts,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt,
updateQueuedPromptText
} from './composer-queue'
@@ -101,37 +100,3 @@ describe('composer queue store', () => {
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
})
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
it('drains the next queued prompt when a turn completes naturally', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
// Regression: previously the Stop button "never worked" because cancelling
// a turn flipped busy → false and the queue immediately re-fired its head.
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
})
it('does not drain when the queue is empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
})
it('does not drain when interrupted even if the queue is also empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
})
it('ignores steady busy state (no true → false transition)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
})
it('ignores busy entry (false → true, not a settle)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
})
it('ignores steady idle state (was not busy)', () => {
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
})
})
-36
View File
@@ -188,39 +188,3 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
writeSession(sid, [])
}
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
export interface AutoDrainSettleInput {
wasBusy: boolean
isBusy: boolean
queueLength: number
userInterrupted: boolean
}
/**
* Decide whether the composer should auto-drain the next queued prompt when a
* turn settles (busy transitions true false).
*
* The queue auto-advances when a turn *completes naturally*, but must NOT
* advance when the user *explicitly interrupted* the turn via the Stop button.
* Conflating the two made the Stop button appear to "never work": cancelling a
* turn flipped busy false, the queue immediately re-fired its head, and the
* agent kept running. An explicit interrupt means stop the queued turns are
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
* per-row "send now" arrow).
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, userInterrupted, wasBusy } = params
// Only react to a true → false transition; ignore steady state and entry.
if (isBusy || !wasBusy) {
return false
}
// An explicit Stop suppresses exactly one auto-drain.
if (userInterrupted) {
return false
}
return queueLength > 0
}
-108
View File
@@ -1,108 +0,0 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import type { ModelOptionProvider } from '@/types/hermes'
const STORAGE_KEY = 'hermes.desktop.visible-models'
/** Models shown per provider in the status-bar dropdown before the user has
* customized the list. Backend `models` are already relevance-ordered. */
export const DEFAULT_VISIBLE_PER_PROVIDER = 5
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
/** A model and its optional `-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
fastId: string | null
id: string
}
/** Collapse a provider's model list so a base model and its `-fast` variant
* become a single family (one row, one toggle). Order is preserved by the
* base model's position. A `…-fast` model with no base stands on its own. */
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
const present = new Set(models)
const families: ModelFamily[] = []
const consumed = new Set<string>()
for (const model of models) {
if (consumed.has(model)) {
continue
}
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
// Represented by its base entry — the base attaches it as `fastId`.
continue
}
const fastId = `${model}-fast`
const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model })
consumed.add(model)
if (hasFast) {
consumed.add(fastId)
}
}
return families
}
function loadVisible(): Set<string> | null {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
} catch {
return null
}
}
/** Explicit set of visible `provider::model` keys, or null when the user
* hasn't customized in which case the curated default applies. */
export const $visibleModels = atom<Set<string> | null>(loadVisible())
export const $modelVisibilityOpen = atom(false)
export function setVisibleModels(keys: Set<string>): void {
$visibleModels.set(new Set(keys))
persistString(STORAGE_KEY, JSON.stringify([...keys]))
}
export function setModelVisibilityOpen(open: boolean): void {
$modelVisibilityOpen.set(open)
}
/** The default-visible key set: the curated top-N per provider. Used both as
* the dropdown fallback and to seed the Edit Models dialog. */
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
const keys = new Set<string>()
for (const provider of providers) {
const families = collapseModelFamilies(provider.models ?? [])
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
keys.add(modelVisibilityKey(provider.slug, family.id))
}
}
return keys
}
/** Resolve which keys are currently visible: the user's explicit set when
* configured, otherwise the curated default for the given providers. */
export function effectiveVisibleKeys(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
return stored ?? defaultVisibleKeys(providers)
}
+3 -41
View File
@@ -18,7 +18,6 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
@@ -27,10 +26,6 @@ export type OnboardingFlow =
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
// listener catches the redirect, and we poll until the worker finishes.
// No code to paste and no user_code to show — just a waiting state.
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
@@ -411,26 +406,6 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}
// Open a sign-in URL via the desktop bridge, falling back to window.open
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
// the flow never silently stalls in a waiting state. Mirrors the pattern in
// apps/desktop/src/app/artifacts/index.tsx.
async function openSignInUrl(url: string) {
if (window.hermesDesktop?.openExternal) {
try {
await window.hermesDesktop.openExternal(url)
return
} catch {
// Bridge present but failed (no OS handler, user denied, etc.). Fall
// through to window.open so the sign-in URL still opens and the flow
// doesn't strand a pending OAuth session in a waiting state.
}
}
window.open(url, '_blank', 'noopener,noreferrer')
}
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
clearPoll()
@@ -444,8 +419,7 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
try {
const start = await startOAuthLogin(provider.id)
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
await openSignInUrl(browserUrl)
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
if (start.flow === 'pkce') {
setFlow({ status: 'awaiting_user', provider, start, code: '' })
@@ -453,26 +427,14 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
return
}
if (start.flow === 'loopback') {
// No code to paste: the redirect lands on the backend's loopback
// listener. Just wait and poll the session until the worker finishes.
setFlow({ status: 'awaiting_browser', provider, start })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
return
}
setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
}
}
// Poll a session-backed flow (device_code or loopback) until it resolves.
// Both shapes only need the session_id to poll; the start is threaded
// through to the error flow so the user can retry from the same context.
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
try {
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
+1 -44
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { mergeWorkingSessions, sessionPinId } from './session'
import { sessionPinId } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@@ -34,46 +34,3 @@ describe('sessionPinId', () => {
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
describe('mergeWorkingSessions', () => {
it('returns the server page untouched when nothing is working', () => {
const previous = [session({ id: 'a' }), session({ id: 'b' })]
const incoming = [session({ id: 'a' })]
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
})
it('keeps a still-working session the server omitted', () => {
// Repro of the disappearing-sessions bug: A finished and is returned by the
// server, but B and C are mid-first-response (message_count 0 in the DB) so
// listSessions(min_messages=1) skips them. They must survive the refresh.
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'a', message_count: 2 })]
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
// The finished session comes from the fresh server payload, not the stale
// optimistic copy.
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
})
it('does not duplicate a working session the server already returned', () => {
const previous = [session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
const merged = mergeWorkingSessions(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
})
it('never resurrects a non-working session the server dropped', () => {
// A deleted/archived session is removed from `previous` optimistically and
// is not in the working set, so it must stay gone after a refresh.
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
const incoming = [session({ id: 'b' })]
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
})
})
+2 -97
View File
@@ -3,15 +3,10 @@ import { atom } from 'nanostores'
import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
import { persistString, storedString } from '@/lib/storage'
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
interface AppAtom<T> {
get: () => T
set: (value: T) => void
@@ -27,33 +22,6 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
/** Merge a fresh server session page into the in-memory list, keeping any
* still-"working" session the server omitted.
*
* A brand-new session's first user message isn't flushed to the SessionDB
* until its turn is persisted, so `listSessions(min_messages=1)` skips
* sessions that are mid-first-response. Because every `message.complete`
* triggers a full refresh, a hard replace makes concurrent new chats vanish
* the instant any one of them finishes. Preserving the working-but-absent
* rows keeps them visible until their own turn persists and the server
* starts returning them. Optimistic deletes/archives already drop the row
* from `previous`, so a removed session can't be resurrected here. */
export function mergeWorkingSessions(
previous: SessionInfo[],
incoming: SessionInfo[],
workingIds: readonly string[]
): SessionInfo[] {
if (workingIds.length === 0) {
return incoming
}
const working = new Set(workingIds)
const incomingIds = new Set(incoming.map(session => session.id))
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
return survivors.length ? [...survivors, ...incoming] : incoming
}
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
@@ -71,7 +39,7 @@ export const $currentProvider = atom('')
export const $currentReasoningEffort = atom('')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $currentCwd = atom('')
export const $currentBranch = atom('')
export const $currentUsage = atom<UsageStats>({
calls: 0,
@@ -105,14 +73,7 @@ export const setCurrentProvider = (next: Updater<string>) => updateAtom($current
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
}
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
@@ -124,53 +85,6 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
// Watchdog tracking — when does a "working" session count as stuck?
// Long-running tool calls (LLM inference, long shell commands, web fetches)
// can take a few minutes legitimately. We allow 8 minutes of complete
// silence on the stream before clearing the working flag; in practice this
// catches gateway hangs and dropped streams without false-positive-clearing
// real long turns.
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
function armSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
sessionWatchdogTimers.delete(sessionId)
// Re-check the latest state at fire-time. If the user already navigated
// away or the session genuinely finished, the timer is a no-op.
if ($workingSessionIds.get().includes(sessionId)) {
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
}
}, SESSION_WATCHDOG_TIMEOUT_MS)
sessionWatchdogTimers.set(sessionId, timer)
}
function clearSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
if (existing) {
clearTimeout(existing)
sessionWatchdogTimers.delete(sessionId)
}
}
/** Call when a streaming event for a session lands. Refreshes the watchdog
* so the session keeps its "working" status as long as data keeps coming. */
export function noteSessionActivity(sessionId: string | null | undefined) {
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
return
}
armSessionWatchdog(sessionId)
}
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
if (!sessionId) {
return
@@ -185,13 +99,4 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
return alreadyWorking ? current.filter(id => id !== sessionId) : current
})
// Bookend the watchdog: arm it whenever a session enters "working",
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
// a streaming event will refresh the timer.
if (working) {
armSessionWatchdog(sessionId)
} else {
clearSessionWatchdog(sessionId)
}
}
+1 -37
View File
@@ -145,34 +145,6 @@ export function openUpdatesWindow(): void {
void checkUpdates()
}
/** Re-read the running app's version from the Electron main process and
* publish it on `$desktopVersion`. Called when the About panel mounts, the
* update flow finishes, and the window regains focus, so the About text
* stays in sync with the just-installed binary instead of frozen at the
* value captured at first-load. */
export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null> {
if (typeof window === 'undefined') {
return null
}
// Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window
// focus handler) all kick this off with `void refreshDesktopVersion()`,
// so any rejection from the IPC bridge (e.g. main process shutting down
// mid-reload, or the bridge not yet ready on first paint) would surface
// as an unhandled promise rejection in the renderer. Swallow it.
try {
const next = await window.hermesDesktop?.getVersion?.()
if (next) {
$desktopVersion.set(next)
}
return next ?? null
} catch {
return null
}
}
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const bridge = window.hermesDesktop?.updates
@@ -186,10 +158,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const status = await bridge.check()
$updateStatus.set(status)
maybeNotifyUpdateAvailable(status)
// The update check pulls the latest hermes_cli + bundled package metadata
// into place. Re-read the running version so About reflects the now-fresh
// checkout rather than the one captured at process start.
void refreshDesktopVersion()
return status
} catch (error) {
@@ -281,7 +249,7 @@ export function startUpdatePoller(): void {
pollerStarted = true
void checkUpdates()
void refreshDesktopVersion()
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
bridge.onProgress(ingestProgress)
window.addEventListener('focus', onFocus)
@@ -307,8 +275,4 @@ function onFocus() {
lastFocusAt = now
void checkUpdates()
// Cheap and safe to re-read on every (throttled) focus: the user may have
// updated Hermes from another window/CLI between focuses, and About should
// catch up without forcing a restart.
void refreshDesktopVersion()
}
+18 -101
View File
@@ -76,7 +76,8 @@
--shadow-header:
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-composer:
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-composer-focus:
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
@@ -132,23 +133,15 @@
--ui-cyan: #4c7f8c;
--ui-blue: #0053fd;
--ui-purple: #9e94d5;
--ui-bg-chrome: color-mix(
in srgb,
var(--theme-background-seed) var(--theme-mix-chrome),
var(--theme-neutral-chrome)
);
--ui-bg-sidebar: color-mix(
in srgb,
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
var(--theme-neutral-sidebar)
);
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
--ui-bg-elevated: color-mix(
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
--ui-bg-card: color-mix(
in srgb,
var(--theme-elevated-seed) var(--theme-mix-elevated),
var(--theme-neutral-card)
var(--ui-accent) 4%,
color-mix(in srgb, var(--ui-base) 4%, transparent)
);
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
--ui-bg-input: #fcfcfc;
--ui-bg-primary: color-mix(
in srgb,
@@ -225,11 +218,7 @@
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
--ui-chat-surface-background: var(--ui-bg-chrome);
--ui-editor-surface-background: var(--ui-bg-chrome);
--ui-chat-bubble-background: color-mix(
in srgb,
var(--theme-bubble-seed) var(--theme-mix-bubble),
var(--theme-neutral-card)
);
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
@@ -283,7 +272,6 @@
--conversation-line-height: 1.125rem;
--conversation-caption-line-height: 1rem;
--conversation-turn-gap: 0.375rem;
--sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem;
--composer-width: 48.75rem;
@@ -638,7 +626,7 @@ canvas {
.scrollbar-dt::-webkit-scrollbar-thumb,
.scrollbar-dt *::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
border-radius: 9999rem;
border-radius: 9999rem;
border: 0.125rem solid transparent;
background-clip: padding-box;
}
@@ -653,41 +641,6 @@ canvas {
.scrollbar-dt *::-webkit-scrollbar-button {
display: none;
}
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
render under document.body, outside the `.scrollbar-dt` scope on
#root. Same visual treatment, applied directly to the overlay
container so its (and only its) internal scrollbar is themed. */
.dt-portal-scrollbar {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
}
.dt-portal-scrollbar::-webkit-scrollbar {
width: 0.375rem;
height: 0.375rem;
}
.dt-portal-scrollbar::-webkit-scrollbar-track,
.dt-portal-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
border-radius: 9999rem;
border: 0.0625rem solid transparent;
background-clip: padding-box;
}
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
background-clip: padding-box;
}
.dt-portal-scrollbar::-webkit-scrollbar-button {
display: none;
}
}
/* Bottom clearance lives on [data-slot='aui_composer-clearance']
@@ -716,52 +669,11 @@ canvas {
padding-inline-start: var(--md-text-indent, 0.5rem);
}
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
[data-slot='aui_user-message-root'],
[data-slot='aui_edit-composer-root'] {
font-size: var(--conversation-text-font-size);
}
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
prompt doesn't dominate the viewport while you read the response stuck
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
edit composer, which already shows the full text). --human-msg-full is the
measured content height (set in UserMessage) so expand/collapse animates to
the real height instead of overshooting the cap. */
.sticky-human-clamp {
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden;
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
}
.sticky-human-clamp[data-clamped='true'] {
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
mask-image: linear-gradient(to bottom, #000 55%, transparent);
}
.composer-human-message:hover .sticky-human-clamp,
.composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto;
-webkit-mask-image: none;
mask-image: none;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the
220px estimate. The browser's native scroll anchoring (overflow-anchor:
auto) would adjust scrollTop for that SAME size delta, so the two
double-correct and the view lurches most visibly on Windows mouse wheels,
whose coarse notches mount/measure several under-estimated turns per tick.
Opt out of native anchoring so only the virtualizer compensates. */
[data-slot='aui_thread-viewport'] {
overflow-anchor: none;
}
[data-slot='aui_thread-content'] {
max-width: var(--composer-width);
padding-inline: 1.5rem;
@@ -950,7 +862,8 @@ canvas {
background: transparent !important;
}
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
@@ -981,8 +894,12 @@ canvas {
margin-top: 1rem;
}
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
[data-slot='aui_assistant-message-content']
[data-slot='aui_thinking-disclosure']
+ [data-slot='tool-block'],
[data-slot='aui_assistant-message-content']
[data-slot='tool-block']
+ [data-slot='aui_thinking-disclosure'] {
margin-top: 0.75rem;
}
+1 -15
View File
@@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
docs_url: string
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
flow: 'device_code' | 'external' | 'pkce'
id: string
name: string
status: OAuthProviderStatus
@@ -73,12 +73,6 @@ export type OAuthStartResponse =
user_code: string
verification_url: string
}
| {
auth_url: string
expires_in: number
flow: 'loopback'
session_id: string
}
export interface OAuthSubmitResponse {
message?: string
@@ -216,14 +210,6 @@ export interface ModelOptionProvider {
free_tier?: boolean
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
unavailable_models?: string[]
/** Per-model option support, keyed by model id (present when the picker
* requested capabilities). Lets the UI gate fast/reasoning controls. */
capabilities?: Record<string, ModelCapabilities>
}
export interface ModelCapabilities {
fast: boolean
reasoning: boolean
}
export interface ModelOptionsResponse {
-26
View File
@@ -1115,36 +1115,10 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
parts = []
skipped: list[str] = []
for skill_name in skill_names:
# Cron jobs historically accepted only skill names here, but the CLI/gateway
# slash-command path lets bundles shadow skills with the same slug. Mirror
# that behavior so `skills: ["my-bundle"]` expands bundle members instead
# of being treated as a missing skill.
bundle_key = resolve_bundle_command_key(skill_name.lstrip("/"))
if bundle_key:
bundle_payload = build_bundle_invocation_message(
bundle_key,
user_instruction="",
task_id=str(job.get("id") or "") or None,
)
if bundle_payload:
bundle_message, _loaded_bundle_skills, _missing_bundle_skills = bundle_payload
if parts:
parts.append("")
parts.append(bundle_message)
continue
logger.warning(
"Cron job '%s': bundle '%s' could not load any skills, skipping",
job.get("name", job.get("id")),
skill_name,
)
skipped.append(skill_name)
continue
try:
loaded = json.loads(skill_view(skill_name))
except (json.JSONDecodeError, TypeError):
-32
View File
@@ -278,38 +278,6 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
chmod 600 "$HERMES_HOME/auth.json"
fi
# gateway_state.json: declare the gateway's INITIAL supervised state on a
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
#
# On a blank volume there is no gateway_state.json, so the boot reconciler
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
# registers the gateway-default s6 slot but leaves it DOWN — it only
# auto-starts when the last recorded state was "running". That means a
# freshly-provisioned container comes up with the gateway down until
# someone starts it (e.g. from the dashboard). An orchestrator that
# provisions a fresh volume and wants the gateway running from first boot
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
# lexicographic order), so the reconciler sees prior_state=running and
# brings the supervised slot up on the very first boot.
#
# This is a generic container contract, not specific to any host: it seeds
# the SAME gateway_state.json the reconciler already consults, exactly as
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
# load-bearing part — on every subsequent boot the persisted state wins,
# so a gateway the operator deliberately stopped stays stopped across
# restarts and we never clobber real runtime state.
#
# Only a literal "running" is honoured (the sole value in the reconciler's
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
# bogus state the reconciler would treat as "no prior state" anyway.
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
chmod 644 "$HERMES_HOME/gateway_state.json"
fi
# --- Sync bundled skills ---
# Invoke the venv's python by absolute path so we don't need a `sh -c`
# wrapper to source the activate script. This is safe because
+1 -18
View File
@@ -4195,25 +4195,8 @@ class APIServerAdapter(BasePlatformAdapter):
return False
async def disconnect(self) -> None:
"""Stop the aiohttp web server and release all owned resources.
Closes the ResponseStore SQLite connection in addition to stopping
the aiohttp web server. Without this, every adapter instance leaks
2 file descriptors (the database file and its WAL sidecar) the
reconnect loop in ``gateway.run`` constructs a fresh adapter on
every retry, so 2 fds/retry × 300s backoff cap 12 fds/hour, which
exhausts the default 2560 fd limit after ~12h of failed reconnects
and turns the whole gateway into a zombie
(OSError: [Errno 24] Too many open files, #37011).
"""
"""Stop the aiohttp web server."""
self._mark_disconnected()
if self._response_store is not None:
try:
self._response_store.close()
except Exception:
logger.debug(
"Failed to close response store for %s", self.name, exc_info=True,
)
if self._site:
await self._site.stop()
self._site = None
+6 -113
View File
@@ -1755,60 +1755,6 @@ def _preserve_queued_followup_history_offset(
return merged
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
"""Best-effort dispose for an adapter that never made it onto ``self.adapters``.
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``
constructs a fresh adapter on every retry attempt. When the connect
call fails for any of the three reasons (non-retryable error,
retryable error, exception during connect) the adapter is dropped
without ever being installed, so nothing else will call its
``disconnect()``. Any resources the adapter opened in ``__init__``
(e.g. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that
holds 2 fds the db file and its WAL sidecar) stay open until
garbage collection sweeps the unreachable object, which Python's
cyclic GC does not do promptly for asyncio-bound objects with
native handles. The cumulative leak is 2 fds × every retry at the
300s backoff cap 12 fds/hour, and the default 2560-fd ulimit
is exhausted in ~12h of continuous failure, after which every
open() call on the gateway raises ``OSError: [Errno 24] Too many
open files`` and the gateway becomes a zombie (#37011).
This helper centralises the dispose-with-suppression so the three
failure paths in the reconnect watcher can all call it without
each one having to know that ``disconnect()`` may itself raise
on a half-constructed adapter.
``adapter`` may be ``None``: the reconnect watcher initialises
``adapter = None`` before the ``try`` so the ``except Exception``
arm can dispose a half-constructed object, and also early-returns
here when ``_create_adapter()`` returned ``None``.
"""
if adapter is None:
return
try:
await adapter.disconnect()
except Exception:
# Half-constructed adapters (e.g. APIServerAdapter that
# crashed during aiohttp app setup) can raise from
# disconnect() on objects that never finished initializing.
# We must not let that escape and abort the watcher loop.
#
# On Python 3.8+, ``asyncio.CancelledError`` inherits from
# ``BaseException`` (not ``Exception``), so this ``except
# Exception`` does not swallow task cancellation. We don't
# re-raise explicitly because the watcher loop intentionally
# treats dispose failures as best-effort: a failed ``disconnect``
# call should not take down the reconnect watcher that
# itself is what's keeping the gateway alive during a partial
# outage.
logger.debug(
"Adapter dispose raised on unowned adapter %r",
getattr(adapter, "name", type(adapter).__name__),
exc_info=True,
)
class GatewayRunner:
"""
Main gateway controller.
@@ -6229,7 +6175,6 @@ class GatewayRunner:
platform.value, attempt,
)
adapter = None
try:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
@@ -6279,15 +6224,6 @@ class GatewayRunner:
"Reconnect %s: non-retryable error (%s), removing from retry queue",
platform.value, adapter.fatal_error_message,
)
# The adapter is about to be dropped from the queue
# without ever being installed on self.adapters, so
# nothing else will call disconnect() on it. We must
# dispose it here, otherwise the resource owners it
# constructed in __init__ (ResponseStore for
# APIServerAdapter, etc.) leak 2 fds each. The
# gateway hits the 2560-fd limit after ~12h of
# failed reconnects at the 300s backoff cap (#37011).
await _dispose_unused_adapter(adapter)
del self._failed_platforms[platform]
else:
self._update_platform_runtime_status(
@@ -6303,14 +6239,6 @@ class GatewayRunner:
"Reconnect %s failed, next retry in %ds",
platform.value, backoff,
)
# Same fd-leak concern as the non-retryable branch
# above: the adapter failed to connect and is being
# thrown away. Without an explicit dispose call, the
# resources it opened in __init__ stay open until
# the next GC pass — and aiohttp/SQLite handles
# don't get GC'd promptly, so 2 fds/retry leak at
# 300s backoff cap = ~12 fds/hour (#37011).
await _dispose_unused_adapter(adapter)
# Retryable failures (network/DNS blips) keep retrying
# at the backoff cap indefinitely — they self-heal once
# connectivity returns. We do NOT auto-pause them: a
@@ -6320,14 +6248,6 @@ class GatewayRunner:
# `not fatal_error_retryable` branch above, so anything
# reaching here is by definition retryable.
except Exception as e:
if adapter is not None:
# An exception escaping the connect call path
# (DNS timeout, aiohttp server.start() crash, etc.)
# leaves the adapter in the same unowned state as
# the two branches above. Dispose so __init__
# resources don't accumulate while the watcher
# keeps retrying.
await _dispose_unused_adapter(adapter)
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
@@ -12588,41 +12508,14 @@ class GatewayRunner:
except Exception:
pass
# Send media files, routing each by type so a TTS clip
# arrives as a voice bubble / a clip as a video rather than
# a generic document. Mirrors the streaming + kanban paths.
from gateway.platforms.base import (
should_send_media_as_audio as _should_send_media_as_audio,
)
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
# Send media files
for media_path, _is_voice in (media_files or []):
_ext = os.path.splitext(media_path)[1].lower()
try:
if _should_send_media_as_audio(source.platform, _ext, _is_voice):
await adapter.send_voice(
chat_id=source.chat_id,
audio_path=media_path,
metadata=_thread_metadata,
)
elif _ext in _VIDEO_EXTS:
await adapter.send_video(
chat_id=source.chat_id,
video_path=media_path,
metadata=_thread_metadata,
)
elif _ext in _IMAGE_EXTS:
await adapter.send_image_file(
chat_id=source.chat_id,
image_path=media_path,
metadata=_thread_metadata,
)
else:
await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
)
await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
)
except Exception:
pass
else:
+4 -19
View File
@@ -107,17 +107,14 @@ def set_session_vars(
user_name: str = "",
session_key: str = "",
message_id: str = "",
cwd: str = "",
) -> list:
"""Set all session context variables and return reset tokens.
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
the ``os.environ`` fallback) rather than restoring prior values these
helpers are not nestable/stack-safe, and the returned tokens are accepted
only for API compatibility.
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
the previous values when the handler exits.
``cwd`` pins the logical working directory for this context.
Returns a list of ``Token`` objects (one per variable) that can be
passed to ``clear_session_vars``.
"""
tokens = [
_SESSION_PLATFORM.set(platform),
@@ -129,12 +126,6 @@ def set_session_vars(
_SESSION_KEY.set(session_key),
_SESSION_MESSAGE_ID.set(message_id),
]
try:
from agent.runtime_cwd import set_session_cwd
set_session_cwd(cwd)
except Exception:
pass
return tokens
@@ -160,12 +151,6 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_MESSAGE_ID,
):
var.set("")
try:
from agent.runtime_cwd import clear_session_cwd
clear_session_cwd()
except Exception:
pass
def get_session_env(name: str, default: str = "") -> str:
-16
View File
@@ -41,8 +41,6 @@ _EPILOGUE = """
Examples:
hermes Start interactive chat
hermes chat -q "Hello" Single query mode
hermes --tui Launch the modern TUI (or set display.interface: tui)
hermes --cli Force the classic REPL (overrides display.interface: tui)
hermes -c Resume the most recent session
hermes -c "my project" Resume a session by name (latest in lineage)
hermes --resume <session_id> Resume a specific session by ID
@@ -220,13 +218,6 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
parser,
"--cli",
action="store_true",
default=False,
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
)
_inherited_flag(
parser,
"--dev",
@@ -378,13 +369,6 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
chat_parser,
"--cli",
action="store_true",
default=False,
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
)
_inherited_flag(
chat_parser,
"--dev",
+1 -3
View File
@@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
entry["last_error_reset_at"] = None
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
if last_refresh is None:
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -3380,8 +3380,6 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
if label and str(label).strip():
state["label"] = str(label).strip()
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
_save_auth_store(auth_store)
+15 -7
View File
@@ -307,20 +307,28 @@ def auth_add_command(args) -> None:
return
if provider == "openai-codex":
# Clear any existing suppression marker so a re-link after `hermes auth
# remove openai-codex` works without the new tokens being skipped.
auth_mod.unsuppress_credential_source(provider, "device_code")
creds = auth_mod._codex_device_code_login()
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds["tokens"]["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
auth_mod._save_codex_tokens(
creds["tokens"],
last_refresh=creds.get("last_refresh"),
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:device_code",
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
last_refresh=creds.get("last_refresh"),
)
pool = load_pool(provider)
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
shown_label = entry.label if entry is not None else label
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "xai-oauth":
+1 -7
View File
@@ -1283,12 +1283,6 @@ DEFAULT_CONFIG = {
# behavior of showing tool-call summaries inline.
"resume_skip_tool_only": True,
"busy_input_mode": "interrupt", # interrupt | queue | steer
# Which interface bare `hermes` (and `hermes chat`) launches by default:
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
# "tui" — the modern Ink TUI (same as passing `--tui`)
# Explicit flags always win over this setting: `--cli` forces the classic
# REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config.
"interface": "cli",
# When true, `hermes --tui` auto-resumes the most recent human-
# facing session on launch instead of forging a fresh one.
# Mirrors `hermes -c` muscle memory. Default off so existing
@@ -2290,7 +2284,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 26,
"_config_version": 25,
}
# =============================================================================
+10 -84
View File
@@ -1,32 +1,16 @@
"""WS-upgrade auth credentials for gated mode.
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
token is injected into the SPA bundle. In gated mode there is no injected
token so this module provides two credential shapes:
token the SPA gets a fresh ticket via the authenticated REST endpoint
``POST /api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
WS upgrade.
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
The SPA gets a fresh ticket via the authenticated REST endpoint
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
upgrade. Single-use, TTL = 30 seconds a leaked ticket is uninteresting.
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
``consume_internal_credential``). This authenticates *server-spawned*
WS clients specifically the embedded-TUI PTY child, which attaches to
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
loopback. A single-use 30s ticket is the wrong shape for that link: the
child reads its attach URL once at startup and **reuses it on every
reconnect**, and on a slow cold boot the child may not dial within 30s.
The internal credential is minted once per process, never expires, is
multi-use, and critically is **never injected into any HTML/SPA**:
it only ever leaves the process via the spawned child's environment, so
browser-side XSS cannot read it. A leaked internal credential grants no
more than a single-use ticket already does (the same two internal WS
endpoints), and the same Origin / host guards still apply downstream.
In-memory; the dashboard is a single process so no distributed coordination
is needed. The module exposes a small functional API rather than a class so
tests can patch ``time.time`` cleanly.
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
single process so no distributed coordination is needed. The module
exposes a small functional API rather than a class so tests can patch
``time.time`` cleanly.
"""
from __future__ import annotations
@@ -34,7 +18,7 @@ from __future__ import annotations
import secrets
import threading
import time
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Tuple
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
@@ -44,16 +28,6 @@ TTL_SECONDS = 30
_lock = threading.Lock()
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
#: The process-lifetime internal credential (see module docstring). Lazily
#: minted on first ``internal_ws_credential()`` call and stable for the life
#: of the process. Guarded by ``_lock``.
_internal_credential: Optional[str] = None
#: Identity recorded for connections that authenticate via the internal
#: credential, so audit logs distinguish them from browser-initiated tickets.
INTERNAL_USER_ID = "server-internal"
INTERNAL_PROVIDER = "server-internal"
class TicketInvalid(Exception):
"""Ticket missing, expired, or already consumed."""
@@ -107,55 +81,7 @@ def _gc_expired_locked() -> None:
_tickets.pop(t, None)
def internal_ws_credential() -> str:
"""Return the process-lifetime internal WS credential, minting it once.
Used by the server to authenticate WS clients it spawns itself (the
embedded-TUI PTY child). The value is stable for the life of the process,
multi-use, and never expires so a server-spawned child can reconnect
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
The credential is never injected into the SPA HTML or returned over any
REST endpoint; it is only ever passed to a child process via its
environment. See the module docstring for the threat-model rationale.
"""
global _internal_credential
with _lock:
if _internal_credential is None:
_internal_credential = secrets.token_urlsafe(32)
return _internal_credential
def consume_internal_credential(value: str) -> Dict[str, Any]:
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
Unlike :func:`consume_ticket` this is **not** single-use the value is
not removed on success, so a server-spawned child can present it on every
(re)connect. Returns the fixed server-internal identity ``info`` dict
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
returns, so a caller that wants to record the connecting identity can; the
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
discards the dict.
A constant-time compare against the (lazily-minted) credential avoids
leaking length / prefix information on mismatch. If no internal
credential has been minted yet, any value is rejected.
"""
with _lock:
expected = _internal_credential
if not value or expected is None:
raise TicketInvalid("no internal credential")
if not secrets.compare_digest(value.encode(), expected.encode()):
raise TicketInvalid("internal credential mismatch")
return {
"user_id": INTERNAL_USER_ID,
"provider": INTERNAL_PROVIDER,
}
def _reset_for_tests() -> None:
"""Test-only: drop all tickets and the internal credential."""
global _internal_credential
"""Test-only: drop all tickets."""
with _lock:
_tickets.clear()
_internal_credential = None
-45
View File
@@ -115,7 +115,6 @@ def build_models_payload(
picker_hints: bool = False,
canonical_order: bool = False,
pricing: bool = False,
capabilities: bool = False,
max_models: int = 50,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
@@ -135,10 +134,6 @@ def build_models_payload(
show $/Mtok columns and gate paid models on free accounts
mirroring the ``hermes model`` CLI picker. Adds network calls
(pricing fetch + Nous tier check); only set for interactive pickers.
- ``capabilities``: add a per-row ``capabilities`` map
``{model: {fast, reasoning}}`` so pickers can gate the model-options
controls (fast toggle / reasoning) to what each model actually
supports, instead of offering knobs the backend would reject.
"""
from hermes_cli.model_switch import list_authenticated_providers
@@ -159,8 +154,6 @@ def build_models_payload(
rows = _reorder_canonical(rows)
if pricing:
_apply_pricing(rows)
if capabilities:
_apply_capabilities(rows)
return {
"providers": rows,
@@ -169,44 +162,6 @@ def build_models_payload(
}
def _apply_capabilities(rows: list[dict]) -> None:
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
enforces). `reasoning` comes from the models.dev catalog when known and
defaults to True otherwise the effort dial is broadly accepted and a
no-op on models that ignore it, whereas hiding it from a capable-but-
uncatalogued model is the worse failure.
"""
from hermes_cli.models import model_supports_fast_mode
try:
from agent.models_dev import get_model_capabilities
except Exception:
get_model_capabilities = None # type: ignore[assignment]
for row in rows:
slug = row.get("slug") or ""
caps: dict[str, dict[str, bool]] = {}
for model in row.get("models") or []:
reasoning = True
if get_model_capabilities is not None and slug:
try:
meta = get_model_capabilities(slug, model)
if meta is not None:
reasoning = bool(meta.supports_reasoning)
except Exception:
reasoning = True
caps[model] = {
"fast": bool(model_supports_fast_mode(model)),
"reasoning": reasoning,
}
row["capabilities"] = caps
# ─── Internal: row post-processing ──────────────────────────────────────
+96 -484
View File
@@ -105,58 +105,6 @@ def _set_process_title() -> None:
pass
# Cheap, dependency-free read of `display.interface` from config.yaml for the
# earliest hot-path decisions (mouse-residue suppression, Termux fast launch)
# that run *before* hermes_cli.config is importable. Mirrors the explicit
# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then
# this config value. Cached so the multiple early callers don't re-parse YAML.
_EARLY_INTERFACE_CACHE: "list | None" = None
def _config_default_interface_early() -> str:
"""Return the configured default interface ("cli"/"tui") via a minimal
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
global _EARLY_INTERFACE_CACHE
if _EARLY_INTERFACE_CACHE is not None:
return _EARLY_INTERFACE_CACHE[0]
value = "cli"
try:
home = os.environ.get("HERMES_HOME")
if home:
cfg_path = os.path.join(home, "config.yaml")
else:
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
if os.path.exists(cfg_path):
import yaml as _yaml_iface
with open(cfg_path, encoding="utf-8") as _f:
raw = _yaml_iface.safe_load(_f) or {}
disp = raw.get("display", {})
if isinstance(disp, dict):
iface = disp.get("interface")
if isinstance(iface, str) and iface.strip().lower() == "tui":
value = "tui"
except Exception:
value = "cli" # best-effort — default to classic REPL on any error
_EARLY_INTERFACE_CACHE = [value]
return value
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
"""Earliest TUI decision, usable before argparse/config imports.
Precedence: explicit ``--cli`` wins (forces classic REPL), then
``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config.
"""
if argv is None:
argv = sys.argv[1:]
if "--cli" in argv:
return False
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
return True
return _config_default_interface_early() == "tui"
# Mouse-tracking residue suppression — runs BEFORE every other import on the
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
# Python launcher is still doing imports (≈100300ms in cooked + echo mode,
@@ -168,7 +116,7 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
def _suppress_mouse_residue_early() -> None:
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
return
if not _wants_tui_early():
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
return
try:
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
@@ -253,10 +201,8 @@ if _try_termux_ultrafast_version():
raise SystemExit(0)
import argparse
import hashlib
import json
import shutil
import stat
import subprocess
from pathlib import Path
from typing import Optional
@@ -1237,33 +1183,6 @@ to avoid false-positive reinstalls on every launch.
"""
def _workspace_root(dir: Path) -> Path:
"""Return the npm workspace root for *dir*.
In a workspace checkout the single ``package-lock.json`` and hoisted
``node_modules/`` live at the workspace root (the parent of the
sub-package directory). Heuristic: if *dir* has a ``package.json``
but **no** ``package-lock.json``, and its **parent** has a
``package-lock.json``, the parent is the workspace root.
Otherwise *dir* itself is the root (standalone project or
prebuilt-bundle layout).
Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and
``_build_web_ui`` so that lockfile/node_modules resolution and
``npm install`` cwd stay consistent a single helper prevents
the checks from diverging if someone accidentally creates a
sub-package lockfile (e.g. running ``npm install`` in the wrong
directory).
"""
if (
(dir / "package.json").is_file()
and not (dir / "package-lock.json").is_file()
and (dir.parent / "package-lock.json").is_file()
):
return dir.parent
return dir
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@@ -1272,12 +1191,6 @@ def _tui_need_npm_install(root: Path) -> bool:
``package.json``), skip reinstall entirely the bundle is self-contained
and there is nothing to install.
With npm workspaces the single ``package-lock.json`` and the hoisted
``node_modules/`` live at the workspace root (the parent of the
``ui-tui/`` directory). The lockfile / ink / marker checks use that
workspace root; only the prebuilt-bundle sentinel stays relative to
*root* (``ui-tui/dist/entry.js``).
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
rewrites can bump the root lockfile's timestamp even when installed deps
@@ -1295,21 +1208,19 @@ def _tui_need_npm_install(root: Path) -> bool:
we'd rather not force a reinstall for them. Falls back to mtime
comparison if either lockfile is unparseable.
"""
lock = root / "package-lock.json"
entry = root / "dist" / "entry.js"
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
# shipped, dist/entry.js is the single runtime artefact.
entry = root / "dist" / "entry.js"
# With npm workspaces the lockfile lives at the workspace root.
ws_root = _workspace_root(root)
lock = ws_root / "package-lock.json"
if entry.is_file() and not lock.is_file():
return False
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
if not ink.is_file():
return True
if not lock.is_file():
return False
marker = ws_root / "node_modules" / ".package-lock.json"
marker = root / "node_modules" / ".package-lock.json"
if not marker.is_file():
return True
@@ -1358,6 +1269,7 @@ _TUI_BUILD_INPUT_FILES = (
"babel.compiler.config.cjs",
"scripts/build.mjs",
"packages/hermes-ink/package.json",
"packages/hermes-ink/package-lock.json",
"packages/hermes-ink/index.js",
"packages/hermes-ink/text-input.js",
)
@@ -1524,8 +1436,6 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
did_install = False
if _tui_need_npm_install(tui_dir):
npm = _node_bin("npm")
@@ -1533,7 +1443,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
print("Installing TUI dependencies…")
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(_workspace_root(tui_dir)),
cwd=str(tui_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -1820,34 +1730,9 @@ def _sync_bundled_skills_quietly() -> None:
pass
def _resolve_use_tui(args) -> bool:
"""Decide whether to launch the TUI for a chat/bare invocation.
Precedence (highest first):
1. ``--cli`` flag always classic REPL
2. ``--tui`` flag / ``HERMES_TUI=1`` always TUI
3. ``display.interface`` config value ("cli" | "tui")
4. default classic REPL
Explicit flags always win over config so muscle memory and scripts keep
working regardless of the configured default.
"""
if getattr(args, "cli", False):
return False
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
return True
try:
from hermes_cli.config import load_config
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
return isinstance(iface, str) and iface.strip().lower() == "tui"
except Exception:
return False
def cmd_chat(args):
"""Run interactive chat CLI."""
use_tui = _resolve_use_tui(args)
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
# Resolve --continue into --resume with the latest session or by name
continue_val = getattr(args, "continue_last", None)
@@ -6718,6 +6603,7 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
return True
for meta in (
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"vite.config.ts",
@@ -6726,10 +6612,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
mp = web_dir / meta
if mp.exists() and mp.stat().st_mtime > dist_mtime:
return True
# Workspace root lockfile (single package-lock.json covers all workspaces).
root_lock = project_root / "package-lock.json"
if root_lock.exists() and root_lock.stat().st_mtime > dist_mtime:
return True
return False
@@ -6926,11 +6808,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if text:
_say(text)
r1 = _run_npm_install_deterministic(
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
if r1.returncode != 0:
_say(
f" {'' if fatal else ''} Web UI npm install failed"
@@ -6991,147 +6869,6 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
return (desktop_dir / "dist" / "index.html").exists()
# ---------------------------------------------------------------------------
# Desktop build stamp — content-hash based skip logic
# ---------------------------------------------------------------------------
# The desktop Electron build is expensive.
# Unlike the web UI (which uses mtime comparison), the desktop uses a
# SHA-256 content hash of the source tree so that:
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
# don't trigger a rebuild
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
# and it will skip if nothing actually changed
# - ``hermes desktop`` (interactive launch) skips the build when the
# stamp matches, making repeated launches fast
#
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
# Schema:
# {
# "contentHash": "<sha256 hex of source files>",
# "sourceMode": true | false,
# "builtAt": "<ISO 8601>"
# }
def _compute_desktop_content_hash(project_root: Path) -> str:
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
plus the root ``package.json`` / ``package-lock.json`` (workspace config
that determines dependency resolution for the desktop workspace).
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
a hardcoded skip-list.
"""
h = hashlib.sha256()
def _hash_file(path: Path) -> None:
rel = str(path.relative_to(project_root))
h.update(rel.encode())
h.update(b"\0")
try:
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
except (OSError, IOError):
pass
h.update(b"\0")
from pathspec import PathSpec
gitignore = project_root / ".gitignore"
lines: list[str] = []
if gitignore.is_file():
lines = gitignore.read_text(encoding="utf-8").splitlines()
spec = PathSpec.from_lines("gitignore", lines)
# Root workspace config
for name in ("package.json", "package-lock.json"):
p = project_root / name
if p.is_file():
rel = str(p.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(p)
# Walk apps/desktop/ — prune ignored directories in-place
desktop_dir = project_root / "apps" / "desktop"
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
# Prune ignored directories so we never descend into them
dirnames[:] = [
d for d in dirnames
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
]
for fn in sorted(filenames):
fp = Path(dirpath) / fn
rel = str(fp.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(fp)
return h.hexdigest()
def _desktop_stamp_path() -> Path:
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "desktop-build-stamp.json"
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
"""Return True when the desktop build output is stale or missing.
Compares the current content hash against the saved stamp. Also returns
True if the expected build artifact doesn't exist (e.g. first run after
``hermes update`` that pulled new source but hasn't built yet).
"""
# If there's no build output at all, we definitely need to build
if source_mode:
if not _desktop_dist_exists(desktop_dir):
return True
else:
if _desktop_packaged_executable(desktop_dir) is None:
return True
stamp_file = _desktop_stamp_path()
if not stamp_file.is_file():
return True
try:
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, KeyError):
return True
# If the mode changed (source vs packaged), force a rebuild
if stamp_data.get("sourceMode") != source_mode:
return True
saved_hash = stamp_data.get("contentHash")
if not saved_hash:
return True
current_hash = _compute_desktop_content_hash(project_root)
return current_hash != saved_hash
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
"""Write the desktop build stamp after a successful build."""
stamp_file = _desktop_stamp_path()
try:
stamp_file.parent.mkdir(parents=True, exist_ok=True)
content_hash = _compute_desktop_content_hash(project_root)
from datetime import datetime, timezone
stamp_data = {
"contentHash": content_hash,
"sourceMode": source_mode,
"builtAt": datetime.now(timezone.utc).isoformat(),
}
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
except Exception as exc:
# Never let stamp-writing block or fail a build
logger.debug("Failed to write desktop build stamp: %s", exc)
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
"""Return the current platform's unpacked Electron app executable."""
release_dir = desktop_dir / "release"
@@ -7192,46 +6929,8 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
print(f" (warning: macOS relaunch fixup skipped: {exc})")
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
"""Configure Electron's Linux SUID sandbox helper when required."""
if sys.platform != "linux":
return True
sandbox = packaged_executable.parent / "chrome-sandbox"
if not sandbox.exists():
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
return False
# Reject symlinks — chown/chmod must not follow an attacker-controlled
# link to an arbitrary path. Use lstat() so we inspect the link itself
# rather than the target, and require a regular file.
try:
sandbox_lstat = sandbox.lstat()
except OSError:
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
return False
if not stat.S_ISREG(sandbox_lstat.st_mode):
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
return False
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
return True
sudo = shutil.which("sudo")
if not sudo:
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
return False
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
if subprocess.run(command, check=False).returncode != 0:
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
return False
return True
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
def cmd_gui(args):
"""Build and launch the local Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
if not (desktop_dir / "package.json").exists():
print(f"Desktop GUI source not found at: {desktop_dir}")
@@ -7255,20 +6954,19 @@ def cmd_gui(args: argparse.Namespace):
source_mode = getattr(args, "source", False)
skip_build = getattr(args, "skip_build", False)
force_build = getattr(args, "force_build", False)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if source_mode or not skip_build:
npm = shutil.which("npm")
if not npm:
print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.")
print("Install Node.js, then run: hermes gui")
print("Desktop GUI requires Node.js/npm to build from this checkout, but npm was not found on PATH.")
print("Install Node.js, then run: hermes desktop")
print("Or download Hermes Desktop from: https://hermes-agent.nousresearch.com/desktop")
sys.exit(1)
else:
npm = None
if skip_build:
if getattr(args, "skip_build", False):
if source_mode:
if not _desktop_dist_exists(desktop_dir):
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
@@ -7289,41 +6987,29 @@ def cmd_gui(args: argparse.Namespace):
else:
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
else:
# Check the content-hash stamp before doing any build work.
# If the source tree hasn't changed since the last successful build,
# skip the npm install + build entirely (saves a ton of useless work).
# --force-build overrides the stamp and always rebuilds.
build_needed = force_build or _desktop_build_needed(
desktop_dir, PROJECT_ROOT, source_mode=source_mode
)
if not build_needed:
build_label = "source build" if source_mode else "packaged app"
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
else:
print("→ Installing desktop workspace dependencies...")
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
if install_result.returncode != 0:
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
if not source_mode:
print("→ Building a local unpacked Electron app from this checkout (not a release installer).")
print("→ Installing desktop workspace dependencies...")
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
if install_result.returncode != 0:
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
# Locally-built apps are ad-hoc signed; make them relaunchable after
# an in-place self-update (otherwise macOS reports "Hermes is
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
# Locally-built apps are ad-hoc signed; make them relaunchable after
# an in-place self-update (otherwise macOS reports "Hermes is
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
# --build-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
@@ -7355,9 +7041,6 @@ def cmd_gui(args: argparse.Namespace):
print(" Expected an unpacked Electron app for the current OS.")
sys.exit(1)
if not _desktop_linux_sandbox_fixup(packaged_executable):
sys.exit(1)
print(f"→ Launching packaged Hermes Desktop: {packaged_executable}")
launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False)
sys.exit(launch_result.returncode)
@@ -7799,21 +7482,8 @@ def _update_via_zip(args):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
@@ -8913,27 +8583,16 @@ def _install_psutil_android_compat(
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs.
The normal path (``ensure_uv()`` in managed_uv) installs the managed
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
which gets a Termux-compatible binary.
"""
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _is_termux_env():
return None
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
if uv_bin or not _is_termux_env():
return uv_bin
try:
print(" → Termux detected: trying to install uv for faster dependency updates...")
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
# After pip install, check managed path first, then PATH
return resolve_uv() or shutil.which("uv")
return shutil.which("uv")
def _update_node_dependencies() -> None:
@@ -8941,48 +8600,45 @@ def _update_node_dependencies() -> None:
if not npm:
return
if not (PROJECT_ROOT / "package.json").exists():
paths = (
("repo root", PROJECT_ROOT),
("ui-tui", PROJECT_ROOT / "ui-tui"),
)
if not any((path / "package.json").exists() for _, path in paths):
return
# With a single workspace lockfile the root install would cover ALL
# workspaces — but apps/desktop pulls in Electron as a devDependency,
# and its postinstall downloads a ~200MB binary. Most users don't
# need desktop during `hermes update`, so we install root-only first
# then add just the workspaces the CLI/TUI/web build actually requires.
# Desktop deps are installed on demand by the desktop launcher
# (see _desktop_build_needed).
print("→ Updating Node.js dependencies...")
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
for label, path in paths:
if not (path / "package.json").exists():
continue
# Step 1: root install (no workspace recursion).
root_args = [*extra_args, "--workspaces=false"]
root_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(root_args),
capture_output=False,
)
if root_result.returncode != 0:
print(" ⚠ npm install failed in repo root")
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")
return
# Stream npm output (no `--silent`, no `capture_output`) so any
# optional dependency postinstall scripts (e.g. `agent-browser`'s
# Chromium fetch on first install) print progress instead of
# appearing to hang silently for minutes (#18840). The
# `_UpdateOutputStream` wrapper installed by the updater mirrors
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
#
# The repo root install also passes `--workspaces=false` so npm
# does not recursively install every `apps/*` workspace (dashboard,
# desktop, shared) — those are installed/built on demand via
# `_build_web_ui()` and the desktop launchers.
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
if path == PROJECT_ROOT:
extra_args.append("--workspaces=false")
# Step 2: install only the workspaces update needs (ui-tui, web).
# --workspace selects specific workspaces; the rest (desktop) are skipped.
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
ws_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(ws_args),
capture_output=False,
)
if ws_result.returncode == 0:
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
else:
print(" ⚠ npm workspace install failed")
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
result = _run_npm_install_deterministic(
npm,
path,
extra_args=tuple(extra_args),
capture_output=False,
)
if result.returncode == 0:
print(f"{label}")
continue
print(f" ⚠ npm install failed in {label}")
stderr = (result.stderr or "").strip() if result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")
@@ -9584,12 +9240,7 @@ def _cmd_update_pip(args):
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current before using it.
update_managed_uv()
uv, _fresh_bootstrap = ensure_uv()
uv = shutil.which("uv")
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
@@ -9604,8 +9255,7 @@ def _cmd_update_pip(args):
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
@@ -10001,21 +9651,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
@@ -10063,25 +9700,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
# Rebuild the desktop app if the source tree changed since the last
# build. ``hermes desktop --build-only`` uses the content-hash stamp
# internally, so this is effectively a no-op when nothing changed.
# Only bother if the user has a desktop app installed (indicated by
# an existing packaged executable or desktop dist); people who have
# never run ``hermes desktop`` shouldn't be forced into a full
# Electron build by ``hermes update``.
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
print("→ Checking if desktop app needs rebuilding...")
build_result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
cwd=PROJECT_ROOT,
check=False,
)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
print()
print("✓ Code updated!")
@@ -11749,7 +11367,7 @@ def cmd_dashboard(args):
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
elif getattr(args, "skip_build", False):
# --build-mode skip trusts the caller to have pre-built the web UI.
# --skip-build trusts the caller to have pre-built the web UI.
# Verify the dist actually exists; otherwise the server will start
# and serve 404s with no obvious cause (issue #23817).
_dist_root = (
@@ -12061,10 +11679,7 @@ def _try_termux_fast_cli_launch() -> bool:
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
return False
# Let the TUI fast path (or full dispatch) handle anything that resolves to
# the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this
# to stay False so the classic fast path still runs.
if _wants_tui_early(argv):
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
return False
if _is_termux_fast_version_argv(argv):
@@ -12139,7 +11754,7 @@ def _try_termux_fast_tui_launch() -> bool:
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
return False
wants_tui = _wants_tui_early(sys.argv[1:])
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
if not wants_tui:
return False
@@ -12158,7 +11773,7 @@ def _try_termux_fast_tui_launch() -> bool:
return False
if getattr(args, "command", None) not in {None, "chat"}:
return False
if not _resolve_use_tui(args):
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
return False
cmd_chat(args)
@@ -15114,13 +14729,20 @@ Examples:
gui_parser = subparsers.add_parser(
"desktop",
aliases=["gui"],
help="Build and launch the native desktop app",
help="Build and launch the local desktop app",
description=(
"Launch the Hermes Electron desktop app. By default this installs "
"workspace Node dependencies, builds the current OS's unpacked "
"Electron app, then launches that packaged artifact."
"Launch the Hermes Electron desktop app from this checkout. By default "
"this installs workspace Node dependencies, builds the current OS's "
"unpacked Electron app with electron-builder --dir, then launches that "
"artifact. It does not download Hermes Desktop; use "
"https://hermes-agent.nousresearch.com/desktop for that."
),
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--source",
action="store_true",
@@ -15149,16 +14771,6 @@ Examples:
"--cwd",
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--force-build",
action="store_true",
help="Force a full rebuild even if the content stamp matches",
)
gui_parser.set_defaults(func=cmd_gui)
# =========================================================================
-228
View File
@@ -1,228 +0,0 @@
"""Managed uv — one path, no guessing.
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
Windows). Every code path that needs uv resolves it from that single location.
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def managed_uv_path() -> Path:
"""Return the path where Hermes keeps *its* uv binary.
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
Windows. The directory may not exist yet callers should use
``ensure_uv()`` to bootstrap it.
"""
home = get_hermes_home()
if platform.system() == "Windows":
return home / "bin" / "uv.exe"
return home / "bin" / "uv"
def resolve_uv() -> Optional[str]:
"""Return the managed uv path if it exists, else ``None``.
No side effects pure lookup.
"""
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
back to pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
print(f" → Installing managed uv into {target.parent} ...")
try:
_install_uv(target)
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
# Verify
result = resolve_uv()
if result:
version = subprocess.run(
[result, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
"""
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
shutil.rmtree(venv_dir, ignore_errors=True)
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path on success, ``None`` if uv isn't available or
the self-update fails (non-fatal the old version still works).
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
return existing
# ---------------------------------------------------------------------------
# Installer internals
# ---------------------------------------------------------------------------
def _install_uv(target: Path) -> None:
"""Bootstrap uv into *target* using the official standalone installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
"""
system = platform.system()
env = {
**os.environ,
# Tell the astral installer to drop the binary in our dir, not
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
# uses UV_INSTALL_DIR.
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
else:
_install_uv_posix(env)
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
installer_path = f.name
try:
subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
check=True,
capture_output=True,
)
subprocess.run(
["sh", installer_path],
env=env,
check=True,
capture_output=True,
)
finally:
try:
os.unlink(installer_path)
except OSError:
pass
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
+7 -15
View File
@@ -241,12 +241,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
# Code Assist serves two flash slugs with different access gates
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
# that subscription/free-tier OAuth users actually reach, while
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
# aren't stuck with a slug cloudcode-pa 404s for them.
"gemini-3-flash-preview",
"gemini-3.5-flash",
],
"zai": [
@@ -1868,21 +1862,19 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
This gates the *speed=fast request parameter*, which Anthropic supports on
Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general
"is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE
model id (``-opus-4.8-fast``) selected via the model field, not the speed
parameter see ``agent.anthropic_adapter._supports_fast_mode`` and its
test. Keep this in lock-step with that adapter gate so the UI never shows a
Fast toggle that the runtime would silently drop.
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
with an unsupported model returns an error." Opus 4.7 explicitly rejects
the ``speed`` parameter with HTTP 400.
"""
raw = _strip_vendor_prefix(str(model_id or ""))
base = raw.split(":")[0]
if not base.startswith("claude-"):
return False
# Only Opus 4.6 supports the speed=fast parameter at present.
# Only Opus 4.6 supports fast mode at present.
return "opus-4-6" in base or "opus-4.6" in base
+17 -20
View File
@@ -202,13 +202,6 @@ TOOL_CATEGORIES = {
"name": "Text-to-Speech",
"icon": "🔊",
"providers": [
{
"name": "Microsoft Edge TTS",
"badge": "★ recommended · free",
"tag": "Good quality, no API key needed",
"env_vars": [],
"tts_provider": "edge",
},
{
"name": "Nous Subscription",
"badge": "subscription",
@@ -219,6 +212,13 @@ TOOL_CATEGORIES = {
"managed_nous_feature": "tts",
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
},
{
"name": "Microsoft Edge TTS",
"badge": "★ recommended · free",
"tag": "Good quality, no API key needed",
"env_vars": [],
"tts_provider": "edge",
},
{
"name": "OpenAI TTS",
"badge": "paid",
@@ -406,26 +406,15 @@ TOOL_CATEGORIES = {
# Per-provider rows for Browserbase, Browser Use, and Firecrawl are
# injected at runtime from plugins.browser.<vendor>.provider via
# _plugin_browser_providers() in _visible_providers(). Only
# non-provider UX setup-flow rows remain here. "Local Browser" is
# listed FIRST so it is the default-highlighted (index 0) choice on a
# fresh install — pressing Enter must land on the free, no-key local
# backend, never on the paid Nous Subscription gateway row:
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
# non-provider UX setup-flow rows remain here:
# - "Nous Subscription (Browser Use cloud)" — managed Browser Use
# billed via Nous subscription (requires_nous_auth +
# override_env_vars). Uses the browser-use plugin as the
# underlying backend but has a distinct setup UX.
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
# - "Camofox" — anti-detection local Firefox; short-circuits the
# cloud-provider dispatch path via _is_camofox_mode().
"providers": [
{
"name": "Local Browser",
"badge": "★ recommended · free",
"tag": "Headless Chromium, no API key needed",
"env_vars": [],
"browser_provider": "local",
"post_setup": "agent_browser",
},
{
"name": "Nous Subscription (Browser Use cloud)",
"badge": "subscription",
@@ -437,6 +426,14 @@ TOOL_CATEGORIES = {
"override_env_vars": ["BROWSER_USE_API_KEY"],
"post_setup": "agent_browser",
},
{
"name": "Local Browser",
"badge": "★ recommended · free",
"tag": "Headless Chromium, no API key needed",
"env_vars": [],
"browser_provider": "local",
"post_setup": "agent_browser",
},
{
"name": "Camofox",
"badge": "free · local",
+70 -552
View File
@@ -9,8 +9,6 @@ Usage:
python -m hermes_cli.main web --port 8080
"""
from contextlib import asynccontextmanager
import asyncio
import base64
import binascii
@@ -86,43 +84,7 @@ except ImportError:
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
#
# State lives on app.state (not module-level globals) so that asyncio.Lock is
# created on the running event loop during lifespan startup. A module-level
# asyncio.Lock() binds to whatever loop was active at import time, which breaks
# when the same module is used across TestClient instances or uvicorn reloads.
# ---------------------------------------------------------------------------
@asynccontextmanager
async def _lifespan(app: "FastAPI"):
app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock()
yield
def _get_event_state(app: "FastAPI"):
"""Return (event_channels, event_lock) from app.state.
Lazily initialises the state if the lifespan hasn't run (e.g. when
TestClient is constructed without a ``with`` block). The lifespan
path is preferred because it guarantees the Lock is created on the
correct event loop, but the lazy path lets existing non-``with``
TestClient usages keep working.
"""
try:
return app.state.event_channels, app.state.event_lock
except AttributeError:
app.state.event_channels = {}
app.state.event_lock = asyncio.Lock()
return app.state.event_channels, app.state.event_lock
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
app = FastAPI(title="Hermes Agent", version=__version__)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
@@ -1665,9 +1627,7 @@ def get_model_options():
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
return build_models_payload(
load_picker_context(), max_models=50, pricing=True, capabilities=True
)
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")
@@ -2975,17 +2935,6 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://www.minimax.io",
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
},
{
"id": "xai-oauth",
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
# callback server, the client opens the browser, and the redirect
# lands back on the loopback listener — no code to copy/paste.
"flow": "loopback",
"cli_command": "hermes auth add xai-oauth",
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
)
@@ -3039,20 +2988,6 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"expires_at": raw.get("expires_at"),
"has_refresh_token": True,
}
if provider_id == "xai-oauth":
raw = hauth.get_xai_oauth_auth_status()
# source_label is meant to be a human-readable origin (auth-store
# path / credential source), not the internal auth_mode string
# ("oauth_pkce"). Prefer the store path, then the source slug.
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "xai_oauth",
"source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
"last_refresh": raw.get("last_refresh"),
}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {"logged_in": False}
@@ -3065,7 +3000,7 @@ async def list_oauth_providers():
Response shape (per provider):
id stable identifier (used in DELETE path)
name human label
flow "pkce" | "device_code" | "external" | "loopback"
flow "pkce" | "device_code" | "external"
cli_command fallback CLI command for users to run manually
docs_url external docs/portal link for the "Learn more" link
status:
@@ -3165,19 +3100,6 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
# 4. On "approved" the background thread has already saved creds; UI
# refreshes the providers list.
#
# Loopback PKCE (xAI Grok):
# 1. POST /api/providers/oauth/xai-oauth/start
# → server binds a 127.0.0.1 callback listener, builds the xAI
# authorize URL, spawns a background worker waiting on the redirect
# → returns { session_id, flow: "loopback", auth_url, expires_in }
# 2. UI opens auth_url in the browser. There is NO user_code/code to
# paste — the redirect lands back on the loopback listener.
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
# (same endpoint as device_code) until status != "pending".
# 4. The worker exchanges the code, persists creds, sets "approved".
# DELETE /sessions/{id} cancels: the worker bails before persisting
# and the callback server is shut down to free the port immediately.
#
# Sessions are kept in-memory only (single-process FastAPI) and time out
# after 15 minutes. A periodic cleanup runs on each /start call to GC
# expired sessions so the dict doesn't grow without bound.
@@ -3561,220 +3483,6 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
# device-code providers there is no user_code to display: the local backend
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
# the browser, and the redirect lands back on the loopback listener. The
# background worker waits for that callback, exchanges the code, and persists
# the tokens exactly like `hermes auth add xai-oauth`.
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow() -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
background worker that waits for the redirect and finishes the exchange.
Returns the authorize URL for the client to open in the browser.
"""
from hermes_cli import auth as hauth
discovery = hauth._xai_oauth_discovery()
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
try:
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
verifier = hauth._oauth_pkce_code_verifier()
challenge = hauth._oauth_pkce_code_challenge(verifier)
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
authorize_url = hauth._xai_oauth_build_authorize_url(
authorization_endpoint=discovery["authorization_endpoint"],
redirect_uri=redirect_uri,
code_challenge=challenge,
state=state,
nonce=nonce,
)
except Exception:
# Binding succeeded but URL construction failed — release the socket
# and join the serving thread so we don't leak a listener (or a
# lingering daemon thread) on the loopback port.
try:
server.shutdown()
server.server_close()
except Exception:
pass
try:
thread.join(timeout=1.0)
except Exception:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback")
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
sess["redirect_uri"] = redirect_uri
sess["verifier"] = verifier
sess["challenge"] = challenge
sess["state"] = state
sess["token_endpoint"] = discovery["token_endpoint"]
sess["discovery"] = discovery
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
threading.Thread(
target=_xai_loopback_worker, args=(sid,), daemon=True,
name=f"oauth-xai-{sid[:6]}",
).start()
return {
"session_id": sid,
"flow": "loopback",
"auth_url": authorize_url,
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
}
def _xai_loopback_worker(session_id: str) -> None:
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
from datetime import datetime, timezone
from hermes_cli import auth as hauth
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
def _fail(message: str) -> None:
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "error"
s["error_message"] = message
def _cancelled() -> bool:
# The session is removed from the registry when the user cancels
# (DELETE /sessions/{id}). If that happened while we were blocked on
# the callback or token exchange, abort instead of persisting tokens
# the user no longer wants.
with _oauth_sessions_lock:
return session_id not in _oauth_sessions
try:
callback = hauth._xai_wait_for_callback(
sess["server"],
sess["thread"],
sess["callback_result"],
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
)
except Exception as exc:
_fail(f"xAI authorization timed out: {exc}")
return
if _cancelled():
return
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
_fail(f"xAI authorization failed: {detail}")
return
if callback.get("state") != sess["state"]:
_fail("xAI authorization failed: state mismatch.")
return
code = str(callback.get("code") or "").strip()
if not code:
_fail("xAI authorization failed: missing authorization code.")
return
try:
payload = hauth._xai_oauth_exchange_code_for_tokens(
token_endpoint=sess["token_endpoint"],
code=code,
redirect_uri=sess["redirect_uri"],
code_verifier=sess["verifier"],
code_challenge=sess["challenge"],
)
access_token = str(payload.get("access_token", "") or "").strip()
refresh_token = str(payload.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
_fail("xAI token exchange did not return the expected tokens.")
return
base_url = hauth._xai_validate_inference_base_url(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
)
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": str(payload.get("id_token", "") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
}
if _cancelled():
return
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "approved"
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
def _add_xai_oauth_pool_entry(
access_token: str, refresh_token: str, base_url: str, last_refresh: str
) -> None:
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
of truth for runtime resolution; the pool entry only matters for the
rotation strategy.
"""
try:
import uuid
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
pool = load_pool("xai-oauth")
existing = [
e for e in pool.entries()
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
]
for e in existing:
try:
pool.remove_entry(getattr(e, "id", ""))
except Exception:
pass
entry = PooledCredential(
provider="xai-oauth",
id=uuid.uuid4().hex[:6],
label="dashboard PKCE",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
access_token=access_token,
refresh_token=refresh_token,
base_url=base_url,
last_refresh=last_refresh,
)
pool.add_entry(entry)
except Exception as e:
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
def _nous_poller(session_id: str) -> None:
"""Background poller that drives a Nous device-code flow to completion."""
from hermes_cli.auth import (
@@ -4021,12 +3729,31 @@ def _codex_full_login_worker(session_id: str) -> None:
if not access_token:
raise RuntimeError("token exchange did not return access_token")
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
# Persist via credential pool — same shape as auth_commands.add_command
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
import uuid as _uuid
pool = load_pool("openai-codex")
base_url = (
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
or DEFAULT_CODEX_BASE_URL
)
entry = PooledCredential(
provider="openai-codex",
id=_uuid.uuid4().hex[:6],
label="dashboard device_code",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:dashboard_device_code",
access_token=access_token,
refresh_token=refresh_token,
base_url=base_url,
)
pool.add_entry(entry)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
@@ -4064,10 +3791,6 @@ async def start_oauth_login(provider_id: str, request: Request):
return _start_anthropic_pkce()
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow
)
except HTTPException:
raise
except Exception as e:
@@ -4094,13 +3817,7 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
async def poll_oauth_session(provider_id: str, session_id: str):
"""Poll a session's status (no auth — read-only state).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
loopback flow (xAI Grok). Both surface progress through the same
background-worker-updated ``status`` field, so a single poll endpoint
serves them all.
"""
"""Poll a device-code session's status (no auth — read-only state)."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
@@ -4123,33 +3840,6 @@ async def cancel_oauth_session(session_id: str, request: Request):
sess = _oauth_sessions.pop(session_id, None)
if sess is None:
return {"ok": False, "message": "session not found"}
# Loopback sessions own a bound 127.0.0.1 callback server. Without an
# explicit shutdown the worker would keep that port held until
# _xai_wait_for_callback times out (up to 5 min). Free it immediately so
# an orphaned listener can't block a subsequent sign-in attempt.
if sess.get("flow") == "loopback":
# The worker is blocked in _xai_wait_for_callback, which polls
# callback_result rather than the server state. Flag the result as
# cancelled so that loop returns on its next tick instead of spinning
# until the timeout — otherwise repeated cancel/retry piles up daemon
# threads. (_cancelled() in the worker then short-circuits before any
# persist.)
result = sess.get("callback_result")
if isinstance(result, dict):
result["error"] = result.get("error") or "cancelled"
server = sess.get("server")
thread = sess.get("thread")
try:
if server is not None:
server.shutdown()
server.server_close()
except Exception:
pass
try:
if thread is not None:
thread.join(timeout=1.0)
except Exception:
pass
return {"ok": True, "session_id": session_id}
@@ -4235,117 +3925,6 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
# pattern is unconstrained — it would otherwise swallow e.g.
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
# ``GET /api/sessions/stats`` as "operate on the session with id
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
# succeed and delete the wrong row). Same story as the older
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
@app.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped see contract above)
and can prune its in-memory list directly from the request.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
return {"count": db.count_empty_sessions()}
finally:
db.close()
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
@@ -4378,7 +3957,6 @@ async def get_session_stats():
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -6605,26 +6183,13 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Packaged Electron loads the desktop renderer over a non-web origin
# such as file://, null, or a custom app:// scheme. This helper is
# called only AFTER _ws_auth_ok has already accepted the WS credential,
# which is the real auth boundary in every mode:
# * loopback bind → legacy dashboard session token
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
# A non-web origin can only be produced by a native client (the desktop
# shell); a DNS-rebinding attack always arrives from an http(s) origin
# and is still match-checked against the bound host below. So once the
# credential check upstream has passed, the Origin guard adds nothing
# for a non-web origin — trust it in every mode.
#
# (Earlier revisions restricted this to loopback, then to non-gated
# binds; both excluded the packaged desktop talking to a remote
# OAuth-gated gateway, whose file:// renderer origin then got rejected
# at the WS upgrade even with a valid ticket. The ticket is the gate,
# not the origin.)
return True
# Packaged Electron loads the desktop renderer over file://, so its
# WebSocket handshake carries a non-web Origin such as file:// or null.
# DNS-rebinding attacks originate from an http(s) site; they cannot
# forge a file:// origin and still hold the loopback session token.
# Public/gated binds have no legitimate non-web client, so keep
# rejecting these origins there.
return bound_host.lower() in _LOOPBACK_HOST_VALUES
if not parsed.netloc:
return False
@@ -6643,21 +6208,10 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
Gated (public bind, no ``--insecure``): one of two credentials
* ``?ticket=<single-use>`` a browser-minted, single-use, 30s-TTL ticket
consumed against the dashboard-auth ticket store. This is what the SPA
(and native clients) use.
* ``?internal=<process-credential>`` the process-lifetime internal
credential, used only by WS clients the server spawns itself (the
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
is multi-use and never expires so the child can reconnect, and is never
injected into the SPA see ``dashboard_auth.ws_tickets`` for the
threat model.
The legacy ``?token=`` path is unconditionally rejected in gated mode
(the SPA bundle isn't carrying the token any longer, and a leaked
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
Gated (public bind, no ``--insecure``): ``?ticket=<single-use>`` query
parameter consumed against the dashboard-auth ticket store. The legacy
token path is unconditionally rejected in this mode (the SPA bundle
isn't carrying the token any longer).
Returns True if the WS should be accepted; callers close with the
appropriate WS code (4401) on False. Audit-logs the rejection so
@@ -6665,36 +6219,17 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
"""
auth_required = bool(getattr(app.state, "auth_required", False))
if auth_required:
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
# Lazy import — keeps this function importable in test harnesses
# that don't bring in the dashboard_auth layer.
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.ws_tickets import (
TicketInvalid,
consume_internal_credential,
consume_ticket,
)
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
# multi-use internal credential rather than a single-use ticket, so
# they survive reconnects and slow cold boots.
internal = ws.query_params.get("internal", "")
if internal:
try:
consume_internal_credential(internal)
return True
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
reason=f"internal: {exc}",
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
try:
consume_ticket(ticket)
return True
@@ -6714,7 +6249,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
# (State is initialised in _lifespan on app startup — see above.)
_event_channels: dict[str, set] = {}
_event_lock = asyncio.Lock()
def _resolve_chat_argv(
@@ -6770,16 +6306,7 @@ def _resolve_chat_argv(
def _build_gateway_ws_url() -> Optional[str]:
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
server-spawned PTY child authenticates with the process-lifetime internal
credential (``?internal=``). It must NOT use a single-use browser ticket:
the child reads this URL once at startup and reuses it on every reconnect,
and a 30s-TTL ticket can expire before a slow cold boot even dials.
"""
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)
@@ -6791,13 +6318,7 @@ def _build_gateway_ws_url() -> Optional[str]:
if ":" in host and not host.startswith("[")
else f"{host}:{port}"
)
if getattr(app.state, "auth_required", False):
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
return f"ws://{netloc}/api/ws?{qs}"
@@ -6807,14 +6328,16 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
Gated mode: authenticates with the process-lifetime internal credential
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
child is a server-spawned process we trust; the credential is multi-use
and never expires, so the child can reconnect ``/api/pub`` without a new
URL. (This previously minted a single-use 30s ticket, which meant the
child could not reconnect and could miss the window on a slow cold boot.)
Connections authenticated this way are recorded under the
``server-internal`` identity in the audit log.
Gated mode: mints a single-use ticket via the dashboard-auth ticket
store (server-side mint, no HTTP round trip the PTY child is a
server-spawned process and we trust it). The ticket binds to the
pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from
browser-initiated tickets.
The single-use lifetime means the PTY child cannot reconnect without a
new sidecar URL. PTY children open ``/api/pub`` once at startup; if
reconnect semantics ever become important, this should be upgraded to
a long-lived process-scoped token.
"""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)
@@ -6825,24 +6348,21 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
if getattr(app.state, "auth_required", False):
# Gated mode — use the internal credential so the WS upgrade survives
# _ws_auth_ok and the child can reconnect.
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
# Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok.
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
qs = urllib.parse.urlencode(
{"internal": internal_ws_credential(), "channel": channel}
)
ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal")
qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel})
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
async def _broadcast_event(app: Any, channel: str, payload: str) -> None:
async def _broadcast_event(channel: str, payload: str) -> None:
"""Fan out one publisher frame to every subscriber on `channel`."""
event_channels, event_lock = _get_event_state(app)
async with event_lock:
subs = list(event_channels.get(channel, ()))
async with _event_lock:
subs = list(_event_channels.get(channel, ()))
for sub in subs:
try:
@@ -7033,7 +6553,7 @@ async def pub_ws(ws: WebSocket) -> None:
try:
while True:
await _broadcast_event(ws.app, channel, await ws.receive_text())
await _broadcast_event(channel, await ws.receive_text())
except WebSocketDisconnect:
pass
@@ -7059,9 +6579,8 @@ async def events_ws(ws: WebSocket) -> None:
await ws.accept()
event_channels, event_lock = _get_event_state(ws.app)
async with event_lock:
event_channels.setdefault(channel, set()).add(ws)
async with _event_lock:
_event_channels.setdefault(channel, set()).add(ws)
try:
while True:
@@ -7072,14 +6591,14 @@ async def events_ws(ws: WebSocket) -> None:
except WebSocketDisconnect:
pass
finally:
async with event_lock:
subs = event_channels.get(channel)
async with _event_lock:
subs = _event_channels.get(channel)
if subs is not None:
subs.discard(ws)
if not subs:
event_channels.pop(channel, None)
_event_channels.pop(channel, None)
def _normalise_prefix(raw: Optional[str]) -> str:
@@ -7226,7 +6745,6 @@ def mount_spa(application: FastAPI):
_BUILTIN_DASHBOARD_THEMES = [
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
+6 -175
View File
@@ -452,9 +452,12 @@ class SessionDB:
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python (managed uv guarantees FTS5). "
"(underlying error: %s)",
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
exc,
)
@@ -3183,178 +3186,6 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Children of every deleted ID are orphaned
(``parent_session_id NULL``), never cascade-deleted, so a
branch / subagent transcript survives an inadvertent parent
delete.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
# Orphan children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def prune_sessions(
self,
older_than_days: int = 90,
-16
View File
@@ -58,22 +58,6 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
''
);
# Verify the default package builds successfully (cross-platform).
# On Linux the runtime checks below already depend on the package,
# but this ensures darwin builders also build it during flake check.
build-package = pkgs.runCommand "hermes-build-package" { } ''
echo "PASS: package built at ${hermes-agent}"
mkdir -p $out
echo "ok" > $out/result
'';
# Verify the devShell builds successfully (cross-platform).
build-devshell = pkgs.runCommand "hermes-build-devshell" { } ''
echo "PASS: devShell built at ${self'.devShells.default}"
mkdir -p $out
echo "ok" > $out/result
'';
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
+33 -26
View File
@@ -8,20 +8,37 @@
# No reimplementation of the agent resolution in this wrapper.
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
let
src = ../apps;
npmDeps = pkgs.fetchNpmDeps {
src = ../apps/desktop;
# buildNpmPackage uses `npm ci` which is strict — peer deps not in the
# lockfile cause network fetch attempts. Fetcher v2 stages the full
# cache (including peer-only deps) so `npm ci` can resolve them offline.
fetcherVersion = 2;
hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (npm // {
pname = "hermes-desktop-renderer";
inherit version;
inherit src npmDeps version;
sourceRoot = "apps/desktop";
doCheck = false;
# The workspace lockfile resolves all peer deps
# correctly so --legacy-peer-deps is not needed.
# --ignore-scripts comes from mkNpmPassthru (shared).
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
# included) so the offline `npm ci` resolves them.
npmDepsFetcherVersion = 2;
# `--ignore-scripts` skips the electron prebuild download (we use nixpkgs
# electron instead). `--legacy-peer-deps` matches the dev workflow —
# apps/desktop has conflicting peer deps (zod, @testing-library) that
# the package.json relies on npm 7+ to relax.
npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ];
makeCacheWritable = true;
buildPhase = ''
@@ -30,23 +47,21 @@ let
# write-build-stamp.cjs replacement. Packaged Electron reads this
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
mkdir -p build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
# Build from apps/desktop/ so vite.config.ts resolves correctly.
# The workspace root's node_modules/ is accessible as ../../node_modules/.
cd apps/desktop
# The vite config aliases react/react-dom to ../../node_modules/react
# (workspace root, where npm dedups them in dev). In the standalone
# nix build there is no workspace root, so the deps are installed
# locally — rewrite the aliases to point at the local copy.
substituteInPlace vite.config.ts \
--replace-quiet '../../node_modules/' './node_modules/'
# vite handles TS transpilation via esbuild — no type-checking.
# We skip `tsc -b` to avoid type errors in test files that don't
# ship in the bundle (real upstream peer-dep version mismatches
# in @testing-library/react v16 — not blocking the build).
# Call vite directly from root node_modules to avoid npx resolving
# through unpatched workspace symlinks.
node ../../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ../..
npx vite build --outDir dist
runHook postBuild
'';
@@ -54,12 +69,8 @@ let
installPhase = ''
runHook preInstall
mkdir -p $out
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# apps/desktop/build was created before the cd. electron/ is source.
cp -r apps/desktop/dist $out/
cp -r apps/desktop/electron $out/
cp -r apps/desktop/build $out/
cp apps/desktop/package.json $out/
cp -r dist electron build $out/
cp package.json $out/
runHook postInstall
'';
});
@@ -95,10 +106,6 @@ stdenv.mkDerivation {
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";
+12 -23
View File
@@ -1,28 +1,13 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
# "ui-tui/package.json"). This file collects them all and passes the
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
# then runs a single `npm i --package-lock-only` if any changed and
# `npm ci` if the lockfile changed.
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
# with stamp-checked setup logic. This file collects and runs them all.
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
map (p: p.passthru.packageJsonPath or null) packages
);
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
# stamps pyproject.toml + uv.lock for Python venv setup).
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
in
{
devShells.default = pkgs.mkShell {
@@ -30,12 +15,16 @@
packages = with pkgs; [
uv
];
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
echo "Ready. Run 'hermes' to start."
'';
shellHook =
let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in
''
echo "Hermes Agent dev shell"
${combined}
echo "Ready. Run 'hermes' to start."
'';
};
};
}
+121 -175
View File
@@ -1,40 +1,15 @@
# nix/lib.nix — Shared helpers for nix stuff
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
# duplicate them. One hash to rule them all.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
# and if any changed, runs a single `npm i --package-lock-only` from
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-WudVthIvvyqaKDr3SwRAswd8csvByzUb+T8jCqeai6g=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# src, npmDeps, npmRoot, npmDepsFetcherVersion
# patchPhase — ensures root lockfile has exactly one trailing newline
# patchPhase — ensures lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# passthru.devShellHook — stamp-checked npm install + hash auto-update
# passthru.npmLockfile — metadata for mkFixLockfiles
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
@@ -44,38 +19,22 @@ in
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
# installPhase = '' ... '';
# })
# pkgs.buildNpmPackage (npm // { ... } # or:
# pkgs.buildNpmPackage ({ ... } // npm)
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
pname, # e.g. "hermes-tui"
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
}:
let
# No sourceRoot — the workspace root (with the single package-lock.json)
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
# so npmConfigHook finds the lockfile there.
in
{
inherit src npmDeps nodejs;
npmRoot = ".";
npmDepsFetcherVersion = 2;
# --ignore-scripts: the workspace includes electron (apps/desktop)
# which has a postinstall that tries to download from github.com.
# nix builds are offline, so all scripts must be skipped. Each
# package sets up its own build commands in buildPhase instead.
npmFlags = [ "--ignore-scripts" ];
inherit nodejs;
patchPhase = ''
runHook prePatch
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Normalize trailing newlines so source and npm-deps always match,
# regardless of what fetchNpmDeps preserves.
sed -i -z 's/\n*$/\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
@@ -83,11 +42,11 @@ in
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
rm -f "\$f1" "\$f2"
exit \$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
@@ -101,71 +60,62 @@ in
REPO_ROOT=$(git rev-parse --show-toplevel)
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
cd "$REPO_ROOT/${folder}"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
# Hash lives in lib.nix — just rebuild to verify.
NIX_FILE="$REPO_ROOT/${nixFile}"
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true)
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
echo got new hash $NEW_HASH
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
nix build .#${attr}
echo "Lockfile updated and build verified for .#${attr}"
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
'')
];
passthru = {
packageJsonPath = "${folder}/package.json";
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
_hermes_npm_stamp() {
sha256sum "${folder}/package.json" "${folder}/package-lock.json" \
2>/dev/null | sha256sum | awk '{print $1}'
}
STAMP=".nix-stamps/${pname}"
STAMP_VALUE="$(_hermes_npm_stamp)"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "${pname}: installing npm dependencies..."
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
# Auto-update the nix hash so it stays in sync with the lockfile
echo "${pname}: prefetching npm deps..."
NIX_FILE="$REPO_ROOT/${nixFile}"
if NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "${folder}/package-lock.json" 2>/dev/null); then
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
echo "${pname}: updated hash to $NEW_HASH"
else
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
fi
mkdir -p .nix-stamps
_hermes_npm_stamp > "$STAMP"
fi
unset -f _hermes_npm_stamp
'';
npmLockfile = {
inherit attr folder nixFile;
};
};
};
# Single devshell hook for all npm workspace packages.
#
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
#
# fixLockfilesExe: absolute path to the fix-lockfiles binary
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
mkNpmDevShellHook =
packageJsonPaths: fixLockfilesExe:
pkgs.writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
# Stamp all workspace package.jsons into one file.
STAMP_DIR=".nix-stamps"
STAMP="$STAMP_DIR/npm-package-jsons"
STAMP_VALUE=$(
${pkgs.coreutils}/bin/sha256sum ${
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
)
PKG_CHANGED=false
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
PKG_CHANGED=true
echo "npm: package.json changed, updating lockfile..."
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$STAMP_VALUE" > "$STAMP"
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci + fix-lockfiles if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
# Aggregate `fix-lockfiles` bin from a list of packages carrying
# passthru.npmLockfile = { attr; folder; nixFile; };
# Invocations:
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
@@ -173,8 +123,12 @@ in
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
{
attr, # flake package attr for fallback verification build, e.g. "tui"
packages, # list of packages with passthru.npmLockfile
}:
let
entries = map (p: p.passthru.npmLockfile) packages;
entryArgs = pkgs.lib.concatMapStringsSep " " (e: "\"${e.attr}:${e.folder}:${e.nixFile}\"") entries;
in
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---apply}"
@@ -188,6 +142,8 @@ in
exit 2 ;;
esac
ENTRIES=(${entryArgs})
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
@@ -204,76 +160,66 @@ in
FIXED=0
REPORT=""
# All workspace packages share the root package-lock.json, so
# we only need to check the hash once.
LOCK_FILE="package-lock.json"
LIB_FILE="nix/lib.nix"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo "prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "ok (via nix build)"
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
for entry in "''${ENTRIES[@]}"; do
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
# Compute the actual hash from the lockfile directly using
# prefetch-npm-deps. This avoids false "ok" from nix build when
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
LOCK_FILE="$FOLDER/package-lock.json"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
exit 0
echo " prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo " ok (via nix build)"
continue
fi
echo "build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
| sed -E 's/npmDepsHash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo "ok"
exit 0
fi
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
else
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` \`$NEW_HASH\`"$'\\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
# the lockfile contents, not the full source tree). Extract the
# correct hash from the nix build error and retry.
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH retrying" >&2
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
echo "verification build failed after hash retry" >&2
exit 1
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
fi
NEW_HASH="$CORRECT_HASH"
else
echo "verification build failed after hash update" >&2
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
FIXED=1
echo "fixed"
fi
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
| sed -E 's/hash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo " ok"
continue
fi
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
NIX_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$NIX_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT+="- [\`$NIX_FILE:$HASH_LINE\`]($NIX_URL) (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\n'
else
REPORT+="- \`$NIX_FILE:$HASH_LINE\` (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\`"$'\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
echo " verification build failed after hash update" >&2
exit 1
fi
FIXED=1
echo " fixed"
fi
done
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{
@@ -289,7 +235,7 @@ in
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hash detected. Run:"
echo "Stale lockfile hashes detected. Run:"
echo " nix run .#fix-lockfiles"
exit 1
fi

Some files were not shown because too many files have changed in this diff Show More